@agentmesh-p2p/sdk 0.1.0 → 0.1.1

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/dist/auth.d.ts CHANGED
@@ -21,8 +21,9 @@ export declare class AuthRejectedError extends Error {
21
21
  /**
22
22
  * Client side: presents frame on a new /mesh/auth/1.0.0 stream and returns
23
23
  * the peer's verified credential. trustedKeys are the control plane keys.
24
+ * The handshake has a timeout of its own; outer, when given, can end it sooner.
24
25
  */
25
- export declare function authenticateWithPeer(conn: Connection, frame: Uint8Array, trustedKeys: Uint8Array[]): Promise<VerifiedBiscuit>;
26
+ export declare function authenticateWithPeer(conn: Connection, frame: Uint8Array, trustedKeys: Uint8Array[], outer?: AbortSignal): Promise<VerifiedBiscuit>;
26
27
  export interface AuthServerOptions {
27
28
  /** This member's current biscuit, read per handshake so a refresh takes effect. */
28
29
  ownBiscuit(): Uint8Array;
package/dist/auth.js CHANGED
@@ -45,9 +45,10 @@ function framed(stream) {
45
45
  /**
46
46
  * Client side: presents frame on a new /mesh/auth/1.0.0 stream and returns
47
47
  * the peer's verified credential. trustedKeys are the control plane keys.
48
+ * The handshake has a timeout of its own; outer, when given, can end it sooner.
48
49
  */
49
- export async function authenticateWithPeer(conn, frame, trustedKeys) {
50
- const signal = AbortSignal.timeout(AUTH_HANDSHAKE_TIMEOUT_MS);
50
+ export async function authenticateWithPeer(conn, frame, trustedKeys, outer) {
51
+ const signal = outer === undefined ? AbortSignal.timeout(AUTH_HANDSHAKE_TIMEOUT_MS) : AbortSignal.any([outer, AbortSignal.timeout(AUTH_HANDSHAKE_TIMEOUT_MS)]);
51
52
  // A peer behind a router is reached over a relayed, limited connection.
52
53
  const stream = await conn.newStream(AUTH_PROTOCOL, { signal, runOnLimitedConnection: true });
53
54
  try {
@@ -95,12 +95,12 @@ export declare class ControlPlaneClient {
95
95
  readonly url: URL;
96
96
  constructor(options: ControlPlaneClientOptions);
97
97
  /** GET /info: OIDC settings, router addresses and the ban list. Unauthenticated. */
98
- info(): Promise<ControlPlaneInfoResponse>;
98
+ info(signal?: AbortSignal): Promise<ControlPlaneInfoResponse>;
99
99
  /**
100
100
  * GET /keys: every signing key the control plane currently trusts,
101
101
  * verified against a key the caller already trusts (the enrollment key).
102
102
  */
103
- keys(trusted: Uint8Array[]): Promise<Uint8Array[]>;
103
+ keys(trusted: Uint8Array[], signal?: AbortSignal): Promise<Uint8Array[]>;
104
104
  /**
105
105
  * POST /enroll with a bootstrap token, then GET /enroll/status until an
106
106
  * operator approves the request if the mesh is not on auto-approve.
@@ -164,16 +164,16 @@ export class ControlPlaneClient {
164
164
  this.#now = options.now ?? (() => Date.now());
165
165
  }
166
166
  /** GET /info: OIDC settings, router addresses and the ban list. Unauthenticated. */
167
- async info() {
168
- const body = await this.#request("GET", "/info");
167
+ async info(signal) {
168
+ const body = await this.#request("GET", "/info", undefined, {}, signal);
169
169
  return fromBinary(ControlPlaneInfoResponseSchema, body);
170
170
  }
171
171
  /**
172
172
  * GET /keys: every signing key the control plane currently trusts,
173
173
  * verified against a key the caller already trusts (the enrollment key).
174
174
  */
175
- async keys(trusted) {
176
- const body = await this.#request("GET", "/keys");
175
+ async keys(trusted, signal) {
176
+ const body = await this.#request("GET", "/keys", undefined, {}, signal);
177
177
  return verifyKeysResponse(fromBinary(KeysResponseSchema, body), trusted, this.#now());
178
178
  }
179
179
  /**
@@ -304,14 +304,14 @@ export class ControlPlaneClient {
304
304
  }
305
305
  throw new ControlPlaneError(path, outcome.status, text);
306
306
  }
307
- async #fetchOnce(method, path, body, headers = {}) {
308
- const controller = new AbortController();
309
- const timer = setTimeout(() => controller.abort(new Error(`control plane ${path}: timed out after ${this.#timeoutMs}ms`)), this.#timeoutMs);
307
+ async #fetchOnce(method, path, body, headers = {}, signal) {
308
+ // The caller's signal cancels the request itself, not only the wait for it.
309
+ const timeout = AbortSignal.timeout(this.#timeoutMs);
310
310
  try {
311
311
  const init = {
312
312
  method,
313
313
  headers: { Accept: PROTOBUF_CONTENT_TYPE, ...headers },
314
- signal: controller.signal,
314
+ signal: signal === undefined ? timeout : AbortSignal.any([signal, timeout]),
315
315
  };
316
316
  if (body !== undefined) {
317
317
  init.body = new Uint8Array(body);
@@ -324,12 +324,15 @@ export class ControlPlaneClient {
324
324
  }
325
325
  return { ok: resp.ok, status: resp.status, buf, dateHeader: resp.headers.get("date") };
326
326
  }
327
- finally {
328
- clearTimeout(timer);
327
+ catch (err) {
328
+ if (timeout.aborted && signal?.aborted !== true) {
329
+ throw new Error(`control plane ${path}: timed out after ${this.#timeoutMs}ms`);
330
+ }
331
+ throw err;
329
332
  }
330
333
  }
331
- async #request(method, path, body, headers = {}) {
332
- const outcome = await this.#fetchOnce(method, path, body, headers);
334
+ async #request(method, path, body, headers = {}, signal) {
335
+ const outcome = await this.#fetchOnce(method, path, body, headers, signal);
333
336
  if (!outcome.ok) {
334
337
  throw new ControlPlaneError(path, outcome.status, new TextDecoder().decode(outcome.buf));
335
338
  }
package/dist/discovery.js CHANGED
@@ -84,7 +84,7 @@ async function ask(conn, req, signal) {
84
84
  */
85
85
  export async function request(routers, req, signal) {
86
86
  const lookup = req.request.case === "findProviders" || req.request.case === "findPeer";
87
- let last;
87
+ const failures = [];
88
88
  let asked = 0;
89
89
  let empty;
90
90
  for (const router of routers) {
@@ -103,7 +103,7 @@ export async function request(routers, req, signal) {
103
103
  return resp;
104
104
  }
105
105
  catch (err) {
106
- last = err;
106
+ failures.push(`${router.peerId}: ${err instanceof Error ? err.message : String(err)}`);
107
107
  if (signal?.aborted === true) {
108
108
  break;
109
109
  }
@@ -115,7 +115,7 @@ export async function request(routers, req, signal) {
115
115
  if (asked === 0) {
116
116
  throw new DirectoryError("no held router to ask");
117
117
  }
118
- throw new DirectoryError(`no held router answered: ${last instanceof Error ? last.message : String(last)}`);
118
+ throw new DirectoryError(`no held router answered: ${failures.join("; ")}`);
119
119
  }
120
120
  /**
121
121
  * Announces this member as a provider of key, reached at addrs, to a held
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export { Identity, canonicalPeerId, peerIdFromPublicKey, libp2pPublicKey, verify
3
3
  export { ControlPlaneClient, ControlPlaneError, EnrollmentRejectedError, InsecureControlPlaneURLError, KeysNotTrustedError, ROLE_NODE, validateControlPlaneURL, verifyKeysResponse, type ControlPlaneClientOptions, type Enrollment, type EnrollBootstrapParams, type PolicyRulesParams, type RegisterParams, type RefreshParams, type RefreshResult, } from "./controlplane.ts";
4
4
  export { attenuateCredential, credentialFromJSON, credentialTimeToLiveSeconds, credentialToJSON, decodeAuthResponse, encodeAuthFrame, sealCredential, withCredentialMethods, type MeshCredential, } from "./credential.ts";
5
5
  export { egressChallenge, enrollChallenge, enrollStatusChallenge, nodesCatalogChallenge, policiesChallenge, refreshChallenge, registerChallenge, revocationsChallenge, } from "./challenges.ts";
6
- export { DEFAULT_ROUTERS, MeshSession, ROUTER_REDIAL_BACKOFFS_MS, candidatesFromRouterInfos, selectRouters, type AdmittedRouter, type DiscoveredProvider, type JoinOptions, type Peer, type RouterCandidate, type SelectRoutersOptions, type ToolCallResult, } from "./session.ts";
6
+ export { DEFAULT_ROUTERS, JOIN_TIMEOUT_MS, MeshSession, ROUTER_REDIAL_BACKOFFS_MS, candidatesFromRouterInfos, selectRouters, type AdmittedRouter, type DiscoveredProvider, type JoinOptions, type Peer, type RouterCandidate, type SelectRoutersOptions, type ToolCallResult, } from "./session.ts";
7
7
  export { BiscuitVerificationError, ROLE_ROUTER, attenuateBiscuit, requireRole, sealBiscuit, verifyPeerBiscuit, type VerifiedBiscuit, } from "./biscuit.ts";
8
8
  export { decodeTARBlockPayload, effectiveTARExpiration, encodeTARBlockFact, encodeTARBlockPayload, evaluateTaskRules, matchHTTPPath, matchServicePattern, matchTaskRule, parseTARBlockSource, validateHTTPGrantPath, validateServicePattern, validateTaskAuthorizationRule, validateTaskRule, type TaskRequestContext, } from "./tar.ts";
9
9
  export { AUTH_HANDLER_OPTIONS, AUTH_PROTOCOL, GOAWAY_MAX_RETRY_AFTER_MS, GOAWAY_PROTOCOL, GOAWAY_STREAM_TIMEOUT_MS, MAX_GOAWAY_MESSAGE_BYTES, MCP_PROTOCOL, ROUTER_SHUN_DURATION_MS, AuthRejectedError, authenticateWithPeer, authStreamHandler, clampGoAwayRetryAfterMs, goAwayReasonName, goAwayStreamHandler, type GoAwayServerOptions, } from "./auth.ts";
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ export { Identity, canonicalPeerId, peerIdFromPublicKey, libp2pPublicKey, verify
16
16
  export { ControlPlaneClient, ControlPlaneError, EnrollmentRejectedError, InsecureControlPlaneURLError, KeysNotTrustedError, ROLE_NODE, validateControlPlaneURL, verifyKeysResponse, } from "./controlplane.js";
17
17
  export { attenuateCredential, credentialFromJSON, credentialTimeToLiveSeconds, credentialToJSON, decodeAuthResponse, encodeAuthFrame, sealCredential, withCredentialMethods, } from "./credential.js";
18
18
  export { egressChallenge, enrollChallenge, enrollStatusChallenge, nodesCatalogChallenge, policiesChallenge, refreshChallenge, registerChallenge, revocationsChallenge, } from "./challenges.js";
19
- export { DEFAULT_ROUTERS, MeshSession, ROUTER_REDIAL_BACKOFFS_MS, candidatesFromRouterInfos, selectRouters, } from "./session.js";
19
+ export { DEFAULT_ROUTERS, JOIN_TIMEOUT_MS, MeshSession, ROUTER_REDIAL_BACKOFFS_MS, candidatesFromRouterInfos, selectRouters, } from "./session.js";
20
20
  export { BiscuitVerificationError, ROLE_ROUTER, attenuateBiscuit, requireRole, sealBiscuit, verifyPeerBiscuit, } from "./biscuit.js";
21
21
  export { decodeTARBlockPayload, effectiveTARExpiration, encodeTARBlockFact, encodeTARBlockPayload, evaluateTaskRules, matchHTTPPath, matchServicePattern, matchTaskRule, parseTARBlockSource, validateHTTPGrantPath, validateServicePattern, validateTaskAuthorizationRule, validateTaskRule, } from "./tar.js";
22
22
  export { AUTH_HANDLER_OPTIONS, AUTH_PROTOCOL, GOAWAY_MAX_RETRY_AFTER_MS, GOAWAY_PROTOCOL, GOAWAY_STREAM_TIMEOUT_MS, MAX_GOAWAY_MESSAGE_BYTES, MCP_PROTOCOL, ROUTER_SHUN_DURATION_MS, AuthRejectedError, authenticateWithPeer, authStreamHandler, clampGoAwayRetryAfterMs, goAwayReasonName, goAwayStreamHandler, } from "./auth.js";
package/dist/mcp.js CHANGED
@@ -21,7 +21,7 @@ import { decodeAuthFrame, decodeAuthResponse } from "./credential.js";
21
21
  /** go-msgio's default message cap, which agentmesh-node's StreamTransport uses. */
22
22
  export const MAX_MCP_MESSAGE_BYTES = 8 * 1024 * 1024;
23
23
  /** The MCP client the SDK identifies itself as. */
24
- export const MCP_CLIENT_INFO = { name: "agent-mesh-sdk", version: "0.1.0" };
24
+ export const MCP_CLIENT_INFO = { name: "agent-mesh-sdk", version: "0.1.1" };
25
25
  /**
26
26
  * A Transport for @modelcontextprotocol/sdk over a libp2p stream that has
27
27
  * already passed the auth handshake.
package/dist/mesh.d.ts CHANGED
@@ -117,9 +117,10 @@ export declare class AgentMesh {
117
117
  * Trades the current biscuit for a fresh one and persists it. The control
118
118
  * plane redeems only the last biscuit it issued, so a lost refresh result
119
119
  * means re-enrolling; persisting before returning keeps that rare.
120
- * Concurrent calls wait for each other.
120
+ * Concurrent calls wait for each other. A refresh whose signal has
121
+ * aborted by the time its turn comes does not start.
121
122
  */
122
- refresh(): Promise<MeshCredential>;
123
+ refresh(signal?: AbortSignal): Promise<MeshCredential>;
123
124
  /**
124
125
  * Adopts a signing key announced by a KEY_ROTATION event, so peers whose
125
126
  * credentials the new key signs verify before the next pull confirms it.
@@ -133,8 +134,11 @@ export declare class AgentMesh {
133
134
  * addresses and the ban set. Each part is attempted even when another
134
135
  * fails; the errors are reported together. Gossip events only bring this
135
136
  * forward; they are never the only way state arrives.
137
+ *
138
+ * An aborted signal cancels the request in flight and throws its reason:
139
+ * nothing more is sent and nothing is written to the state directory.
136
140
  */
137
- syncControlPlane(): Promise<ControlPlaneSync>;
141
+ syncControlPlane(signal?: AbortSignal): Promise<ControlPlaneSync>;
138
142
  /**
139
143
  * The frame that opens every stream to a peer: this member's biscuit plus
140
144
  * the service it wants (e.g. "mcp://calculator").
package/dist/mesh.js CHANGED
@@ -194,14 +194,16 @@ export class AgentMesh {
194
194
  * Trades the current biscuit for a fresh one and persists it. The control
195
195
  * plane redeems only the last biscuit it issued, so a lost refresh result
196
196
  * means re-enrolling; persisting before returning keeps that rare.
197
- * Concurrent calls wait for each other.
197
+ * Concurrent calls wait for each other. A refresh whose signal has
198
+ * aborted by the time its turn comes does not start.
198
199
  */
199
- async refresh() {
200
- const run = this.#refreshChain.then(() => this.#refreshOnce(), () => this.#refreshOnce());
200
+ async refresh(signal) {
201
+ const run = this.#refreshChain.then(() => this.#refreshOnce(signal), () => this.#refreshOnce(signal));
201
202
  this.#refreshChain = run.then(() => undefined, () => undefined);
202
203
  return run;
203
204
  }
204
- async #refreshOnce() {
205
+ async #refreshOnce(signal) {
206
+ signal?.throwIfAborted();
205
207
  let jwt;
206
208
  if (this.#jwtSource !== undefined) {
207
209
  try {
@@ -214,6 +216,10 @@ export class AgentMesh {
214
216
  // Best effort: fall back to session-only refresh if the token source is temporarily unavailable.
215
217
  }
216
218
  }
219
+ // The last point to stop. The /refresh request itself is not cut short:
220
+ // the control plane spends the old biscuit when it answers, so only a
221
+ // refresh that runs to the end can save the new one.
222
+ signal?.throwIfAborted();
217
223
  const result = await this.controlPlane.refresh({
218
224
  identity: this.identity,
219
225
  biscuit: this.#credential.biscuit,
@@ -256,33 +262,37 @@ export class AgentMesh {
256
262
  * addresses and the ban set. Each part is attempted even when another
257
263
  * fails; the errors are reported together. Gossip events only bring this
258
264
  * forward; they are never the only way state arrives.
265
+ *
266
+ * An aborted signal cancels the request in flight and throws its reason:
267
+ * nothing more is sent and nothing is written to the state directory.
259
268
  */
260
- async syncControlPlane() {
269
+ async syncControlPlane(signal) {
261
270
  const errors = [];
262
271
  let keysChanged = false;
263
272
  let refreshed = false;
264
273
  try {
265
- const keys = await this.controlPlane.keys(this.#credential.controlPlaneKeys);
274
+ const keys = await this.controlPlane.keys(this.#credential.controlPlaneKeys, signal);
266
275
  if (keys.length === 0) {
267
276
  throw new Error("/keys returned no keys");
268
277
  }
269
278
  keysChanged = !sameKeySet(keys, this.#credential.controlPlaneKeys);
270
279
  this.#credential = withCredentialMethods({ ...this.#credential, controlPlaneKeys: keys });
271
280
  if (credentialPredatesRotation(this.#credential)) {
272
- await this.refresh();
281
+ await this.refresh(signal);
273
282
  refreshed = true;
274
283
  }
275
284
  }
276
285
  catch (err) {
277
286
  errors.push(`keys: ${err instanceof Error ? err.message : String(err)}`);
278
287
  }
288
+ signal?.throwIfAborted();
279
289
  let bannedPeerIds;
280
290
  let routers;
281
291
  // Taken before the request: a ban recorded after this instant cannot be
282
292
  // in the answer, so its absence must not be read as an unban.
283
293
  const fetchedAt = new Date();
284
294
  try {
285
- const info = await this.controlPlane.info();
295
+ const info = await this.controlPlane.info(signal);
286
296
  routers = info.routers;
287
297
  this.#routers = info.routers;
288
298
  const discovered = info.routerAddresses.length > 0
@@ -296,6 +306,7 @@ export class AgentMesh {
296
306
  catch (err) {
297
307
  errors.push(`info: ${err instanceof Error ? err.message : String(err)}`);
298
308
  }
309
+ signal?.throwIfAborted();
299
310
  if (keysChanged || bannedPeerIds !== undefined) {
300
311
  await this.save();
301
312
  }
package/dist/session.d.ts CHANGED
@@ -78,7 +78,12 @@ export interface JoinOptions extends MeshHostOptions {
78
78
  * requiredLabels. Held on every call, MCP and HTTP alike; no call waives it.
79
79
  */
80
80
  egressRequireLabels?: Record<string, string>;
81
- /** Bounds the whole join. */
81
+ /**
82
+ * Bounds the whole join: the control plane sync, and the dial, auth
83
+ * handshake and relay reservation with every router tried. Without one the
84
+ * join is bounded by JOIN_TIMEOUT_MS (60s). A join that stops throws an
85
+ * error naming each router tried and the step it was in.
86
+ */
82
87
  signal?: AbortSignal;
83
88
  }
84
89
  export interface AdmittedRouter {
@@ -124,6 +129,17 @@ export interface ToolCallResult {
124
129
  /** The raw MCP result. */
125
130
  raw: unknown;
126
131
  }
132
+ /**
133
+ * Bounds a join whose caller passed no signal: each step has a timeout of
134
+ * its own, but a join tries them in turn over every router it is offered.
135
+ */
136
+ export declare const JOIN_TIMEOUT_MS = 60000;
137
+ /**
138
+ * Bounds the control plane sync a join starts with. The sync is best effort,
139
+ * so a control plane that does not answer in time leaves the join to go on
140
+ * with the stored credential instead of using up the whole join.
141
+ */
142
+ export declare const JOIN_SYNC_TIMEOUT_MS = 5000;
127
143
  /**
128
144
  * A member that is on the mesh: a libp2p host authenticated with at least
129
145
  * one router, answering the auth handshake for peers that dial it, and
@@ -184,7 +200,7 @@ export declare class MeshSession {
184
200
  * the mesh's record TTL. A member with no relayed address announces
185
201
  * nothing: there is nothing to reach it at.
186
202
  */
187
- announce(): Promise<void>;
203
+ announce(signal?: AbortSignal): Promise<void>;
188
204
  /** Whether peerId is currently one of the routers this member holds. */
189
205
  isHeldRouter(peerId: string): boolean;
190
206
  /** Handles a /mesh/goaway/1.0.0 message from a held router: shuns it, detaches it, and tops up from the catalog. */
@@ -312,5 +328,8 @@ export declare function candidatesFromRouterInfos(routers: readonly RouterInfo[]
312
328
  * and orders by matching prefer labels (descending) then load + jitter (ascending).
313
329
  */
314
330
  export declare function selectRouters(candidates: readonly RouterCandidate[], k: number, options?: SelectRoutersOptions): RouterCandidate[];
315
- /** Implements AgentMesh.join(); lives here to keep mesh.ts free of libp2p. */
316
- export declare function joinMesh(mesh: AgentMesh, options?: JoinOptions): Promise<MeshSession>;
331
+ /**
332
+ * Implements AgentMesh.join(); lives here to keep mesh.ts free of libp2p.
333
+ * timeoutMs bounds a join whose caller passed no signal.
334
+ */
335
+ export declare function joinMesh(mesh: AgentMesh, options?: JoinOptions, timeoutMs?: number): Promise<MeshSession>;
package/dist/session.js CHANGED
@@ -51,6 +51,17 @@ const DEFAULT_RELAY_CHECK_MS = 30 * 1000;
51
51
  const DEFAULT_ANNOUNCE_MS = 5 * 60 * 1000;
52
52
  /** What close() waits for the member's record to be withdrawn. */
53
53
  const WITHDRAW_TIMEOUT_MS = 2 * 1000;
54
+ /**
55
+ * Bounds a join whose caller passed no signal: each step has a timeout of
56
+ * its own, but a join tries them in turn over every router it is offered.
57
+ */
58
+ export const JOIN_TIMEOUT_MS = 60_000;
59
+ /**
60
+ * Bounds the control plane sync a join starts with. The sync is best effort,
61
+ * so a control plane that does not answer in time leaves the join to go on
62
+ * with the stored credential instead of using up the whole join.
63
+ */
64
+ export const JOIN_SYNC_TIMEOUT_MS = 5_000;
54
65
  /** How long a provider's positive egress verdict is kept; agentmesh-node's labelGateTTL. */
55
66
  const EGRESS_VERDICT_TTL_MS = 5 * 60 * 1000;
56
67
  /**
@@ -220,7 +231,7 @@ export class MeshSession {
220
231
  * the mesh's record TTL. A member with no relayed address announces
221
232
  * nothing: there is nothing to reach it at.
222
233
  */
223
- async announce() {
234
+ async announce(signal) {
224
235
  if (this.#closed || this.#isTaskView) {
225
236
  return;
226
237
  }
@@ -228,7 +239,7 @@ export class MeshSession {
228
239
  if (addrs.length === 0) {
229
240
  return;
230
241
  }
231
- await provide(this.#directoryRouters(), await peerKey(this.peerId), addrs);
242
+ await provide(this.#directoryRouters(), await peerKey(this.peerId), addrs, signal);
232
243
  this.#announced = addrs;
233
244
  }
234
245
  /** Whether peerId is currently one of the routers this member holds. */
@@ -372,14 +383,15 @@ export class MeshSession {
372
383
  /**
373
384
  * Runs the handshake on a router connection once: the connection:open
374
385
  * listener and an explicit admission both call this, and the second waits
375
- * for the first instead of running a second handshake.
386
+ * for the first instead of running a second handshake. The signal applies
387
+ * to a handshake this call starts; one already running keeps its own.
376
388
  */
377
- #authenticateRouter(conn) {
389
+ #authenticateRouter(conn, signal) {
378
390
  const pending = this.#routerHandshakes.get(conn.id);
379
391
  if (pending !== undefined) {
380
392
  return pending;
381
393
  }
382
- const run = authenticateWithPeer(conn, this.mesh.authFrame(), this.mesh.credential.controlPlaneKeys).finally(() => {
394
+ const run = authenticateWithPeer(conn, this.mesh.authFrame(), this.mesh.credential.controlPlaneKeys, signal).finally(() => {
383
395
  this.#routerHandshakes.delete(conn.id);
384
396
  });
385
397
  this.#routerHandshakes.set(conn.id, run);
@@ -496,6 +508,8 @@ export class MeshSession {
496
508
  */
497
509
  async topUpRouters(signal) {
498
510
  const failures = [];
511
+ // A function, not a narrowed value: the signal aborts across the awaits below.
512
+ const aborted = () => signal?.aborted === true;
499
513
  const want = this.#wantRouters();
500
514
  const candidates = this.#candidatesForHolding();
501
515
  const tried = new Set();
@@ -513,22 +527,37 @@ export class MeshSession {
513
527
  }
514
528
  let progress = false;
515
529
  for (const cand of picks) {
530
+ // A router not dialed yet is not tried under a signal that has
531
+ // already aborted: it would fail at once and be shunned for nothing.
532
+ if (aborted()) {
533
+ return failures;
534
+ }
516
535
  tried.add(cand.peerId);
536
+ // Each step has a timeout of its own; the failure names the one the
537
+ // router was in, so a join that hangs says where.
538
+ let step = `dial ${cand.addresses.map(String).join(", ")}`;
517
539
  try {
518
540
  const conn = await this.node.dial(cand.addresses, signal !== undefined ? { signal } : {});
519
- const credential = await this.#authenticateRouter(conn);
541
+ step = "auth handshake";
542
+ const credential = await this.#authenticateRouter(conn, signal);
520
543
  requireRole(credential, ROLE_ROUTER);
521
544
  const addr = connectedAddress(conn, cand.peerId);
522
545
  if (this.#reserveRelay) {
546
+ step = "relay reservation";
523
547
  await this.#reserveOnRouter(cand.peerId, addr);
524
548
  }
525
549
  this.#upsertHeldRouter({ peerId: cand.peerId, addr, credential });
526
550
  progress = true;
527
551
  }
528
552
  catch (err) {
529
- this.#shunRouter(cand.peerId, ROUTER_SHUN_DURATION_MS);
553
+ failures.push(`${cand.peerId}: ${step}: ${errorText(err)}`);
530
554
  await this.node.hangUp(peerIdFromString(cand.peerId)).catch(() => { });
531
- failures.push(`${cand.peerId}: ${err instanceof Error ? err.message : String(err)}`);
555
+ // A router cut off by the signal did not fail on its own, so it is
556
+ // not shunned.
557
+ if (aborted()) {
558
+ return failures;
559
+ }
560
+ this.#shunRouter(cand.peerId, ROUTER_SHUN_DURATION_MS);
532
561
  }
533
562
  }
534
563
  if (!progress && picks.length < need) {
@@ -1187,8 +1216,14 @@ export function selectRouters(candidates, k, options = {}) {
1187
1216
  });
1188
1217
  return scored.slice(0, k).map((s) => s.candidate);
1189
1218
  }
1190
- /** Implements AgentMesh.join(); lives here to keep mesh.ts free of libp2p. */
1191
- export async function joinMesh(mesh, options = {}) {
1219
+ /**
1220
+ * Implements AgentMesh.join(); lives here to keep mesh.ts free of libp2p.
1221
+ * timeoutMs bounds a join whose caller passed no signal.
1222
+ */
1223
+ export async function joinMesh(mesh, options = {}, timeoutMs = JOIN_TIMEOUT_MS) {
1224
+ const signal = options.signal ?? AbortSignal.timeout(timeoutMs);
1225
+ // Why the join stopped: the caller's reason, or its own timeout.
1226
+ const stopReason = () => (options.signal !== undefined ? errorText(signal.reason) : `timed out after ${timeoutMs}ms`);
1192
1227
  // The pull agentmesh-node makes before it starts: a member resuming from its
1193
1228
  // state directory after a key rotation would otherwise verify the routers,
1194
1229
  // which already hold credentials under the new key, against the keys it
@@ -1196,10 +1231,14 @@ export async function joinMesh(mesh, options = {}) {
1196
1231
  // once joined. Best effort; the stored credential serves when the control
1197
1232
  // plane cannot be reached.
1198
1233
  try {
1199
- await mesh.syncControlPlane();
1234
+ await mesh.syncControlPlane(AbortSignal.any([signal, AbortSignal.timeout(JOIN_SYNC_TIMEOUT_MS)]));
1200
1235
  }
1201
1236
  catch {
1202
- // Joining goes on with what the credential holds.
1237
+ // Joining goes on with what the credential holds, unless the caller
1238
+ // stopped the join while the control plane kept it waiting.
1239
+ if (signal.aborted) {
1240
+ throw new Error(`join stopped while syncing with the control plane: ${stopReason()}`);
1241
+ }
1203
1242
  }
1204
1243
  const rawAddrs = options.routerAddresses ?? mesh.credential.routerAddresses;
1205
1244
  if (rawAddrs.length === 0 && mesh.routers.length === 0) {
@@ -1225,14 +1264,18 @@ export async function joinMesh(mesh, options = {}) {
1225
1264
  onGoAway: (peerId, msg, retryAfterMs) => session?.handleGoAway(peerId, msg, retryAfterMs),
1226
1265
  }), AUTH_HANDLER_OPTIONS);
1227
1266
  session = new MeshSession(mesh, node, [], authenticatedPeers, banned, options);
1228
- const failures = await session.topUpRouters(options.signal);
1229
- if (session.routers.length === 0) {
1267
+ const failures = await session.topUpRouters(signal);
1268
+ // One admitted router is enough to be on the mesh; only the caller's own
1269
+ // signal turns that into a failure.
1270
+ if (options.signal?.aborted === true || session.routers.length === 0) {
1230
1271
  const detail = failures.length > 0 ? `:\n ${failures.join("\n ")}` : "";
1231
- throw new Error(`no router admitted this member${detail}`);
1272
+ throw new Error(signal.aborted ? `join stopped: ${stopReason()}${detail}` : `no router admitted this member${detail}`);
1232
1273
  }
1233
1274
  // 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(() => { });
1275
+ // next announcement retries; the member is on the mesh regardless. Under
1276
+ // the caller's signal only: a join that ran out of its own time still
1277
+ // announces the routers it holds.
1278
+ await session.announce(options.signal).catch(() => { });
1236
1279
  return session;
1237
1280
  }
1238
1281
  catch (err) {
@@ -1245,6 +1288,9 @@ export async function joinMesh(mesh, options = {}) {
1245
1288
  throw err;
1246
1289
  }
1247
1290
  }
1291
+ function errorText(err) {
1292
+ return err instanceof Error ? err.message : String(err);
1293
+ }
1248
1294
  /** The peer a multiaddr ends at, in canonical form: its trailing `/p2p/<id>`, or undefined for a relay address with no target yet. */
1249
1295
  function targetPeerOf(ma) {
1250
1296
  const last = ma.getComponents().at(-1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentmesh-p2p/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Native SDK for joining an Agent Mesh from a Node.js agent process or a browser page",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {