@cello-protocol/transport 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,680 @@
1
+ /**
2
+ * CELLO SignalingManager — directory signaling stream lifecycle + step-6 auth + manifest polling.
3
+ *
4
+ * Owns:
5
+ * - Heartbeat keepalive (ping every N seconds, pong timeout N seconds)
6
+ * - Exponential backoff reconnect (1s → maxBackoffMs cap, max N attempts)
7
+ * - directory_signaling status observable: 'connected' | 'reconnecting' | 'lost'
8
+ * - Outbound operation queue during reconnect (bounded FIFO)
9
+ * - Step-6 directory challenge verification (MANIFEST-002, RFC 8032)
10
+ * - Background manifest polling
11
+ *
12
+ * Connection lifecycle pseudocode:
13
+ * 1. constructor(opts):
14
+ * a. Store config (heartbeat/backoff/queue params)
15
+ * b. Set status = 'reconnecting'
16
+ * c. Begin connection loop immediately (no pre-attempt state)
17
+ *
18
+ * 2. connectionLoop():
19
+ * a. Call connect() (injectable — full 7-step handshake in prod)
20
+ * b. On success: set status = 'connected', log directory.signaling.connected,
21
+ * drain outbound queue, then start heartbeat loop
22
+ * c. On failure: log directory.signaling.reconnecting with attempt/backoffMs,
23
+ * wait backoffMs, increment attempt. If attempt > max, transition to 'lost'.
24
+ *
25
+ * 3. heartbeatLoop():
26
+ * a. Every heartbeatIntervalMs, send { type: 'ping', ts: counter++ }
27
+ * b. Start pong timeout timer (heartbeatTimeoutMs)
28
+ * c. On pong received: reset timeout timer
29
+ * d. On timeout: declare stream dead, transition to 'reconnecting'
30
+ * e. On send error: declare stream dead immediately
31
+ *
32
+ * 4. submitMcpOperation():
33
+ * a. If 'connected': return { ok: true }
34
+ * b. If 'reconnecting': return { ok: false, reason: 'signaling_reconnecting', guidance }
35
+ * c. If 'lost': return { ok: false, reason: 'signaling_lost', guidance }
36
+ *
37
+ * 5. submitInternalOperation(execute):
38
+ * a. If 'connected': execute immediately, return result
39
+ * b. If 'reconnecting' + queue not full: queue, return pending promise
40
+ * c. If 'reconnecting' + queue full: return { ok: false, reason: 'signaling_queue_full' }
41
+ * d. If 'lost': return { ok: false, reason: 'signaling_lost', guidance }
42
+ *
43
+ * 6. drainQueue():
44
+ * a. For each queued op in FIFO order: call execute(), resolve/reject caller promise
45
+ * b. On execute() failure: reject that op, continue with next
46
+ *
47
+ * 7. flushQueue(reason):
48
+ * a. Reject all pending ops with the given error response
49
+ * b. Clear queue
50
+ *
51
+ * 8. stop():
52
+ * a. Set stopped = true
53
+ * b. Cancel backoff wait (if in progress)
54
+ * c. Cancel heartbeat timers
55
+ * d. Close current stream (if any)
56
+ * e. Flush queue with shutdown error
57
+ * f. Do NOT transition to 'lost'
58
+ *
59
+ * Step-6 directory challenge verification (RFC 8032):
60
+ * processStep5Frame() is called inside production connect() after auth_ok.
61
+ * TBS format (authoritative):
62
+ * UTF-8('cello-directory-auth-challenge-v1\n') +
63
+ * UTF-8(nodeId) + '\n' + UTF-8(agentPubkeyHex) + '\n' +
64
+ * UTF-8(nonceHex) + '\n' + UTF-8(isoTimestamp)
65
+ */
66
+ import { verifyManifest } from "@cello-protocol/crypto";
67
+ export class InMemorySignalingOutboundQueue {
68
+ #frames = [];
69
+ enqueue(frame) {
70
+ this.#frames.push(frame);
71
+ }
72
+ getFrames() {
73
+ return this.#frames;
74
+ }
75
+ drain() {
76
+ return this.#frames.splice(0);
77
+ }
78
+ }
79
+ // ─── Guidance strings ─────────────────────────────────────────────────────────
80
+ const GUIDANCE_RECONNECTING = "The directory signaling stream is reconnecting. The daemon reconnects automatically — wait for directory_signaling to show connected in cello status before retrying this operation.";
81
+ const GUIDANCE_LOST = "The daemon has lost its connection to the directory and automatic reconnection has failed. Check network connectivity, then run cello logout followed by cello login to restart the directory connection.";
82
+ const GUIDANCE_QUEUE_FULL = "The internal operation queue is at capacity (64 operations pending). The daemon is reconnecting — wait for directory_signaling to show connected in cello status.";
83
+ const GUIDANCE_SHUTDOWN = "The daemon is shutting down. All pending operations have been cancelled.";
84
+ export class SignalingManager {
85
+ _status = "reconnecting";
86
+ _stopped = false;
87
+ _currentStream = null;
88
+ _currentDirectoryNodeId = null;
89
+ _queue = [];
90
+ _pingCounter = 0;
91
+ _heartbeatInterval = null;
92
+ _heartbeatTimeout = null;
93
+ _backoffTimeout = null;
94
+ _backoffResolve = null;
95
+ _draining = false;
96
+ // Connection lifecycle config
97
+ _connect;
98
+ _logger;
99
+ _heartbeatIntervalMs;
100
+ _heartbeatTimeoutMs;
101
+ _maxReconnectAttempts;
102
+ _initialBackoffMs;
103
+ _maxBackoffMs;
104
+ _outboundQueueCap;
105
+ // Step-6 challenge verification state (MANIFEST-002)
106
+ _challengeVerifier;
107
+ _pendingNonce = null;
108
+ _agentPubkeyHex = null;
109
+ // M7-SESSION-001: registered inbound message handlers.
110
+ // Handlers are called for every inbound frame not consumed by built-in logic.
111
+ _inboundHandlers = [];
112
+ // Manifest polling state (MANIFEST-002)
113
+ _pollScheduler;
114
+ _manifestVersionStore;
115
+ _manifestProvider;
116
+ _correlationId;
117
+ _rootKeys;
118
+ _threshold;
119
+ // Per-poll-cycle correlation id, minted at dispatch and threaded through to the
120
+ // response handler so every event in one poll flow is correlatable (DOD-INV-8).
121
+ #pollCounter = 0;
122
+ #pollCorrelationId = "";
123
+ constructor(opts) {
124
+ this._connect = opts.connect ?? (() => Promise.reject(new Error("no_connect_configured")));
125
+ this._logger = opts.logger;
126
+ this._heartbeatIntervalMs = opts.heartbeatIntervalMs ?? 15_000;
127
+ this._heartbeatTimeoutMs = opts.heartbeatTimeoutMs ?? 15_000;
128
+ this._maxReconnectAttempts = opts.maxReconnectAttempts ?? 10;
129
+ this._initialBackoffMs = opts.initialBackoffMs ?? 1_000;
130
+ this._maxBackoffMs = opts.maxBackoffMs ?? 60_000;
131
+ this._outboundQueueCap = opts.outboundQueueCap ?? 64;
132
+ // MANIFEST-002 optional fields
133
+ this._challengeVerifier = opts.challengeVerifier;
134
+ this._pollScheduler = opts.pollScheduler;
135
+ this._manifestVersionStore = opts.manifestVersionStore;
136
+ this._manifestProvider = opts.manifestProvider;
137
+ this._correlationId = opts.correlationId ?? "";
138
+ this._rootKeys = opts.rootKeys ?? [];
139
+ this._threshold = opts.threshold ?? 0;
140
+ // Begin connection immediately — status starts as 'reconnecting'
141
+ void this.reconnectLoop();
142
+ }
143
+ // ─── Public API — connection status ─────────────────────────────────────────
144
+ get status() {
145
+ return this._status;
146
+ }
147
+ get queueDepth() {
148
+ return this._queue.length;
149
+ }
150
+ /**
151
+ * M7-SESSION-001: Send a frame directly on the active signaling stream.
152
+ * Returns ok:true when connected and send succeeded.
153
+ * Returns ok:false with reason when not connected or send failed.
154
+ */
155
+ async sendRaw(frame) {
156
+ if (this._status !== "connected" || !this._currentStream) {
157
+ if (this._status === "reconnecting") {
158
+ return { ok: false, reason: "signaling_reconnecting", guidance: GUIDANCE_RECONNECTING };
159
+ }
160
+ return { ok: false, reason: "signaling_lost", guidance: GUIDANCE_LOST };
161
+ }
162
+ try {
163
+ await this._currentStream.send(frame);
164
+ return { ok: true };
165
+ }
166
+ catch (err) {
167
+ const msg = err instanceof Error ? err.message : String(err);
168
+ return { ok: false, reason: "signaling_lost", guidance: `Send failed: ${msg}` };
169
+ }
170
+ }
171
+ /**
172
+ * M7-SESSION-001: Register a handler for inbound signaling frames.
173
+ * Called for every frame not consumed by the built-in heartbeat/manifest logic.
174
+ * Returns an unregister function.
175
+ */
176
+ registerInboundHandler(handler) {
177
+ this._inboundHandlers.push(handler);
178
+ return () => {
179
+ const idx = this._inboundHandlers.indexOf(handler);
180
+ if (idx !== -1)
181
+ this._inboundHandlers.splice(idx, 1);
182
+ };
183
+ }
184
+ /**
185
+ * Submit an MCP tool call operation.
186
+ * When connected: returns { ok: true }.
187
+ * When reconnecting/lost: returns immediately with rejection + guidance.
188
+ * NEVER queued.
189
+ */
190
+ async submitMcpOperation() {
191
+ if (this._status === "connected") {
192
+ return { ok: true };
193
+ }
194
+ if (this._status === "reconnecting") {
195
+ return { ok: false, reason: "signaling_reconnecting", guidance: GUIDANCE_RECONNECTING };
196
+ }
197
+ return { ok: false, reason: "signaling_lost", guidance: GUIDANCE_LOST };
198
+ }
199
+ /**
200
+ * Submit an internal protocol operation (FROST ceremony, manifest poll).
201
+ * When connected: executes immediately, returns result.
202
+ * When reconnecting: queues if capacity allows, returns pending promise.
203
+ * When lost: rejects immediately.
204
+ * When queue full: rejects immediately with signaling_queue_full.
205
+ */
206
+ submitInternalOperation(execute) {
207
+ if (this._status === "connected" && !this._draining) {
208
+ return execute();
209
+ }
210
+ if (this._status === "lost") {
211
+ return Promise.resolve({ ok: false, reason: "signaling_lost", guidance: GUIDANCE_LOST });
212
+ }
213
+ if (this._status === "reconnecting" || this._draining) {
214
+ if (this._queue.length >= this._outboundQueueCap) {
215
+ return Promise.resolve({ ok: false, reason: "signaling_queue_full", guidance: GUIDANCE_QUEUE_FULL });
216
+ }
217
+ return new Promise((resolve, reject) => {
218
+ this._queue.push({
219
+ execute: execute,
220
+ resolve: resolve,
221
+ reject,
222
+ });
223
+ });
224
+ }
225
+ return Promise.resolve({ ok: false, reason: "signaling_lost", guidance: GUIDANCE_LOST });
226
+ }
227
+ /**
228
+ * Graceful shutdown. Cancels reconnect loop, flushes queue with shutdown error.
229
+ * Does NOT transition to 'lost'.
230
+ */
231
+ async stop() {
232
+ this._stopped = true;
233
+ this.cancelHeartbeat();
234
+ this.cancelBackoffWait();
235
+ if (this._currentStream) {
236
+ this._currentStream.close();
237
+ this._currentStream = null;
238
+ }
239
+ if (this._streamDeathResolve) {
240
+ this._streamDeathResolve();
241
+ this._streamDeathResolve = null;
242
+ }
243
+ if (this._pollScheduler) {
244
+ this._pollScheduler.cancel();
245
+ }
246
+ this.flushQueue("signaling_shutdown", GUIDANCE_SHUTDOWN);
247
+ }
248
+ // ─── Public API — step-6 challenge verification (MANIFEST-002) ───────────────
249
+ /**
250
+ * Store the nonce and agent pubkey from handshake steps 2–3.
251
+ * Must be called before processStep5Frame().
252
+ */
253
+ setHandshakeContext(nonceHex, agentPubkeyHex) {
254
+ this._pendingNonce = nonceHex;
255
+ this._agentPubkeyHex = agentPubkeyHex;
256
+ }
257
+ /**
258
+ * Process a signaling_auth_ok frame (step 6 of the 7-step handshake).
259
+ * Called inside production connect() after receiving auth_ok.
260
+ *
261
+ * Pseudocode (RFC 8032 — Ed25519 verify):
262
+ * 1. If frame has no nodeId/signature/timestamp: log warn, return no_identity_proof.
263
+ * 2. Validate handshake context was set.
264
+ * 3. Build TBS bytes.
265
+ * 4. Call challengeVerifier.verifyChallenge(nodeId, tbsBytes, signature).
266
+ * 5. Log directory.auth.challenge.verified (INFO) or directory.auth.challenge.failed (ERROR).
267
+ */
268
+ processStep5Frame(frame) {
269
+ if (!this._challengeVerifier) {
270
+ return { verified: false, reason: "no_challenge_verifier" };
271
+ }
272
+ const { nodeId, signature, timestamp } = frame;
273
+ if (!nodeId || !signature || !timestamp) {
274
+ this._logger.warn("directory.auth.challenge.skipped", {
275
+ correlationId: this._correlationId,
276
+ reason: "no_identity_proof",
277
+ hasNodeId: !!nodeId,
278
+ hasSignature: !!signature,
279
+ hasTimestamp: !!timestamp,
280
+ });
281
+ return { verified: false, reason: "no_identity_proof" };
282
+ }
283
+ // Consume nonce and pubkey immediately — nonces are single-use.
284
+ // Clearing before verification prevents stale-nonce reuse across reconnects (SI-003).
285
+ const pendingNonce = this._pendingNonce;
286
+ const agentPubkeyHex = this._agentPubkeyHex;
287
+ this._pendingNonce = null;
288
+ this._agentPubkeyHex = null;
289
+ if (pendingNonce === null || agentPubkeyHex === null) {
290
+ this._logger.error("directory.auth.challenge.failed", {
291
+ correlationId: this._correlationId,
292
+ nodeId,
293
+ reason: "handshake_context_missing",
294
+ });
295
+ return { verified: false, reason: "handshake_context_missing" };
296
+ }
297
+ const tbsBytes = buildStep5Tbs({
298
+ nodeId,
299
+ agentPubkeyHex,
300
+ nonceHex: pendingNonce,
301
+ isoTimestamp: timestamp,
302
+ });
303
+ const result = this._challengeVerifier.verifyChallenge(nodeId, tbsBytes, signature);
304
+ if (result.valid) {
305
+ this._logger.info("directory.auth.challenge.verified", {
306
+ correlationId: this._correlationId,
307
+ nodeId,
308
+ });
309
+ return { verified: true };
310
+ }
311
+ else {
312
+ this._logger.error("directory.auth.challenge.failed", {
313
+ correlationId: this._correlationId,
314
+ nodeId,
315
+ reason: result.reason,
316
+ });
317
+ return { verified: false, reason: result.reason };
318
+ }
319
+ }
320
+ // ─── Public API — manifest polling (MANIFEST-002) ────────────────────────────
321
+ /**
322
+ * Handle a manifest_poll_response frame from the directory.
323
+ */
324
+ async handleManifestPollResponse(manifest) {
325
+ if (!this._manifestProvider || !this._manifestVersionStore)
326
+ return;
327
+ const correlationId = this.#pollCorrelationId || this._correlationId;
328
+ // Verify-before-adopt: the directory is ONLY a transport for the manifest, never a
329
+ // trust anchor. A rogue/compromised directory cannot make us adopt a forged, expired,
330
+ // or rolled-back manifest. Rescheduling is NOT done here — the poll loop is driven
331
+ // from the dispatch side (#schedulePoll) so a lost/ignored response can't stall it.
332
+ // Defense-in-depth: never adopt against a 0/missing threshold (verifyManifest would
333
+ // pass an unsigned manifest at threshold 0). The daemon composition root already
334
+ // rejects threshold < 1 before wiring poll deps; this guards the manager directly.
335
+ if (this._threshold < 1) {
336
+ this._logger.error("directory.auth.manifest.threshold.invalid", {
337
+ correlationId,
338
+ manifestVersion: manifest.version,
339
+ threshold: this._threshold,
340
+ });
341
+ return;
342
+ }
343
+ const verifyResult = verifyManifest(manifest, this._rootKeys, this._threshold);
344
+ if (!verifyResult.ok) {
345
+ this._logger.error("directory.auth.manifest.signature.invalid", {
346
+ correlationId,
347
+ manifestVersion: manifest.version,
348
+ reason: verifyResult.reason,
349
+ detail: verifyResult.detail,
350
+ });
351
+ return;
352
+ }
353
+ const now = new Date();
354
+ const notBefore = new Date(manifest.not_before);
355
+ if (now < notBefore) {
356
+ this._logger.warn("directory.auth.manifest.not.yet.valid", {
357
+ correlationId,
358
+ manifestVersion: manifest.version,
359
+ notBefore: manifest.not_before,
360
+ });
361
+ return;
362
+ }
363
+ const expiresAt = new Date(manifest.expires);
364
+ if (expiresAt <= now) {
365
+ this._logger.error("directory.auth.manifest.expired", {
366
+ correlationId,
367
+ manifestVersion: manifest.version,
368
+ expiresAt: manifest.expires,
369
+ });
370
+ return;
371
+ }
372
+ const lastSeen = await this._manifestVersionStore.getLastSeenVersion();
373
+ if (lastSeen !== null && manifest.version < lastSeen) {
374
+ this._logger.warn("directory.auth.manifest.version.rollback", {
375
+ correlationId,
376
+ manifestVersion: manifest.version,
377
+ lastSeenVersion: lastSeen,
378
+ });
379
+ return;
380
+ }
381
+ if (lastSeen !== null && manifest.version === lastSeen) {
382
+ // Already current — nothing to adopt.
383
+ return;
384
+ }
385
+ const oldVersion = this._manifestProvider.getCurrentManifest()?.version ?? null;
386
+ this._manifestProvider.updateManifest(manifest);
387
+ await this._manifestVersionStore.persistVersion(manifest.version);
388
+ this._logger.info("directory.auth.manifest.poll.success", {
389
+ correlationId,
390
+ oldVersion,
391
+ newVersion: manifest.version,
392
+ });
393
+ }
394
+ /**
395
+ * Dispatch a manifest_poll_request frame over the LIVE signaling stream. Mints a fresh
396
+ * per-cycle correlation id so the dispatch and its eventual response are correlatable.
397
+ * A no-op send when not connected — the next interval still fires (see #schedulePoll),
398
+ * and a clean reconnect restarts polling via runConnectedPhase.
399
+ */
400
+ dispatchManifestPoll() {
401
+ this.#pollCounter += 1;
402
+ this.#pollCorrelationId = `${this._correlationId || "daemon"}:manifest-poll:${this.#pollCounter}`;
403
+ const correlationId = this.#pollCorrelationId;
404
+ void this.sendRaw({ type: "manifest_poll_request" }).then((res) => {
405
+ if (res.ok) {
406
+ this._logger.info("directory.auth.manifest.poll.dispatched", { correlationId });
407
+ }
408
+ });
409
+ }
410
+ /** Start background polling after step-6 authentication completes. */
411
+ startPolling() {
412
+ this.#schedulePoll();
413
+ }
414
+ /** Cancel pending poll timer (called on disconnect). */
415
+ stopPolling() {
416
+ this._pollScheduler?.cancel();
417
+ }
418
+ #schedulePoll() {
419
+ if (!this._pollScheduler)
420
+ return;
421
+ this._pollScheduler.scheduleNext(async () => {
422
+ if (this._stopped)
423
+ return;
424
+ // Re-arm the NEXT poll around dispatching so the loop is purely time-driven and
425
+ // self-healing: a lost, failed, or ignored manifest_poll_response can never stall
426
+ // future polls (code review HIGH). stopPolling()/stop() cancel the armed timer on
427
+ // disconnect; runConnectedPhase re-arms on reconnect. Both #schedulePoll() and the
428
+ // sync part of dispatchManifestPoll run without an await, so a concurrent
429
+ // stop/cancel cannot interleave to resurrect a cancelled loop.
430
+ this.dispatchManifestPoll();
431
+ this.#schedulePoll();
432
+ });
433
+ }
434
+ // ─── Private: Connection Loop ─────────────────────────────────────────────────
435
+ async reconnectLoop() {
436
+ const initialResult = await this.attemptConnect();
437
+ if (!initialResult)
438
+ return; // stopped
439
+ if (initialResult.success) {
440
+ await this.runConnectedPhase(initialResult.result);
441
+ if (this._stopped)
442
+ return;
443
+ }
444
+ await this.runReconnectCycle(initialResult.success ? this._lastDisconnectReason : initialResult.error);
445
+ }
446
+ async runReconnectCycle(initialError) {
447
+ let lastError = initialError ?? "";
448
+ for (let attempt = 1; attempt <= this._maxReconnectAttempts; attempt++) {
449
+ if (this._stopped)
450
+ return;
451
+ const backoffMs = Math.min(this._initialBackoffMs * (2 ** (attempt - 1)), this._maxBackoffMs);
452
+ this._logger.info("directory.signaling.reconnecting", {
453
+ attempt,
454
+ backoffMs,
455
+ directoryNodeId: this._currentDirectoryNodeId ?? "unknown",
456
+ });
457
+ const cancelled = await this.backoffWait(backoffMs);
458
+ if (cancelled)
459
+ return;
460
+ if (this._stopped)
461
+ return;
462
+ const connectResult = await this.attemptConnect();
463
+ if (!connectResult)
464
+ return;
465
+ if (connectResult.success) {
466
+ await this.runConnectedPhase(connectResult.result);
467
+ if (this._stopped)
468
+ return;
469
+ return this.runReconnectCycle();
470
+ }
471
+ lastError = connectResult.error;
472
+ }
473
+ if (this._stopped)
474
+ return;
475
+ this._logger.error("directory.signaling.reconnect.failed", {
476
+ attempt: this._maxReconnectAttempts,
477
+ maxAttempts: this._maxReconnectAttempts,
478
+ lastError,
479
+ });
480
+ this._status = "lost";
481
+ this.flushQueue("signaling_lost", GUIDANCE_LOST);
482
+ }
483
+ async attemptConnect() {
484
+ try {
485
+ const result = await this._connect();
486
+ if (this._stopped) {
487
+ result.stream.close();
488
+ return null;
489
+ }
490
+ return { success: true, result };
491
+ }
492
+ catch (err) {
493
+ if (this._stopped)
494
+ return null;
495
+ const errorMessage = err instanceof Error ? err.message : String(err);
496
+ return { success: false, error: errorMessage };
497
+ }
498
+ }
499
+ async runConnectedPhase(result) {
500
+ this._currentStream = result.stream;
501
+ this._currentDirectoryNodeId = result.directoryNodeId;
502
+ this._status = "connected";
503
+ this._logger.info("directory.signaling.connected", {
504
+ directoryNodeId: result.directoryNodeId,
505
+ manifestVersion: result.manifestVersion,
506
+ });
507
+ result.stream.onMessage((frame) => {
508
+ this.handleMessage(frame);
509
+ });
510
+ // Drain queue before starting heartbeat. Starting heartbeat first creates a
511
+ // race: if the heartbeat interval fires during a slow drain, declareStreamDead
512
+ // may be called before waitForStreamDeath() has set _streamDeathResolve.
513
+ await this.drainQueue();
514
+ if (this._stopped)
515
+ return;
516
+ this.startHeartbeat();
517
+ // DOD-AUTH-2: start the background manifest poll now the stream is live. Poll
518
+ // lifecycle = connection lifecycle — stopPolling() is the symmetric call on stream
519
+ // death below. #schedulePoll is a no-op when no pollScheduler was configured (M6
520
+ // backward-compat), so this is safe on every connection.
521
+ this.startPolling();
522
+ await this.waitForStreamDeath();
523
+ if (this._stopped)
524
+ return;
525
+ this.stopPolling();
526
+ this._status = "reconnecting";
527
+ }
528
+ // ─── Private: Heartbeat ───────────────────────────────────────────────────────
529
+ startHeartbeat() {
530
+ this._heartbeatInterval = setInterval(() => {
531
+ this.sendPing();
532
+ }, this._heartbeatIntervalMs);
533
+ }
534
+ async sendPing() {
535
+ if (!this._currentStream || this._stopped)
536
+ return;
537
+ this._pingCounter++;
538
+ const frame = { type: "ping", ts: this._pingCounter };
539
+ try {
540
+ await this._currentStream.send(frame);
541
+ if (!this._heartbeatTimeout) {
542
+ this._heartbeatTimeout = setTimeout(() => {
543
+ this._heartbeatTimeout = null;
544
+ this.declareStreamDead("heartbeat_timeout");
545
+ }, this._heartbeatTimeoutMs);
546
+ }
547
+ }
548
+ catch (err) {
549
+ const errorMessage = err instanceof Error ? err.message : String(err);
550
+ this.declareStreamDead(errorMessage);
551
+ }
552
+ }
553
+ handleMessage(frame) {
554
+ if (typeof frame !== "object" || frame === null)
555
+ return;
556
+ const f = frame;
557
+ if (f.type === "pong") {
558
+ if (this._heartbeatTimeout) {
559
+ clearTimeout(this._heartbeatTimeout);
560
+ this._heartbeatTimeout = null;
561
+ }
562
+ return;
563
+ }
564
+ if (f.type === "manifest_poll_response" && f.manifest) {
565
+ void this.handleManifestPollResponse(f.manifest);
566
+ return;
567
+ }
568
+ // M7-SESSION-001: dispatch to registered inbound handlers (e.g. seal_interrupted_ack/rejection)
569
+ if (this._inboundHandlers.length > 0) {
570
+ for (const handler of this._inboundHandlers) {
571
+ try {
572
+ handler(f);
573
+ }
574
+ catch (err) {
575
+ this._logger.error("signaling.inbound.handler.error", {
576
+ frameType: typeof f.type === "string" ? f.type : "unknown",
577
+ error: err instanceof Error ? err.message : String(err),
578
+ });
579
+ }
580
+ }
581
+ }
582
+ }
583
+ cancelHeartbeat() {
584
+ if (this._heartbeatInterval) {
585
+ clearInterval(this._heartbeatInterval);
586
+ this._heartbeatInterval = null;
587
+ }
588
+ if (this._heartbeatTimeout) {
589
+ clearTimeout(this._heartbeatTimeout);
590
+ this._heartbeatTimeout = null;
591
+ }
592
+ }
593
+ // ─── Private: Stream death ────────────────────────────────────────────────────
594
+ _streamDeathResolve = null;
595
+ _lastDisconnectReason = "";
596
+ waitForStreamDeath() {
597
+ return new Promise((resolve) => {
598
+ this._streamDeathResolve = resolve;
599
+ });
600
+ }
601
+ declareStreamDead(reason) {
602
+ if (this._status !== "connected")
603
+ return;
604
+ this._lastDisconnectReason = reason;
605
+ this.cancelHeartbeat();
606
+ this._logger.warn("directory.signaling.disconnected", {
607
+ directoryNodeId: this._currentDirectoryNodeId ?? "unknown",
608
+ reason,
609
+ });
610
+ if (this._currentStream) {
611
+ this._currentStream.close();
612
+ this._currentStream = null;
613
+ }
614
+ if (this._streamDeathResolve) {
615
+ this._streamDeathResolve();
616
+ this._streamDeathResolve = null;
617
+ }
618
+ }
619
+ // ─── Private: Backoff ─────────────────────────────────────────────────────────
620
+ backoffWait(ms) {
621
+ return new Promise((resolve) => {
622
+ this._backoffResolve = () => resolve(true);
623
+ this._backoffTimeout = setTimeout(() => {
624
+ this._backoffResolve = null;
625
+ this._backoffTimeout = null;
626
+ resolve(false);
627
+ }, ms);
628
+ });
629
+ }
630
+ cancelBackoffWait() {
631
+ if (this._backoffTimeout) {
632
+ clearTimeout(this._backoffTimeout);
633
+ this._backoffTimeout = null;
634
+ }
635
+ if (this._backoffResolve) {
636
+ this._backoffResolve();
637
+ this._backoffResolve = null;
638
+ }
639
+ }
640
+ // ─── Private: Queue ───────────────────────────────────────────────────────────
641
+ async drainQueue() {
642
+ this._draining = true;
643
+ while (this._queue.length > 0) {
644
+ const op = this._queue.shift();
645
+ try {
646
+ const result = await op.execute();
647
+ op.resolve(result);
648
+ }
649
+ catch (err) {
650
+ op.reject(err);
651
+ }
652
+ }
653
+ this._draining = false;
654
+ }
655
+ flushQueue(reason, guidance) {
656
+ const response = { ok: false, reason, guidance };
657
+ for (const op of this._queue) {
658
+ op.resolve(response);
659
+ }
660
+ this._queue = [];
661
+ }
662
+ }
663
+ // ─── Step-5 TBS builder ───────────────────────────────────────────────────────
664
+ const TBS_LABEL = "cello-directory-auth-challenge-v1\n";
665
+ /**
666
+ * Build the TBS (to-be-signed) bytes for step-5 directory identity verification.
667
+ *
668
+ * Authoritative format:
669
+ * UTF-8('cello-directory-auth-challenge-v1\n') +
670
+ * UTF-8(nodeId) + '\n' + UTF-8(agentPubkeyHex) + '\n' +
671
+ * UTF-8(nonceHex) + '\n' + UTF-8(isoTimestamp)
672
+ *
673
+ * Crypto reference: RFC 8032 (the signing input is these bytes verbatim).
674
+ */
675
+ export function buildStep5Tbs(opts) {
676
+ const enc = new TextEncoder();
677
+ const str = TBS_LABEL + opts.nodeId + "\n" + opts.agentPubkeyHex + "\n" + opts.nonceHex + "\n" + opts.isoTimestamp;
678
+ return enc.encode(str);
679
+ }
680
+ //# sourceMappingURL=signaling-manager.js.map