@agentmesh-p2p/sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +182 -2
  2. package/dist/auth.d.ts +66 -0
  3. package/dist/auth.js +164 -0
  4. package/dist/authorizer.d.ts +41 -0
  5. package/dist/authorizer.js +201 -0
  6. package/dist/base58.d.ts +2 -0
  7. package/dist/base58.js +57 -0
  8. package/dist/biscuit.d.ts +75 -0
  9. package/dist/biscuit.js +250 -0
  10. package/dist/bytes.d.ts +10 -0
  11. package/dist/bytes.js +51 -0
  12. package/dist/challenges.d.ts +16 -0
  13. package/dist/challenges.js +55 -0
  14. package/dist/conformance-join.d.ts +1 -0
  15. package/dist/conformance-join.js +257 -0
  16. package/dist/conformance.d.ts +1 -0
  17. package/dist/conformance.js +60 -0
  18. package/dist/controlplane.d.ts +123 -0
  19. package/dist/controlplane.js +368 -0
  20. package/dist/credential.d.ts +66 -0
  21. package/dist/credential.js +124 -0
  22. package/dist/discovery.d.ts +50 -0
  23. package/dist/discovery.js +150 -0
  24. package/dist/gen/agentmesh_pb.d.ts +2740 -0
  25. package/dist/gen/agentmesh_pb.js +591 -0
  26. package/dist/gen/datalog.d.ts +39 -0
  27. package/dist/gen/datalog.js +84 -0
  28. package/dist/host.d.ts +47 -0
  29. package/dist/host.js +109 -0
  30. package/dist/http1.d.ts +68 -0
  31. package/dist/http1.js +371 -0
  32. package/dist/identity.d.ts +31 -0
  33. package/dist/identity.js +125 -0
  34. package/dist/index.d.ts +18 -0
  35. package/dist/index.js +30 -0
  36. package/dist/libp2p-http-node.d.ts +17 -0
  37. package/dist/libp2p-http-node.js +116 -0
  38. package/dist/libp2p-http.d.ts +165 -0
  39. package/dist/libp2p-http.js +581 -0
  40. package/dist/mcp.d.ts +67 -0
  41. package/dist/mcp.js +157 -0
  42. package/dist/mesh.d.ts +151 -0
  43. package/dist/mesh.js +370 -0
  44. package/dist/platform/ingress.browser.d.ts +1 -0
  45. package/dist/platform/ingress.browser.js +16 -0
  46. package/dist/platform/ingress.d.ts +1 -0
  47. package/dist/platform/ingress.js +15 -0
  48. package/dist/platform/state.browser.d.ts +5 -0
  49. package/dist/platform/state.browser.js +47 -0
  50. package/dist/platform/state.d.ts +5 -0
  51. package/dist/platform/state.js +44 -0
  52. package/dist/platform/transports.browser.d.ts +3 -0
  53. package/dist/platform/transports.browser.js +27 -0
  54. package/dist/platform/transports.d.ts +3 -0
  55. package/dist/platform/transports.js +27 -0
  56. package/dist/platform/types.d.ts +7 -0
  57. package/dist/platform/types.js +14 -0
  58. package/dist/platform/wasm.browser.d.ts +2 -0
  59. package/dist/platform/wasm.browser.js +16 -0
  60. package/dist/platform/wasm.d.ts +2 -0
  61. package/dist/platform/wasm.js +56 -0
  62. package/dist/session.d.ts +316 -0
  63. package/dist/session.js +1269 -0
  64. package/dist/sync.d.ts +41 -0
  65. package/dist/sync.js +111 -0
  66. package/dist/tar.d.ts +25 -0
  67. package/dist/tar.js +406 -0
  68. package/package.json +74 -4
