@cello-protocol/daemon 0.0.182 → 0.0.183
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/agent-settings-keys.d.ts.map +1 -1
- package/dist/agent-settings-keys.js +18 -0
- package/dist/agent-settings-keys.js.map +1 -1
- package/dist/consortium-bootstrap.d.ts.map +1 -1
- package/dist/consortium-bootstrap.js +117 -2
- package/dist/consortium-bootstrap.js.map +1 -1
- package/dist/content-encryption-status.d.ts +97 -0
- package/dist/content-encryption-status.d.ts.map +1 -0
- package/dist/content-encryption-status.js +122 -0
- package/dist/content-encryption-status.js.map +1 -0
- package/dist/content-park-client.d.ts +10 -0
- package/dist/content-park-client.d.ts.map +1 -1
- package/dist/content-park-client.js +84 -3
- package/dist/content-park-client.js.map +1 -1
- package/dist/content-park.d.ts.map +1 -1
- package/dist/content-park.js +184 -13
- package/dist/content-park.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +306 -23
- package/dist/daemon.js.map +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +119 -0
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-layer.d.ts +0 -2
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js.map +1 -1
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +34 -3
- package/dist/initiate-session-handler.js.map +1 -1
- package/dist/manifest-deps.d.ts +0 -11
- package/dist/manifest-deps.d.ts.map +1 -1
- package/dist/manifest-deps.js +28 -2
- package/dist/manifest-deps.js.map +1 -1
- package/dist/manifest-validity.d.ts +2 -0
- package/dist/manifest-validity.d.ts.map +1 -1
- package/dist/manifest-validity.js +1 -1
- package/dist/manifest-validity.js.map +1 -1
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +19 -0
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/park-envelope.d.ts +23 -0
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +76 -0
- package/dist/park-envelope.js.map +1 -1
- package/dist/register-handler.d.ts.map +1 -1
- package/dist/register-handler.js +76 -0
- package/dist/register-handler.js.map +1 -1
- package/dist/relay-only.d.ts +133 -0
- package/dist/relay-only.d.ts.map +1 -0
- package/dist/relay-only.js +183 -0
- package/dist/relay-only.js.map +1 -0
- package/dist/retry-queue.d.ts +26 -1
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +18 -1
- package/dist/retry-queue.js.map +1 -1
- package/dist/session-ceremony.d.ts +6 -0
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +26 -0
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-content-handlers.d.ts +12 -0
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +39 -4
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +247 -4
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +3088 -304
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-read-handlers.d.ts.map +1 -1
- package/dist/session-read-handlers.js +16 -1
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-relay-client.d.ts +116 -0
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +395 -6
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-salt-agreement.d.ts +58 -4
- package/dist/session-salt-agreement.d.ts.map +1 -1
- package/dist/session-salt-agreement.js +110 -3
- package/dist/session-salt-agreement.js.map +1 -1
- package/dist/signaling-connect.d.ts +18 -1
- package/dist/signaling-connect.d.ts.map +1 -1
- package/dist/signaling-connect.js +164 -1
- package/dist/signaling-connect.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire-content-hash.d.ts +15 -3
- package/dist/wire-content-hash.d.ts.map +1 -1
- package/dist/wire-content-hash.js +15 -3
- package/dist/wire-content-hash.js.map +1 -1
- package/package.json +5 -5
|
@@ -72,6 +72,97 @@ export const TERMINAL_RELAY_REFUSALS = new Set([
|
|
|
72
72
|
export function isTerminalRelayRefusal(reason) {
|
|
73
73
|
return reason !== undefined && TERMINAL_RELAY_REFUSALS.has(reason);
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* DOD-M15-RELAYSLOTS-1 — **WHICH REFUSALS JUSTIFY TRYING A DIFFERENT RELAY.**
|
|
77
|
+
*
|
|
78
|
+
* We run several relays, so "move on to the next one" is always available — which is exactly why it
|
|
79
|
+
* needs a rule. Moving on from a problem that every relay will have turns one client-side fault into
|
|
80
|
+
* what looks like a fleet-wide outage, and the operator then goes looking for a broken relay.
|
|
81
|
+
*
|
|
82
|
+
* ENUMERATED, never pattern-matched, for the same reason `TERMINAL_RELAY_REFUSALS` is: a substring
|
|
83
|
+
* rule would silently absorb a future reason nobody has considered.
|
|
84
|
+
*/
|
|
85
|
+
const RELAY_SIDE_REFUSALS = new Set([
|
|
86
|
+
/**
|
|
87
|
+
* The relay holds no directory public key, so it can verify nothing and is refusing everyone.
|
|
88
|
+
* That is this relay being misconfigured, not us being wrong — another relay is the right move,
|
|
89
|
+
* and it is the whole reason we run more than one.
|
|
90
|
+
*/
|
|
91
|
+
"online_token_no_directory_key",
|
|
92
|
+
]);
|
|
93
|
+
/**
|
|
94
|
+
* Classify a relay's auth refusal: what to tell the operator, and whether another relay would help.
|
|
95
|
+
*
|
|
96
|
+
* Everything not in `RELAY_SIDE_REFUSALS` defaults to "do not try another", and that default is the
|
|
97
|
+
* safe direction. A token problem reproduces identically on every relay in the fleet, so retrying
|
|
98
|
+
* around the fleet spends real time turning a client fault into an apparent outage — and a slot cap
|
|
99
|
+
* IS satisfiable elsewhere, but spreading to another relay papers over sessions that leaked and
|
|
100
|
+
* brings the same wall back on the next one.
|
|
101
|
+
*/
|
|
102
|
+
export function classifyRelayAuthRefusal(reason, extra = {}) {
|
|
103
|
+
const tryAnotherRelay = RELAY_SIDE_REFUSALS.has(reason);
|
|
104
|
+
let advice;
|
|
105
|
+
switch (reason) {
|
|
106
|
+
case "online_token_required":
|
|
107
|
+
advice = "This agent has no online token from a directory yet. It is issued when a directory " +
|
|
108
|
+
"marks the agent online, so this usually clears itself on the next directory connection. If " +
|
|
109
|
+
"it persists, the agent is not reaching any directory — check that first, not the relay.";
|
|
110
|
+
break;
|
|
111
|
+
case "online_token_expired":
|
|
112
|
+
advice = "The online token has expired and is refreshed on the next directory connection. If " +
|
|
113
|
+
"it keeps expiring, this machine's clock or its directory connection is the thing to look at.";
|
|
114
|
+
break;
|
|
115
|
+
case "online_token_signature_invalid":
|
|
116
|
+
case "online_token_malformed":
|
|
117
|
+
case "online_token_lifetime_too_long":
|
|
118
|
+
advice = "This relay would not accept the token this agent was issued. Most often the relay " +
|
|
119
|
+
"and the directory are not in the same consortium — check which directories this relay is " +
|
|
120
|
+
"configured to trust.";
|
|
121
|
+
break;
|
|
122
|
+
case "online_token_pubkey_mismatch":
|
|
123
|
+
advice = "The token names a different key from the one this agent signed with. That is an " +
|
|
124
|
+
"identity mix-up on this machine, not a relay problem.";
|
|
125
|
+
break;
|
|
126
|
+
case "online_token_no_directory_key":
|
|
127
|
+
advice = "This relay holds no directory public key, so it cannot verify anyone and is " +
|
|
128
|
+
"refusing every agent. Its operator needs to configure one; another relay will work now.";
|
|
129
|
+
break;
|
|
130
|
+
case "slot_cap_exceeded":
|
|
131
|
+
advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
|
|
132
|
+
? `This agent already holds ${String(extra.slotsHeld)} of a maximum ${String(extra.slotCap)} ` +
|
|
133
|
+
"reservations on this relay, and none is idle enough to reclaim. That is almost always " +
|
|
134
|
+
"sessions that were never closed — close some and this clears. Moving to another relay " +
|
|
135
|
+
"would work now and hit the same wall there."
|
|
136
|
+
: "This agent already holds the most reservations one agent may hold on this relay. That is " +
|
|
137
|
+
"almost always sessions that were never closed — close some and this clears.";
|
|
138
|
+
break;
|
|
139
|
+
case "session_tuple_cap_exceeded":
|
|
140
|
+
advice = extra.slotsHeld !== undefined && extra.slotCap !== undefined
|
|
141
|
+
? `You already have ${String(extra.slotsHeld)} conversations open with this counterparty, ` +
|
|
142
|
+
`which is the maximum of ${String(extra.slotCap)} this relay allows between one pair of ` +
|
|
143
|
+
"agents. Close some and try again — this is almost always conversations that were never " +
|
|
144
|
+
"closed rather than ones anybody is still using."
|
|
145
|
+
: "You already have the maximum number of concurrent conversations open with this " +
|
|
146
|
+
"counterparty. Close some and try again.";
|
|
147
|
+
break;
|
|
148
|
+
case "rate_limited":
|
|
149
|
+
advice = extra.retryAfterMs !== undefined
|
|
150
|
+
? `This relay is throttling this agent; it clears on its own in about ${String(Math.ceil(extra.retryAfterMs / 1000))}s.`
|
|
151
|
+
: "This relay is throttling this agent; it clears on its own after the throttle window.";
|
|
152
|
+
break;
|
|
153
|
+
default:
|
|
154
|
+
advice = "This agent could not authenticate to this relay, so it cannot hold a reservation " +
|
|
155
|
+
"here and is reachable only over a direct connection.";
|
|
156
|
+
}
|
|
157
|
+
return {
|
|
158
|
+
reason,
|
|
159
|
+
advice,
|
|
160
|
+
tryAnotherRelay,
|
|
161
|
+
...(extra.slotsHeld !== undefined ? { slotsHeld: extra.slotsHeld } : {}),
|
|
162
|
+
...(extra.slotCap !== undefined ? { slotCap: extra.slotCap } : {}),
|
|
163
|
+
...(extra.retryAfterMs !== undefined ? { retryAfterMs: extra.retryAfterMs } : {}),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
75
166
|
export const LEAF_KIND_MSG = 0x00;
|
|
76
167
|
/** Control leaf (SEAL etc.) — two distinct-sender ctrl leaves trigger directory notarization. */
|
|
77
168
|
export const LEAF_KIND_CTRL = 0x02;
|
|
@@ -167,6 +258,18 @@ export class AgentRelayClient {
|
|
|
167
258
|
#logger;
|
|
168
259
|
#receiptStore;
|
|
169
260
|
#sealLeafStore;
|
|
261
|
+
/** DOD-M15-RELAYSLOTS-1 — read fresh at every auth. See `AgentRelayClientOpts.onlineToken`. */
|
|
262
|
+
#onlineToken;
|
|
263
|
+
/**
|
|
264
|
+
* DOD-M15-RELAYSLOTS-1: the last refusal this relay gave us, classified. Kept because a log line
|
|
265
|
+
* reaches neither the operator asking why their agent is unreachable nor the code deciding
|
|
266
|
+
* whether a different relay would do any better.
|
|
267
|
+
*/
|
|
268
|
+
#lastAuthRefusal = null;
|
|
269
|
+
/** The last classified auth refusal from this relay, or null if the last attempt succeeded. */
|
|
270
|
+
getLastAuthRefusal() {
|
|
271
|
+
return this.#lastAuthRefusal;
|
|
272
|
+
}
|
|
170
273
|
#stream = null;
|
|
171
274
|
#connecting = null;
|
|
172
275
|
#closed = false;
|
|
@@ -182,6 +285,13 @@ export class AgentRelayClient {
|
|
|
182
285
|
// The sender-signed structure1_cbor of the in-flight submit, paired with its ack so the
|
|
183
286
|
// SubmitResult can carry it (the ack itself only returns the relay's structure2_cbor).
|
|
184
287
|
#pendingStructure1 = null;
|
|
288
|
+
/**
|
|
289
|
+
* DOD-M15-SEALWIRE-1 bullet 5 (sent half) — OUR signature over the in-flight `#pendingStructure1`,
|
|
290
|
+
* paired with its ack for the same reason that one is: the ack returns the relay's record, never
|
|
291
|
+
* ours. Cleared wherever `#pendingStructure1` is cleared; the two must never drift apart, because
|
|
292
|
+
* a signature paired with the WRONG signed bytes is worse than no signature at all.
|
|
293
|
+
*/
|
|
294
|
+
#pendingSignature = null;
|
|
185
295
|
// The in-flight submit's leaf kind (0x00 msg / 0x02 ctrl), paired with its ack so
|
|
186
296
|
// #captureReceipt can persist it alongside the Structure2/Structure1 carry bytes for the unilateral seal.
|
|
187
297
|
#pendingLeafKind = null;
|
|
@@ -205,6 +315,7 @@ export class AgentRelayClient {
|
|
|
205
315
|
this.#logger = opts.logger;
|
|
206
316
|
this.#receiptStore = opts.receiptStore;
|
|
207
317
|
this.#sealLeafStore = opts.sealLeafStore;
|
|
318
|
+
this.#onlineToken = opts.onlineToken;
|
|
208
319
|
}
|
|
209
320
|
/** The agent's K_local public key as hex — the responder identity for auto-acknowledge. */
|
|
210
321
|
get senderPubkeyHex() {
|
|
@@ -236,6 +347,29 @@ export class AgentRelayClient {
|
|
|
236
347
|
.then(() => undefined, () => undefined);
|
|
237
348
|
}
|
|
238
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* DOD-M15-RELAYAUTH-1 review H1 — **present the assignment and WAIT for the relay to say it
|
|
352
|
+
* recorded it.**
|
|
353
|
+
*
|
|
354
|
+
* `registerSession` above presents eagerly and forgets: the record is queued onto the submit chain
|
|
355
|
+
* and nobody can observe when it lands. That is correct for the witness relay, where the only
|
|
356
|
+
* requirement is "before the first submit". It is NOT sufficient for the relay that GATES A DIAL,
|
|
357
|
+
* because there the record is a precondition of an action we are about to take on another thread
|
|
358
|
+
* of the protocol — and losing that race denies a legitimate dial (review H1).
|
|
359
|
+
*
|
|
360
|
+
* Chained on `#submitChain` exactly like `#doSubmit`, so it cannot interleave with a submit on the
|
|
361
|
+
* same stream. Idempotent by construction: `#doRecord` returns `true` immediately once the session
|
|
362
|
+
* is recorded, so calling this straight after `registerSession` waits for the record that call
|
|
363
|
+
* already queued rather than sending a second one.
|
|
364
|
+
*
|
|
365
|
+
* Returns whether the relay recorded it. NEVER throws — a caller must be free to proceed on false
|
|
366
|
+
* (a dial that might be denied still beats no dial at all).
|
|
367
|
+
*/
|
|
368
|
+
async recordAssignmentAndWait(node, sessionIdHex) {
|
|
369
|
+
const run = this.#submitChain.then(() => this.#doRecord(node, sessionIdHex));
|
|
370
|
+
this.#submitChain = run.then(() => undefined, () => undefined);
|
|
371
|
+
return run.catch(() => false);
|
|
372
|
+
}
|
|
239
373
|
/**
|
|
240
374
|
* Present the directory-signed assignment to the relay. Idempotent
|
|
241
375
|
* (no-op once `recorded`, or when the session has no assignment — direct/persisted/legacy sessions).
|
|
@@ -327,12 +461,25 @@ export class AgentRelayClient {
|
|
|
327
461
|
hasSessions() {
|
|
328
462
|
return this.#sessions.size > 0;
|
|
329
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Is THIS session already registered on this client?
|
|
466
|
+
*
|
|
467
|
+
* DOD-M15-RELAYLEAK-1 (review MEDIUM-5). The detached seal transport releases its registration
|
|
468
|
+
* when the submit finishes, and "did I register it, or did I find it already there?" is the
|
|
469
|
+
* difference between releasing my own and **pulling a live one out from under a concurrent
|
|
470
|
+
* caller** — which closes the client that caller is mid-`submitLeaf` on. `hasSessions()` cannot
|
|
471
|
+
* answer it: it is a count, and by then the id is in the set either way.
|
|
472
|
+
*/
|
|
473
|
+
hasSession(sessionIdHex) {
|
|
474
|
+
return this.#sessions.has(sessionIdHex);
|
|
475
|
+
}
|
|
330
476
|
/** Settle the one outstanding submit (if any) exactly once. */
|
|
331
477
|
#settlePending(r) {
|
|
332
478
|
const resolve = this.#pendingAck;
|
|
333
479
|
this.#pendingAck = null;
|
|
334
480
|
this.#pendingAckSessionHex = null;
|
|
335
481
|
this.#pendingStructure1 = null;
|
|
482
|
+
this.#pendingSignature = null;
|
|
336
483
|
this.#pendingLeafKind = null;
|
|
337
484
|
if (resolve)
|
|
338
485
|
resolve(r);
|
|
@@ -472,6 +619,8 @@ export class AgentRelayClient {
|
|
|
472
619
|
const s2 = frame["structure2_cbor"];
|
|
473
620
|
const structure2Cbor = s2 instanceof Uint8Array ? s2 : undefined;
|
|
474
621
|
const structure1Cbor = this.#pendingStructure1 ?? undefined;
|
|
622
|
+
// Captured with structure1Cbor and BEFORE #settlePending clears both — see #pendingSignature.
|
|
623
|
+
const senderSignature = this.#pendingSignature ?? undefined;
|
|
475
624
|
// Verify the relay's signed ordering record and durably store the receipt BEFORE
|
|
476
625
|
// settling (which clears #pendingStructure1, the source of the content hash + session id). A
|
|
477
626
|
// signed-but-INVALID ACK rejects the submit so the send does not settle ok on an unverified sequence.
|
|
@@ -479,7 +628,7 @@ export class AgentRelayClient {
|
|
|
479
628
|
this.#settlePending(rejectSubmit
|
|
480
629
|
? { ok: false, reason: "relay_ack_signature_invalid" }
|
|
481
630
|
: seq >= 0
|
|
482
|
-
? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor }
|
|
631
|
+
? { ok: true, sequence_number: seq, structure1_cbor: structure1Cbor, structure2_cbor: structure2Cbor, sender_signature: senderSignature }
|
|
483
632
|
: { ok: false, reason: "relay_ack_malformed" });
|
|
484
633
|
}
|
|
485
634
|
else if (type === "hash_submit_error") {
|
|
@@ -487,7 +636,16 @@ export class AgentRelayClient {
|
|
|
487
636
|
// Carry the relay's `detail` through — see `SubmitResult`. Reading the class and discarding
|
|
488
637
|
// what happened is how a refusal arrives as a bare code with no cause attached to it.
|
|
489
638
|
const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
|
|
490
|
-
|
|
639
|
+
// DOD-M15-RELAYABUSE-1: the relay knows when its window clears and says so. Carried, not
|
|
640
|
+
// dropped — `#doSubmit` waits it out and resubmits, so a throttle never reaches the operator.
|
|
641
|
+
const rawRetry = frame["retry_after_ms"];
|
|
642
|
+
const retry_after_ms = typeof rawRetry === "number" && Number.isFinite(rawRetry) && rawRetry > 0 ? rawRetry : undefined;
|
|
643
|
+
this.#settlePending({
|
|
644
|
+
ok: false,
|
|
645
|
+
reason,
|
|
646
|
+
...(detail ? { detail } : {}),
|
|
647
|
+
...(retry_after_ms !== undefined ? { retry_after_ms } : {}),
|
|
648
|
+
});
|
|
491
649
|
}
|
|
492
650
|
else if (type === "assignment_ok") {
|
|
493
651
|
// The relay verified + recorded our client-presented assignment.
|
|
@@ -499,7 +657,23 @@ export class AgentRelayClient {
|
|
|
499
657
|
else if (type === "assignment_invalid") {
|
|
500
658
|
// The relay rejected the assignment (e.g. directory_signature_invalid — not signed by any
|
|
501
659
|
// consortium directory). Fail LOUD: the session has no relay witness until this is resolved.
|
|
502
|
-
|
|
660
|
+
const reason = typeof frame["reason"] === "string" ? frame["reason"] : "unknown";
|
|
661
|
+
this.#logger.warn("session.relay.assignment.invalid", { relayPeerId: this.#relayPeerId, reason });
|
|
662
|
+
/**
|
|
663
|
+
* DOD-M15-RELAYSLOTS-1 review M2 — **the tuple cap has to reach the operator too.**
|
|
664
|
+
*
|
|
665
|
+
* Clause 7 says EVERY refusal reaches them with a cause and an affordance, and this one was
|
|
666
|
+
* arriving as `assignment_invalid` in a log. It is also the refusal most likely to hit a real
|
|
667
|
+
* person, for the reason the order itself gives: nobody knows what sessions they have open, so
|
|
668
|
+
* whoever hits it believes they have none. Routed through the same classifier and onto the
|
|
669
|
+
* same surface as every other relay refusal.
|
|
670
|
+
*/
|
|
671
|
+
const concurrent = typeof frame["concurrent_sessions"] === "number" ? frame["concurrent_sessions"] : undefined;
|
|
672
|
+
const cap = typeof frame["session_cap"] === "number" ? frame["session_cap"] : undefined;
|
|
673
|
+
this.#lastAuthRefusal = classifyRelayAuthRefusal(reason, {
|
|
674
|
+
...(concurrent !== undefined ? { slotsHeld: concurrent } : {}),
|
|
675
|
+
...(cap !== undefined ? { slotCap: cap } : {}),
|
|
676
|
+
});
|
|
503
677
|
const r = this.#pendingRecord;
|
|
504
678
|
this.#pendingRecord = null;
|
|
505
679
|
if (r)
|
|
@@ -559,6 +733,36 @@ export class AgentRelayClient {
|
|
|
559
733
|
});
|
|
560
734
|
}
|
|
561
735
|
}
|
|
736
|
+
else if (type === "relay_slot_reclaimed") {
|
|
737
|
+
/**
|
|
738
|
+
* DOD-M15-RELAYSLOTS-1 clause 8 — **the reaped party is told, and this is where it lands.**
|
|
739
|
+
*
|
|
740
|
+
* The relay reclaimed this agent's circuit reservation to free capacity. Without a branch
|
|
741
|
+
* here the frame fell off the end of this chain and was discarded in silence, which is the
|
|
742
|
+
* trap the order records in its own words: a refusal that only reaches the relay's log does
|
|
743
|
+
* not exist. From the agent's side the reservation simply stops working.
|
|
744
|
+
*
|
|
745
|
+
* Recorded as a refusal so it reaches `cello_status` through the same surface as every other
|
|
746
|
+
* relay refusal, with the same shape: a cause, and what to do about it.
|
|
747
|
+
*/
|
|
748
|
+
const idleMs = typeof frame["idle_ms"] === "number" ? frame["idle_ms"] : undefined;
|
|
749
|
+
const detail = typeof frame["detail"] === "string" ? frame["detail"] : undefined;
|
|
750
|
+
this.#lastAuthRefusal = {
|
|
751
|
+
reason: "slot_reclaimed",
|
|
752
|
+
advice: detail ?? "This relay reclaimed your circuit reservation to free capacity because it " +
|
|
753
|
+
"had carried no traffic for a long time. Your agent stays online and rebuilds its receiver " +
|
|
754
|
+
"automatically; a new session will take a fresh reservation.",
|
|
755
|
+
// Not the relay's fault and not ours — it was under pressure and we were the quietest. The
|
|
756
|
+
// client rebuilds against the same pool, so there is nothing to fail over from.
|
|
757
|
+
tryAnotherRelay: false,
|
|
758
|
+
};
|
|
759
|
+
this.#logger.warn("session.relay.slot_reclaimed", {
|
|
760
|
+
relayPeerId: this.#relayPeerId,
|
|
761
|
+
...(idleMs !== undefined ? { idleHours: Math.round(idleMs / 3_600_000) } : {}),
|
|
762
|
+
impact: "this relay reclaimed our circuit reservation to free capacity. Until a receiver is " +
|
|
763
|
+
"rebuilt, this agent is reachable only over a direct connection.",
|
|
764
|
+
});
|
|
765
|
+
}
|
|
562
766
|
// session_interrupted / content_park_notify are out of scope here — session interruption
|
|
563
767
|
// is handled by the session node manager's dedicated relay-stream watcher.
|
|
564
768
|
}
|
|
@@ -570,6 +774,61 @@ export class AgentRelayClient {
|
|
|
570
774
|
async connect(node) {
|
|
571
775
|
return this.#ensureConnected(node);
|
|
572
776
|
}
|
|
777
|
+
/**
|
|
778
|
+
* DOD-M15-RELAYAUTH-1 review HIGH-1 — prove key possession FROM THIS NODE, on its own stream.
|
|
779
|
+
*
|
|
780
|
+
* ⚠️ **`connect()` CANNOT be used for this, and using it was the defect.** `#ensureConnected`
|
|
781
|
+
* returns `true` the moment `#stream` is non-null, and `#stream` belongs to whichever node
|
|
782
|
+
* connected FIRST. An agent legitimately runs several nodes against one relay — the node promoted
|
|
783
|
+
* into a live session, plus the replacement standing receiver built behind it — and they share
|
|
784
|
+
* one `AgentRelayClient` because the cache is keyed `${agent}::${relay}`. So calling `connect()`
|
|
785
|
+
* from the replacement receiver short-circuited on the session node's stream, sent nothing, and
|
|
786
|
+
* the relay never saw that receiver's transport identity: it revoked the reservation ~15s later,
|
|
787
|
+
* the watchdog rebuilt, and the agent churned on a ~45s loop holding no usable circuit address
|
|
788
|
+
* for as long as the conversation lasted. Any future "reuse the existing connection" optimisation
|
|
789
|
+
* here reintroduces exactly that.
|
|
790
|
+
*
|
|
791
|
+
* So this always opens its own short-lived stream from `node`, and marks it
|
|
792
|
+
* `purpose: "reservation"` so the relay proves possession WITHOUT rebinding the agent's delivery
|
|
793
|
+
* stream (which would steal the live session's inbound leaves — see the relay-side dispatch).
|
|
794
|
+
*/
|
|
795
|
+
async proveReservation(node) {
|
|
796
|
+
if (this.#closed)
|
|
797
|
+
return false;
|
|
798
|
+
for (const addr of this.#relayAddrs) {
|
|
799
|
+
try {
|
|
800
|
+
await node.dial(addr);
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
catch { /* try the next address */ }
|
|
804
|
+
}
|
|
805
|
+
let stream;
|
|
806
|
+
try {
|
|
807
|
+
stream = await node.newStream(this.#relayPeerId, RELAY_PROTOCOL_ID);
|
|
808
|
+
}
|
|
809
|
+
catch (err) {
|
|
810
|
+
this.#logger.warn("session.relay.reservation_proof.failed", {
|
|
811
|
+
relayPeerId: this.#relayPeerId, reason: "stream", error: extractErrorMessage(err),
|
|
812
|
+
});
|
|
813
|
+
return false;
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
const iter = lp.decode(stream)[Symbol.asyncIterator]();
|
|
817
|
+
const ok = await this.#authenticate(stream, iter, "reservation");
|
|
818
|
+
this.#logger.info("session.relay.reservation_proof.result", {
|
|
819
|
+
relayPeerId: this.#relayPeerId,
|
|
820
|
+
nodePeerId: node.getPeerId(),
|
|
821
|
+
ok,
|
|
822
|
+
// DOD-M15-RELAYSLOTS-1: name the cause here too. `ok: false` alone sent people looking at
|
|
823
|
+
// the transport for what is usually a token or a cap.
|
|
824
|
+
...(ok ? {} : { refusalReason: this.#lastAuthRefusal?.reason ?? "no_relay_verdict" }),
|
|
825
|
+
});
|
|
826
|
+
return ok;
|
|
827
|
+
}
|
|
828
|
+
finally {
|
|
829
|
+
await stream.close().catch(() => { });
|
|
830
|
+
}
|
|
831
|
+
}
|
|
573
832
|
/** Ensure an authenticated stream exists, (re)dialing from `node` if needed. */
|
|
574
833
|
async #ensureConnected(node) {
|
|
575
834
|
if (this.#closed)
|
|
@@ -622,7 +881,7 @@ export class AgentRelayClient {
|
|
|
622
881
|
this.#startReader(stream, iter);
|
|
623
882
|
return true;
|
|
624
883
|
}
|
|
625
|
-
async #authenticate(stream, iter) {
|
|
884
|
+
async #authenticate(stream, iter, purpose) {
|
|
626
885
|
const challengeRes = await nextWithTimeout(iter, RELAY_AUTH_TIMEOUT_MS);
|
|
627
886
|
if (challengeRes.done || challengeRes.value === undefined) {
|
|
628
887
|
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "no_challenge" });
|
|
@@ -646,8 +905,37 @@ export class AgentRelayClient {
|
|
|
646
905
|
return false;
|
|
647
906
|
}
|
|
648
907
|
const authSig = await this.#keyProvider.sign(buildRelayAuthPayload(nonce, this.#senderPubkey));
|
|
908
|
+
/**
|
|
909
|
+
* DOD-M15-RELAYSLOTS-1: read the token NOW, not at construction — it is reissued on every
|
|
910
|
+
* signaling reconnect and the one this client was built with is usually already gone.
|
|
911
|
+
*
|
|
912
|
+
* When there is none we send the auth anyway. Declining to try would replace a named refusal
|
|
913
|
+
* from the relay (`online_token_required`, which says what is wrong and what to do) with
|
|
914
|
+
* silence on both sides — and silence is what an operator reads as "the product is broken".
|
|
915
|
+
*/
|
|
916
|
+
const onlineToken = this.#onlineToken?.();
|
|
917
|
+
if (!onlineToken) {
|
|
918
|
+
this.#logger.warn("session.relay.auth.no_online_token", {
|
|
919
|
+
relayPeerId: this.#relayPeerId,
|
|
920
|
+
impact: "authenticating without the directory's online token. The relay will refuse this and " +
|
|
921
|
+
"will not let this node keep a circuit reservation, so the agent is reachable by nobody " +
|
|
922
|
+
"over this relay. The usual cause is that no directory connection has been established " +
|
|
923
|
+
"yet; the next signaling connect issues a token and the receiver re-authenticates.",
|
|
924
|
+
});
|
|
925
|
+
}
|
|
649
926
|
try {
|
|
650
|
-
stream.send(lp.encode.single(encodeCbor({
|
|
927
|
+
stream.send(lp.encode.single(encodeCbor({
|
|
928
|
+
type: "relay_auth_response",
|
|
929
|
+
pubkey: this.#senderPubkey,
|
|
930
|
+
signature: authSig,
|
|
931
|
+
// DOD-M15-RELAYAUTH-1: absent for the ordinary session auth (which also registers this
|
|
932
|
+
// stream as the agent's delivery target). `"reservation"` proves possession from THIS
|
|
933
|
+
// node's transport identity and nothing more — see proveReservation().
|
|
934
|
+
...(purpose ? { purpose } : {}),
|
|
935
|
+
// DOD-M15-RELAYSLOTS-1: opaque bytes from the directory, forwarded verbatim. The client
|
|
936
|
+
// never parses them — a format it does not read is a format it cannot get wrong.
|
|
937
|
+
...(onlineToken ? { online_token: onlineToken } : {}),
|
|
938
|
+
})));
|
|
651
939
|
}
|
|
652
940
|
catch (err) {
|
|
653
941
|
this.#logger.warn("session.relay.auth.failed", { relayPeerId: this.#relayPeerId, reason: "response_send", error: extractErrorMessage(err) });
|
|
@@ -667,12 +955,50 @@ export class AgentRelayClient {
|
|
|
667
955
|
return false;
|
|
668
956
|
}
|
|
669
957
|
if (ackFrame["type"] !== "relay_auth_ok") {
|
|
958
|
+
/**
|
|
959
|
+
* DOD-M15-RELAYABUSE-1 review F2 — **THE RELAY SAYS WHY, AND WE USED TO THROW IT AWAY.**
|
|
960
|
+
*
|
|
961
|
+
* `relay_auth_failed` carries a `reason` — `rate_limited`, `signature_invalid`, `nonce_expired`,
|
|
962
|
+
* `nonce_reused`, `nonce_unknown` — and, when throttled, a `retry_after_ms`. This branch
|
|
963
|
+
* collapsed all of them into the single word `auth_rejected`, so a throttled agent looked
|
|
964
|
+
* exactly like a bad signature, which looked exactly like a dead relay. That is precisely the
|
|
965
|
+
* distinction the order that added those refusals set out to create, undone at the last hop.
|
|
966
|
+
*
|
|
967
|
+
* The difference matters to whoever is looking: `rate_limited` clears by itself and says when;
|
|
968
|
+
* `signature_invalid` never clears and means a key or clock problem; a nonce failure means the
|
|
969
|
+
* handshake raced and an immediate retry is the right move. One label for all three sends
|
|
970
|
+
* someone to look for a broken relay in all three cases.
|
|
971
|
+
*/
|
|
972
|
+
const relayReason = typeof ackFrame["reason"] === "string" ? ackFrame["reason"] : undefined;
|
|
973
|
+
const retryAfterMs = typeof ackFrame["retry_after_ms"] === "number" ? ackFrame["retry_after_ms"] : undefined;
|
|
974
|
+
/**
|
|
975
|
+
* DOD-M15-RELAYSLOTS-1: keep the refusal, do not merely log it.
|
|
976
|
+
*
|
|
977
|
+
* Everything below writes an excellent warn line into a file nobody opens. The operator who
|
|
978
|
+
* runs `cello_use_agent` and finds their agent unreachable never sees it, and the daemon
|
|
979
|
+
* deciding whether another relay would help cannot read it either. So the classified refusal
|
|
980
|
+
* — reason, what to do about it, and whether to fail over — is stored where both can reach it.
|
|
981
|
+
*/
|
|
982
|
+
const slotsHeld = typeof ackFrame["slots_held"] === "number" ? ackFrame["slots_held"] : undefined;
|
|
983
|
+
const slotCap = typeof ackFrame["slot_cap"] === "number" ? ackFrame["slot_cap"] : undefined;
|
|
984
|
+
this.#lastAuthRefusal = classifyRelayAuthRefusal(ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame", {
|
|
985
|
+
...(slotsHeld !== undefined ? { slotsHeld } : {}),
|
|
986
|
+
...(slotCap !== undefined ? { slotCap } : {}),
|
|
987
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
988
|
+
});
|
|
670
989
|
this.#logger.warn("session.relay.auth.failed", {
|
|
671
990
|
relayPeerId: this.#relayPeerId,
|
|
672
|
-
reason: ackFrame["type"] === "relay_auth_failed" ? "auth_rejected" : "unexpected_frame",
|
|
991
|
+
reason: ackFrame["type"] === "relay_auth_failed" ? (relayReason ?? "auth_rejected") : "unexpected_frame",
|
|
992
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
993
|
+
impact: relayReason === "rate_limited"
|
|
994
|
+
? "this relay is throttling us; it clears on its own after the stated window, and until it does this agent cannot reserve or witness here"
|
|
995
|
+
: "this agent could not authenticate to this relay, so it cannot witness leaves or hold a reservation here",
|
|
673
996
|
});
|
|
674
997
|
return false;
|
|
675
998
|
}
|
|
999
|
+
// DOD-M15-RELAYSLOTS-1: a success clears the stored refusal, so a stale one is never reported
|
|
1000
|
+
// as the current state of a relay that has since started admitting us.
|
|
1001
|
+
this.#lastAuthRefusal = null;
|
|
676
1002
|
return true;
|
|
677
1003
|
}
|
|
678
1004
|
#startReader(stream, iter) {
|
|
@@ -906,6 +1232,21 @@ export class AgentRelayClient {
|
|
|
906
1232
|
* and returned as-is — retrying those would be pointless traffic masking a real state.
|
|
907
1233
|
*/
|
|
908
1234
|
static #SESSION_NOT_FOUND_ATTEMPTS = 3;
|
|
1235
|
+
/**
|
|
1236
|
+
* DOD-M15-RELAYABUSE-1 review F1: how many times a throttled submit waits out the relay's stated
|
|
1237
|
+
* window before the refusal is surfaced. Three, because the window is a fixed sliding minute —
|
|
1238
|
+
* two waits clear any ordinary burst, and a third failure means something other than this
|
|
1239
|
+
* sender's own volume is going on, which the operator should hear about.
|
|
1240
|
+
*/
|
|
1241
|
+
static #RATE_LIMITED_ATTEMPTS = 3;
|
|
1242
|
+
/** Used only when the relay names no window (an older relay, or a malformed value). */
|
|
1243
|
+
static #RATE_LIMITED_FALLBACK_MS = 5_000;
|
|
1244
|
+
/**
|
|
1245
|
+
* Ceiling on a single wait, so a relay reporting an implausible window cannot park a send
|
|
1246
|
+
* indefinitely — a hostile or misconfigured relay must not be able to stall a sender by
|
|
1247
|
+
* answering `retry_after_ms: 3600000`. Past this the send fails and says so.
|
|
1248
|
+
*/
|
|
1249
|
+
static #RATE_LIMITED_MAX_WAIT_MS = 65_000;
|
|
909
1250
|
async #doSubmit(node, sessionId, contentHash, leafKind, contentBytes) {
|
|
910
1251
|
const sessionIdHex = Buffer.from(sessionId).toString("hex");
|
|
911
1252
|
// Snapshotted BEFORE the first attempt, and it is the whole safety of this loop.
|
|
@@ -945,6 +1286,51 @@ export class AgentRelayClient {
|
|
|
945
1286
|
});
|
|
946
1287
|
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
|
|
947
1288
|
}
|
|
1289
|
+
/**
|
|
1290
|
+
* DOD-M15-RELAYABUSE-1 review F1 — **A THROTTLE IS BACK-PRESSURE, NOT AN ERROR.** (Andre,
|
|
1291
|
+
* 2026-08-31: retry on the relay's own timing; surface only if the retry also fails.)
|
|
1292
|
+
*
|
|
1293
|
+
* Without this the relay's `rate_limited` fell into the caller's catch-all: one log line, and
|
|
1294
|
+
* `cello_send` returned `{ok:true, delivered:true}` for a message the relay had refused to
|
|
1295
|
+
* witness — on the parked path telling the operator it was *"sealed, witnessed and on its way"*.
|
|
1296
|
+
* The leaf went out unwitnessed with no sequence number, and the seal later covered a transcript
|
|
1297
|
+
* missing it.
|
|
1298
|
+
*
|
|
1299
|
+
* This is the ONE refusal that is safely retryable and self-clearing, and the relay tells us
|
|
1300
|
+
* exactly when. So we wait it out here, where the wait is invisible, rather than handing the
|
|
1301
|
+
* agent an error for a condition that resolves in under a minute. Bounded: if the window is
|
|
1302
|
+
* absent or implausible we fall back to a fixed wait, and after
|
|
1303
|
+
* `#RATE_LIMITED_ATTEMPTS` the refusal is returned and the caller surfaces it — Option 2 as
|
|
1304
|
+
* the fallback, not the first move.
|
|
1305
|
+
*/
|
|
1306
|
+
for (let attempt = 1; attempt < _a.#RATE_LIMITED_ATTEMPTS
|
|
1307
|
+
&& !result.ok
|
|
1308
|
+
&& result.reason === "rate_limited"
|
|
1309
|
+
&& !this.#closed; attempt++) {
|
|
1310
|
+
const waitMs = Math.min(result.retry_after_ms !== undefined ? result.retry_after_ms : _a.#RATE_LIMITED_FALLBACK_MS, _a.#RATE_LIMITED_MAX_WAIT_MS);
|
|
1311
|
+
this.#logger.info("session.relay.submit.throttled", {
|
|
1312
|
+
relayPeerId: this.#relayPeerId,
|
|
1313
|
+
sessionShort: sessionIdHex.slice(0, 16),
|
|
1314
|
+
attempt,
|
|
1315
|
+
waitMs,
|
|
1316
|
+
retryAfterMsFromRelay: result.retry_after_ms,
|
|
1317
|
+
impact: "the relay is throttling this sender; waiting out its stated window and resubmitting — the message is NOT lost and the operator is not told, because this clears on its own",
|
|
1318
|
+
});
|
|
1319
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
1320
|
+
if (this.#closed)
|
|
1321
|
+
break;
|
|
1322
|
+
result = await this.#doSubmitOnce(node, sessionId, contentHash, leafKind, contentBytes);
|
|
1323
|
+
}
|
|
1324
|
+
if (!result.ok && result.reason === "rate_limited") {
|
|
1325
|
+
// Option 2, the fallback: it did not clear within our budget, so the caller must hear it
|
|
1326
|
+
// rather than be told the message was witnessed.
|
|
1327
|
+
this.#logger.warn("session.relay.submit.throttle_persisted", {
|
|
1328
|
+
relayPeerId: this.#relayPeerId,
|
|
1329
|
+
sessionShort: sessionIdHex.slice(0, 16),
|
|
1330
|
+
attempts: _a.#RATE_LIMITED_ATTEMPTS,
|
|
1331
|
+
impact: "this message was NOT witnessed by the relay — it has no sequence number and will not appear in the notarized record",
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
948
1334
|
// The relay lost a session we had successfully recorded — sealed, idle-swept, or restarted.
|
|
949
1335
|
// Report THAT, rather than letting the caller read a bare `session_not_found` that reads like
|
|
950
1336
|
// the first-message race. Never re-present here: recreating it is the resurrection above.
|
|
@@ -1041,6 +1427,7 @@ export class AgentRelayClient {
|
|
|
1041
1427
|
// Remember this submit's sender-signed structure1_cbor so its ack can return the full
|
|
1042
1428
|
// ordering record (the ack itself carries only the relay's structure2_cbor).
|
|
1043
1429
|
this.#pendingStructure1 = structure1;
|
|
1430
|
+
this.#pendingSignature = signature;
|
|
1044
1431
|
this.#pendingLeafKind = leafKind;
|
|
1045
1432
|
try {
|
|
1046
1433
|
stream.send(lp.encode.single(frame));
|
|
@@ -1050,6 +1437,7 @@ export class AgentRelayClient {
|
|
|
1050
1437
|
this.#pendingAck = null;
|
|
1051
1438
|
this.#pendingAckSessionHex = null;
|
|
1052
1439
|
this.#pendingStructure1 = null;
|
|
1440
|
+
this.#pendingSignature = null;
|
|
1053
1441
|
this.#pendingLeafKind = null;
|
|
1054
1442
|
}
|
|
1055
1443
|
this.#logger.warn("session.relay.submit.send.failed", { relayPeerId: this.#relayPeerId, error: extractErrorMessage(err) });
|
|
@@ -1072,6 +1460,7 @@ export class AgentRelayClient {
|
|
|
1072
1460
|
this.#pendingAck = null;
|
|
1073
1461
|
this.#pendingAckSessionHex = null;
|
|
1074
1462
|
this.#pendingStructure1 = null;
|
|
1463
|
+
this.#pendingSignature = null;
|
|
1075
1464
|
this.#pendingLeafKind = null;
|
|
1076
1465
|
}
|
|
1077
1466
|
}
|