@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
|
Binary file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 740 300" role="img">
|
|
3
|
+
<title>Keep WebMCP. Add a door beside it if the agent is alone.</title>
|
|
4
|
+
<rect width="740" height="300" fill="#fafafa"/>
|
|
5
|
+
<text x="24" y="26" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#666">A person in the tab keeps WebMCP. An agent alone can become a customer at the door beside it.</text>
|
|
6
|
+
<rect x="20" y="44" width="340" height="140" rx="10" fill="#111"/>
|
|
7
|
+
<text x="190" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#fff">A person is in the tab</text>
|
|
8
|
+
<text x="190" y="114" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#ddd">Keep WebMCP</text>
|
|
9
|
+
<text x="190" y="144" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#bbb">the page is theirs</text>
|
|
10
|
+
<rect x="380" y="44" width="340" height="140" rx="10" fill="#fff" stroke="#111"/>
|
|
11
|
+
<text x="550" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#111">The agent is alone</text>
|
|
12
|
+
<text x="550" y="114" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#555">Add a door beside WebMCP</text>
|
|
13
|
+
<text x="550" y="144" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#111">they can become a customer</text>
|
|
14
|
+
<rect x="20" y="200" width="700" height="80" rx="10" fill="#fff" stroke="#111"/>
|
|
15
|
+
<text x="370" y="232" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" font-weight="700" fill="#111">GET /.well-known/agent-card.json 200</text>
|
|
16
|
+
<text x="370" y="260" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" font-weight="700" fill="#111">POST / message/send 200 signed reply</text>
|
|
17
|
+
</svg>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 740 280" role="img" aria-labelledby="t d">
|
|
2
|
+
<title id="t">x402 teaches with HTTP 402; Agent Entry teaches on GET 200</title>
|
|
3
|
+
<desc id="d">x402: request the resource, receive HTTP 402 and PAYMENT-REQUIRED, retry with PAYMENT-SIGNATURE. Agent Entry: GET the Agent Card as 200 before the knock, then POST already correct, answered 200. A refusal is JSON-RPC on HTTP 200, not HTTP 402.</desc>
|
|
4
|
+
<rect width="740" height="280" fill="#fafafa"/>
|
|
5
|
+
<rect x="20" y="20" width="340" height="240" rx="10" fill="#fff" stroke="#111"/>
|
|
6
|
+
<text x="190" y="48" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#111">x402 (HTTP)</text>
|
|
7
|
+
<text x="190" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">1. ask for the resource</text>
|
|
8
|
+
<text x="190" y="108" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">2. HTTP 402 + PAYMENT-REQUIRED</text>
|
|
9
|
+
<text x="190" y="136" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">3. retry with PAYMENT-SIGNATURE</text>
|
|
10
|
+
<text x="190" y="164" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#333">4. 200 if paid</text>
|
|
11
|
+
<text x="190" y="204" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#666">The error is how the client learns</text>
|
|
12
|
+
<text x="190" y="224" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#666">what to do next.</text>
|
|
13
|
+
<rect x="380" y="20" width="340" height="240" rx="10" fill="#111"/>
|
|
14
|
+
<text x="550" y="48" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="15" font-weight="700" fill="#fff">Agent Entry</text>
|
|
15
|
+
<text x="550" y="80" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">1. GET agent-card.json (200)</text>
|
|
16
|
+
<text x="550" y="108" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">2. read how to sign (AE-8)</text>
|
|
17
|
+
<text x="550" y="136" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">3. POST message/send (200)</text>
|
|
18
|
+
<text x="550" y="164" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="13" fill="#fff">4. signed reply, same request</text>
|
|
19
|
+
<text x="550" y="204" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#ddd">The POST is not a retry after an error.</text>
|
|
20
|
+
<text x="550" y="224" text-anchor="middle" font-family="ui-sans-serif, system-ui, sans-serif" font-size="12" fill="#ddd">A refusal is JSON-RPC on HTTP 200.</text>
|
|
21
|
+
</svg>
|
package/muretai-agent-entry.mjs
CHANGED
|
@@ -761,7 +761,7 @@ export function signEnvelope(seedHex, fields) {
|
|
|
761
761
|
/** Question 1 ONLY: does `sig` verify under the key DERIVED FROM `from`, over the six
|
|
762
762
|
* fields? Total and fail-closed — a malformed DID, bad base64, unrenderable number or
|
|
763
763
|
* short signature all answer false rather than throwing. */
|
|
764
|
-
export function verifyEnvelopeSignature(fields) {
|
|
764
|
+
export function verifyEnvelopeSignature(fields, opts = {}) {
|
|
765
765
|
try {
|
|
766
766
|
if (!fields || typeof fields !== 'object') return false;
|
|
767
767
|
// `from` (the key) and `sig` must be there; `to` may be the EMPTY STRING — that is how
|
|
@@ -772,7 +772,11 @@ export function verifyEnvelopeSignature(fields) {
|
|
|
772
772
|
assertEncodable(payload);
|
|
773
773
|
const sig = Buffer.from(String(fields.sig), 'base64');
|
|
774
774
|
if (sig.length !== 64) return false;
|
|
775
|
-
|
|
775
|
+
// Payload `from` stays the root DID. `signerDid` is the verifying key when the
|
|
776
|
+
// sender enrolled a delegated op-key (T142 A2); omitted → `from` (the un-enrolled
|
|
777
|
+
// / this-door-reply case).
|
|
778
|
+
const signerDid = opts.signerDid || fields.from;
|
|
779
|
+
return verifyBytes(publicKeyFromDid(signerDid), sig, Buffer.from(payload, 'utf8'));
|
|
776
780
|
} catch {
|
|
777
781
|
return false;
|
|
778
782
|
}
|
|
@@ -805,12 +809,82 @@ export function verifyEnvelope(fields, opts = {}) {
|
|
|
805
809
|
const recipient = opts.recipientDid ?? opts.me ?? fields.recipientDid ?? null;
|
|
806
810
|
if (typeof recipient !== 'string' || !recipient) return false;
|
|
807
811
|
if (fields.to !== recipient) return false;
|
|
808
|
-
return verifyEnvelopeSignature(fields);
|
|
812
|
+
return verifyEnvelopeSignature(fields, opts);
|
|
809
813
|
} catch {
|
|
810
814
|
return false;
|
|
811
815
|
}
|
|
812
816
|
}
|
|
813
817
|
|
|
818
|
+
// ================================================================ KeyState (inline op-key, T142 A2)
|
|
819
|
+
//
|
|
820
|
+
// A persisted muretai identity enrolls a genesis KeyState at birth and signs
|
|
821
|
+
// messages with a delegated op-key while `from` stays the root DID. This door
|
|
822
|
+
// used to verify under `from` only, which refused every default-enrolled
|
|
823
|
+
// visitor. Resolve the op-key from a valid inline KeyState (root-signed, pin
|
|
824
|
+
// to the claimed `from`); a missing or invalid record falls back to `from`.
|
|
825
|
+
// No directory, no pin store — first-contact, same as the Python twin.
|
|
826
|
+
|
|
827
|
+
const KEYSTATE_TYP = 'muretai/keystate/1';
|
|
828
|
+
// Lockstep with shared/keystate._FIELDS_V1 / _signed_names: presence of
|
|
829
|
+
// encPubPqHash (even "") selects the T142 list. A V1-only list made every
|
|
830
|
+
// default-enrolled visitor fail verify and fall back to the root DID, so
|
|
831
|
+
// the op-signed envelope was -32001 at this door only (Python twin accepted).
|
|
832
|
+
const KEYSTATE_FIELDS_V1 = [
|
|
833
|
+
'typ', 'rootDid', 'epoch', 'rootKey', 'rootNextHash',
|
|
834
|
+
'opDid', 'opNextHash', 'encPub', 'encNextHash',
|
|
835
|
+
'guardiansHash', 'revokedOps', 'notBefore', 'notAfter', 'ts',
|
|
836
|
+
];
|
|
837
|
+
function keystateSignedNames(ks) {
|
|
838
|
+
if (ks && Object.prototype.hasOwnProperty.call(ks, 'encPubPqHash')) {
|
|
839
|
+
return KEYSTATE_FIELDS_V1.concat(['encPubPqHash']);
|
|
840
|
+
}
|
|
841
|
+
return KEYSTATE_FIELDS_V1;
|
|
842
|
+
}
|
|
843
|
+
const MAX_KEYSTATE_EPOCH = 2147483647; // 2**31 - 1, shared/keystate.MAX_EPOCH
|
|
844
|
+
|
|
845
|
+
export function verifyKeystate(ks, expectedRootDid, now) {
|
|
846
|
+
try {
|
|
847
|
+
if (!ks || typeof ks !== 'object') return false;
|
|
848
|
+
if (ks.typ !== KEYSTATE_TYP) return false;
|
|
849
|
+
const rootDid = ks.rootDid;
|
|
850
|
+
const epoch = ks.epoch;
|
|
851
|
+
if (!Number.isInteger(epoch) || epoch < 0 || epoch > MAX_KEYSTATE_EPOCH) return false;
|
|
852
|
+
if (expectedRootDid != null && rootDid !== expectedRootDid) return false;
|
|
853
|
+
const didKey = publicKeyHexFromDid(rootDid);
|
|
854
|
+
if (ks.rootKey !== didKey) return false;
|
|
855
|
+
const sig = Buffer.from(String(ks.sig), 'base64');
|
|
856
|
+
if (sig.length !== 64) return false;
|
|
857
|
+
const payloadObj = {};
|
|
858
|
+
for (const k of keystateSignedNames(ks)) {
|
|
859
|
+
payloadObj[k] = ks[k] === undefined ? null : ks[k];
|
|
860
|
+
}
|
|
861
|
+
const payload = canonicalJSON(payloadObj);
|
|
862
|
+
const pub = Buffer.from(String(ks.rootKey), 'hex');
|
|
863
|
+
if (pub.length !== 32) return false;
|
|
864
|
+
if (!verifyBytes(pub, sig, Buffer.from(payload, 'utf8'))) return false;
|
|
865
|
+
if (now != null) {
|
|
866
|
+
const nb = ks.notBefore || 0;
|
|
867
|
+
const na = ks.notAfter;
|
|
868
|
+
if (now < nb) return false;
|
|
869
|
+
if (na != null && now > na) return false;
|
|
870
|
+
}
|
|
871
|
+
return true;
|
|
872
|
+
} catch {
|
|
873
|
+
return false;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export function resolveOpDid(rootDid, inlineKeystate, now) {
|
|
878
|
+
if (inlineKeystate && verifyKeystate(inlineKeystate, rootDid, now)) {
|
|
879
|
+
const burned = Array.isArray(inlineKeystate.revokedOps)
|
|
880
|
+
? inlineKeystate.revokedOps : [];
|
|
881
|
+
const op = inlineKeystate.opDid || rootDid;
|
|
882
|
+
if (burned.includes(op)) return rootDid;
|
|
883
|
+
return op;
|
|
884
|
+
}
|
|
885
|
+
return rootDid;
|
|
886
|
+
}
|
|
887
|
+
|
|
814
888
|
// ================================================================ Web Bot Auth (RFC 9421 subset, verify-only) — T107
|
|
815
889
|
//
|
|
816
890
|
// The INBOUND half only: did the holder of one of the keys this entry was GIVEN sign
|
|
@@ -1770,6 +1844,43 @@ function isThenable(v) {
|
|
|
1770
1844
|
return v !== null && typeof v === 'object' && typeof v.then === 'function';
|
|
1771
1845
|
}
|
|
1772
1846
|
|
|
1847
|
+
/**
|
|
1848
|
+
* `then1(x, f)` — apply `f` to `x`, awaiting `x` only if it is a promise.
|
|
1849
|
+
*
|
|
1850
|
+
* This is what lets ONE verification ladder serve both the synchronous in-memory state and
|
|
1851
|
+
* an asynchronous external `store` (T105), instead of the ladder being written twice and
|
|
1852
|
+
* drifting. Writing it twice was the alternative considered and rejected: the ladder is the
|
|
1853
|
+
* security-critical path, its steps are ORDER-DEPENDENT, and two copies of an ordered
|
|
1854
|
+
* security argument is how one of them silently stops matching the other.
|
|
1855
|
+
*
|
|
1856
|
+
* The sync branch is not an optimisation — it is the contract. With no store, every step
|
|
1857
|
+
* returns a plain value, the ladder returns a plain value, and `handleRequest` (the
|
|
1858
|
+
* synchronous entry point) keeps working exactly as it always has.
|
|
1859
|
+
*/
|
|
1860
|
+
function then1(x, f) {
|
|
1861
|
+
return isThenable(x) ? x.then(f) : f(x);
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
/**
|
|
1865
|
+
* Validate a caller-supplied `store` and return it unchanged.
|
|
1866
|
+
*
|
|
1867
|
+
* Checked ONCE at construction rather than per call, and it throws rather than filling in a
|
|
1868
|
+
* default: a store missing `seenMessage` would otherwise silently disable replay protection,
|
|
1869
|
+
* and the failure would appear as "the door works" until somebody replayed a message.
|
|
1870
|
+
*/
|
|
1871
|
+
function asStore(store) {
|
|
1872
|
+
const REQUIRED = ['seenMessage', 'getAccount', 'putAccount', 'getDeviceOwner',
|
|
1873
|
+
'putDeviceOwner'];
|
|
1874
|
+
const missing = REQUIRED.filter((m) => typeof store[m] !== 'function');
|
|
1875
|
+
if (missing.length) {
|
|
1876
|
+
throw new TypeError(
|
|
1877
|
+
`createAgentEntry: store is missing ${missing.join(', ')}. A store must implement all `
|
|
1878
|
+
+ `of ${REQUIRED.join(', ')} — a partial store would disable a security rule silently `
|
|
1879
|
+
+ 'rather than loudly.');
|
|
1880
|
+
}
|
|
1881
|
+
return store;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1773
1884
|
// ---------------------------------------------------------------- baseUrl canonicalisation
|
|
1774
1885
|
|
|
1775
1886
|
/** RFC 3986 `pchar` plus '/' — the only characters an accepted path may carry. Everything
|
|
@@ -2385,6 +2496,7 @@ export function createAgentEntry({
|
|
|
2385
2496
|
howToUrl = FIRST_KNOCK_URL,
|
|
2386
2497
|
observer = null,
|
|
2387
2498
|
wbaVerifiers = null,
|
|
2499
|
+
store = null,
|
|
2388
2500
|
} = {}) {
|
|
2389
2501
|
if (!seedHex) throw new TypeError('createAgentEntry: seedHex is required');
|
|
2390
2502
|
if (!baseUrl) throw new TypeError('createAgentEntry: baseUrl is required (it is signed into the card)');
|
|
@@ -2531,6 +2643,53 @@ export function createAgentEntry({
|
|
|
2531
2643
|
// Unlike the ledger it is READ on every message, so only a real store can carry it.
|
|
2532
2644
|
const deviceOwner = new Map();
|
|
2533
2645
|
const replay = new ReplayGuard();
|
|
2646
|
+
|
|
2647
|
+
// ---------------------------------------------------------------- the state seam (T105)
|
|
2648
|
+
//
|
|
2649
|
+
// WHY. The three structures above live in CLOSURE MEMORY, and a serverless instance keeps
|
|
2650
|
+
// none of them between requests. The round-trip shape suits those platforms perfectly —
|
|
2651
|
+
// one signed POST in, one signed reply out, nothing to keep awake — so what blocks them is
|
|
2652
|
+
// not the shape but the STATE. Losing `replay` re-opens every message inside the freshness
|
|
2653
|
+
// window to replay; losing `deviceOwner` resets the no-re-ownership rule to
|
|
2654
|
+
// trust-on-first-use at every cold start.
|
|
2655
|
+
//
|
|
2656
|
+
// `store` is an OPTIONAL duck-typed object whose five methods may each return a value or a
|
|
2657
|
+
// promise:
|
|
2658
|
+
//
|
|
2659
|
+
// seenMessage(messageId, ttlSeconds) -> bool TRUE when the id was NEW (and is now
|
|
2660
|
+
// remembered). The test and the remember
|
|
2661
|
+
// MUST be atomic in the backing store.
|
|
2662
|
+
// getAccount(did) -> row | null
|
|
2663
|
+
// putAccount(did, row) -> void row === null DELETES the row.
|
|
2664
|
+
// getDeviceOwner(deviceDid)-> ownerDid | null
|
|
2665
|
+
// putDeviceOwner(dev, own) -> void
|
|
2666
|
+
//
|
|
2667
|
+
// Rate counters are deliberately NOT in this interface, and that is the approved design,
|
|
2668
|
+
// not an omission: a bound that costs a store write per request is its own denial of
|
|
2669
|
+
// service, and losing one fails open for a single minute — a bounded loss, unlike a lost
|
|
2670
|
+
// replay set or a lost pin.
|
|
2671
|
+
//
|
|
2672
|
+
// WITH NO STORE the default below is SYNCHRONOUS and wraps exactly the Maps above, so the
|
|
2673
|
+
// no-store path stays byte-identical and `handleRequest` (the sync entry point) keeps
|
|
2674
|
+
// working. With a store the message path returns a promise, which `route()` already
|
|
2675
|
+
// propagates and `handleRequestAsync` already awaits.
|
|
2676
|
+
const memoryStore = {
|
|
2677
|
+
seenMessage: (messageId) => replay.checkAndRemember(messageId),
|
|
2678
|
+
getAccount: (did) => ledger.get(did) ?? null,
|
|
2679
|
+
putAccount: (did, row) => {
|
|
2680
|
+
if (row === null) { ledger.delete(did); return; }
|
|
2681
|
+
ledger.set(did, row);
|
|
2682
|
+
while (ledger.size > maxAccounts) {
|
|
2683
|
+
const oldest = ledger.keys().next();
|
|
2684
|
+
if (oldest.done) break;
|
|
2685
|
+
ledger.delete(oldest.value);
|
|
2686
|
+
}
|
|
2687
|
+
},
|
|
2688
|
+
getDeviceOwner: (deviceDid) => deviceOwner.get(deviceDid) ?? null,
|
|
2689
|
+
putDeviceOwner: (deviceDid, ownerDid) => { deviceOwner.set(deviceDid, ownerDid); },
|
|
2690
|
+
};
|
|
2691
|
+
const state = store ? asStore(store) : memoryStore;
|
|
2692
|
+
const storeIsExternal = Boolean(store);
|
|
2534
2693
|
const anonRate = new RateBound(anonRatePerMin);
|
|
2535
2694
|
// A tier is ON unless its ceiling is a non-positive or non-finite number. Constructed
|
|
2536
2695
|
// rather than clamped, so "disabled" is one absent object and never a bound of 0 — which
|
|
@@ -2726,25 +2885,24 @@ export function createAgentEntry({
|
|
|
2726
2885
|
return sigEnvelope;
|
|
2727
2886
|
}
|
|
2728
2887
|
|
|
2888
|
+
/** Record contact from an account. Returns the row (or a promise of it, with a store). */
|
|
2729
2889
|
function noteContact(accountDid) {
|
|
2730
2890
|
const now = nowEpoch();
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
const
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
}
|
|
2747
|
-
return fresh;
|
|
2891
|
+
return then1(state.getAccount(accountDid), (row) => {
|
|
2892
|
+
if (row) {
|
|
2893
|
+
// A store hands back a COPY, so the row must be written home again. The in-memory
|
|
2894
|
+
// store hands back the live object and the write is a no-op re-set of the same
|
|
2895
|
+
// reference — one code path, correct for both.
|
|
2896
|
+
const updated = { ...row, messages: (row.messages || 0) + 1, last_seen: now };
|
|
2897
|
+
return then1(state.putAccount(accountDid, updated), () => updated);
|
|
2898
|
+
}
|
|
2899
|
+
// FIRST CONTACT IS ACCOUNT CREATION. There is no signup form: the sender proved
|
|
2900
|
+
// control of a device key one line above, which is strictly more than an email link.
|
|
2901
|
+
// The row is keyed by the ACCOUNT (the owner when bound), so sibling devices are one
|
|
2902
|
+
// customer.
|
|
2903
|
+
const fresh = { first_seen: now, last_seen: now, messages: 1 };
|
|
2904
|
+
return then1(state.putAccount(accountDid, fresh), () => fresh);
|
|
2905
|
+
});
|
|
2748
2906
|
}
|
|
2749
2907
|
|
|
2750
2908
|
/** When a device that ALREADY has an unbound ledger row first proves its owner, move that
|
|
@@ -2752,13 +2910,19 @@ export function createAgentEntry({
|
|
|
2752
2910
|
* resolves to the device DID and must not merge, or stripping a binding would become a
|
|
2753
2911
|
* way to read the owner's history. */
|
|
2754
2912
|
function foldDeviceIntoOwner(deviceDid, ownerDid) {
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2913
|
+
return then1(state.getAccount(deviceDid), (devRow) => {
|
|
2914
|
+
if (!devRow) return undefined;
|
|
2915
|
+
return then1(state.putAccount(deviceDid, null), () =>
|
|
2916
|
+
then1(state.getAccount(ownerDid), (ownerRow) => {
|
|
2917
|
+
if (!ownerRow) return state.putAccount(ownerDid, devRow);
|
|
2918
|
+
const merged = {
|
|
2919
|
+
...ownerRow,
|
|
2920
|
+
messages: (ownerRow.messages || 0) + (devRow.messages || 0),
|
|
2921
|
+
first_seen: Math.min(ownerRow.first_seen, devRow.first_seen),
|
|
2922
|
+
};
|
|
2923
|
+
return state.putAccount(ownerDid, merged);
|
|
2924
|
+
}));
|
|
2925
|
+
});
|
|
2762
2926
|
}
|
|
2763
2927
|
|
|
2764
2928
|
/**
|
|
@@ -2806,17 +2970,18 @@ export function createAgentEntry({
|
|
|
2806
2970
|
if (!verifyDeviceBindingV2(binding, { now, expectedDeviceDid: from })) {
|
|
2807
2971
|
return { ok: false, reason: 'device binding does not verify' };
|
|
2808
2972
|
}
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2973
|
+
return then1(state.getDeviceOwner(from), (pinned) => {
|
|
2974
|
+
if (pinned !== null && pinned !== undefined && pinned !== rootDid) {
|
|
2975
|
+
return { ok: false, reason:
|
|
2976
|
+
'device is already bound to a different owner (a device DID is never re-owned '
|
|
2977
|
+
+ '— a new owner means a new device key)' };
|
|
2978
|
+
}
|
|
2979
|
+
if (pinned === null || pinned === undefined) {
|
|
2980
|
+
return then1(state.putDeviceOwner(from, rootDid), () =>
|
|
2981
|
+
then1(foldDeviceIntoOwner(from, rootDid), () => ({ ok: true, account: rootDid })));
|
|
2982
|
+
}
|
|
2983
|
+
return { ok: true, account: rootDid };
|
|
2984
|
+
});
|
|
2820
2985
|
}
|
|
2821
2986
|
|
|
2822
2987
|
/** The FROZEN backend-handoff shape (agent/webhookwake.py::_envelope). The site's own
|
|
@@ -3097,16 +3262,19 @@ export function createAgentEntry({
|
|
|
3097
3262
|
`the anonymous lane is limited to ${anonRatePerMin} replies per minute — `
|
|
3098
3263
|
+ 'sign your message to lift the bound');
|
|
3099
3264
|
}
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3265
|
+
return then1(state.seenMessage(msg.messageId, REPLAY_TTL_S), (fresh) => {
|
|
3266
|
+
if (!fresh) {
|
|
3267
|
+
return rpcError(reqId, ERRORS.REPLAY_REJECTED,
|
|
3268
|
+
'duplicate messageId (replay) detected');
|
|
3269
|
+
}
|
|
3270
|
+
// T107: the interesting case — an anonymous inquiry whose TRANSPORT a known key
|
|
3271
|
+
// signed. `verified` STAYS false (the WBA signature covers @authority +
|
|
3272
|
+
// signature-agent, not the text), no ledger row is minted (the header set is a
|
|
3273
|
+
// bearer credential and replayable while it lives), and the anon rate bound
|
|
3274
|
+
// above already applied. Identify, don't enrol.
|
|
3275
|
+
return respond(backendEnvelope(msg, { verified: false, peerDid: null,
|
|
3276
|
+
wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, '');
|
|
3277
|
+
});
|
|
3110
3278
|
}
|
|
3111
3279
|
// 5. addressed to someone else. Checked BEFORE decoding `from`, so a junk DID in a
|
|
3112
3280
|
// misaddressed message never reaches the base58 decoder.
|
|
@@ -3131,12 +3299,13 @@ export function createAgentEntry({
|
|
|
3131
3299
|
if (!Number.isSafeInteger(ts) || Math.abs(nowEpoch() - ts) > CLOCK_WINDOW_S) {
|
|
3132
3300
|
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'timestamp out of range (clock skew or replay)');
|
|
3133
3301
|
}
|
|
3134
|
-
// 7. the signature itself
|
|
3135
|
-
//
|
|
3302
|
+
// 7. the signature itself. Payload `from` is the root DID; the verifying key is
|
|
3303
|
+
// the delegated op-key when a valid inline KeyState is attached (T142 A2).
|
|
3136
3304
|
const messageId = msg.messageId;
|
|
3137
3305
|
const fields = { from, to, messageId, contextId: msg.contextId ?? null,
|
|
3138
3306
|
timestamp: ts, text, sig };
|
|
3139
|
-
|
|
3307
|
+
const signerDid = resolveOpDid(from, meta.keystate, ts);
|
|
3308
|
+
if (!verifyEnvelope(fields, { recipientDid: did, signerDid })) {
|
|
3140
3309
|
return rpcError(reqId, ERRORS.UNAUTHENTICATED, 'signature does not match');
|
|
3141
3310
|
}
|
|
3142
3311
|
// 8. duplicate messageId inside the replay window — AFTER the verify, and the ORDER is the
|
|
@@ -3148,40 +3317,54 @@ export function createAgentEntry({
|
|
|
3148
3317
|
// rule the signed lane's ceiling already follows one step below, for the same reason; it
|
|
3149
3318
|
// was simply never applied here. The cost of the swap is one Ed25519 verify spent on a
|
|
3150
3319
|
// replayed VALID message, which an attacker must first have obtained.
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3320
|
+
return then1(state.seenMessage(messageId, REPLAY_TTL_S), (fresh) => {
|
|
3321
|
+
if (!fresh) {
|
|
3322
|
+
return rpcError(reqId, ERRORS.REPLAY_REJECTED, 'duplicate messageId (replay) detected');
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
// 9. T102 account layer. An OPTIONAL countersigned v2 binding collapses an owner's
|
|
3326
|
+
// device DIDs to ONE account; a present-but-INVALID binding fails closed with the
|
|
3327
|
+
// SAME UNAUTHENTICATED code (never a silent downgrade to unbound). Absent → the
|
|
3328
|
+
// device DID.
|
|
3329
|
+
return then1(resolveAccount(meta.binding, from), (acct) => {
|
|
3330
|
+
if (!acct.ok) return rpcError(reqId, ERRORS.UNAUTHENTICATED, acct.reason);
|
|
3331
|
+
const account = acct.account;
|
|
3332
|
+
const ownerDid = account !== from ? account : null;
|
|
3333
|
+
|
|
3334
|
+
// 10. THE SIGNED LANE'S CEILING. Here and not earlier: before the signature a
|
|
3335
|
+
// stranger could spend somebody else's budget by naming them, and before
|
|
3336
|
+
// `resolveAccount` an owner's devices would each get their own. Here and not
|
|
3337
|
+
// later: a refused flood must grow neither the ledger nor whatever the
|
|
3338
|
+
// responder costs.
|
|
3339
|
+
// PER-ACCOUNT FIRST, deliberately — one loud peer is then stopped by ITS OWN
|
|
3340
|
+
// window without drawing down the shared one, so it cannot starve everybody
|
|
3341
|
+
// else on its way to being refused. Neither refusal names its ceiling: a
|
|
3342
|
+
// published number is a calibration table telling a flood exactly how many keys
|
|
3343
|
+
// to mint.
|
|
3344
|
+
//
|
|
3345
|
+
// These two bounds stay IN PROCESS even with an external store, and that is the
|
|
3346
|
+
// approved design rather than an omission: a ceiling that costs a store write
|
|
3347
|
+
// per request is its own denial of service, and losing a counter fails open for
|
|
3348
|
+
// one minute — bounded, unlike a lost replay set or a lost device pin. A
|
|
3349
|
+
// serverless deployment therefore gets its ceiling per instance; put a real one
|
|
3350
|
+
// at the edge if that matters.
|
|
3351
|
+
if (signedAccountRate && !signedAccountRate.allow(account)) {
|
|
3352
|
+
return rpcError(reqId, ERRORS.RATE_LIMITED,
|
|
3353
|
+
'you are sending faster than this door answers — slow down and retry');
|
|
3354
|
+
}
|
|
3355
|
+
if (signedTotalRate && !signedTotalRate.allow()) {
|
|
3356
|
+
return rpcError(reqId, ERRORS.RATE_LIMITED,
|
|
3357
|
+
'this entry is at its ceiling right now — retry shortly');
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
return then1(noteContact(account), () =>
|
|
3361
|
+
// T107: `wba_did` may legitimately differ from `peer_did` (the transport signer
|
|
3362
|
+
// vs the message signer) — both facts are honest, and the schema says which is
|
|
3363
|
+
// which.
|
|
3364
|
+
respond(backendEnvelope(msg, { verified: true, peerDid: from, ownerDid,
|
|
3365
|
+
wbaDid: wbaIdentify(reqHeaders) }), reqId, msg, from));
|
|
3366
|
+
});
|
|
3367
|
+
});
|
|
3185
3368
|
}
|
|
3186
3369
|
|
|
3187
3370
|
/** Hand the envelope to the watcher, and make sure it can cost nothing.
|
|
@@ -3419,8 +3602,14 @@ export function createAgentEntry({
|
|
|
3419
3602
|
function handleRequest(method, path, headers, bodyBuffer) {
|
|
3420
3603
|
const out = route(method, path, bodyBuffer, headers);
|
|
3421
3604
|
if (isThenable(out)) {
|
|
3422
|
-
|
|
3423
|
-
|
|
3605
|
+
// Two different causes, and an operator can only fix the one they are told about.
|
|
3606
|
+
// A `store` makes EVERY message path async by construction, so saying "responder is
|
|
3607
|
+
// async" to somebody who passed a synchronous responder and a KV store would send
|
|
3608
|
+
// them looking in the wrong place entirely.
|
|
3609
|
+
return rpcError(null, ERRORS.INTERNAL_ERROR, storeIsExternal
|
|
3610
|
+
? 'this entry has an external store, so every message is answered asynchronously — '
|
|
3611
|
+
+ 'serve it through listen()/handleRequestAsync()'
|
|
3612
|
+
: 'responder is async — serve this agent entry through listen()/handleRequestAsync()');
|
|
3424
3613
|
}
|
|
3425
3614
|
return out;
|
|
3426
3615
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@muretai/agent-entry",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.11.0",
|
|
4
|
+
"description": "Agents already visit your site. Give them a way to become a customer: know who knocked, remember them next time, answer in the same request. Zero dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "muretai-agent-entry.mjs",
|
|
7
7
|
"exports": {
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
"muretai-agent-entry.mjs",
|
|
12
12
|
"conformance/",
|
|
13
13
|
"examples/server.mjs",
|
|
14
|
+
"diagrams/",
|
|
15
|
+
"scripts/distill/",
|
|
14
16
|
"LICENSE",
|
|
15
17
|
"spec/",
|
|
16
18
|
"README.md"
|
|
@@ -20,7 +22,10 @@
|
|
|
20
22
|
},
|
|
21
23
|
"scripts": {
|
|
22
24
|
"test": "node conformance/run.mjs",
|
|
23
|
-
"example": "node examples/server.mjs"
|
|
25
|
+
"example": "node examples/server.mjs",
|
|
26
|
+
"distill": "node scripts/distill/loop.mjs",
|
|
27
|
+
"distill:record": "node scripts/distill/record.mjs",
|
|
28
|
+
"distill:measure": "node scripts/distill/measure.mjs"
|
|
24
29
|
},
|
|
25
30
|
"keywords": [
|
|
26
31
|
"ai-agent",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Local feedback loop (this machine only)
|
|
2
|
+
|
|
3
|
+
Turn knocks you already answered into a **proposed** `skills[]` menu, and
|
|
4
|
+
measure whether that menu would have made the next visitor’s first knock
|
|
5
|
+
useful.
|
|
6
|
+
|
|
7
|
+
Nothing here uploads visitor text, DIDs, or traces. The Distiller is not
|
|
8
|
+
imported by `muretai-agent-entry.mjs`.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
npm run distill # fixtures → skills.json → holdout measure
|
|
12
|
+
npm run distill -- --m0 # also run the door conformance suite
|
|
13
|
+
npm run distill:record # print the observer snippet
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Record (optional)
|
|
17
|
+
|
|
18
|
+
`createAgentEntry({ observer: fileSink() })` appends POST outcomes to
|
|
19
|
+
`var/traces.jsonl` (gitignored). Card/notice GETs are dropped. DIDs are
|
|
20
|
+
not written. The door still discards the observer’s return — a full disk
|
|
21
|
+
cannot change a signed reply.
|
|
22
|
+
|
|
23
|
+
## Admission
|
|
24
|
+
|
|
25
|
+
Publish the proposed menu onto *your* card only when:
|
|
26
|
+
|
|
27
|
+
1. holdout first-knock useful rises
|
|
28
|
+
2. emptying the Distiller kills that lift
|
|
29
|
+
3. every proposed `examples[]` entry is answerable by *your* responder
|
|
30
|
+
4. the door conformance suite is still green
|
|
31
|
+
|
|
32
|
+
This package never patches a stranger’s `skills[]`.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Traces → generated/skills.json + rules.json.
|
|
4
|
+
* Empty Distiller: --empty (mutation). Never writes the running card.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
7
|
+
import { dirname, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { inferRules, loadCases, proposedSkills, readJsonl, tracesFromCases, respond } from './lib.mjs';
|
|
10
|
+
|
|
11
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const ROOT = resolve(HERE, '../..');
|
|
13
|
+
const OUT = resolve(ROOT, 'generated');
|
|
14
|
+
|
|
15
|
+
function localTraces(path) {
|
|
16
|
+
return readJsonl(path).map((row, i) => {
|
|
17
|
+
const text = row.text || row.doc?.text || '';
|
|
18
|
+
const env = respond(text);
|
|
19
|
+
return {
|
|
20
|
+
id: row.id || `local-${i + 1}`,
|
|
21
|
+
intent: /\b(book|reserve|slot)\b/i.test(text) ? 'book' : (/\b(price|cost|how much)\b/i.test(text) ? 'price' : 'other'),
|
|
22
|
+
text,
|
|
23
|
+
tag: env.tag,
|
|
24
|
+
environment_useful: env.useful,
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const empty = process.argv.includes('--empty');
|
|
30
|
+
const tracesPath = process.env.AE_TRACES || resolve(ROOT, 'var/traces.jsonl');
|
|
31
|
+
const train = tracesFromCases(loadCases(), 'train');
|
|
32
|
+
const local = existsSync(tracesPath) ? localTraces(tracesPath) : [];
|
|
33
|
+
const rules = empty ? { empty: true, examples: [], evidence: [] } : inferRules([...train, ...local]);
|
|
34
|
+
const skills = proposedSkills(rules);
|
|
35
|
+
|
|
36
|
+
mkdirSync(OUT, { recursive: true });
|
|
37
|
+
writeFileSync(resolve(OUT, 'rules.json'), JSON.stringify(rules, null, 2) + '\n');
|
|
38
|
+
writeFileSync(resolve(OUT, 'skills.json'), JSON.stringify(skills, null, 2) + '\n');
|
|
39
|
+
writeFileSync(resolve(OUT, 'R.json'), JSON.stringify({
|
|
40
|
+
traces: train.length + local.length,
|
|
41
|
+
local: local.length,
|
|
42
|
+
empty,
|
|
43
|
+
book_require_day: Boolean(rules.book_require_day),
|
|
44
|
+
examples: rules.examples || [],
|
|
45
|
+
}, null, 2) + '\n');
|
|
46
|
+
|
|
47
|
+
process.stdout.write(
|
|
48
|
+
`distilled ${train.length} fixture + ${local.length} local traces`
|
|
49
|
+
+ (empty ? ' (empty Distiller)' : '')
|
|
50
|
+
+ ` → generated/skills.json (${skills.length} skill(s))\n`,
|
|
51
|
+
);
|