@@ -0,0 +1,1269 @@
1
+ // Copyright 2026 Google LLC
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+ var _a;
15
+ import { timestampMs } from "@bufbuild/protobuf/wkt";
16
+ import { TopicValidatorResult } from "@libp2p/gossipsub";
17
+ import { peerIdFromString } from "@libp2p/peer-id";
18
+ import { isMultiaddr, multiaddr } from "@multiformats/multiaddr";
19
+ import { AUTH_HANDLER_OPTIONS, AUTH_PROTOCOL, GOAWAY_PROTOCOL, ROUTER_SHUN_DURATION_MS, authenticateWithPeer, authStreamHandler, goAwayReasonName, goAwayStreamHandler, } from "./auth.js";
20
+ import { ROLE_ROUTER, attenuateBiscuit, requirePublish, requireRole, sealBiscuit } from "./biscuit.js";
21
+ import { ROLE_NODE } from "./controlplane.js";
22
+ import { encodeAuthFrame } from "./credential.js";
23
+ import { canonicalPeerId } from "./identity.js";
24
+ import { findPeer, findProviders, isServiceType, parseServiceTarget, peerKey, provide, serviceKey, withdraw } from "./discovery.js";
25
+ import { createMeshHost, listenThroughRelay } from "./host.js";
26
+ import { openMCPSession, requireEgressLabels } from "./mcp.js";
27
+ import { HTTP_HANDLER_OPTIONS, HTTP_PROTOCOL, a2aEndpoint, fetchOverStream, httpRequestOverStream, meshURL, splitMeshURL, } from "./libp2p-http.js";
28
+ import { ingressHandler } from "./platform/ingress.js";
29
+ import { BanSet, GOSSIP_EVENTS_TOPIC, MeshEvent_Type, verifyMeshEvent } from "./sync.js";
30
+ /** Default number of routers a member attaches to and reserves on at join. */
31
+ export const DEFAULT_ROUTERS = 2;
32
+ /** Default backoff delays before replacing a dropped held router. */
33
+ export const ROUTER_REDIAL_BACKOFFS_MS = [2_000, 4_000, 8_000];
34
+ const DISCOVERY_TIMEOUT_MS = 5_000;
35
+ /** Bounds the DHT walk connect() falls back to for a peer no admitted router relays. */
36
+ const PEER_ROUTING_TIMEOUT_MS = 10_000;
37
+ const DEFAULT_REFRESH_LEAD_MS = 60 * 60 * 1000;
38
+ const DEFAULT_REFRESH_RETRY_MS = 30 * 1000;
39
+ const MIN_REFRESH_DELAY_MS = 2_000;
40
+ /** agentmesh-node's --control-plane-sync-interval default. */
41
+ const DEFAULT_POLICY_SYNC_MS = 15 * 60 * 1000;
42
+ /** agentmesh-node's --control-plane-sync-interval default, and its 2s first pull. */
43
+ const DEFAULT_CONTROL_PLANE_SYNC_MS = 15 * 60 * 1000;
44
+ const FIRST_CONTROL_PLANE_SYNC_MS = 2_000;
45
+ const DEFAULT_CONTROL_PLANE_SYNC_JITTER_MS = 2_000;
46
+ const DEFAULT_RELAY_CHECK_MS = 30 * 1000;
47
+ /**
48
+ * How often a member announces where it is reached: agentmesh-node's default
49
+ * reprovide interval, a third of the mesh's default record TTL.
50
+ */
51
+ const DEFAULT_ANNOUNCE_MS = 5 * 60 * 1000;
52
+ /** What close() waits for the member's record to be withdrawn. */
53
+ const WITHDRAW_TIMEOUT_MS = 2 * 1000;
54
+ /** How long a provider's positive egress verdict is kept; agentmesh-node's labelGateTTL. */
55
+ const EGRESS_VERDICT_TTL_MS = 5 * 60 * 1000;
56
+ /**
57
+ * A member that is on the mesh: a libp2p host authenticated with at least
58
+ * one router, answering the auth handshake for peers that dial it, and
59
+ * keeping its credential fresh. Close it to leave.
60
+ */
61
+ export class MeshSession {
62
+ mesh;
63
+ node;
64
+ routers;
65
+ /** Peers that passed the inbound auth handshake, with their credential's expiration. */
66
+ authenticatedPeers;
67
+ /** Peers the control plane has banned; connections to and from them are refused. */
68
+ banned;
69
+ /** Count of /mesh/goaway/1.0.0 frames received from held routers, keyed by reason name (DRAINING, OVERLOADED, REASON_UNSPECIFIED). */
70
+ goAwayReceived;
71
+ /** Routers currently shunned (after a go-away or failed redial), mapped to the unix ms when the shun expires. */
72
+ shunnedRouters;
73
+ /** This member's agent, once acceptA2A was called. */
74
+ endpoint;
75
+ #refreshTimer;
76
+ #policyTimer;
77
+ #syncTimer;
78
+ #relayTimer;
79
+ #announceTimer;
80
+ /** The addresses of the record last announced under this member's peer key. */
81
+ #announced = [];
82
+ #syncing;
83
+ #keepingRelay;
84
+ #keepRelayPending = false;
85
+ #heldRouterIds;
86
+ #relayListeners;
87
+ #freeRelayListeners;
88
+ #reserveRelay;
89
+ #targetRouters;
90
+ #routerSelector;
91
+ #routerPrefer;
92
+ #pinnedRouterAddresses;
93
+ #redialBackoffsMs;
94
+ #refreshLeadMs;
95
+ #refreshRetryMs;
96
+ #policySyncMs;
97
+ #syncIntervalMs;
98
+ #syncJitterMs;
99
+ #policyRules;
100
+ #egressRequireLabels;
101
+ /** Peers verified as enrolled and holding the floor, until when; misses are never kept. */
102
+ #egressVerdicts = new Map();
103
+ #taskBiscuit;
104
+ #isTaskView;
105
+ #closed = false;
106
+ constructor(mesh, node, routers, authenticatedPeers, banned, options, relayListeners, taskBiscuit, sharedState) {
107
+ this.mesh = mesh;
108
+ this.node = node;
109
+ this.routers = routers;
110
+ this.authenticatedPeers = authenticatedPeers;
111
+ this.banned = banned;
112
+ this.goAwayReceived = sharedState?.goAwayReceived ?? new Map();
113
+ this.shunnedRouters = sharedState?.shunnedRouters ?? new Map();
114
+ this.#heldRouterIds = sharedState?.heldRouterIds ?? new Set(routers.map((r) => r.peerId));
115
+ this.#relayListeners = relayListeners ?? new Map();
116
+ this.#freeRelayListeners = sharedState?.freeRelayListeners ?? [];
117
+ this.#reserveRelay = options.reserveRelay ?? true;
118
+ this.#targetRouters = options.routers !== undefined && options.routers > 0 ? options.routers : DEFAULT_ROUTERS;
119
+ this.#routerSelector = options.routerSelector;
120
+ this.#routerPrefer = options.routerPrefer;
121
+ this.#pinnedRouterAddresses = options.routerAddresses;
122
+ this.#redialBackoffsMs = options.routerRedialBackoffsMs ?? ROUTER_REDIAL_BACKOFFS_MS;
123
+ this.#refreshLeadMs = options.refreshLeadMs ?? DEFAULT_REFRESH_LEAD_MS;
124
+ this.#refreshRetryMs = options.refreshRetryMs ?? DEFAULT_REFRESH_RETRY_MS;
125
+ this.#policySyncMs = options.policySyncIntervalMs ?? DEFAULT_POLICY_SYNC_MS;
126
+ this.#syncIntervalMs = options.controlPlaneSyncIntervalMs ?? DEFAULT_CONTROL_PLANE_SYNC_MS;
127
+ this.#syncJitterMs = options.controlPlaneSyncJitterMs ?? DEFAULT_CONTROL_PLANE_SYNC_JITTER_MS;
128
+ this.#egressRequireLabels = options.egressRequireLabels;
129
+ this.#taskBiscuit = taskBiscuit;
130
+ this.#isTaskView = taskBiscuit !== undefined;
131
+ if (!this.#isTaskView) {
132
+ this.#scheduleRefresh();
133
+ this.#listenForEvents();
134
+ this.#keepRouterAdmissions();
135
+ if (this.#syncIntervalMs > 0) {
136
+ this.#scheduleSync(Math.min(FIRST_CONTROL_PLANE_SYNC_MS, this.#syncIntervalMs));
137
+ }
138
+ this.#relayTimer = setInterval(() => void this.keepRelay().catch(() => { }), options.relayCheckIntervalMs ?? DEFAULT_RELAY_CHECK_MS);
139
+ this.#relayTimer.unref?.();
140
+ this.#announceTimer = setInterval(() => void this.announce().catch(() => { }), options.announceIntervalMs ?? DEFAULT_ANNOUNCE_MS);
141
+ this.#announceTimer.unref?.();
142
+ }
143
+ }
144
+ get peerId() {
145
+ return this.node.peerId.toString();
146
+ }
147
+ /** The Biscuit presented on outbound service calls (task-attenuated when derived via attenuate()). */
148
+ get biscuit() {
149
+ return this.#taskBiscuit ?? this.mesh.credential.biscuit;
150
+ }
151
+ /**
152
+ * Returns a task-scoped MeshSession view sharing the underlying libp2p host
153
+ * whose outbound MCP and HTTP service calls carry a Biscuit attenuated offline
154
+ * in memory with rule.
155
+ */
156
+ async attenuate(rule) {
157
+ const nextBiscuit = await attenuateBiscuit(this.biscuit, rule, this.mesh.credential.controlPlaneKeys);
158
+ return this.#deriveWithBiscuit(nextBiscuit);
159
+ }
160
+ /**
161
+ * Returns a MeshSession view whose outbound Biscuit is sealed so downstream
162
+ * holders cannot append any further blocks.
163
+ */
164
+ async seal() {
165
+ const sealed = await sealBiscuit(this.biscuit, this.mesh.credential.controlPlaneKeys);
166
+ return this.#deriveWithBiscuit(sealed);
167
+ }
168
+ #deriveWithBiscuit(taskBiscuit) {
169
+ const opts = {
170
+ refreshLeadMs: this.#refreshLeadMs,
171
+ refreshRetryMs: this.#refreshRetryMs,
172
+ policySyncIntervalMs: this.#policySyncMs,
173
+ controlPlaneSyncIntervalMs: 0,
174
+ controlPlaneSyncJitterMs: this.#syncJitterMs,
175
+ reserveRelay: this.#reserveRelay,
176
+ routers: this.#targetRouters,
177
+ routerRedialBackoffsMs: this.#redialBackoffsMs,
178
+ ...(this.#routerSelector !== undefined ? { routerSelector: this.#routerSelector } : {}),
179
+ ...(this.#routerPrefer !== undefined ? { routerPrefer: this.#routerPrefer } : {}),
180
+ ...(this.#pinnedRouterAddresses !== undefined ? { routerAddresses: this.#pinnedRouterAddresses } : {}),
181
+ ...(this.#egressRequireLabels !== undefined ? { egressRequireLabels: this.#egressRequireLabels } : {}),
182
+ };
183
+ return new _a(this.mesh, this.node, this.routers, this.authenticatedPeers, this.banned, opts, this.#relayListeners, taskBiscuit, {
184
+ heldRouterIds: this.#heldRouterIds,
185
+ freeRelayListeners: this.#freeRelayListeners,
186
+ goAwayReceived: this.goAwayReceived,
187
+ shunnedRouters: this.shunnedRouters,
188
+ });
189
+ }
190
+ /**
191
+ * The URL a fetch bound to this session (fetch()) takes for a service on a
192
+ * peer: http://mesh/mesh/<peer-id>/<type>/<name>/<path>, the shape of
193
+ * agentmesh-node's egress proxy and of an agent card it rewrote.
194
+ */
195
+ static meshURL(peerId, targetService, path = "") {
196
+ return meshURL(peerId, targetService, path);
197
+ }
198
+ /** The mesh URL of this member's own agent, once acceptA2A was called. */
199
+ get agentURL() {
200
+ return this.endpoint === undefined ? undefined : meshURL(this.peerId, this.endpoint.service);
201
+ }
202
+ /** Addresses peers can dial this member on, including relayed ones. */
203
+ get addresses() {
204
+ return this.node.getMultiaddrs();
205
+ }
206
+ /** The `.../p2p-circuit/p2p/<self>` addresses reserved on held routers. */
207
+ get relayAddresses() {
208
+ return this.node
209
+ .getMultiaddrs()
210
+ .filter((ma) => {
211
+ const s = ma.toString();
212
+ return s.includes("/p2p-circuit") && [...this.#heldRouterIds].some((id) => s.includes(`/p2p/${id}/p2p-circuit`));
213
+ });
214
+ }
215
+ /**
216
+ * Announces where this member is reached, its relayed addresses on the
217
+ * routers it holds, under its peer key, so that a peer attached to other
218
+ * routers finds it by ID. Runs after join, when the addresses change and
219
+ * every announceIntervalMs; the record outlives its last announcement by
220
+ * the mesh's record TTL. A member with no relayed address announces
221
+ * nothing: there is nothing to reach it at.
222
+ */
223
+ async announce() {
224
+ if (this.#closed || this.#isTaskView) {
225
+ return;
226
+ }
227
+ const addrs = this.relayAddresses.map(String).sort();
228
+ if (addrs.length === 0) {
229
+ return;
230
+ }
231
+ await provide(this.#directoryRouters(), await peerKey(this.peerId), addrs);
232
+ this.#announced = addrs;
233
+ }
234
+ /** Whether peerId is currently one of the routers this member holds. */
235
+ isHeldRouter(peerId) {
236
+ try {
237
+ return this.#heldRouterIds.has(canonicalPeerId(peerId));
238
+ }
239
+ catch {
240
+ return false;
241
+ }
242
+ }
243
+ /** Handles a /mesh/goaway/1.0.0 message from a held router: shuns it, detaches it, and tops up from the catalog. */
244
+ async handleGoAway(peerId, msg, retryAfterMs) {
245
+ let canonical;
246
+ try {
247
+ canonical = canonicalPeerId(peerId);
248
+ }
249
+ catch {
250
+ return;
251
+ }
252
+ if (!this.#heldRouterIds.has(canonical)) {
253
+ return;
254
+ }
255
+ const reason = goAwayReasonName(msg.reason);
256
+ this.goAwayReceived.set(reason, (this.goAwayReceived.get(reason) ?? 0) + 1);
257
+ this.#shunRouter(canonical, retryAfterMs);
258
+ await this.#detachRouter(canonical);
259
+ await this.keepRelay().catch(() => { });
260
+ }
261
+ #shunRouter(peerId, durationMs) {
262
+ this.shunnedRouters.set(peerId, Date.now() + durationMs);
263
+ }
264
+ #isRouterShunned(peerId, nowMs = Date.now()) {
265
+ const expiry = this.shunnedRouters.get(peerId);
266
+ if (expiry === undefined) {
267
+ return false;
268
+ }
269
+ if (expiry <= nowMs) {
270
+ this.shunnedRouters.delete(peerId);
271
+ return false;
272
+ }
273
+ return true;
274
+ }
275
+ async #detachRouter(peerId) {
276
+ this.#heldRouterIds.delete(peerId);
277
+ const idx = this.routers.findIndex((r) => r.peerId === peerId);
278
+ if (idx !== -1) {
279
+ this.routers.splice(idx, 1);
280
+ }
281
+ const listener = this.#relayListeners.get(peerId);
282
+ if (listener !== undefined) {
283
+ this.#relayListeners.delete(peerId);
284
+ this.#freeRelayListeners.push(listener);
285
+ }
286
+ try {
287
+ await this.node.hangUp(peerIdFromString(peerId));
288
+ }
289
+ catch {
290
+ // Already disconnected.
291
+ }
292
+ }
293
+ async #reserveOnRouter(peerId, addr) {
294
+ const existing = this.#relayListeners.get(peerId);
295
+ if (existing !== undefined) {
296
+ await existing.listen(addr.encapsulate("/p2p-circuit"));
297
+ return;
298
+ }
299
+ const free = this.#freeRelayListeners.pop();
300
+ if (free !== undefined) {
301
+ await free.listen(addr.encapsulate("/p2p-circuit"));
302
+ this.#relayListeners.set(peerId, free);
303
+ return;
304
+ }
305
+ const listener = await listenThroughRelay(this.node, addr);
306
+ this.#relayListeners.set(peerId, listener);
307
+ }
308
+ #upsertHeldRouter(router) {
309
+ this.#heldRouterIds.add(router.peerId);
310
+ const idx = this.routers.findIndex((r) => r.peerId === router.peerId);
311
+ if (idx !== -1) {
312
+ this.routers[idx] = router;
313
+ }
314
+ else {
315
+ this.routers.push(router);
316
+ }
317
+ }
318
+ #candidatesForHolding() {
319
+ if (this.#pinnedRouterAddresses !== undefined) {
320
+ return candidatesFromRouterInfos([], this.#pinnedRouterAddresses, this.mesh.routers);
321
+ }
322
+ return candidatesFromRouterInfos(this.mesh.routers, this.mesh.credential.routerAddresses);
323
+ }
324
+ #wantRouters() {
325
+ const candidates = this.#candidatesForHolding();
326
+ let matched = 0;
327
+ for (const c of candidates) {
328
+ if (matchesLabels(c.labels, this.#routerSelector)) {
329
+ matched++;
330
+ }
331
+ }
332
+ if (matched > 0 && matched < this.#targetRouters) {
333
+ return matched;
334
+ }
335
+ return this.#targetRouters;
336
+ }
337
+ #candidateAddressesFor(peerId, fallback) {
338
+ for (const c of candidatesFromRouterInfos(this.mesh.routers, this.mesh.credential.routerAddresses)) {
339
+ if (c.peerId === peerId && c.addresses.length > 0) {
340
+ return c.addresses;
341
+ }
342
+ }
343
+ const listed = this.mesh.credential.routerAddresses
344
+ .map((a) => {
345
+ try {
346
+ return multiaddr(a);
347
+ }
348
+ catch {
349
+ return undefined;
350
+ }
351
+ })
352
+ .filter((ma) => ma !== undefined && targetPeerOf(ma) === peerId);
353
+ return listed.length > 0 ? listed : [fallback];
354
+ }
355
+ /**
356
+ * A router forgets the admission with the connection it came on, and
357
+ * js-libp2p reconnects to a relay on its own, so every new connection to a
358
+ * router runs the handshake again: the relay's own renewal of the
359
+ * reservation then still passes the router's check.
360
+ */
361
+ #keepRouterAdmissions() {
362
+ this.node.addEventListener("connection:open", (evt) => {
363
+ const conn = evt.detail;
364
+ if (this.#closed || !this.routers.some((r) => r.peerId === conn.remotePeer.toString())) {
365
+ return;
366
+ }
367
+ void this.#authenticateRouter(conn).catch(() => { });
368
+ });
369
+ }
370
+ /** Handshakes in flight by connection id: one per connection, whoever asks first. */
371
+ #routerHandshakes = new Map();
372
+ /**
373
+ * Runs the handshake on a router connection once: the connection:open
374
+ * listener and an explicit admission both call this, and the second waits
375
+ * for the first instead of running a second handshake.
376
+ */
377
+ #authenticateRouter(conn) {
378
+ const pending = this.#routerHandshakes.get(conn.id);
379
+ if (pending !== undefined) {
380
+ return pending;
381
+ }
382
+ const run = authenticateWithPeer(conn, this.mesh.authFrame(), this.mesh.credential.controlPlaneKeys).finally(() => {
383
+ this.#routerHandshakes.delete(conn.id);
384
+ });
385
+ this.#routerHandshakes.set(conn.id, run);
386
+ return run;
387
+ }
388
+ /** The held routers as the directory client asks them, each with its open connection. */
389
+ #directoryRouters() {
390
+ return this.routers.map((r) => ({
391
+ peerId: r.peerId,
392
+ connection: () => this.node.getConnections(peerIdFromString(r.peerId)).find((c) => c.status === "open"),
393
+ }));
394
+ }
395
+ /**
396
+ * Maintains held routers and relay reservations: when a held router's
397
+ * connection or reservation drops, redials it first (2s, 4s, 8s by default)
398
+ * using its current address from the catalog; if it does not recover or
399
+ * refuses auth, shuns it and tops up from the catalog. Runs on
400
+ * relayCheckIntervalMs and immediately after a /mesh/goaway/1.0.0 frame.
401
+ */
402
+ keepRelay() {
403
+ if (this.#keepingRelay !== undefined) {
404
+ this.#keepRelayPending = true;
405
+ return this.#keepingRelay;
406
+ }
407
+ const run = async () => {
408
+ do {
409
+ this.#keepRelayPending = false;
410
+ await this.#keepRelayOnce();
411
+ } while (this.#keepRelayPending && !this.#closed);
412
+ };
413
+ this.#keepingRelay = run().finally(() => {
414
+ this.#keepingRelay = undefined;
415
+ });
416
+ return this.#keepingRelay;
417
+ }
418
+ async #keepRelayOnce() {
419
+ if (this.#closed) {
420
+ return;
421
+ }
422
+ try {
423
+ await this.#maintainRouters();
424
+ }
425
+ finally {
426
+ const addrs = this.relayAddresses.map(String).sort();
427
+ if (!this.#closed && addrs.length > 0 && addrs.join(" ") !== this.#announced.join(" ")) {
428
+ await this.announce().catch(() => { });
429
+ }
430
+ }
431
+ }
432
+ async #maintainRouters() {
433
+ const held = this.routers.filter((r) => this.#heldRouterIds.has(r.peerId));
434
+ for (const r of held) {
435
+ if (this.#closed) {
436
+ return;
437
+ }
438
+ if (this.#isRouterShunned(r.peerId)) {
439
+ await this.#detachRouter(r.peerId);
440
+ continue;
441
+ }
442
+ const isConnected = this.node
443
+ .getConnections(peerIdFromString(r.peerId))
444
+ .some((c) => c.status === "open" && !c.remoteAddr.toString().includes("/p2p-circuit"));
445
+ const hasRelay = !this.#reserveRelay || this.relayAddresses.some((ma) => ma.toString().includes(`/p2p/${r.peerId}/p2p-circuit`));
446
+ if (isConnected && hasRelay) {
447
+ continue;
448
+ }
449
+ let reconnected = false;
450
+ for (const backoffMs of [0, ...this.#redialBackoffsMs]) {
451
+ if (this.#closed || this.#isRouterShunned(r.peerId)) {
452
+ break;
453
+ }
454
+ if (backoffMs > 0) {
455
+ await new Promise((resolve) => setTimeout(resolve, backoffMs));
456
+ }
457
+ if (this.#closed || this.#isRouterShunned(r.peerId)) {
458
+ break;
459
+ }
460
+ const addrs = this.#candidateAddressesFor(r.peerId, r.addr);
461
+ let conn;
462
+ try {
463
+ conn = await this.node.dial(addrs);
464
+ }
465
+ catch {
466
+ continue;
467
+ }
468
+ try {
469
+ const credential = await this.#authenticateRouter(conn);
470
+ requireRole(credential, ROLE_ROUTER);
471
+ const addr = connectedAddress(conn, r.peerId);
472
+ if (this.#reserveRelay) {
473
+ await this.#reserveOnRouter(r.peerId, addr);
474
+ }
475
+ this.#upsertHeldRouter({ peerId: r.peerId, addr, credential });
476
+ reconnected = true;
477
+ break;
478
+ }
479
+ catch {
480
+ // Router refused auth or reservation after connecting; fail over immediately.
481
+ break;
482
+ }
483
+ }
484
+ if (!reconnected && !this.#closed) {
485
+ this.#shunRouter(r.peerId, ROUTER_SHUN_DURATION_MS);
486
+ await this.#detachRouter(r.peerId);
487
+ }
488
+ }
489
+ if (!this.#closed && this.#heldRouterIds.size < this.#wantRouters()) {
490
+ await this.topUpRouters();
491
+ }
492
+ }
493
+ /**
494
+ * Attaches and reserves on additional routers from the catalog until this
495
+ * session holds wantRouters() routers or no more eligible candidates remain.
496
+ */
497
+ async topUpRouters(signal) {
498
+ const failures = [];
499
+ const want = this.#wantRouters();
500
+ const candidates = this.#candidatesForHolding();
501
+ const tried = new Set();
502
+ while (!this.#closed && this.#heldRouterIds.size < want) {
503
+ const need = want - this.#heldRouterIds.size;
504
+ const exclude = new Set([...this.#heldRouterIds, ...tried]);
505
+ const picks = selectRouters(candidates, need, {
506
+ ...(this.#routerSelector !== undefined ? { selector: this.#routerSelector } : {}),
507
+ ...(this.#routerPrefer !== undefined ? { prefer: this.#routerPrefer } : {}),
508
+ attached: exclude,
509
+ shunned: this.shunnedRouters,
510
+ });
511
+ if (picks.length === 0) {
512
+ break;
513
+ }
514
+ let progress = false;
515
+ for (const cand of picks) {
516
+ tried.add(cand.peerId);
517
+ try {
518
+ const conn = await this.node.dial(cand.addresses, signal !== undefined ? { signal } : {});
519
+ const credential = await this.#authenticateRouter(conn);
520
+ requireRole(credential, ROLE_ROUTER);
521
+ const addr = connectedAddress(conn, cand.peerId);
522
+ if (this.#reserveRelay) {
523
+ await this.#reserveOnRouter(cand.peerId, addr);
524
+ }
525
+ this.#upsertHeldRouter({ peerId: cand.peerId, addr, credential });
526
+ progress = true;
527
+ }
528
+ catch (err) {
529
+ this.#shunRouter(cand.peerId, ROUTER_SHUN_DURATION_MS);
530
+ await this.node.hangUp(peerIdFromString(cand.peerId)).catch(() => { });
531
+ failures.push(`${cand.peerId}: ${err instanceof Error ? err.message : String(err)}`);
532
+ }
533
+ }
534
+ if (!progress && picks.length < need) {
535
+ break;
536
+ }
537
+ }
538
+ return failures;
539
+ }
540
+ /**
541
+ * Connects to a peer; see Peer for how it is named. Returns the
542
+ * connection, reused if one is already open. A banned peer is refused
543
+ * here and by the connection gater. A peer named by ID that no admitted
544
+ * router relays for is looked up in the routers' DHT, and failing that
545
+ * tried through every router the control plane lists that this member
546
+ * has not joined through; a relay opens a circuit only for a source it
547
+ * authenticated, so each such router is admitted first. Which router each
548
+ * side joined through does not decide whether they can talk.
549
+ */
550
+ async connect(peer, signal) {
551
+ const { peerId, addrs } = this.dialTargets(peer);
552
+ if (peerId !== undefined && this.banned.has(peerId)) {
553
+ throw new Error(`peer ${peerId} is banned by the control plane`);
554
+ }
555
+ const options = signal !== undefined ? { signal } : {};
556
+ try {
557
+ return await this.node.dial(addrs, options);
558
+ }
559
+ catch (err) {
560
+ if (peerId === undefined || (typeof peer === "string" && peer.startsWith("/")) || isMultiaddr(peer) || signal?.aborted === true) {
561
+ throw err;
562
+ }
563
+ for (const more of [() => this.#routedAddresses(peerId, signal), () => this.#unjoinedRouterAddresses(peerId, signal)]) {
564
+ const routed = await more();
565
+ if (routed.length === 0) {
566
+ continue;
567
+ }
568
+ try {
569
+ return await this.node.dial(routed, options);
570
+ }
571
+ catch (routedErr) {
572
+ err = routedErr;
573
+ }
574
+ }
575
+ throw err;
576
+ }
577
+ }
578
+ /**
579
+ * The relayed paths to a peer through the routers the control plane lists
580
+ * that this member has not joined through, each admitted first. The list
581
+ * is the one the credential carries, refreshed by every control plane
582
+ * pull, so a router that came up after join is tried too.
583
+ */
584
+ async #unjoinedRouterAddresses(peerId, signal) {
585
+ const out = [];
586
+ for (const text of this.mesh.credential.routerAddresses) {
587
+ let addr;
588
+ try {
589
+ addr = multiaddr(text);
590
+ }
591
+ catch {
592
+ continue;
593
+ }
594
+ const router = targetPeerOf(addr);
595
+ if (router === undefined || router === peerId || this.banned.has(router) || this.routers.some((r) => r.peerId === router)) {
596
+ continue;
597
+ }
598
+ try {
599
+ out.push((await this.#admitRouter(addr, signal)).addr.encapsulate(`/p2p-circuit/p2p/${peerId}`));
600
+ }
601
+ catch {
602
+ continue;
603
+ }
604
+ }
605
+ return out;
606
+ }
607
+ /**
608
+ * The addresses the directory knows for a peer, its relayed ones through
609
+ * routers this member has admitted by then: a relay opens a circuit only
610
+ * for a source it authenticated, so a router met this way is dialed and
611
+ * passed the handshake first, and joins the admitted set.
612
+ */
613
+ async #routedAddresses(peerId, signal) {
614
+ const lookup = signal ?? AbortSignal.timeout(PEER_ROUTING_TIMEOUT_MS);
615
+ let found;
616
+ try {
617
+ found = await findPeer(this.#directoryRouters(), peerId, lookup);
618
+ }
619
+ catch {
620
+ return [];
621
+ }
622
+ if (found === undefined) {
623
+ return [];
624
+ }
625
+ const out = [];
626
+ for (const text of found.addrs) {
627
+ const ma = multiaddr(text);
628
+ const circuit = text.indexOf("/p2p-circuit");
629
+ if (circuit === -1) {
630
+ out.push(targetPeerOf(ma) === undefined ? ma.encapsulate(`/p2p/${peerId}`) : ma);
631
+ continue;
632
+ }
633
+ const relayAddr = multiaddr(text.slice(0, circuit));
634
+ const relay = targetPeerOf(relayAddr);
635
+ if (relay === undefined || this.banned.has(relay)) {
636
+ continue;
637
+ }
638
+ if (!this.routers.some((r) => r.peerId === relay)) {
639
+ try {
640
+ await this.#admitRouter(relayAddr, signal);
641
+ }
642
+ catch {
643
+ continue;
644
+ }
645
+ }
646
+ out.push(relayAddr.encapsulate(`/p2p-circuit/p2p/${peerId}`));
647
+ }
648
+ return out;
649
+ }
650
+ /** Dials a router, runs the handshake and, its role verified, adds it to the admitted set. */
651
+ async #admitRouter(addr, signal) {
652
+ const conn = await this.node.dial(addr, signal !== undefined ? { signal } : {});
653
+ const credential = await this.#authenticateRouter(conn);
654
+ requireRole(credential, ROLE_ROUTER);
655
+ const peerId = conn.remotePeer.toString();
656
+ const router = { peerId, addr: connectedAddress(conn, peerId), credential };
657
+ if (!this.routers.some((r) => r.peerId === peerId)) {
658
+ this.routers.push(router);
659
+ }
660
+ return router;
661
+ }
662
+ /** The addresses connect() dials for a peer, in the order libp2p tries them. */
663
+ dialTargets(peer) {
664
+ if (typeof peer === "string" && !peer.startsWith("/")) {
665
+ const peerId = canonicalPeerId(peer);
666
+ return { peerId, addrs: this.relayedAddresses(peerId) };
667
+ }
668
+ if (typeof peer === "string" || isMultiaddr(peer)) {
669
+ const ma = typeof peer === "string" ? multiaddr(peer) : peer;
670
+ return { peerId: targetPeerOf(ma), addrs: [ma] };
671
+ }
672
+ const peerId = canonicalPeerId(peer.peerId);
673
+ const advertised = peer.addrs.map((text) => {
674
+ const ma = multiaddr(text);
675
+ return targetPeerOf(ma) === undefined ? ma.encapsulate(`/p2p/${peerId}`) : ma;
676
+ });
677
+ return { peerId, addrs: [...advertised, ...this.relayedAddresses(peerId)] };
678
+ }
679
+ /** `<router>/p2p-circuit/p2p/<peer>` through every router that admitted this member. */
680
+ relayedAddresses(peerId) {
681
+ return this.routers.map((r) => r.addr.encapsulate(`/p2p-circuit/p2p/${peerId}`));
682
+ }
683
+ /** Connects to a peer and runs the mutual auth handshake, returning its verified credential. */
684
+ async authenticate(peer, signal) {
685
+ const conn = await this.connect(peer, signal);
686
+ return authenticateWithPeer(conn, this.mesh.authFrame(), this.mesh.credential.controlPlaneKeys);
687
+ }
688
+ /**
689
+ * Asks the directory for peers offering a service: `"mcp://calc"`, the
690
+ * same string callTool and request take, or a type alone (`"mcp"`) for
691
+ * every service of that type, or (type, name). A router this member holds
692
+ * answers; bounded by the timeout.
693
+ */
694
+ async discover(service, name, options = {}) {
695
+ const target = service.includes("://") ? parseServiceTarget(service) : { type: service, name };
696
+ if (!isServiceType(target.type)) {
697
+ throw new Error(`service type must be mcp, inference, a2a or egress, got ${JSON.stringify(target.type)}`);
698
+ }
699
+ const key = await serviceKey(target.type, target.name);
700
+ const signal = AbortSignal.timeout(options.timeoutMs ?? DISCOVERY_TIMEOUT_MS);
701
+ const limit = options.limit ?? 20;
702
+ const found = new Map();
703
+ for (const record of await findProviders(this.#directoryRouters(), key, limit, signal)) {
704
+ if (record.peerId === this.peerId) {
705
+ continue;
706
+ }
707
+ const entry = found.get(record.peerId) ?? { peerId: record.peerId, addrs: [] };
708
+ for (const text of record.addrs) {
709
+ if (!entry.addrs.includes(text)) {
710
+ entry.addrs.push(text);
711
+ }
712
+ }
713
+ found.set(record.peerId, entry);
714
+ }
715
+ return [...found.values()].slice(0, limit);
716
+ }
717
+ /**
718
+ * Opens an MCP session with a provider for targetService ("mcp://<name>",
719
+ * or "" for the provider's own catalog tools).
720
+ */
721
+ async openMCP(peer, targetService, options = {}) {
722
+ const conn = await this.connect(peer, options.signal);
723
+ return openMCPSession(conn, encodeAuthFrame(this.biscuit, targetService), this.mesh.credential.controlPlaneKeys, { ...options, policyRules: this.policyRules }, this.#egressRequireLabels);
724
+ }
725
+ /** Lists the tools a provider serves for a service. */
726
+ async listTools(peer, targetService, options = {}) {
727
+ const mcp = await this.openMCP(peer, targetService, options);
728
+ try {
729
+ const { tools } = await mcp.client.listTools();
730
+ return tools.map((t) => (t.description !== undefined ? { name: t.name, description: t.description } : { name: t.name }));
731
+ }
732
+ finally {
733
+ await mcp.close();
734
+ }
735
+ }
736
+ /** Calls one tool on a provider's service. */
737
+ async callTool(peer, targetService, tool, args = {}, options = {}) {
738
+ const mcp = await this.openMCP(peer, targetService, options);
739
+ try {
740
+ const result = await mcp.client.callTool({ name: tool, arguments: args });
741
+ const content = Array.isArray(result.content) ? result.content : [];
742
+ return {
743
+ isError: result.isError === true,
744
+ text: content.filter((c) => c.type === "text" && typeof c.text === "string").map((c) => c.text),
745
+ raw: result,
746
+ };
747
+ }
748
+ finally {
749
+ await mcp.close();
750
+ }
751
+ }
752
+ /** Refreshes now and reschedules; exposed so a caller can force it. */
753
+ async refresh() {
754
+ await this.#refreshCredential();
755
+ this.#scheduleRefresh();
756
+ }
757
+ /**
758
+ * Trades the credential for a fresh one and shows it to every router this
759
+ * member is connected to. A router admits a peer until the biscuit it was
760
+ * shown expires, whatever the connection does; a refreshed credential it
761
+ * never sees leaves it refusing relay circuits to this member once the
762
+ * old one lapses, with every connection still open.
763
+ */
764
+ async #refreshCredential() {
765
+ await this.mesh.refresh();
766
+ await this.#readmitRouters();
767
+ }
768
+ /**
769
+ * Runs the handshake again on the open connection to each admitted
770
+ * router; the router records the expiry of the credential it is shown.
771
+ * A router without an open connection is left to keepRelay and the
772
+ * connection:open listener, which handshake on the next connection.
773
+ */
774
+ async #readmitRouters() {
775
+ await Promise.all(this.routers.map(async (r) => {
776
+ const conn = this.node.getConnections(peerIdFromString(r.peerId)).find((c) => c.status === "open");
777
+ if (conn === undefined) {
778
+ return;
779
+ }
780
+ try {
781
+ await this.#authenticateRouter(conn);
782
+ }
783
+ catch {
784
+ // The router keeps the admission it has until the old credential lapses.
785
+ }
786
+ }));
787
+ }
788
+ /**
789
+ * Pulls keys, bans and router addresses from the control plane now, and
790
+ * the mesh policy when accepting callers, then applies them: a newly
791
+ * banned peer is hung up on and dropped from the admitted set. Concurrent
792
+ * calls share one pull. Errors of individual parts are in the result, not
793
+ * thrown.
794
+ */
795
+ sync() {
796
+ this.#syncing ??= this.#syncOnce().finally(() => {
797
+ this.#syncing = undefined;
798
+ });
799
+ return this.#syncing;
800
+ }
801
+ async #syncOnce() {
802
+ const result = await this.mesh.syncControlPlane();
803
+ if (result.refreshed) {
804
+ await this.#readmitRouters();
805
+ }
806
+ if (result.bannedPeerIds !== undefined) {
807
+ const { banned } = this.banned.reconcile(canonicalPeerIds(result.bannedPeerIds), result.fetchedAt);
808
+ await Promise.all(banned.map((peerId) => this.#evict(peerId)));
809
+ }
810
+ if (!this.#closed && this.#heldRouterIds.size < this.#wantRouters()) {
811
+ await this.topUpRouters().catch(() => { });
812
+ }
813
+ if (!this.#closed) {
814
+ void this.keepRelay().catch(() => { });
815
+ }
816
+ // Callers evaluate the policy too: it is what grants a provider the
817
+ // service it serves (requirePublish), not only what an A2A endpoint
818
+ // authorizes its callers with.
819
+ try {
820
+ await this.syncPolicy();
821
+ }
822
+ catch (err) {
823
+ result.errors.push(`policy: ${err instanceof Error ? err.message : String(err)}`);
824
+ }
825
+ return result;
826
+ }
827
+ /** Asks for a pull soon, after a random delay so a fleet told at once does not pull at once. */
828
+ triggerSync() {
829
+ if (this.#closed) {
830
+ return;
831
+ }
832
+ this.#scheduleSync(Math.floor(Math.random() * (this.#syncJitterMs + 1)));
833
+ }
834
+ #scheduleSync(delayMs) {
835
+ clearTimeout(this.#syncTimer);
836
+ this.#syncTimer = setTimeout(() => {
837
+ this.sync()
838
+ .catch(() => { })
839
+ .finally(() => {
840
+ if (!this.#closed && this.#syncIntervalMs > 0) {
841
+ // Stretched by up to a tenth so a fleet started together does not pull together.
842
+ this.#scheduleSync(this.#syncIntervalMs + Math.floor(Math.random() * (this.#syncIntervalMs / 10 + 1)));
843
+ }
844
+ });
845
+ }, delayMs);
846
+ this.#syncTimer.unref?.();
847
+ }
848
+ /** Drops a banned peer: its admission and its connections. */
849
+ async #evict(peerId) {
850
+ this.authenticatedPeers.delete(peerId);
851
+ try {
852
+ await this.node.hangUp(peerIdFromString(peerId));
853
+ }
854
+ catch {
855
+ // Not connected, or already gone.
856
+ }
857
+ }
858
+ /**
859
+ * The control plane's gossip events, relayed by the routers. The topic
860
+ * validator drops anything not signed by a trusted control plane key, so
861
+ * a peer whose libp2p key signed the envelope still cannot get an
862
+ * unsigned event through; a stale event is ignored, not penalized.
863
+ */
864
+ #listenForEvents() {
865
+ const pubsub = this.node.services.pubsub;
866
+ pubsub.topicValidators.set(GOSSIP_EVENTS_TOPIC, (_peer, message) => {
867
+ if (message.type !== "signed") {
868
+ return TopicValidatorResult.Reject;
869
+ }
870
+ return verifyMeshEvent(message.data, this.mesh.credential.controlPlaneKeys) !== undefined ? TopicValidatorResult.Accept : TopicValidatorResult.Reject;
871
+ });
872
+ pubsub.addEventListener("message", (evt) => {
873
+ if (evt.detail.topic !== GOSSIP_EVENTS_TOPIC) {
874
+ return;
875
+ }
876
+ const event = verifyMeshEvent(evt.detail.data, this.mesh.credential.controlPlaneKeys);
877
+ if (event === undefined) {
878
+ return;
879
+ }
880
+ switch (event.type) {
881
+ case MeshEvent_Type.BANNED:
882
+ // Not persisted: a restarted member picks the ban back up from /info.
883
+ if (this.banned.add(event.peerId, timestampMs(event.eventTime))) {
884
+ void this.#evict(event.peerId);
885
+ }
886
+ break;
887
+ case MeshEvent_Type.KEY_ROTATION:
888
+ if (event.newPublicKey.length === 32) {
889
+ this.mesh.addTrustedKey(event.newPublicKey);
890
+ }
891
+ this.triggerSync();
892
+ break;
893
+ case MeshEvent_Type.POLICY_UPDATE:
894
+ this.triggerSync();
895
+ break;
896
+ }
897
+ });
898
+ pubsub.subscribe(GOSSIP_EVENTS_TOPIC);
899
+ }
900
+ /**
901
+ * The mesh policy rules this member evaluates for callers, as the control
902
+ * plane rendered them (PolicyConfigGetResponse.datalog_rules). Empty until
903
+ * the first control plane sync, acceptA2A() or syncPolicy().
904
+ */
905
+ get policyRules() {
906
+ return this.#policyRules ?? [];
907
+ }
908
+ /** Re-reads the mesh policy from the control plane. */
909
+ async syncPolicy() {
910
+ this.#policyRules = await this.mesh.controlPlane.policyRules(this.mesh.identity, this.mesh.credential.biscuit);
911
+ }
912
+ /**
913
+ * Calls an inference or A2A service on a provider over /libp2p-http, the
914
+ * way agentmesh-node's egress proxy does for /mesh/<peer>/<type>/<name>/<path>.
915
+ */
916
+ async request(peer, targetService, path, options = {}) {
917
+ const conn = await this.#egressConnection(peer, targetService, options.signal);
918
+ return httpRequestOverStream(conn, this.biscuit, targetService, path, options);
919
+ }
920
+ /**
921
+ * The connection an HTTP call goes out on, its peer verified as an enrolled
922
+ * node holding the floor and granted the service before anything is sent
923
+ * (agentmesh-node's VerifyProvider).
924
+ */
925
+ async #egressConnection(peer, targetService, signal) {
926
+ const conn = await this.connect(peer, signal);
927
+ const verdict = `${conn.remotePeer.toString()}|${targetService}`;
928
+ const until = this.#egressVerdicts.get(verdict);
929
+ if (until !== undefined && until.getTime() > Date.now()) {
930
+ return conn;
931
+ }
932
+ const provider = await authenticateWithPeer(conn, this.mesh.authFrame(), this.mesh.credential.controlPlaneKeys);
933
+ // Only nodes host services; a router's or an admin's credential is a member, not a provider.
934
+ requireRole(provider, ROLE_NODE);
935
+ await requirePublish(provider, targetService, this.policyRules);
936
+ requireEgressLabels(provider, this.#egressRequireLabels);
937
+ this.#egressVerdicts.set(verdict, new Date(Date.now() + EGRESS_VERDICT_TTL_MS));
938
+ return conn;
939
+ }
940
+ /**
941
+ * A `fetch` bound to the mesh, for clients built on fetch such as the A2A
942
+ * SDK's (`fetchImpl`): a request to http://mesh/mesh/<peer-id>/<type>/<name>/<path>
943
+ * is carried to that peer over /libp2p-http with this member's credential.
944
+ * Response bodies stream, so message/stream works. See MeshSession.meshURL.
945
+ */
946
+ fetch() {
947
+ return async (input, init) => {
948
+ const request = new Request(input, init);
949
+ const { peerId, target } = splitMeshURL(new URL(request.url));
950
+ const [svcType = "", svcName = ""] = target.slice(1).split("/");
951
+ const conn = await this.#egressConnection(peerId, `${svcType}://${svcName}`, request.signal);
952
+ const streamOptions = {};
953
+ if (init?.signal !== undefined && init.signal !== null) {
954
+ streamOptions.signal = init.signal;
955
+ }
956
+ return fetchOverStream(conn, this.biscuit, request, streamOptions);
957
+ };
958
+ }
959
+ /**
960
+ * Makes this member's agent reachable: other members call it as
961
+ * `a2a://<name>` by peer ID, through a router, and the SDK answers
962
+ * /libp2p-http with the spec's url (an A2A server beside this process),
963
+ * handler or listener (in this process; an Express app with the A2A SDK's
964
+ * handlers is a listener). Nothing is announced: no DHT record, no
965
+ * catalog entry. A tool, a model or a service others should find by name
966
+ * is published by a agentmesh-node. Fetches the mesh policy first and keeps it
967
+ * current; a policy that cannot be read fails the call, since an agent
968
+ * without it could only authorize what callers carry in their own tokens.
969
+ * Returns the service target callers use. One agent per session.
970
+ */
971
+ async acceptA2A(spec) {
972
+ if (this.endpoint !== undefined) {
973
+ throw new Error(`this session already accepts ${this.endpoint.service}`);
974
+ }
975
+ const endpoint = a2aEndpoint(spec);
976
+ await this.syncPolicy();
977
+ const providerOptions = {
978
+ trustedKeys: () => this.mesh.credential.controlPlaneKeys,
979
+ ownBiscuit: () => this.mesh.credential.biscuit,
980
+ policyRules: () => this.policyRules,
981
+ isBanned: (peerId) => this.banned.has(peerId),
982
+ onAuthorized: (peerId, verified) => this.authenticatedPeers.set(peerId, verified.expiration),
983
+ };
984
+ await this.node.handle(HTTP_PROTOCOL, ingressHandler(endpoint, providerOptions), HTTP_HANDLER_OPTIONS);
985
+ this.#policyTimer = setInterval(() => void this.syncPolicy().catch(() => { }), this.#policySyncMs);
986
+ this.#policyTimer.unref?.();
987
+ this.endpoint = endpoint;
988
+ return endpoint.service;
989
+ }
990
+ #scheduleRefresh() {
991
+ if (this.#closed) {
992
+ return;
993
+ }
994
+ clearTimeout(this.#refreshTimer);
995
+ const dueMs = this.mesh.credential.expiration * 1000 - this.#refreshLeadMs - Date.now();
996
+ const delay = Math.max(MIN_REFRESH_DELAY_MS, dueMs);
997
+ this.#refreshTimer = setTimeout(() => {
998
+ this.#refreshCredential().then(() => this.#scheduleRefresh(), () => {
999
+ if (!this.#closed) {
1000
+ this.#refreshTimer = setTimeout(() => this.#scheduleRefresh(), this.#refreshRetryMs);
1001
+ this.#refreshTimer.unref?.();
1002
+ }
1003
+ });
1004
+ }, delay);
1005
+ // A pending refresh must not keep an otherwise finished process alive.
1006
+ this.#refreshTimer.unref?.();
1007
+ }
1008
+ async close() {
1009
+ const announced = this.#announced.length > 0 && !this.#closed && !this.#isTaskView;
1010
+ this.#closed = true;
1011
+ clearTimeout(this.#refreshTimer);
1012
+ clearTimeout(this.#syncTimer);
1013
+ clearInterval(this.#policyTimer);
1014
+ clearInterval(this.#relayTimer);
1015
+ clearInterval(this.#announceTimer);
1016
+ if (announced) {
1017
+ // Best effort: the record expires on its own within the record TTL.
1018
+ await withdraw(this.#directoryRouters(), await peerKey(this.peerId), AbortSignal.timeout(WITHDRAW_TIMEOUT_MS)).catch(() => { });
1019
+ this.#announced = [];
1020
+ }
1021
+ if (!this.#isTaskView) {
1022
+ await this.node.stop();
1023
+ }
1024
+ }
1025
+ }
1026
+ _a = MeshSession;
1027
+ /**
1028
+ * Builds router candidates from ControlPlaneInfoResponse.routers, falling back
1029
+ * to router_addresses when routers is empty (an older control plane). When
1030
+ * routerMetadata is provided alongside explicit fallbackAddresses, labels and
1031
+ * load from routerMetadata are attached by peerId.
1032
+ */
1033
+ export function candidatesFromRouterInfos(routers, fallbackAddresses, routerMetadata = []) {
1034
+ if (routers.length > 0) {
1035
+ const out = [];
1036
+ for (const r of routers) {
1037
+ let peerId;
1038
+ if (r.peerId !== "") {
1039
+ try {
1040
+ peerId = canonicalPeerId(r.peerId);
1041
+ }
1042
+ catch {
1043
+ peerId = undefined;
1044
+ }
1045
+ }
1046
+ const addrs = [];
1047
+ for (const raw of r.addresses) {
1048
+ let ma;
1049
+ try {
1050
+ ma = multiaddr(raw);
1051
+ }
1052
+ catch {
1053
+ continue;
1054
+ }
1055
+ const target = targetPeerOf(ma);
1056
+ if (peerId === undefined && target !== undefined) {
1057
+ peerId = target;
1058
+ }
1059
+ if (peerId !== undefined && (target === undefined || target === peerId)) {
1060
+ addrs.push(target === undefined ? ma.encapsulate(`/p2p/${peerId}`) : ma);
1061
+ }
1062
+ }
1063
+ if (peerId === undefined || addrs.length === 0) {
1064
+ continue;
1065
+ }
1066
+ out.push({
1067
+ peerId,
1068
+ addresses: addrs,
1069
+ labels: { ...r.labels },
1070
+ connections: r.connections,
1071
+ connectionLimit: r.connectionLimit,
1072
+ });
1073
+ }
1074
+ return out;
1075
+ }
1076
+ const metaByPeer = new Map();
1077
+ for (const r of routerMetadata) {
1078
+ if (r.peerId !== "") {
1079
+ try {
1080
+ metaByPeer.set(canonicalPeerId(r.peerId), r);
1081
+ }
1082
+ catch {
1083
+ // Ignore malformed peer IDs.
1084
+ }
1085
+ }
1086
+ }
1087
+ const byId = new Map();
1088
+ for (const raw of fallbackAddresses) {
1089
+ let ma;
1090
+ try {
1091
+ ma = multiaddr(raw);
1092
+ }
1093
+ catch {
1094
+ continue;
1095
+ }
1096
+ const peerId = targetPeerOf(ma);
1097
+ if (peerId === undefined) {
1098
+ continue;
1099
+ }
1100
+ const existing = byId.get(peerId);
1101
+ if (existing !== undefined) {
1102
+ existing.addresses.push(ma);
1103
+ }
1104
+ else {
1105
+ const meta = metaByPeer.get(peerId);
1106
+ byId.set(peerId, {
1107
+ peerId,
1108
+ addresses: [ma],
1109
+ labels: meta !== undefined ? { ...meta.labels } : {},
1110
+ connections: meta?.connections ?? 0,
1111
+ connectionLimit: meta?.connectionLimit ?? 0,
1112
+ });
1113
+ }
1114
+ }
1115
+ return [...byId.values()];
1116
+ }
1117
+ function matchesLabels(routerLabels, selector) {
1118
+ if (selector === undefined) {
1119
+ return true;
1120
+ }
1121
+ for (const [k, v] of Object.entries(selector)) {
1122
+ if (routerLabels[k] !== v) {
1123
+ return false;
1124
+ }
1125
+ }
1126
+ return true;
1127
+ }
1128
+ function countMatchingLabels(routerLabels, prefer) {
1129
+ if (prefer === undefined) {
1130
+ return 0;
1131
+ }
1132
+ let n = 0;
1133
+ for (const [k, v] of Object.entries(prefer)) {
1134
+ if (routerLabels[k] === v) {
1135
+ n++;
1136
+ }
1137
+ }
1138
+ return n;
1139
+ }
1140
+ function routerLoad(c) {
1141
+ if (c.connectionLimit <= 0) {
1142
+ return 0.5;
1143
+ }
1144
+ const load = c.connections / c.connectionLimit;
1145
+ if (load < 0) {
1146
+ return 0;
1147
+ }
1148
+ if (load > 1) {
1149
+ return 1;
1150
+ }
1151
+ return load;
1152
+ }
1153
+ /**
1154
+ * Picks up to k routers from candidates, mirroring agentmesh-node's selectRouters:
1155
+ * filters by selector, excludes already-attached and currently-shunned routers,
1156
+ * and orders by matching prefer labels (descending) then load + jitter (ascending).
1157
+ */
1158
+ export function selectRouters(candidates, k, options = {}) {
1159
+ if (k <= 0) {
1160
+ return [];
1161
+ }
1162
+ const nowMs = options.nowMs ?? Date.now();
1163
+ const rand = options.random ?? Math.random;
1164
+ const scored = [];
1165
+ for (const c of candidates) {
1166
+ if (options.attached?.has(c.peerId) === true) {
1167
+ continue;
1168
+ }
1169
+ const shunExpiry = options.shunned?.get(c.peerId);
1170
+ if (shunExpiry !== undefined && shunExpiry > nowMs) {
1171
+ continue;
1172
+ }
1173
+ if (!matchesLabels(c.labels, options.selector)) {
1174
+ continue;
1175
+ }
1176
+ scored.push({
1177
+ candidate: c,
1178
+ preferScore: countMatchingLabels(c.labels, options.prefer),
1179
+ load: routerLoad(c) + rand() * 0.05,
1180
+ });
1181
+ }
1182
+ scored.sort((a, b) => {
1183
+ if (a.preferScore !== b.preferScore) {
1184
+ return b.preferScore - a.preferScore;
1185
+ }
1186
+ return a.load - b.load;
1187
+ });
1188
+ return scored.slice(0, k).map((s) => s.candidate);
1189
+ }
1190
+ /** Implements AgentMesh.join(); lives here to keep mesh.ts free of libp2p. */
1191
+ export async function joinMesh(mesh, options = {}) {
1192
+ // The pull agentmesh-node makes before it starts: a member resuming from its
1193
+ // state directory after a key rotation would otherwise verify the routers,
1194
+ // which already hold credentials under the new key, against the keys it
1195
+ // persisted, and the sync that would have brought the new key runs only
1196
+ // once joined. Best effort; the stored credential serves when the control
1197
+ // plane cannot be reached.
1198
+ try {
1199
+ await mesh.syncControlPlane();
1200
+ }
1201
+ catch {
1202
+ // Joining goes on with what the credential holds.
1203
+ }
1204
+ const rawAddrs = options.routerAddresses ?? mesh.credential.routerAddresses;
1205
+ if (rawAddrs.length === 0 && mesh.routers.length === 0) {
1206
+ throw new Error(options.routerAddresses === undefined ? "credential lists no router addresses; the control plane had no active router at enrollment" : "routerAddresses names no router");
1207
+ }
1208
+ // Validate explicit routerAddresses up front so malformed multiaddrs fail fast.
1209
+ for (const a of rawAddrs) {
1210
+ multiaddr(a);
1211
+ }
1212
+ const banned = new BanSet();
1213
+ const node = await createMeshHost(mesh.identity, { ...options, banned });
1214
+ const authenticatedPeers = new Map();
1215
+ let session;
1216
+ try {
1217
+ await node.handle(AUTH_PROTOCOL, authStreamHandler({
1218
+ ownBiscuit: () => mesh.credential.biscuit,
1219
+ trustedKeys: () => mesh.credential.controlPlaneKeys,
1220
+ isBanned: (peerId) => banned.has(peerId),
1221
+ onAuthenticated: (peerId, verified) => authenticatedPeers.set(peerId, verified.expiration),
1222
+ }), AUTH_HANDLER_OPTIONS);
1223
+ await node.handle(GOAWAY_PROTOCOL, goAwayStreamHandler({
1224
+ isHeldRouter: (peerId) => session?.isHeldRouter(peerId) === true,
1225
+ onGoAway: (peerId, msg, retryAfterMs) => session?.handleGoAway(peerId, msg, retryAfterMs),
1226
+ }), AUTH_HANDLER_OPTIONS);
1227
+ session = new MeshSession(mesh, node, [], authenticatedPeers, banned, options);
1228
+ const failures = await session.topUpRouters(options.signal);
1229
+ if (session.routers.length === 0) {
1230
+ const detail = failures.length > 0 ? `:\n ${failures.join("\n ")}` : "";
1231
+ throw new Error(`no router admitted this member${detail}`);
1232
+ }
1233
+ // Best effort: a router that does not take the record is a warning the
1234
+ // next announcement retries; the member is on the mesh regardless.
1235
+ await session.announce().catch(() => { });
1236
+ return session;
1237
+ }
1238
+ catch (err) {
1239
+ if (session !== undefined) {
1240
+ await session.close().catch(() => { });
1241
+ }
1242
+ else {
1243
+ await Promise.resolve(node.stop()).catch(() => { });
1244
+ }
1245
+ throw err;
1246
+ }
1247
+ }
1248
+ /** The peer a multiaddr ends at, in canonical form: its trailing `/p2p/<id>`, or undefined for a relay address with no target yet. */
1249
+ function targetPeerOf(ma) {
1250
+ const last = ma.getComponents().at(-1);
1251
+ return last?.name === "p2p" && last.value !== undefined ? canonicalPeerId(last.value) : undefined;
1252
+ }
1253
+ /** The remote address of a connection, ending in `/p2p/<peerId>`. */
1254
+ function connectedAddress(conn, peerId) {
1255
+ return targetPeerOf(conn.remoteAddr) === undefined ? conn.remoteAddr.encapsulate(`/p2p/${peerId}`) : conn.remoteAddr;
1256
+ }
1257
+ /** Canonicalizes a list from the control plane, dropping entries that are not peer IDs. */
1258
+ function canonicalPeerIds(ids) {
1259
+ const out = [];
1260
+ for (const id of ids) {
1261
+ try {
1262
+ out.push(canonicalPeerId(id));
1263
+ }
1264
+ catch {
1265
+ // Not a peer ID; it can match nothing, so it bans nothing.
1266
+ }
1267
+ }
1268
+ return out;
1269
+ }