@muretai/agent-entry 1.10.0 → 1.11.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 +161 -38
- package/conformance/receptor-check.mjs +904 -0
- package/diagrams/become.png +0 -0
- package/diagrams/desk.svg +17 -0
- package/diagrams/x402.svg +21 -0
- package/muretai-agent-entry.mjs +276 -87
- package/package.json +8 -3
- package/scripts/distill/README.md +32 -0
- package/scripts/distill/distill.mjs +51 -0
- package/scripts/distill/fixtures.json +10 -0
- package/scripts/distill/lib.mjs +100 -0
- package/scripts/distill/loop.mjs +46 -0
- package/scripts/distill/measure.mjs +80 -0
- package/scripts/distill/record.mjs +90 -0
- package/scripts/distill/test.mjs +43 -0
- package/spec/skill-distill.md +219 -0
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* conformance/receptor-check.mjs — is a live URL a conformant Agent Entry?
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS SHIPS BESIDE `run.mjs`. That file checks a LIBRARY against golden vectors: the
|
|
6
|
+
* bytes this build signs, and the messages it must refuse. It cannot see a running door.
|
|
7
|
+
* This one is pointed at a URL and answers the only question a site owner actually has —
|
|
8
|
+
* "my install is deployed; does it obey the contract?" — over plain HTTP, so it judges a
|
|
9
|
+
* door on any stack: this package, the PHP plugin, the serverless template, a hand-written
|
|
10
|
+
* implementation nobody here has read.
|
|
11
|
+
*
|
|
12
|
+
* IT HAS A TWIN, AND THE TWO MUST AGREE ROW FOR ROW. `tools/receptor_check.py` in Muretai
|
|
13
|
+
* core is this file in Python. For ONE door both tools emit the same rows in the same
|
|
14
|
+
* order, each with the same `id`, label, level and outcome; they agree on the verdict and
|
|
15
|
+
* the exit status, and their `--json` documents are comparable field for field. Two
|
|
16
|
+
* implementations of one contract that disagree are worse than one, because a door
|
|
17
|
+
* certified by one and refused by the other tells a site owner nothing. The harness is a
|
|
18
|
+
* plain diff:
|
|
19
|
+
*
|
|
20
|
+
* diff <(python3 tools/receptor_check.py --json URL) \
|
|
21
|
+
* <(node conformance/receptor-check.mjs --json URL)
|
|
22
|
+
*
|
|
23
|
+
* (after normalising the six values that genuinely cannot be identical — the clock in
|
|
24
|
+
* `sig.fresh`'s detail, and the per-run random DIDs and ids; see NORMALISATION below).
|
|
25
|
+
*
|
|
26
|
+
* NEUTRALITY. This file imports NOTHING but `./muretai-agent-entry.mjs` and Node builtins.
|
|
27
|
+
* No dependency, no vendored core, no network at import time. That constraint is the
|
|
28
|
+
* reason several decisions in the Python twin were CHANGED rather than mirrored: it proves
|
|
29
|
+
* a card the way any HTTP client can, and a check a neutral implementation cannot run is
|
|
30
|
+
* not a conformance check.
|
|
31
|
+
*
|
|
32
|
+
* DECLARED EXEMPTIONS from the parity contract, and they are the only two:
|
|
33
|
+
* * `--help` / CLI-misuse TEXT. argparse's usage block is Python-shaped and reproducing
|
|
34
|
+
* it here would be cargo cult. Only the EXIT STATUS of those paths is pinned (0 for
|
|
35
|
+
* `--help`, 2 for misuse), and so is the bad-URL message, which both tools print to
|
|
36
|
+
* stderr with JSON quoting. argparse's flag ABBREVIATION (`--hand` for `--handshake`)
|
|
37
|
+
* is part of this exemption: this file accepts full flag names only.
|
|
38
|
+
* * URL-PARSER edge cases in the positional argument. WHATWG `new URL` punycodes IDN
|
|
39
|
+
* hosts, percent-encodes path characters and normalises `http:/host`; Python's
|
|
40
|
+
* `urlsplit` does none of that. The argument must be an ASCII, already-percent-encoded
|
|
41
|
+
* `http(s)://host[:port][/path]`. Inside one tool the divergence cancels — `cardScope`
|
|
42
|
+
* runs the dialled url and the card's url through the SAME parser — so it can only
|
|
43
|
+
* bite when the card spells a character one way and the operator spells it the other.
|
|
44
|
+
*
|
|
45
|
+
* ONE HELPER EXISTS THERE AND MUST NOT EXIST HERE. The Python twin carries `_collapse`,
|
|
46
|
+
* which joins REPEATED response headers instead of keeping the last one, because
|
|
47
|
+
* `email.message` is last-one-wins and a WordPress page emits its own
|
|
48
|
+
* `Link: …rel="https://api.w.org/"` beside the entry's door signpost. Node's
|
|
49
|
+
* `Headers.get()` already comma-joins repeated fields per the Fetch spec — which is
|
|
50
|
+
* exactly the RFC 9110 §5.3 behaviour that helper restores — so there is nothing to
|
|
51
|
+
* restore here. Both runtimes join with `', '`, which is why `methodSet()`'s comma split
|
|
52
|
+
* works unchanged for both. This is a LANGUAGE difference, not a behaviour difference: do
|
|
53
|
+
* not add a matching helper, and do not "fix" the Python one into a dict comprehension.
|
|
54
|
+
*
|
|
55
|
+
* WHAT IT CHECKS. Two tiers, because the tool is pointed at a stranger's production URL:
|
|
56
|
+
*
|
|
57
|
+
* READ-ONLY (default) — sends no message, mints no account:
|
|
58
|
+
* * the plain card at /.well-known/agent-card.json (valid, names a DID)
|
|
59
|
+
* * the legacy alias /.well-known/agent.json is BYTE-IDENTICAL
|
|
60
|
+
* * the signed envelope at /.well-known/agent-card.sig.json verifies under the card's
|
|
61
|
+
* DID, carries an INTEGER `ts`, and is FRESH (<= CARD_SIG_MAX_AGE_S, 6h)
|
|
62
|
+
* * the SIGNED card's own `url` names the origin+path that was dialled, and that card
|
|
63
|
+
* advertises an open door
|
|
64
|
+
* * OPTIONS on the door and the card path answer 204 with an `Allow` that a
|
|
65
|
+
* per-resource CORS `Access-Control-Allow-Methods` AGREES with, `*` origin, and NO
|
|
66
|
+
* `Access-Control-Allow-Credentials` (the header that would turn `*` into a hole)
|
|
67
|
+
* * an unknown path is not a DOOR: no JSON-RPC answer to a POST there, and no 204 +
|
|
68
|
+
* `Allow` from OPTIONS (the preflight must not become a path oracle). Deliberately
|
|
69
|
+
* NOT "the origin 404s" — an entry hosted inside a site shares the origin with a
|
|
70
|
+
* site that owns its own routing and legitimately answers other paths its own way
|
|
71
|
+
* * (advisory) the notice route carries the `Link` door signpost
|
|
72
|
+
*
|
|
73
|
+
* HANDSHAKE (--handshake) — sends signed messages, MAY create one account row:
|
|
74
|
+
* * a real signed message/send returns an INLINE reply that verifies under the door's
|
|
75
|
+
* DID, echoes the contextId, and stamps an integer timestamp
|
|
76
|
+
* * the attack battery every door must refuse: tampered text (-32001), wrong recipient
|
|
77
|
+
* (-32003), stale/future timestamp (-32002), replayed messageId (-32002), oversize
|
|
78
|
+
* text (-32005), missing signature (-32001), unparseable body (400), >1 MiB (413)
|
|
79
|
+
*
|
|
80
|
+
* The read-only tier is safe against any URL. The handshake tier writes to the door's
|
|
81
|
+
* ledger and is the real acceptance gate for a new implementation; run it against a door
|
|
82
|
+
* you own (or a throwaway install), not a stranger's.
|
|
83
|
+
*
|
|
84
|
+
* NORMALISATION, for anyone building the parity harness. Six values cannot be identical
|
|
85
|
+
* and every one is bounded by construction rather than by hope: the clock (regex the
|
|
86
|
+
* detail of row `sig.fresh` ONLY — `s/\bage -?\d+(\.\d+)?h\b/age <AGE>h/`), the per-run
|
|
87
|
+
* random DID / messageId / contextId (`did:key:z…` -> `<did>`, then `receptor-dup-<32
|
|
88
|
+
* hex>`, then bare 32-hex, IN THAT ORDER), and socket/DNS/TLS error wording — which never
|
|
89
|
+
* reaches a diffed field at all, because `req()` folds every transport failure to status
|
|
90
|
+
* `null` and `fmtStatus` spells that `no response`. Run the two tools sequentially with a
|
|
91
|
+
* gap (never concurrently) when `--handshake` is used: the tier sends 12 POSTs and two
|
|
92
|
+
* runs back to back is 24 against a door whose anonymous ceiling is 30/min. A `-32004` in
|
|
93
|
+
* any detail means the parity run is VOID, not failed.
|
|
94
|
+
*
|
|
95
|
+
* ONE MORE VOID CONDITION, and it is a door's non-determinism rather than a twin's.
|
|
96
|
+
* Against a door that answers the >1 MiB probe WITHOUT DRAINING the request body, the
|
|
97
|
+
* connection can be reset while the 1 MiB is still going out; Python's urllib surfaces
|
|
98
|
+
* that as no response and undici still reads the answer, so `refuse.body_too_large`'s
|
|
99
|
+
* detail reads `got no response` there and `got 200` here. Measured, and it is a RACE, not
|
|
100
|
+
* a rule — the same door gave both answers minutes apart, and a door that answers 413
|
|
101
|
+
* without draining (the defensive shape) was stable in both. If exactly one run spells
|
|
102
|
+
* that row `no response`, re-run: the parity run is VOID, not failed.
|
|
103
|
+
*
|
|
104
|
+
* USAGE
|
|
105
|
+
* node conformance/receptor-check.mjs https://shop.example
|
|
106
|
+
* node conformance/receptor-check.mjs https://shop.example/support # path-mounted
|
|
107
|
+
* node conformance/receptor-check.mjs --handshake http://127.0.0.1:8788
|
|
108
|
+
* node conformance/receptor-check.mjs --json https://shop.example
|
|
109
|
+
*
|
|
110
|
+
* Exit status is 0 only when every hard check passed (advisories never fail the run), 1
|
|
111
|
+
* when any row FAILed, and 2 for a bad URL or CLI misuse — so this gates a plugin's CI.
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
import { Buffer } from 'node:buffer';
|
|
115
|
+
import process from 'node:process';
|
|
116
|
+
|
|
117
|
+
import {
|
|
118
|
+
AGENT_CARD_PATH, AGENT_CARD_PATH_LEGACY, AGENT_CARD_SIG_PATH, AGENT_ENTRY_REL,
|
|
119
|
+
CARD_SIG_REFRESH_S, didFromSeedHex, newId, newSeedHex, signEnvelope,
|
|
120
|
+
verifyCardEnvelope, verifyEnvelopeSignature,
|
|
121
|
+
} from '../muretai-agent-entry.mjs';
|
|
122
|
+
|
|
123
|
+
/** How stale a VISITOR tolerates a card envelope: one full re-mint period
|
|
124
|
+
* (`CARD_SIG_REFRESH_S`, what the SERVING side does) of legitimate staleness, plus five
|
|
125
|
+
* more of slack for an unsynchronised clock on either side. The derivation is written out
|
|
126
|
+
* — rather than a bare `21600` — because these are two DIFFERENT quantities that both
|
|
127
|
+
* spell "an hour" in the neighbouring constant, and substituting the refresh period for
|
|
128
|
+
* the tolerance tightens the check 6x and fails honest doors. The Python twin, which can
|
|
129
|
+
* import neither, states the same derivation. */
|
|
130
|
+
const CARD_SIG_MAX_AGE_S = 6 * CARD_SIG_REFRESH_S;
|
|
131
|
+
|
|
132
|
+
/** The door's text ceiling — the number the CONTRACT names. Stated locally, exactly as the
|
|
133
|
+
* Python twin states it, because there it CANNOT be read from the product:
|
|
134
|
+
* `protocol.MAX_TEXT_BYTES` is overridden by `AGENTNET_MAX_TEXT_BYTES`, so an operator's
|
|
135
|
+
* environment would change the size of the oversize probe and the twins would send
|
|
136
|
+
* different bodies on one machine and the same on every other. */
|
|
137
|
+
const MAX_TEXT_BYTES = 64 * 1024;
|
|
138
|
+
|
|
139
|
+
/** Every request, both tiers. */
|
|
140
|
+
const TIMEOUT_MS = 15_000;
|
|
141
|
+
|
|
142
|
+
/** Sent on EVERY request by both twins. undici announces its own `user-agent` plus an
|
|
143
|
+
* `accept-encoding` nobody chose, urllib announces `Python-urllib/3.x` — and a WAF that
|
|
144
|
+
* behaves differently by User-Agent then hands the two tools different statuses for the
|
|
145
|
+
* same door. `identity` additionally guarantees both compare the same BYTES on the
|
|
146
|
+
* byte-identical row. */
|
|
147
|
+
const FIXED_HEADERS = {
|
|
148
|
+
'User-Agent': 'agent-entry-check/1',
|
|
149
|
+
Accept: '*/*',
|
|
150
|
+
'Accept-Encoding': 'identity',
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** The `--json` document's contract version. Bumped only when the row schema or the
|
|
154
|
+
* top-level keys change, so a future contract change is DETECTED by a parity harness
|
|
155
|
+
* rather than silently mis-diffed. */
|
|
156
|
+
const SCHEMA_VERSION = 1;
|
|
157
|
+
|
|
158
|
+
// `ignoreBOM: true` is not "ignore a BOM" — it means "do not STRIP one", which is what the
|
|
159
|
+
// twins need. TextDecoder's default silently removes a leading U+FEFF, so a card served with
|
|
160
|
+
// EF BB BF parsed cleanly here while Python's `body.decode("utf-8")` kept the character and
|
|
161
|
+
// `json.loads` refused it: the same bytes, CONFORMANT on one twin and NOT CONFORMANT on the
|
|
162
|
+
// other. RFC 8259 §8.1 says an implementation MUST NOT add a BOM to JSON, so refusing is also
|
|
163
|
+
// the right answer — the twins now refuse together.
|
|
164
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------- shared formatters
|
|
167
|
+
//
|
|
168
|
+
// Four functions, same names and same output in both twins. Every value that reaches a
|
|
169
|
+
// diffed field goes through one of them, which is what keeps runtime-specific spellings
|
|
170
|
+
// (Python's `repr`, `type(x).__name__`, a JSON decoder's error message, a socket error's
|
|
171
|
+
// class name) out of the comparison BY CONSTRUCTION rather than by regex afterwards.
|
|
172
|
+
|
|
173
|
+
/** The decimal status, or `no response` when no status line arrived. NEVER the exception
|
|
174
|
+
* class or message: that is how socket/DNS/TLS wording — on which no two runtimes will
|
|
175
|
+
* ever agree — is kept out of every diffed field. */
|
|
176
|
+
function fmtStatus(status) {
|
|
177
|
+
return status === null ? 'no response' : String(status);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** JSON spelling of a SCALAR. Replaces every Python `!r`: repr uses single quotes, spells
|
|
181
|
+
* `None`/`True`, and escapes differently from every other language. Restricted to scalars
|
|
182
|
+
* by contract, so no object's key order or float rendering can leak in. */
|
|
183
|
+
function fmtJson(value) {
|
|
184
|
+
return JSON.stringify(value === undefined ? null : value);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A method set as text, sorted, or `(absent)` when empty. Used for LABELS as well as
|
|
188
|
+
* details — a Python `sorted(...)` list repr in a label (`['GET', 'HEAD', 'OPTIONS']`)
|
|
189
|
+
* has no matching spelling here, and a label is the field that gets diffed. */
|
|
190
|
+
function fmtMethods(methods) {
|
|
191
|
+
const ordered = [...methods].sort();
|
|
192
|
+
return ordered.length ? ordered.join(', ') : '(absent)';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** One of null / boolean / number / string / array / object. Replaces Python's
|
|
196
|
+
* `type(v).__name__`: `NoneType`, `int` and `float` are Python words, and int vs float is
|
|
197
|
+
* a distinction JSON does not even carry. */
|
|
198
|
+
function jsonType(value) {
|
|
199
|
+
if (value === null || value === undefined) return 'null';
|
|
200
|
+
if (Array.isArray(value)) return 'array';
|
|
201
|
+
return typeof value; // 'boolean' | 'number' | 'string' | 'object'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The SAFE-WHOLE-NUMBER rule, applied identically in both twins: a number, finite, with no
|
|
206
|
+
* fractional part, inside ±(2**53 - 1). Returns `{ ok, value }`.
|
|
207
|
+
*
|
|
208
|
+
* Not `Number.isInteger` alone and, on the Python side, emphatically not
|
|
209
|
+
* `isinstance(v, int)` — that pairing was the most consequential divergence between the
|
|
210
|
+
* twins. `{"ts": 1756000000.0}` parses to a Python float (which `isinstance(v, int)`
|
|
211
|
+
* refuses) and to a JS number for which `Number.isSafeInteger` is TRUE: the same document,
|
|
212
|
+
* two LEVELS. The information the raw token carried (`.0` or not) is destroyed by every
|
|
213
|
+
* JSON parser and no runtime can recover it, so the rule has to be one both can compute
|
|
214
|
+
* from the PARSED value.
|
|
215
|
+
*/
|
|
216
|
+
function wholeNumber(value) {
|
|
217
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return { ok: false, value: null };
|
|
218
|
+
if (!Number.isInteger(value)) return { ok: false, value: null };
|
|
219
|
+
if (Math.abs(value) > Number.MAX_SAFE_INTEGER) return { ok: false, value: null };
|
|
220
|
+
return { ok: true, value };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `got a non-integer number` when it IS a number, else `got <jsonType>` — so a float and
|
|
224
|
+
* a string are diagnosed differently without naming a runtime's type. */
|
|
225
|
+
function numberDetail(value) {
|
|
226
|
+
return jsonType(value) === 'number' ? 'got a non-integer number' : `got ${jsonType(value)}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------- result accumulation
|
|
230
|
+
|
|
231
|
+
/** PASS / FAIL / WARN / INFO, printed as we go and summarised at the end. Only FAIL sets
|
|
232
|
+
* the exit code; WARN is an advisory (a signpost missing, a soft-optional route), INFO is
|
|
233
|
+
* context. The verdict a human reads and the status a CI gate reads are the same object. */
|
|
234
|
+
class Report {
|
|
235
|
+
constructor({ asJson }) {
|
|
236
|
+
this.asJson = asJson;
|
|
237
|
+
this.rows = [];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** `id` FIRST, and always present. It is the PARITY KEY: diffing two tools on labels
|
|
241
|
+
* alone means any wording improvement silently renumbers the stream and the harness
|
|
242
|
+
* reports forty phantom failures. `failed`/`warned` stay LABELS so the summary has no
|
|
243
|
+
* third source of truth — it is derived from these rows. */
|
|
244
|
+
add(id, level, label, detail = '') {
|
|
245
|
+
this.rows.push({ id, level, label, detail });
|
|
246
|
+
if (!this.asJson) {
|
|
247
|
+
const mark = { PASS: 'ok', FAIL: 'FAIL', WARN: 'warn', INFO: '--' }[level];
|
|
248
|
+
console.log(detail ? `${mark}: ${label} (${detail})` : `${mark}: ${label}`);
|
|
249
|
+
}
|
|
250
|
+
return level !== 'FAIL';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A hard check. Passing prints ok; failing prints FAIL and taints the exit code. */
|
|
254
|
+
check(id, cond, label, detail = '') {
|
|
255
|
+
return this.add(id, cond ? 'PASS' : 'FAIL', label, cond ? '' : detail);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A soft check: a false result is an advisory, never a failure. */
|
|
259
|
+
warn(id, cond, label, detail = '') {
|
|
260
|
+
return this.add(id, cond ? 'PASS' : 'WARN', label, cond ? '' : detail);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
info(id, label, detail = '') {
|
|
264
|
+
this.add(id, 'INFO', label, detail);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
get failed() {
|
|
268
|
+
return this.rows.filter((r) => r.level === 'FAIL').map((r) => r.label);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
get warned() {
|
|
272
|
+
return this.rows.filter((r) => r.level === 'WARN').map((r) => r.label);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------- raw HTTP helper
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* One HTTP round trip, NO REDIRECTS FOLLOWED. Returns `{status, headers, body}` where
|
|
280
|
+
* `status` is null and `headers` empty when no status line arrived (the failure mode a
|
|
281
|
+
* door with an unhandled exception shows), so a single bad route cannot abort the run.
|
|
282
|
+
*
|
|
283
|
+
* `redirect: 'manual'` reports a 3xx AS the status it is, which is the more correct
|
|
284
|
+
* posture for a conformance checker — the card path is normative — and it is also the only
|
|
285
|
+
* way the twins can agree: urllib follows 301/302/303/307 but NOT 308 and converts a
|
|
286
|
+
* redirected POST to GET, while `fetch` preserves the method on 307/308, so a followed
|
|
287
|
+
* redirect means the two tools silently tested different requests. It also keeps the SSRF
|
|
288
|
+
* surface of a tool pointed at stranger URLs closed.
|
|
289
|
+
*
|
|
290
|
+
* The exception is deliberately DISCARDED rather than reported: `UND_ERR_CONNECT_TIMEOUT`,
|
|
291
|
+
* `ECONNREFUSED` and `CERTIFICATE_VERIFY_FAILED` have no Python spelling, and a value no
|
|
292
|
+
* row reads is a value a future edit will start reading.
|
|
293
|
+
*/
|
|
294
|
+
/** The body, but only if the door answered the encoding we asked for.
|
|
295
|
+
*
|
|
296
|
+
* Both twins send `Accept-Encoding: identity`. Measured on Node 26: `fetch` DECOMPRESSES a
|
|
297
|
+
* `Content-Encoding: gzip` response transparently anyway while KEEPING the header, where
|
|
298
|
+
* Python's urllib hands back the compressed bytes. So a door — or, far more likely, a CDN in
|
|
299
|
+
* front of one — that ignores the request header made this twin parse a card the Python twin
|
|
300
|
+
* could not read: the same door, two verdicts.
|
|
301
|
+
*
|
|
302
|
+
* Neither behaviour is worth preserving. We asked for identity; a response that is not
|
|
303
|
+
* identity is not the one we asked for. Both twins drop it, so the rows below fail together
|
|
304
|
+
* and the door is told it ignored the header. The header survives decompression in both
|
|
305
|
+
* runtimes, which is what makes one rule expressible on both sides.
|
|
306
|
+
*/
|
|
307
|
+
function identityBody(headers, raw) {
|
|
308
|
+
const enc = (headers.get('content-encoding') || '').trim().toLowerCase();
|
|
309
|
+
if (enc && enc !== 'identity') return Buffer.alloc(0);
|
|
310
|
+
return raw;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function req(method, url, { body = null, headers = {} } = {}) {
|
|
314
|
+
try {
|
|
315
|
+
const res = await fetch(url, {
|
|
316
|
+
method,
|
|
317
|
+
body,
|
|
318
|
+
headers: { ...FIXED_HEADERS, ...headers },
|
|
319
|
+
redirect: 'manual',
|
|
320
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
321
|
+
});
|
|
322
|
+
return { status: res.status, headers: res.headers,
|
|
323
|
+
body: identityBody(res.headers, Buffer.from(await res.arrayBuffer())) };
|
|
324
|
+
} catch {
|
|
325
|
+
return { status: null, headers: new Headers(), body: Buffer.alloc(0) };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Split an `Allow:` / `Access-Control-Allow-Methods:` value into a method set, order- and
|
|
330
|
+
* whitespace-insensitively, so 'POST, OPTIONS' and 'OPTIONS,POST' compare equal. */
|
|
331
|
+
function methodSet(headerValue) {
|
|
332
|
+
if (!headerValue) return new Set();
|
|
333
|
+
return new Set(headerValue.split(',').map((m) => m.trim().toUpperCase()).filter(Boolean));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function isSubset(small, big) {
|
|
337
|
+
for (const v of small) if (!big.has(v)) return false;
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function sameSet(a, b) {
|
|
342
|
+
return a.size === b.size && isSubset(a, b);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The canonical ORIGIN **plus path prefix** a url addresses, or '' if it is not an http(s)
|
|
347
|
+
* url at all. `https://h/alice/` and `https://H:443/alice` both give `https://h/alice`;
|
|
348
|
+
* `https://h` and `https://h/` both give `https://h`.
|
|
349
|
+
*
|
|
350
|
+
* This is Muretai's `Outbox.card_scope` MINUS its `_same_machine` allowance, which lets a
|
|
351
|
+
* peer advertise a LAN IP while you dial 127.0.0.1. A Muretai NODE needs that (its card
|
|
352
|
+
* url comes from the host's outbound address, so without it `peer add` refuses a node
|
|
353
|
+
* running on the operator's own machine); an AGENT ENTRY's card url is operator-configured
|
|
354
|
+
* (`AGENT_ENTRY_BASE_URL`), so the same allowance has no honest case here — and a carve-out
|
|
355
|
+
* that needs a machine's own interface list is one a neutral implementation could not
|
|
356
|
+
* reproduce anyway. Both twins now fail identically and say "point the checker at the same
|
|
357
|
+
* URL the card names".
|
|
358
|
+
*
|
|
359
|
+
* Empty on ANYTHING unparseable, and an empty scope on EITHER side is never a proof.
|
|
360
|
+
*/
|
|
361
|
+
function cardScope(url) {
|
|
362
|
+
if (typeof url !== 'string' || !url.trim()) return '';
|
|
363
|
+
let parsed;
|
|
364
|
+
try {
|
|
365
|
+
parsed = new URL(url.trim());
|
|
366
|
+
} catch {
|
|
367
|
+
return '';
|
|
368
|
+
}
|
|
369
|
+
const scheme = parsed.protocol.replace(/:$/, '').toLowerCase();
|
|
370
|
+
if (scheme !== 'http' && scheme !== 'https') return '';
|
|
371
|
+
let host = parsed.hostname.toLowerCase();
|
|
372
|
+
if (host.endsWith('.')) host = host.slice(0, -1); // ONE trailing dot: DNS-equal
|
|
373
|
+
if (!host) return '';
|
|
374
|
+
// `URL.port` is already '' for the scheme's default port, and `URL.hostname` already
|
|
375
|
+
// keeps the brackets on an IPv6 literal — the two normalisations Python has to perform
|
|
376
|
+
// by hand.
|
|
377
|
+
const origin = parsed.port ? `${scheme}://${host}:${parsed.port}` : `${scheme}://${host}`;
|
|
378
|
+
return origin + parsed.pathname.replace(/\/+$/, '');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Does this card advertise an open door? Read off the SIGNED card only. Every level is
|
|
382
|
+
* type-guarded: `card` is whatever JSON a stranger served, and a non-object section must
|
|
383
|
+
* answer "no", never throw. */
|
|
384
|
+
function openDoorFlag(card) {
|
|
385
|
+
if (!card || typeof card !== 'object' || Array.isArray(card)) return false;
|
|
386
|
+
for (const key of ['agentEntry', 'muretai']) {
|
|
387
|
+
const section = card[key];
|
|
388
|
+
if (section && typeof section === 'object' && !Array.isArray(section)
|
|
389
|
+
&& section.open_door) return true;
|
|
390
|
+
}
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ---------------------------------------------------------------- signed-message probes
|
|
395
|
+
|
|
396
|
+
/** A throwaway signer that touches no key store (seed generated in memory). The door will
|
|
397
|
+
* mint one ledger row for it on the first legitimate message; that is the cost of proving
|
|
398
|
+
* the POST ladder, and why the battery is opt-in. */
|
|
399
|
+
function ephemeralIdentity() {
|
|
400
|
+
const seedHex = newSeedHex();
|
|
401
|
+
return { seedHex, did: didFromSeedHex(seedHex) };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* A signed A2A message/send request, BUILT LITERALLY. `tamperText` rewrites the text AFTER
|
|
406
|
+
* signing, so the envelope no longer matches — the door must reject it (-32001).
|
|
407
|
+
*
|
|
408
|
+
* The key order below is the one the Python twin's `protocol.rpc_request` +
|
|
409
|
+
* `Message.to_a2a` shape produces, minus the fourteen null metadata keys `to_a2a` emits
|
|
410
|
+
* (`vc`, `coordination`, `group`, `deal`, `held_vc*`, `keystate`, `binding`, …) which this
|
|
411
|
+
* checker has no business inventing and no reason to send. Both twins build the minimal
|
|
412
|
+
* envelope and serialise it compactly, so the two POST byte-identical bodies — the
|
|
413
|
+
* strongest available form of "exactly the same".
|
|
414
|
+
*/
|
|
415
|
+
function signedRequest(sender, toDid, text, opts = {}) {
|
|
416
|
+
const { messageId = null, contextId = null, timestamp = null, tamperText = null } = opts;
|
|
417
|
+
const mid = messageId || newId();
|
|
418
|
+
const ts = timestamp === null ? Math.floor(Date.now() / 1000) : timestamp;
|
|
419
|
+
const sig = signEnvelope(sender.seedHex,
|
|
420
|
+
{ from: sender.did, to: toDid, messageId: mid, contextId, timestamp: ts, text });
|
|
421
|
+
return {
|
|
422
|
+
jsonrpc: '2.0',
|
|
423
|
+
id: newId(),
|
|
424
|
+
method: 'message/send',
|
|
425
|
+
params: {
|
|
426
|
+
message: {
|
|
427
|
+
kind: 'message',
|
|
428
|
+
role: 'user',
|
|
429
|
+
parts: [{ kind: 'text', text: tamperText === null ? text : tamperText }],
|
|
430
|
+
messageId: mid,
|
|
431
|
+
contextId,
|
|
432
|
+
metadata: { timestamp: ts, from: sender.did, to: toDid, sig },
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** POST a JSON-RPC object (or raw bytes) to the door and parse the reply. Returns
|
|
439
|
+
* `{status, parsed}`. `JSON.stringify` is compact, which is why the Python twin abandoned
|
|
440
|
+
* `protocol.dumps` (whose default separators put a space after every `,` and `:`): the two
|
|
441
|
+
* would otherwise write different bytes for the same logical request, and at the 1 MiB
|
|
442
|
+
* boundary a few hundred bytes of separator whitespace is the difference between testing
|
|
443
|
+
* 413 and not. */
|
|
444
|
+
async function postRpc(doorUrl, obj, raw = null) {
|
|
445
|
+
const data = raw === null ? Buffer.from(JSON.stringify(obj), 'utf8') : raw;
|
|
446
|
+
const res = await req('POST', doorUrl, { body: data,
|
|
447
|
+
headers: { 'Content-Type': 'application/json' } });
|
|
448
|
+
const txt = res.body.toString('utf8');
|
|
449
|
+
try {
|
|
450
|
+
return { status: res.status, parsed: txt ? JSON.parse(txt) : {} };
|
|
451
|
+
} catch {
|
|
452
|
+
return { status: res.status, parsed: { raw: txt } };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function errCode(resp) {
|
|
457
|
+
const e = resp && typeof resp === 'object' ? resp.error : null;
|
|
458
|
+
return e && typeof e === 'object' && !Array.isArray(e) ? e.code ?? null : null;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** JSON.parse over STRICT UTF-8 — `Buffer.toString('utf8')` substitutes U+FFFD for an
|
|
462
|
+
* invalid byte and would then parse on, where Python's `body.decode("utf-8")` raises.
|
|
463
|
+
* Returns `{ok, value}`; a non-object value is handed back as-is and guarded at the use
|
|
464
|
+
* site, exactly as the Python twin guards it. */
|
|
465
|
+
function parseJsonBody(body) {
|
|
466
|
+
try {
|
|
467
|
+
return { ok: true, value: JSON.parse(STRICT_UTF8.decode(body)) };
|
|
468
|
+
} catch {
|
|
469
|
+
return { ok: false, value: null };
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function asObject(value) {
|
|
474
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------- the checks
|
|
478
|
+
|
|
479
|
+
/** The non-invasive tier. Returns the DID the card NAMED (or null when the card could not
|
|
480
|
+
* even be read), which the handshake tier needs. */
|
|
481
|
+
async function readOnlyChecks(rawBase, rep) {
|
|
482
|
+
const base = rawBase.replace(/\/+$/, '');
|
|
483
|
+
const cardUrl = base + AGENT_CARD_PATH;
|
|
484
|
+
const legacyUrl = base + AGENT_CARD_PATH_LEGACY;
|
|
485
|
+
const sigUrl = base + AGENT_CARD_SIG_PATH;
|
|
486
|
+
|
|
487
|
+
// -- the plain card exists and is a real A2A card
|
|
488
|
+
const first = await req('GET', cardUrl);
|
|
489
|
+
const location = first.headers.get('location');
|
|
490
|
+
if (!rep.check('card.get', first.status === 200, `GET ${AGENT_CARD_PATH} -> 200`,
|
|
491
|
+
`got ${fmtStatus(first.status)}`
|
|
492
|
+
+ (location ? `; Location ${fmtJson(location)} — re-run against that URL` : ''))) {
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
const cardBody = first.body;
|
|
496
|
+
const cardParsed = parseJsonBody(cardBody);
|
|
497
|
+
if (!cardParsed.ok) {
|
|
498
|
+
// The decoder's message is a runtime spelling (`Unexpected token < in JSON at position
|
|
499
|
+
// 0` vs `Expecting value: line 1 column 1 (char 0)`); a byte count says the same thing
|
|
500
|
+
// in one language.
|
|
501
|
+
rep.check('card.json', false, 'the card is valid JSON',
|
|
502
|
+
`the body is not JSON (${cardBody.length} bytes)`);
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
const card = asObject(cardParsed.value);
|
|
506
|
+
const did = card.did ?? null;
|
|
507
|
+
rep.check('card.did', typeof did === 'string' && did.startsWith('did:key:'),
|
|
508
|
+
'the card names a did:key', `got ${fmtJson(did)}`);
|
|
509
|
+
|
|
510
|
+
// -- the legacy alias is BYTE-IDENTICAL (an additive path, never a fork)
|
|
511
|
+
const legacy = await req('GET', legacyUrl);
|
|
512
|
+
const sameBytes = Buffer.compare(legacy.body, cardBody) === 0;
|
|
513
|
+
rep.check('card.legacy_identical', legacy.status === 200 && sameBytes,
|
|
514
|
+
`the ${AGENT_CARD_PATH_LEGACY} alias is byte-identical to the card`,
|
|
515
|
+
`legacy status ${fmtStatus(legacy.status)}, ${sameBytes ? 'same' : 'differs'} bytes`);
|
|
516
|
+
|
|
517
|
+
// -- the signed envelope: valid, integer ts, verifies under the card's DID, fresh
|
|
518
|
+
let verified = null;
|
|
519
|
+
const sigRes = await req('GET', sigUrl);
|
|
520
|
+
if (rep.check('sig.get', sigRes.status === 200, `GET ${AGENT_CARD_SIG_PATH} -> 200`,
|
|
521
|
+
`got ${fmtStatus(sigRes.status)}`)) {
|
|
522
|
+
const envParsed = parseJsonBody(sigRes.body);
|
|
523
|
+
if (!envParsed.ok) {
|
|
524
|
+
rep.check('sig.json', false, 'the signed envelope is valid JSON',
|
|
525
|
+
`the body is not JSON (${sigRes.body.length} bytes)`);
|
|
526
|
+
} else {
|
|
527
|
+
const env = asObject(envParsed.value);
|
|
528
|
+
const ts = env.ts ?? null;
|
|
529
|
+
const whole = wholeNumber(ts);
|
|
530
|
+
rep.check('sig.ts_integer', whole.ok,
|
|
531
|
+
'the envelope `ts` is an integer (a non-Python verifier can read it)',
|
|
532
|
+
numberDetail(ts));
|
|
533
|
+
if (whole.ok) {
|
|
534
|
+
// Verified against the INTEGER READING, which is the only reading this runtime
|
|
535
|
+
// has. The card-envelope payload canonicalises `ts` AS RECEIVED, and Python
|
|
536
|
+
// renders a float via `repr`, so a float-ts envelope verifies THERE and is
|
|
537
|
+
// structurally unverifiable HERE; the twin was changed to read the integer too,
|
|
538
|
+
// because calling such an envelope "verifies" lies to the site owner.
|
|
539
|
+
verified = did ? verifyCardEnvelope({ ...env, ts: whole.value }, did) : null;
|
|
540
|
+
rep.check('sig.verifies', verified !== null,
|
|
541
|
+
"the signed card envelope verifies under the card's DID");
|
|
542
|
+
const age = Date.now() / 1000 - whole.value;
|
|
543
|
+
rep.check('sig.fresh', Math.abs(age) <= CARD_SIG_MAX_AGE_S,
|
|
544
|
+
`the signed card is fresh (age <= ${Math.round(CARD_SIG_MAX_AGE_S / 3600)}h)`,
|
|
545
|
+
`age ${(age / 3600).toFixed(1)}h — a live door re-signs hourly; a stale one is `
|
|
546
|
+
+ 'a static file that stopped being re-signed');
|
|
547
|
+
} else {
|
|
548
|
+
rep.check('sig.verifies', false,
|
|
549
|
+
"the signed card envelope verifies under the card's DID",
|
|
550
|
+
'the envelope ts is not an integer epoch, so its signed bytes cannot be '
|
|
551
|
+
+ 'reproduced outside Python');
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// -- the SIGNED card names THIS origin and path, and the door is open.
|
|
557
|
+
//
|
|
558
|
+
// Both rows are read off the card `sig.verifies` proved, never off the plain one: an
|
|
559
|
+
// unverified card is a statement by whoever holds the origin right now, and a claim
|
|
560
|
+
// nobody signed proves nothing about the DID. This pair replaces the Python twin's old
|
|
561
|
+
// `fetch_card_verified` call, which could be satisfied through a MURETAI RELAY — a proof
|
|
562
|
+
// an Agent Entry conformance check must not be satisfiable by, and one this file could
|
|
563
|
+
// not have reproduced without importing core.
|
|
564
|
+
const noCard = 'the card envelope did not verify, so nothing it claims is proven';
|
|
565
|
+
const mine = cardScope(base);
|
|
566
|
+
const theirs = verified ? cardScope(verified.url) : '';
|
|
567
|
+
rep.check('origin.url_binding', Boolean(mine) && Boolean(theirs) && mine === theirs,
|
|
568
|
+
"the signed card's own url names the origin and path that were dialled",
|
|
569
|
+
verified === null ? noCard
|
|
570
|
+
: `the card says ${fmtJson(theirs)}; you dialled ${fmtJson(mine)} — point the `
|
|
571
|
+
+ 'checker at the same URL the card names');
|
|
572
|
+
rep.check('origin.open_door', openDoorFlag(verified),
|
|
573
|
+
'the card advertises an open door (agentEntry/muretai.open_door)',
|
|
574
|
+
verified === null ? noCard : `got ${fmtJson(false)}`);
|
|
575
|
+
|
|
576
|
+
// -- OPTIONS on the card path: 204, Allow says GET/HEAD/OPTIONS, CORS agrees, no creds
|
|
577
|
+
await optionsChecks('options.card', cardUrl, new Set(['GET', 'HEAD', 'OPTIONS']),
|
|
578
|
+
'the card path', rep);
|
|
579
|
+
// -- OPTIONS on the door: 204, Allow includes POST+OPTIONS, CORS agrees, no creds
|
|
580
|
+
await optionsChecks('options.door', `${base}/`, new Set(['POST', 'OPTIONS']),
|
|
581
|
+
'the door', rep);
|
|
582
|
+
|
|
583
|
+
// -- an unknown path must not be a DOOR.
|
|
584
|
+
//
|
|
585
|
+
// The check is deliberately not "the origin 404s". An entry that owns its whole origin
|
|
586
|
+
// (a dedicated Node process) does 404 everything else, but an entry hosted INSIDE a
|
|
587
|
+
// site — a CMS plugin, a framework route — shares the origin with a site that owns its
|
|
588
|
+
// own routing and legitimately answers unknown paths its own way (WordPress on plain
|
|
589
|
+
// permalinks serves the home page for any path at all). Demanding a 404 there judges the
|
|
590
|
+
// SITE, not the door, and would fail every coexisting deployment.
|
|
591
|
+
//
|
|
592
|
+
// What must hold in BOTH shapes is that the ENTRY does not answer where it should not: a
|
|
593
|
+
// POST to a non-door path must not produce a JSON-RPC entry answer (a door at an address
|
|
594
|
+
// that was never published), and OPTIONS must not hand back the entry's own 204 +
|
|
595
|
+
// `Allow`, which would turn the preflight into a path oracle.
|
|
596
|
+
const unknown = `${base}/receptor-check-not-a-route-9z8y7x`;
|
|
597
|
+
|
|
598
|
+
const unknownPost = await req('POST', unknown, {
|
|
599
|
+
body: Buffer.from('{"jsonrpc":"2.0","id":1,"method":"message/send","params":{}}', 'utf8'),
|
|
600
|
+
headers: { 'Content-Type': 'application/json' },
|
|
601
|
+
});
|
|
602
|
+
rep.check('unknown.post',
|
|
603
|
+
!unknownPost.body.toString('latin1').toLowerCase().includes('jsonrpc'),
|
|
604
|
+
'POST an unknown path is NOT answered by the entry',
|
|
605
|
+
`status ${fmtStatus(unknownPost.status)} returned a JSON-RPC body — the door is `
|
|
606
|
+
+ 'answering an address that was never published');
|
|
607
|
+
|
|
608
|
+
// The entry's OPTIONS answer for a resource it owns is a 204 carrying `Allow`. Either of
|
|
609
|
+
// those on a path it does NOT own is the path oracle. The presence of CORS headers is
|
|
610
|
+
// deliberately not the signal: both reference implementations keep the origin-wide CORS
|
|
611
|
+
// default on their 404s too, so testing for it would fail a correct entry.
|
|
612
|
+
const unknownOptions = await req('OPTIONS', unknown);
|
|
613
|
+
rep.check('unknown.options',
|
|
614
|
+
!(unknownOptions.status === 204 || unknownOptions.headers.has('allow')),
|
|
615
|
+
'OPTIONS an unknown path is not answered by the entry (no path oracle)',
|
|
616
|
+
`status ${fmtStatus(unknownOptions.status)}, `
|
|
617
|
+
+ `Allow: ${fmtJson(unknownOptions.headers.get('allow'))}`);
|
|
618
|
+
|
|
619
|
+
// An INFO row's LABEL is one of a fixed set — the observed status lives in the detail,
|
|
620
|
+
// because the label is the field the parity harness diffs.
|
|
621
|
+
const unknownGet = await req('GET', unknown);
|
|
622
|
+
if (unknownGet.status === 404) {
|
|
623
|
+
rep.info('unknown.get', 'an unknown path 404s — this entry owns its whole origin',
|
|
624
|
+
`GET -> ${fmtStatus(unknownGet.status)}`);
|
|
625
|
+
} else {
|
|
626
|
+
rep.info('unknown.get',
|
|
627
|
+
'an unknown path is answered by the site — this entry is hosted inside a site that '
|
|
628
|
+
+ 'owns its own routing',
|
|
629
|
+
`GET -> ${fmtStatus(unknownGet.status)}`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// -- (advisory) the notice route carries the door signpost. A guest mount keeps the
|
|
633
|
+
// site's own front page (GET <mount> is 405), so its absence is not a failure.
|
|
634
|
+
const notice = await req('GET', `${base}/`);
|
|
635
|
+
const link = notice.headers.get('link') || '';
|
|
636
|
+
if (notice.status === 200) {
|
|
637
|
+
rep.warn('notice.link', link.includes(AGENT_ENTRY_REL),
|
|
638
|
+
`the notice route carries the Link door signpost (rel=${AGENT_ENTRY_REL})`,
|
|
639
|
+
`Link: ${link || '(absent)'}`);
|
|
640
|
+
} else {
|
|
641
|
+
rep.info('notice.link',
|
|
642
|
+
'the mount does not serve a notice page (guest mount / site keeps its front page) — '
|
|
643
|
+
+ 'the Link signpost belongs on a page the site does serve',
|
|
644
|
+
`GET / -> ${fmtStatus(notice.status)}`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// The handshake tier runs whenever the card NAMED a DID, WHATEVER this tier concluded:
|
|
648
|
+
// every refusal in the battery is worth measuring on a door whose card is stale or whose
|
|
649
|
+
// CORS is wrong. Only a falsy DID produces `handshake.skipped`.
|
|
650
|
+
return did;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** OPTIONS must answer 204 with an `Allow` describing THIS resource, a CORS
|
|
654
|
+
* `Access-Control-Allow-Methods` that does not contradict it, `*` origin, and NO
|
|
655
|
+
* `Access-Control-Allow-Credentials`. */
|
|
656
|
+
async function optionsChecks(prefix, url, expectMethods, what, rep) {
|
|
657
|
+
const res = await req('OPTIONS', url);
|
|
658
|
+
rep.check(`${prefix}.status`, res.status === 204, `OPTIONS ${what} -> 204`,
|
|
659
|
+
`got ${fmtStatus(res.status)}`);
|
|
660
|
+
const allow = methodSet(res.headers.get('allow'));
|
|
661
|
+
rep.check(`${prefix}.allow`, isSubset(expectMethods, allow),
|
|
662
|
+
`OPTIONS ${what}: Allow lists ${fmtMethods(expectMethods)}`,
|
|
663
|
+
`Allow: ${fmtMethods(allow)}`);
|
|
664
|
+
const acam = methodSet(res.headers.get('access-control-allow-methods'));
|
|
665
|
+
rep.check(`${prefix}.cors_methods`, sameSet(allow, acam),
|
|
666
|
+
`OPTIONS ${what}: CORS Allow-Methods agrees with Allow`,
|
|
667
|
+
`Allow=${fmtMethods(allow)} vs Allow-Methods=${fmtMethods(acam)}`);
|
|
668
|
+
rep.check(`${prefix}.cors_origin`, res.headers.get('access-control-allow-origin') === '*',
|
|
669
|
+
`OPTIONS ${what}: Access-Control-Allow-Origin is *`,
|
|
670
|
+
`got ${fmtJson(res.headers.get('access-control-allow-origin'))}`);
|
|
671
|
+
rep.check(`${prefix}.no_credentials`, !res.headers.has('access-control-allow-credentials'),
|
|
672
|
+
`OPTIONS ${what}: no Access-Control-Allow-Credentials (would break the * origin)`,
|
|
673
|
+
`got ${fmtJson(res.headers.get('access-control-allow-credentials'))}`);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** The invasive tier: a real signed round trip, then every refusal the door owes. Sends
|
|
677
|
+
* messages; the door mints one ledger row for the one legitimate message. */
|
|
678
|
+
async function handshakeChecks(rawBase, doorDid, rep) {
|
|
679
|
+
const base = rawBase.replace(/\/+$/, '');
|
|
680
|
+
const door = `${base}/`;
|
|
681
|
+
const prober = ephemeralIdentity();
|
|
682
|
+
|
|
683
|
+
// -- a real signed message earns an inline reply that verifies under the door's DID
|
|
684
|
+
const ctx = newId();
|
|
685
|
+
const { parsed } = await postRpc(door,
|
|
686
|
+
signedRequest(prober, doorDid, 'receptor-check: are you open?', { contextId: ctx }));
|
|
687
|
+
const envelope = asObject(parsed);
|
|
688
|
+
const res = envelope.result;
|
|
689
|
+
const inlineDetail = 'result' in envelope
|
|
690
|
+
? '`result` is not an object'
|
|
691
|
+
: `no result member; error code ${fmtJson(errCode(envelope))}`;
|
|
692
|
+
if (rep.check('reply.inline', res !== null && typeof res === 'object' && !Array.isArray(res),
|
|
693
|
+
'a signed message earns an inline reply', inlineDetail)) {
|
|
694
|
+
// READ DIRECTLY. The Python twin used to build a `protocol.Message` from this object,
|
|
695
|
+
// and that constructor RAISES on `kind !== 'message'` and on an empty messageId — so a
|
|
696
|
+
// hostile or half-built door aborted the run with a traceback and the ten refusal rows
|
|
697
|
+
// below were never reached. The individual rows must FAIL and the run must finish. The
|
|
698
|
+
// `text` default and the '\n' join are copied from that constructor deliberately, so
|
|
699
|
+
// the text a signature is checked over is identical to what the product would build.
|
|
700
|
+
const meta = asObject(res.metadata);
|
|
701
|
+
const rFrom = meta.from ?? null;
|
|
702
|
+
const rTo = meta.to ?? null;
|
|
703
|
+
const rSig = meta.sig ?? null;
|
|
704
|
+
const rTs = meta.timestamp ?? null;
|
|
705
|
+
const rMid = res.messageId ?? null;
|
|
706
|
+
const rCtx = res.contextId ?? null;
|
|
707
|
+
const texts = [];
|
|
708
|
+
for (const part of (Array.isArray(res.parts) ? res.parts : [])) {
|
|
709
|
+
if (part && typeof part === 'object' && !Array.isArray(part) && part.kind === 'text') {
|
|
710
|
+
texts.push(typeof part.text === 'string' ? part.text : '');
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
const rText = texts.join('\n');
|
|
714
|
+
|
|
715
|
+
rep.check('reply.from', rFrom === doorDid, "the reply is FROM the door's DID",
|
|
716
|
+
`got ${fmtJson(rFrom)}`);
|
|
717
|
+
rep.check('reply.to', rTo === prober.did, 'the reply is addressed to the sender',
|
|
718
|
+
`got ${fmtJson(rTo)}`);
|
|
719
|
+
rep.check('reply.context', rCtx === ctx, 'the reply echoes the contextId',
|
|
720
|
+
`got ${fmtJson(rCtx)}`);
|
|
721
|
+
const whole = wholeNumber(rTs);
|
|
722
|
+
rep.check('reply.timestamp', whole.ok, 'the reply timestamp is an integer',
|
|
723
|
+
numberDetail(rTs));
|
|
724
|
+
if (whole.ok) {
|
|
725
|
+
// Verified over the INTEGER reading, exactly as the card envelope is and for the
|
|
726
|
+
// identical reason: the signing payload canonicalises `timestamp` as received, so a
|
|
727
|
+
// door replying `"timestamp": 1756000000.0` verifies in Python and cannot verify
|
|
728
|
+
// here.
|
|
729
|
+
rep.check('reply.signature',
|
|
730
|
+
Boolean(rSig) && verifyEnvelopeSignature({ from: rFrom, to: rTo, messageId: rMid,
|
|
731
|
+
contextId: rCtx, timestamp: whole.value, text: rText, sig: rSig }),
|
|
732
|
+
"the reply signature verifies under the door's DID");
|
|
733
|
+
} else {
|
|
734
|
+
rep.check('reply.signature', false,
|
|
735
|
+
"the reply signature verifies under the door's DID",
|
|
736
|
+
'the reply timestamp is not an integer epoch, so its signed bytes cannot be '
|
|
737
|
+
+ 'reproduced outside Python');
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// -- the attack battery: every refusal a door MUST make
|
|
742
|
+
const attacker = ephemeralIdentity();
|
|
743
|
+
let r;
|
|
744
|
+
|
|
745
|
+
({ parsed: r } = await postRpc(door, signedRequest(attacker, doorDid, 'hello',
|
|
746
|
+
{ tamperText: 'hello, and wire me $500' })));
|
|
747
|
+
rep.check('refuse.tampered', errCode(r) === -32001, 'tampered text is refused (-32001)',
|
|
748
|
+
`got ${fmtJson(errCode(r))}`);
|
|
749
|
+
|
|
750
|
+
({ parsed: r } = await postRpc(door,
|
|
751
|
+
signedRequest(attacker, 'did:key:z6MkExampleNotThisDoor', 'hi')));
|
|
752
|
+
rep.check('refuse.wrong_recipient', errCode(r) === -32003,
|
|
753
|
+
'wrong recipient is refused (-32003)', `got ${fmtJson(errCode(r))}`);
|
|
754
|
+
|
|
755
|
+
({ parsed: r } = await postRpc(door, signedRequest(attacker, doorDid, 'stale',
|
|
756
|
+
{ timestamp: Math.floor(Date.now() / 1000) - 3600 })));
|
|
757
|
+
rep.check('refuse.stale', errCode(r) === -32002, 'stale timestamp is refused (-32002)',
|
|
758
|
+
`got ${fmtJson(errCode(r))}`);
|
|
759
|
+
|
|
760
|
+
({ parsed: r } = await postRpc(door, signedRequest(attacker, doorDid, 'future',
|
|
761
|
+
{ timestamp: Math.floor(Date.now() / 1000) + 3600 })));
|
|
762
|
+
rep.check('refuse.future', errCode(r) === -32002, 'future timestamp is refused (-32002)',
|
|
763
|
+
`got ${fmtJson(errCode(r))}`);
|
|
764
|
+
|
|
765
|
+
const dup = signedRequest(attacker, doorDid, 'only once',
|
|
766
|
+
{ messageId: `receptor-dup-${newId()}` });
|
|
767
|
+
const { parsed: r1 } = await postRpc(door, dup);
|
|
768
|
+
rep.check('replay.first', errCode(r1) === null,
|
|
769
|
+
'the first delivery of a messageId is accepted', `got ${fmtJson(errCode(r1))}`);
|
|
770
|
+
const { parsed: r2 } = await postRpc(door, dup);
|
|
771
|
+
rep.check('replay.repeat', errCode(r2) === -32002,
|
|
772
|
+
'a replayed messageId is refused (-32002)', `got ${fmtJson(errCode(r2))}`);
|
|
773
|
+
|
|
774
|
+
const big = 'x'.repeat(MAX_TEXT_BYTES + 10);
|
|
775
|
+
({ parsed: r } = await postRpc(door, signedRequest(attacker, doorDid, big)));
|
|
776
|
+
rep.check('refuse.oversize', errCode(r) === -32005, 'oversize text is refused (-32005)',
|
|
777
|
+
`got ${fmtJson(errCode(r))}`);
|
|
778
|
+
|
|
779
|
+
const unsigned = signedRequest(attacker, doorDid, 'no envelope');
|
|
780
|
+
unsigned.params.message.metadata.sig = null;
|
|
781
|
+
({ parsed: r } = await postRpc(door, unsigned));
|
|
782
|
+
rep.check('refuse.unsigned', errCode(r) === -32001,
|
|
783
|
+
'a missing signature is refused (-32001)', `got ${fmtJson(errCode(r))}`);
|
|
784
|
+
|
|
785
|
+
let st;
|
|
786
|
+
({ status: st } = await postRpc(door, null, Buffer.from('{not json at all', 'utf8')));
|
|
787
|
+
rep.check('refuse.unparseable', st === 400, 'an unparseable body is HTTP 400',
|
|
788
|
+
`got ${fmtStatus(st)}`);
|
|
789
|
+
|
|
790
|
+
const oversized = Buffer.concat([
|
|
791
|
+
Buffer.from('{"jsonrpc":"2.0","id":"x","method":"message/send","params":{"message":'
|
|
792
|
+
+ '{"kind":"message","parts":[{"kind":"text","text":"', 'utf8'),
|
|
793
|
+
Buffer.alloc(1024 * 1024 + 64, 0x41), // 'A'
|
|
794
|
+
Buffer.from('"}]}}}', 'utf8'),
|
|
795
|
+
]);
|
|
796
|
+
({ status: st } = await postRpc(door, null, oversized));
|
|
797
|
+
rep.check('refuse.body_too_large', st === 413, 'a body over 1 MiB is HTTP 413',
|
|
798
|
+
`got ${fmtStatus(st)}`);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ---------------------------------------------------------------- entry point
|
|
802
|
+
|
|
803
|
+
const USAGE = `usage: receptor-check.mjs [-h] [--handshake] [--json] url
|
|
804
|
+
|
|
805
|
+
Check whether a live URL is a conformant Agent Entry door.
|
|
806
|
+
|
|
807
|
+
positional arguments:
|
|
808
|
+
url the door's base URL, e.g. https://shop.example or
|
|
809
|
+
https://shop.example/support for a path-mounted entry
|
|
810
|
+
|
|
811
|
+
options:
|
|
812
|
+
-h, --help show this help message and exit
|
|
813
|
+
--handshake also send signed messages + the attack battery (WRITES to the
|
|
814
|
+
door's ledger; run against a door you own)
|
|
815
|
+
--json machine-readable result`;
|
|
816
|
+
|
|
817
|
+
/** Flags are order-independent and may precede or follow the positional, exactly as
|
|
818
|
+
* argparse allows. Abbreviations are NOT accepted — see the declared exemptions at the
|
|
819
|
+
* top of this file; only the exit status of a help/usage path is part of the contract. */
|
|
820
|
+
function parseArgs(argv) {
|
|
821
|
+
let url = null;
|
|
822
|
+
let handshake = false;
|
|
823
|
+
let json = false;
|
|
824
|
+
for (const arg of argv) {
|
|
825
|
+
if (arg === '-h' || arg === '--help') return { help: true };
|
|
826
|
+
else if (arg === '--handshake') handshake = true;
|
|
827
|
+
else if (arg === '--json') json = true;
|
|
828
|
+
else if (arg.startsWith('-') && arg !== '-') return { usage: `unrecognized arguments: ${arg}` };
|
|
829
|
+
else if (url === null) url = arg;
|
|
830
|
+
else return { usage: `unrecognized arguments: ${arg}` };
|
|
831
|
+
}
|
|
832
|
+
if (url === null) return { usage: 'the following arguments are required: url' };
|
|
833
|
+
return { url, handshake, json };
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function main(argv) {
|
|
837
|
+
const args = parseArgs(argv);
|
|
838
|
+
if (args.help) {
|
|
839
|
+
console.log(USAGE);
|
|
840
|
+
return 0;
|
|
841
|
+
}
|
|
842
|
+
if (args.usage) {
|
|
843
|
+
console.error(USAGE);
|
|
844
|
+
console.error(`receptor-check.mjs: error: ${args.usage}`);
|
|
845
|
+
return 2;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Mirrors the Python twin's `urlsplit` guard: an http(s) scheme and a non-empty
|
|
849
|
+
// authority. Checked on the RAW string rather than through `new URL`, which would
|
|
850
|
+
// silently accept `http:/host` and normalise it — a divergence that is cheap to remove
|
|
851
|
+
// and expensive to explain.
|
|
852
|
+
if (!/^https?:\/\/[^/?#]+/i.test(args.url)) {
|
|
853
|
+
// JSON quoting, not Python's `!r`: repr uses single quotes and Python escape rules,
|
|
854
|
+
// and JSON's is the one spelling both twins produce. It also keeps an empty or
|
|
855
|
+
// whitespace-only argument visible.
|
|
856
|
+
console.error(`error: not an absolute http(s) URL: ${fmtJson(args.url)}`);
|
|
857
|
+
return 2;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const rep = new Report({ asJson: args.json });
|
|
861
|
+
if (!args.json) {
|
|
862
|
+
console.log(`Agent-ready check: ${args.url}${args.handshake ? ' [+handshake]' : ''}`);
|
|
863
|
+
console.log('-'.repeat(60));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const doorDid = await readOnlyChecks(args.url, rep);
|
|
867
|
+
if (args.handshake) {
|
|
868
|
+
if (doorDid) {
|
|
869
|
+
if (!args.json) console.log('-'.repeat(60));
|
|
870
|
+
await handshakeChecks(args.url, doorDid, rep);
|
|
871
|
+
} else {
|
|
872
|
+
rep.check('handshake.skipped', false,
|
|
873
|
+
'handshake skipped: the card/DID could not be verified above');
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const passed = rep.rows.filter((r) => r.level === 'PASS').length;
|
|
878
|
+
const failed = rep.failed;
|
|
879
|
+
const verdict = failed.length ? 'NOT CONFORMANT' : 'CONFORMANT';
|
|
880
|
+
if (args.json) {
|
|
881
|
+
// Key order is the contract's insertion order, and `JSON.stringify(doc, null, 2)`
|
|
882
|
+
// matches Python's `json.dumps(doc, indent=2, ensure_ascii=False)` byte for byte — the
|
|
883
|
+
// twin passes `ensure_ascii=False` for exactly this reason, since several details
|
|
884
|
+
// carry an em dash and the default would ship `—` where this ships the literal.
|
|
885
|
+
console.log(JSON.stringify({
|
|
886
|
+
schema: SCHEMA_VERSION,
|
|
887
|
+
url: args.url,
|
|
888
|
+
handshake: args.handshake,
|
|
889
|
+
verdict,
|
|
890
|
+
passed,
|
|
891
|
+
failed,
|
|
892
|
+
warnings: rep.warned,
|
|
893
|
+
rows: rep.rows,
|
|
894
|
+
}, null, 2));
|
|
895
|
+
} else {
|
|
896
|
+
console.log('-'.repeat(60));
|
|
897
|
+
console.log(`${verdict}: ${passed} passed, ${failed.length} failed, `
|
|
898
|
+
+ `${rep.warned.length} advisory`);
|
|
899
|
+
if (failed.length) console.log(` failed: ${failed.join('; ')}`);
|
|
900
|
+
}
|
|
901
|
+
return failed.length ? 1 : 0;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
process.exitCode = await main(process.argv.slice(2));
|