@cello-protocol/transport 0.0.4 → 0.0.5
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/autonat-service.d.ts +85 -0
- package/dist/autonat-service.d.ts.map +1 -0
- package/dist/autonat-service.js +117 -0
- package/dist/autonat-service.js.map +1 -0
- package/dist/autonat.d.ts +90 -0
- package/dist/autonat.d.ts.map +1 -0
- package/dist/autonat.js +84 -0
- package/dist/autonat.js.map +1 -0
- package/dist/content-cap.d.ts +43 -0
- package/dist/content-cap.d.ts.map +1 -0
- package/dist/content-cap.js +58 -0
- package/dist/content-cap.js.map +1 -0
- package/dist/index.d.ts +11 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest-interfaces.d.ts +143 -0
- package/dist/manifest-interfaces.d.ts.map +1 -0
- package/dist/manifest-interfaces.js +22 -0
- package/dist/manifest-interfaces.js.map +1 -0
- package/dist/manifest-stubs.d.ts +93 -0
- package/dist/manifest-stubs.d.ts.map +1 -0
- package/dist/manifest-stubs.js +192 -0
- package/dist/manifest-stubs.js.map +1 -0
- package/dist/node.d.ts +12 -1
- package/dist/node.d.ts.map +1 -1
- package/dist/node.js +187 -4
- package/dist/node.js.map +1 -1
- package/dist/protocols.d.ts +14 -0
- package/dist/protocols.d.ts.map +1 -1
- package/dist/protocols.js +14 -0
- package/dist/protocols.js.map +1 -1
- package/dist/signaling-manager.d.ts +274 -0
- package/dist/signaling-manager.d.ts.map +1 -0
- package/dist/signaling-manager.js +680 -0
- package/dist/signaling-manager.js.map +1 -0
- package/dist/types.d.ts +54 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -2
|
@@ -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
|