@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.
@@ -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));