@cello-protocol/daemon 0.0.31 → 0.0.33
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/daemon.d.ts +1 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +633 -17
- package/dist/daemon.js.map +1 -1
- package/dist/notification-dispatcher.d.ts +7 -0
- package/dist/notification-dispatcher.d.ts.map +1 -1
- package/dist/notification-dispatcher.js +22 -0
- package/dist/notification-dispatcher.js.map +1 -1
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +15 -4
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-node-manager.d.ts +66 -2
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +403 -28
- package/dist/session-node-manager.js.map +1 -1
- package/dist/telegram-bot-client.d.ts +34 -0
- package/dist/telegram-bot-client.d.ts.map +1 -0
- package/dist/telegram-bot-client.js +36 -0
- package/dist/telegram-bot-client.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +5 -4
|
@@ -81,7 +81,18 @@ import { encodeSealPayload } from "@cello-protocol/protocol-types";
|
|
|
81
81
|
import { AgentRelayClient, LEAF_KIND_CTRL } from "./session-relay-client.js";
|
|
82
82
|
import { RelayReceiptStore } from "./relay-receipt-store.js";
|
|
83
83
|
import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
|
|
84
|
+
import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
|
|
84
85
|
const CBOR_ENC = new Encoder({ tagUint8Array: false });
|
|
86
|
+
// M8C-ABUSE-1: persistence bounds — the non-M9 remainder (per-message cap + outbound rate are
|
|
87
|
+
// M9's, not rebuilt here). "Whitelisted senders bounded only by disk" (DoD) — these bounds apply
|
|
88
|
+
// ONLY to sessions/senders that are NOT a known contact (CONTACT-1's whitelist).
|
|
89
|
+
/** Anti-drip-feed: cumulative RECEIVED bytes per session, from a non-contact sender. */
|
|
90
|
+
export const ABUSE_MAX_SESSION_RECEIVED_BYTES = 25 * 1024 * 1024; // 25 MB
|
|
91
|
+
/** Anti-drip-feed via many sessions: active sessions a single unknown counterparty may hold open
|
|
92
|
+
* with one agent at once. */
|
|
93
|
+
export const ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER = 3;
|
|
94
|
+
/** Anti-swarm: total active sessions from ALL unknown (non-contact) counterparties combined, per agent. */
|
|
95
|
+
export const ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL = 50;
|
|
85
96
|
// ─── SessionNodeManager ───────────────────────────────────────────────────────
|
|
86
97
|
export class SessionNodeManager {
|
|
87
98
|
#factory;
|
|
@@ -92,6 +103,11 @@ export class SessionNodeManager {
|
|
|
92
103
|
#relayReceiptStore = null;
|
|
93
104
|
/** FED-OPTIONB-SEAL-001: the per-session leaf log (both parties) carried at a unilateral seal. */
|
|
94
105
|
#sealLeafStore = null;
|
|
106
|
+
// M9-CORE-001: the inbound screening seam. Every byte that reaches the agent passes
|
|
107
|
+
// through #appendVerifiedContent's buffer write; screenInbound gates it there, on every
|
|
108
|
+
// arrival path (direct, held-release, recovered-park). Defaults to always-allow when no
|
|
109
|
+
// gateway is configured (SI-001: still a verdict, not an ungated pass).
|
|
110
|
+
#securityGateway;
|
|
95
111
|
#activeNodes = new Map();
|
|
96
112
|
// M7 DOD-SPINE-6 / MSG-001-3b: ONE relay witness client per AGENT (keyed by agent name).
|
|
97
113
|
// The relay authenticates and keys delivery by the agent's K_local pubkey, so all of an
|
|
@@ -182,6 +198,11 @@ export class SessionNodeManager {
|
|
|
182
198
|
// daemon.ts (it depends on the IPC server). Never required — when unset,
|
|
183
199
|
// state changes are persisted and logged but no push notification is emitted.
|
|
184
200
|
#onSessionStateChanged = null;
|
|
201
|
+
// M8C-MSGWAKE-1 (channel stage 2): fired when a verified inbound message is buffered for
|
|
202
|
+
// cello_receive, so the daemon can push a content-free `cello_message` doorbell. Wired in
|
|
203
|
+
// daemon.ts (depends on the notification dispatcher). Content-free by signature — carries only
|
|
204
|
+
// agent / session / senderPubkey, NEVER the plaintext (INV-CONTENTFREE).
|
|
205
|
+
#onContentArrived = null;
|
|
185
206
|
// CELLO-M7-MSG-001 (AC-001/AC-002/AC-003): the send is no longer fire-and-forget.
|
|
186
207
|
// After a content_frame is delivered over the direct session channel, the sender
|
|
187
208
|
// arms a TTF timer and waits for an unsigned, transport-authenticated `persisted`
|
|
@@ -214,6 +235,7 @@ export class SessionNodeManager {
|
|
|
214
235
|
}
|
|
215
236
|
this.#autoNatProbers = opts.autoNatProbers ?? (() => []);
|
|
216
237
|
this.#srRetryDelaysMs = opts.standingReceiverRetryDelaysMs ?? [1_000, 5_000, 15_000];
|
|
238
|
+
this.#securityGateway = opts.securityGateway ?? new PassthroughGatewayClient();
|
|
217
239
|
}
|
|
218
240
|
/**
|
|
219
241
|
* CELLO-M7-MSG-001: wire the durable-backstop side effects of the awaiting-ACK
|
|
@@ -237,30 +259,40 @@ export class SessionNodeManager {
|
|
|
237
259
|
* MSG-001-3b (2b): deposit un-confirmed content to the relay store-and-forward backstop — keyed
|
|
238
260
|
* to the recipient, on the SAME relay this session is witnessed by — so an offline recipient
|
|
239
261
|
* recovers it (at the sequence the witness already assigned, R1). Best-effort, never throws.
|
|
262
|
+
* DOD-LEAVEMSG-1: returns whether the deposit actually succeeded (false if no hook/relay is
|
|
263
|
+
* configured, or the hook rejects) so a caller with a live response to shape (sendContent) can
|
|
264
|
+
* distinguish "genuinely parked" from "nothing recoverable" instead of guessing. Callers that
|
|
265
|
+
* fire this from an async backstop with no live caller (the TTF-expiry path) may ignore the
|
|
266
|
+
* result — the deposit itself and its logging are unchanged either way.
|
|
240
267
|
*/
|
|
241
|
-
#parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) {
|
|
268
|
+
async #parkContent(agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) {
|
|
242
269
|
const hook = this.#contentParkHook;
|
|
243
270
|
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
244
271
|
if (!hook || !entry || !entry.relayPeerId || !entry.relayAddrs)
|
|
245
|
-
return;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
272
|
+
return false;
|
|
273
|
+
try {
|
|
274
|
+
await hook({
|
|
275
|
+
sessionId,
|
|
276
|
+
recipientPubkeyHex: entry.counterpartyPubkey,
|
|
277
|
+
relayPeerId: entry.relayPeerId,
|
|
278
|
+
relayAddrs: entry.relayAddrs,
|
|
279
|
+
contentHashHex,
|
|
280
|
+
content,
|
|
281
|
+
// DOD-MSG-4 (2b): carry the relay's signed ordering record so the parked entry is self-ordering
|
|
282
|
+
// on recover too (sealed INTO the ciphertext envelope — INV-3: the relay still sees only ciphertext).
|
|
283
|
+
structure1Cbor,
|
|
284
|
+
structure2Cbor,
|
|
285
|
+
});
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
258
289
|
this.#logger.warn("content.park.deposit.failed", {
|
|
259
290
|
sessionId,
|
|
260
291
|
contentHash: contentHashHex,
|
|
261
292
|
error: err instanceof Error ? err.message : String(err),
|
|
262
293
|
});
|
|
263
|
-
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
264
296
|
}
|
|
265
297
|
// ─── Initialization ──────────────────────────────────────────────────────
|
|
266
298
|
async initialize() {
|
|
@@ -391,6 +423,31 @@ export class SessionNodeManager {
|
|
|
391
423
|
last_delivered_seq INTEGER NOT NULL,
|
|
392
424
|
PRIMARY KEY (agent_name, session_id)
|
|
393
425
|
)
|
|
426
|
+
`);
|
|
427
|
+
// M8C-CONTACT-1: binary per-agent contact whitelist. This is an ACCESS-CONTROL LIST, not a
|
|
428
|
+
// setting — it belongs alongside message_watermarks/sessions as its own real subsystem, not
|
|
429
|
+
// behind the parked M9-CFG-001 config store. Identity PINS to the pubkey at add time (never
|
|
430
|
+
// re-resolved); known stays known until explicitly removed (no TTL/expiry on membership).
|
|
431
|
+
this.#db.exec(`
|
|
432
|
+
CREATE TABLE IF NOT EXISTS contacts (
|
|
433
|
+
agent_name TEXT NOT NULL,
|
|
434
|
+
pubkey TEXT NOT NULL,
|
|
435
|
+
added_at INTEGER NOT NULL,
|
|
436
|
+
PRIMARY KEY (agent_name, pubkey)
|
|
437
|
+
)
|
|
438
|
+
`);
|
|
439
|
+
// M8C-TGDOOR-1: daemon-wide Telegram settings (bot token + allowlisted operator chat). A
|
|
440
|
+
// NEW dedicated table — NOT folded into the parked M9-CFG-001 config store, because a bot
|
|
441
|
+
// token has no sensible default (a required credential, unlike AWAY/TTL/CONTACT's real
|
|
442
|
+
// defaults) and can't legitimately wait for M9. Singleton row (id=1) — "token = daemon
|
|
443
|
+
// setting" (DoD), not per-agent.
|
|
444
|
+
this.#db.exec(`
|
|
445
|
+
CREATE TABLE IF NOT EXISTS telegram_settings (
|
|
446
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
447
|
+
bot_token TEXT NOT NULL,
|
|
448
|
+
allowlisted_chat_id TEXT NOT NULL,
|
|
449
|
+
updated_at INTEGER NOT NULL
|
|
450
|
+
)
|
|
394
451
|
`);
|
|
395
452
|
// Step 2: Detect interrupted sessions (SIGKILL detection — AC-010).
|
|
396
453
|
// Any 'active' row in a freshly-started daemon is a remnant of a prior
|
|
@@ -569,6 +626,121 @@ export class SessionNodeManager {
|
|
|
569
626
|
.all(agentName);
|
|
570
627
|
return rows;
|
|
571
628
|
}
|
|
629
|
+
/** M8C-CONTACT-1: is this pubkey a known contact of this agent? */
|
|
630
|
+
isContact(agentName, pubkey) {
|
|
631
|
+
if (!this.#db)
|
|
632
|
+
return false;
|
|
633
|
+
const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_name = ? AND pubkey = ?").get(agentName, pubkey);
|
|
634
|
+
return row !== undefined;
|
|
635
|
+
}
|
|
636
|
+
/** M8C-CONTACT-1: pin a contact at add time — idempotent (re-adding an existing contact is a
|
|
637
|
+
* no-op, never refreshes added_at; identity does not get re-resolved). */
|
|
638
|
+
addContact(agentName, pubkey) {
|
|
639
|
+
if (!this.#db || !pubkey)
|
|
640
|
+
return;
|
|
641
|
+
this.#db
|
|
642
|
+
.prepare("INSERT OR IGNORE INTO contacts (agent_name, pubkey, added_at) VALUES (?, ?, ?)")
|
|
643
|
+
.run(agentName, pubkey, Date.now());
|
|
644
|
+
}
|
|
645
|
+
/** M8C-CONTACT-1: known stays known until explicitly removed. */
|
|
646
|
+
removeContact(agentName, pubkey) {
|
|
647
|
+
if (!this.#db)
|
|
648
|
+
return false;
|
|
649
|
+
const res = this.#db.prepare("DELETE FROM contacts WHERE agent_name = ? AND pubkey = ?").run(agentName, pubkey);
|
|
650
|
+
return res.changes > 0;
|
|
651
|
+
}
|
|
652
|
+
/** M8C-CONTACT-1: list an agent's known contacts, oldest-added first. */
|
|
653
|
+
listContacts(agentName) {
|
|
654
|
+
if (!this.#db)
|
|
655
|
+
return [];
|
|
656
|
+
return this.#db
|
|
657
|
+
.prepare("SELECT pubkey, added_at FROM contacts WHERE agent_name = ? ORDER BY added_at ASC")
|
|
658
|
+
.all(agentName);
|
|
659
|
+
}
|
|
660
|
+
/** M8C-ABUSE-1: cumulative RECEIVED byte total for a session (anti-drip-feed accounting). */
|
|
661
|
+
#getReceivedBytesTotal(agentName, sessionId) {
|
|
662
|
+
if (!this.#db)
|
|
663
|
+
return 0;
|
|
664
|
+
const row = this.#db
|
|
665
|
+
.prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE agent_name = ? AND session_id = ? AND direction = 'received'")
|
|
666
|
+
.get(agentName, sessionId);
|
|
667
|
+
return row.total;
|
|
668
|
+
}
|
|
669
|
+
/** M8C-ABUSE-1 (reviewer HIGH fix, D18): bytes currently sitting in the out-of-order hold
|
|
670
|
+
* buffer for this session — NOT yet committed leaves, but real bytes in memory that would
|
|
671
|
+
* otherwise let multiple held chunks each individually pass the size gate while cumulatively
|
|
672
|
+
* exceeding it once #releaseHeld drains them. */
|
|
673
|
+
#getHeldBytesTotal(agentName, sessionId) {
|
|
674
|
+
const held = this.#heldContent.get(this.#k(agentName, sessionId));
|
|
675
|
+
if (!held)
|
|
676
|
+
return 0;
|
|
677
|
+
let total = 0;
|
|
678
|
+
for (const entry of held.values())
|
|
679
|
+
total += entry.content.length;
|
|
680
|
+
return total;
|
|
681
|
+
}
|
|
682
|
+
/** M8C-ABUSE-1: non-terminal sessions this agent currently holds with the given counterparty.
|
|
683
|
+
* Reviewer HIGH fix (aeffb82f, D18): counting `status = 'active'` ONLY let a counterparty
|
|
684
|
+
* evade the bound for free by disconnecting (a trivial, attacker-controlled action that flips
|
|
685
|
+
* a session to 'interrupted' — markInterruptedWithDetails) and opening a fresh session,
|
|
686
|
+
* repeated indefinitely. 'interrupted' sessions still accept content (ingestReceivedContent
|
|
687
|
+
* explicitly allows both statuses) and are NOT terminal (sealed/seal_interrupted_pending are),
|
|
688
|
+
* so they must still count against the bound. */
|
|
689
|
+
countActiveSessionsForCounterparty(agentName, counterpartyPubkey) {
|
|
690
|
+
if (!this.#db)
|
|
691
|
+
return 0;
|
|
692
|
+
const row = this.#db
|
|
693
|
+
.prepare("SELECT COUNT(*) AS n FROM sessions WHERE agent_name = ? AND counterparty_pubkey = ? AND status IN ('active', 'interrupted')")
|
|
694
|
+
.get(agentName, counterpartyPubkey);
|
|
695
|
+
return row.n;
|
|
696
|
+
}
|
|
697
|
+
/** M8C-ABUSE-1 (anti-swarm): non-terminal sessions this agent holds with counterparties that are
|
|
698
|
+
* NOT a known contact — the global cap counts across ALL unknown senders combined. Same
|
|
699
|
+
* 'interrupted'-status fix as countActiveSessionsForCounterparty above. */
|
|
700
|
+
countActiveSessionsFromUnknownSenders(agentName) {
|
|
701
|
+
if (!this.#db)
|
|
702
|
+
return 0;
|
|
703
|
+
const row = this.#db
|
|
704
|
+
.prepare(`SELECT COUNT(*) AS n FROM sessions s
|
|
705
|
+
WHERE s.agent_name = ? AND s.status IN ('active', 'interrupted')
|
|
706
|
+
AND NOT EXISTS (SELECT 1 FROM contacts c WHERE c.agent_name = s.agent_name AND c.pubkey = s.counterparty_pubkey)`)
|
|
707
|
+
.get(agentName);
|
|
708
|
+
return row.n;
|
|
709
|
+
}
|
|
710
|
+
/** M8C-ABUSE-1: is a NEW inbound session from this counterparty within the acceptance bounds?
|
|
711
|
+
* Known contacts are exempt entirely ("bounded only by disk" — DoD). Checked BEFORE accepting
|
|
712
|
+
* a fresh inbound session (the counts reflect sessions already active, not yet counting this one). */
|
|
713
|
+
checkUnknownSenderAcceptanceBound(agentName, counterpartyPubkey) {
|
|
714
|
+
if (this.isContact(agentName, counterpartyPubkey))
|
|
715
|
+
return { ok: true };
|
|
716
|
+
const perSender = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
|
|
717
|
+
if (perSender >= ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER) {
|
|
718
|
+
return { ok: false, reason: "abuse_bound_sessions_per_sender" };
|
|
719
|
+
}
|
|
720
|
+
const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
|
|
721
|
+
if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
|
|
722
|
+
return { ok: false, reason: "abuse_bound_unknown_sessions_global" };
|
|
723
|
+
}
|
|
724
|
+
return { ok: true };
|
|
725
|
+
}
|
|
726
|
+
/** M8C-TGDOOR-1: the daemon-wide Telegram bot settings, or null if never configured. */
|
|
727
|
+
getTelegramSettings() {
|
|
728
|
+
if (!this.#db)
|
|
729
|
+
return null;
|
|
730
|
+
const row = this.#db
|
|
731
|
+
.prepare("SELECT bot_token, allowlisted_chat_id FROM telegram_settings WHERE id = 1")
|
|
732
|
+
.get();
|
|
733
|
+
return row ? { botToken: row.bot_token, allowlistedChatId: row.allowlisted_chat_id } : null;
|
|
734
|
+
}
|
|
735
|
+
/** M8C-TGDOOR-1: persist (or replace) the singleton Telegram settings row. */
|
|
736
|
+
setTelegramSettings(botToken, allowlistedChatId) {
|
|
737
|
+
if (!this.#db)
|
|
738
|
+
return;
|
|
739
|
+
this.#db
|
|
740
|
+
.prepare(`INSERT INTO telegram_settings (id, bot_token, allowlisted_chat_id, updated_at) VALUES (1, ?, ?, ?)
|
|
741
|
+
ON CONFLICT(id) DO UPDATE SET bot_token = excluded.bot_token, allowlisted_chat_id = excluded.allowlisted_chat_id, updated_at = excluded.updated_at`)
|
|
742
|
+
.run(botToken, allowlistedChatId, Date.now());
|
|
743
|
+
}
|
|
572
744
|
/** DOD-LOOP-1: whether the given agent has a standing receiver ready (any agent if omitted). */
|
|
573
745
|
getStandingReceiverReady(agentName) {
|
|
574
746
|
if (agentName !== undefined)
|
|
@@ -642,6 +814,13 @@ export class SessionNodeManager {
|
|
|
642
814
|
setOnSessionStateChanged(cb) {
|
|
643
815
|
this.#onSessionStateChanged = cb;
|
|
644
816
|
}
|
|
817
|
+
/**
|
|
818
|
+
* M8C-MSGWAKE-1: inject the content-arrival callback (daemon.ts → NotificationDispatcher.
|
|
819
|
+
* dispatchCelloMessage). Setter injection, same construction-order reason as above.
|
|
820
|
+
*/
|
|
821
|
+
setOnContentArrived(cb) {
|
|
822
|
+
this.#onContentArrived = cb;
|
|
823
|
+
}
|
|
645
824
|
/**
|
|
646
825
|
* DOD-LOOP-1: the session core is keyed by (agentName, sessionId), NOT sessionId alone. Two of
|
|
647
826
|
* the operator's own agents (the loopback case) can hold the two ends of the SAME session_id on
|
|
@@ -1827,7 +2006,7 @@ export class SessionNodeManager {
|
|
|
1827
2006
|
await stream.close();
|
|
1828
2007
|
}
|
|
1829
2008
|
catch { /* best-effort close */ }
|
|
1830
|
-
return { ok: true };
|
|
2009
|
+
return { ok: true, delivered: true };
|
|
1831
2010
|
}
|
|
1832
2011
|
catch (err) {
|
|
1833
2012
|
// The send failed after (possibly) arming the awaiting tracking — drop it so a
|
|
@@ -1836,7 +2015,13 @@ export class SessionNodeManager {
|
|
|
1836
2015
|
// 2b: direct delivery failed (counterparty offline). The hash is already witnessed (R1, the
|
|
1837
2016
|
// sequence is assigned), so deposit the content to the relay store-and-forward backstop now;
|
|
1838
2017
|
// the recipient pulls + recovers it on next online (DOD-MSG-3/4).
|
|
1839
|
-
|
|
2018
|
+
// DOD-LEAVEMSG-1: the deposit is now AWAITED (was fire-and-forget) so a genuine park success
|
|
2019
|
+
// can be reported as "dispatched to relay" instead of a raw stream failure — the operator/
|
|
2020
|
+
// agent sees the truth (the message IS in flight, just not direct), not a false negative.
|
|
2021
|
+
const parked = await this.#parkContent(agentName, sessionId, Buffer.from(contentHash).toString("hex"), content, orderingS1, orderingS2);
|
|
2022
|
+
if (parked) {
|
|
2023
|
+
return { ok: true, delivered: false, parked: true };
|
|
2024
|
+
}
|
|
1840
2025
|
// error.message extracted — never [object Object]. libp2p/cross-package errors are not
|
|
1841
2026
|
// always `instanceof Error` in this realm, so fall back to a message property / JSON.
|
|
1842
2027
|
const errMsg = err instanceof Error
|
|
@@ -2066,7 +2251,7 @@ export class SessionNodeManager {
|
|
|
2066
2251
|
*
|
|
2067
2252
|
* @returns the appended leaf index (as sequenceNumber) on success.
|
|
2068
2253
|
*/
|
|
2069
|
-
ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId) {
|
|
2254
|
+
async ingestReceivedContent(agentName, sessionId, content, contentHash, correlationId) {
|
|
2070
2255
|
// The transcript is frozen ONLY once it is COMMITTED + signed — 'sealed' or
|
|
2071
2256
|
// 'seal_interrupted_pending' (the bilateral seal commitment) — because a later FROST
|
|
2072
2257
|
// notarization attests that exact root; a late leaf would diverge from it.
|
|
@@ -2102,9 +2287,15 @@ export class SessionNodeManager {
|
|
|
2102
2287
|
return { ok: false, reason: "content_hash_mismatch" };
|
|
2103
2288
|
}
|
|
2104
2289
|
const entry = this.#activeNodes.get(this.#k(agentName, sessionId));
|
|
2105
|
-
|
|
2106
|
-
?? this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey
|
|
2107
|
-
|
|
2290
|
+
let senderPubkey = entry?.counterpartyPubkey
|
|
2291
|
+
?? this.getSessionRecord(agentName, sessionId)?.counterparty_pubkey;
|
|
2292
|
+
if (!senderPubkey) {
|
|
2293
|
+
// M8C-MSGWAKE-1 (reviewer F1): the sender can't be resolved from the active node or the session
|
|
2294
|
+
// record. MSGWAKE now surfaces this as the doorbell's `from`, so a chronic miss would silently
|
|
2295
|
+
// ship `from: "unknown"` on every wake — log it loudly instead of papering it in.
|
|
2296
|
+
this.#logger.warn("session.content.sender_unresolved", { sessionId, agentName, correlationId });
|
|
2297
|
+
senderPubkey = "unknown";
|
|
2298
|
+
}
|
|
2108
2299
|
// DOD-MSG-5: a content_hash satisfies AT MOST ONE Merkle leaf, exactly once. If this hash is
|
|
2109
2300
|
// already a leaf in the tree — it arrived BOTH directly and via the relay-park backstop, or it
|
|
2110
2301
|
// is a replay — do NOT append a second leaf and do NOT double-count it. The recipient already
|
|
@@ -2122,6 +2313,151 @@ export class SessionNodeManager {
|
|
|
2122
2313
|
// entry (e.g. after auto-recover already drained it) must not count it as a fresh recovery.
|
|
2123
2314
|
return { ok: true, leafIndex: existingIdx, sequenceNumber: existingIdx, appendedCount: 0 };
|
|
2124
2315
|
}
|
|
2316
|
+
// M8C-ABUSE-1 (reviewer HIGH fix, D18): per-session total-size cap (anti-drip-feed) —
|
|
2317
|
+
// "whitelisted senders bounded only by disk" (DoD), so a known contact is exempt entirely.
|
|
2318
|
+
// MUST run BEFORE the hold-branch below — the original placement (after it) let a
|
|
2319
|
+
// non-contact sender drip-feed unbounded bytes by making every message arrive "out of order"
|
|
2320
|
+
// relative to the relay witness (held content skipped the cap entirely, then #releaseHeld
|
|
2321
|
+
// appended it later with no re-check). Accounts for bytes already committed AND bytes
|
|
2322
|
+
// currently sitting in the hold buffer (multiple held chunks could otherwise each individually
|
|
2323
|
+
// pass the check while cumulatively exceeding it once released). Runs BEFORE the M9 screening
|
|
2324
|
+
// seam below (cheap + synchronous — fail fast on volume before spending gateway compute on
|
|
2325
|
+
// content headed for rejection anyway); both gates are independent and either rejects on its
|
|
2326
|
+
// own criteria, so ordering between them does not change correctness.
|
|
2327
|
+
if (!this.isContact(agentName, senderPubkey)) {
|
|
2328
|
+
const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
|
|
2329
|
+
const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
|
|
2330
|
+
if (priorTotal + heldTotal + content.length > ABUSE_MAX_SESSION_RECEIVED_BYTES) {
|
|
2331
|
+
this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
|
|
2332
|
+
sessionId,
|
|
2333
|
+
agentName,
|
|
2334
|
+
senderPubkey,
|
|
2335
|
+
priorTotal,
|
|
2336
|
+
heldTotal,
|
|
2337
|
+
incoming: content.length,
|
|
2338
|
+
cap: ABUSE_MAX_SESSION_RECEIVED_BYTES,
|
|
2339
|
+
correlationId,
|
|
2340
|
+
});
|
|
2341
|
+
return { ok: false, reason: "session_size_limit_exceeded" };
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
// M9-CORE-001: the inbound screening seam (INV-5). Screen here — after the content is proven
|
|
2345
|
+
// authentic (hash cross-check) and confirmed not a duplicate, before it is either held for
|
|
2346
|
+
// ordering or appended to the agent-facing buffer. This is the SINGLE inbound funnel: direct
|
|
2347
|
+
// arrivals, recovered/parked content (daemon recover → here), and held-then-released content
|
|
2348
|
+
// (held below, screened now, released already-screened) all pass this point. A non-allow
|
|
2349
|
+
// verdict means the content is NOT delivered to the agent: it is not held, not buffered, and
|
|
2350
|
+
// no leaf is appended — the message stays un-acked so the sender's TTF/park/retry redelivers
|
|
2351
|
+
// it once the gateway is reachable again (DB-001 fail-closed: hold, never expose ungated).
|
|
2352
|
+
const inboundVerdict = await this.#securityGateway.screenInbound(content, {
|
|
2353
|
+
direction: "inbound",
|
|
2354
|
+
agentName,
|
|
2355
|
+
sessionId,
|
|
2356
|
+
correlationId,
|
|
2357
|
+
});
|
|
2358
|
+
// M9 terminal-vs-transient split. A TERMINAL block (inboundVerdict.terminal) is a detector
|
|
2359
|
+
// rejecting the CONTENT itself — a confident non-allowlisted language (IN-003), a high-score
|
|
2360
|
+
// injection (IN-002), or an oversized payload (IN-001). The identical bytes would be rejected
|
|
2361
|
+
// identically on redelivery, so holding them un-acked would loop the sender forever. Instead a
|
|
2362
|
+
// terminal block is `screenedOut`: it records a leaf binding the ORIGINAL content hash and is
|
|
2363
|
+
// acknowledged (the sender stops), but is NEVER buffered for the agent (cello_receive never sees
|
|
2364
|
+
// it). The leaf is REQUIRED, not cosmetic: the sender appended this leaf at its CANONICAL position
|
|
2365
|
+
// on send, so a terminal block must take the SAME strict-in-order path as a delivered message —
|
|
2366
|
+
// record the leaf at its canonical index, not in arrival order — or the two parties' hash chains
|
|
2367
|
+
// diverge by POSITION and the bilateral seal cross-check mismatches (code-review HIGH-1). The only
|
|
2368
|
+
// difference from a normal message is that it leafs WITHOUT buffering. A TRANSIENT block (a
|
|
2369
|
+
// fail-closed gateway_unavailable / governance_timeout) records nothing and is not acked.
|
|
2370
|
+
const terminalBlock = inboundVerdict.disposition === "block" && inboundVerdict.terminal === true;
|
|
2371
|
+
if (inboundVerdict.disposition !== "allow" && inboundVerdict.disposition !== "redact" && !terminalBlock) {
|
|
2372
|
+
// TRANSIENT block / warn HOLD (do not deliver, do not leaf, do not ack). The message stays
|
|
2373
|
+
// un-acked so the sender's TTF/park/retry redelivers and re-screens it once the gateway recovers.
|
|
2374
|
+
// (If we committed a leaf, dedup would later swallow the redelivery and the agent would never
|
|
2375
|
+
// receive it.)
|
|
2376
|
+
if (inboundVerdict.reason === GOVERNANCE_TIMEOUT) {
|
|
2377
|
+
this.#logger.error("security.gateway.timeout", {
|
|
2378
|
+
sessionId,
|
|
2379
|
+
reason: inboundVerdict.reason,
|
|
2380
|
+
correlationId,
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
else if (inboundVerdict.reason === GATEWAY_UNAVAILABLE) {
|
|
2384
|
+
this.#logger.error("security.gateway.unavailable", {
|
|
2385
|
+
direction: "inbound",
|
|
2386
|
+
reason: inboundVerdict.reason,
|
|
2387
|
+
correlationId,
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
else {
|
|
2391
|
+
this.#logger.warn("security.gateway.inbound.blocked", {
|
|
2392
|
+
sessionId,
|
|
2393
|
+
disposition: inboundVerdict.disposition,
|
|
2394
|
+
reason: inboundVerdict.reason,
|
|
2395
|
+
correlationId,
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
return { ok: false, reason: inboundVerdict.reason ?? "inbound_screen_blocked" };
|
|
2399
|
+
}
|
|
2400
|
+
if (terminalBlock) {
|
|
2401
|
+
this.#logger.warn("security.gateway.inbound.terminal_block", {
|
|
2402
|
+
sessionId,
|
|
2403
|
+
disposition: inboundVerdict.disposition,
|
|
2404
|
+
reason: inboundVerdict.reason,
|
|
2405
|
+
correlationId,
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
// M9-IN-001: a `redact` verdict (inbound sanitization) DELIVERS the sanitized text to the agent,
|
|
2409
|
+
// while the Merkle leaf still binds the ORIGINAL content hash below — the transcript records what
|
|
2410
|
+
// the peer actually sent; the agent sees the sanitized form. `allow` leaves the content unchanged.
|
|
2411
|
+
// A terminal block carries the original bytes here only so its leaf binds the right hash; it is
|
|
2412
|
+
// never delivered (the screenedOut flag below routes it to a leaf-without-buffer).
|
|
2413
|
+
const deliverContent = inboundVerdict.disposition === "redact" && inboundVerdict.content !== undefined
|
|
2414
|
+
? inboundVerdict.content
|
|
2415
|
+
: content;
|
|
2416
|
+
// B1 (review): screenInbound above is the ONLY suspension point in this method. Before M9 the
|
|
2417
|
+
// dedup check (indexOfHash, above) and the leaf append (below) ran with no yield between them,
|
|
2418
|
+
// so check-then-append was atomic under Node's single thread. The await reopened that window:
|
|
2419
|
+
// two concurrent ingests of the SAME content hash — e.g. a direct retry and a park-recovery
|
|
2420
|
+
// racing on reconnect — could BOTH pass the dedup check before either appends, producing two
|
|
2421
|
+
// leaves for one hash (DOD-MSG-5 break → leafIndex≠canonicalSeq → root divergence). Re-check
|
|
2422
|
+
// dedup now that we have resumed; everything from here to the append is synchronous (atomic),
|
|
2423
|
+
// so the first to resume appends and the second sees its leaf and dedups.
|
|
2424
|
+
const dedupAfterScreen = this.getSessionTree(agentName, sessionId).indexOfHash(contentHashHex);
|
|
2425
|
+
if (dedupAfterScreen >= 0) {
|
|
2426
|
+
this.#logger.info("session.content.deduplicated", {
|
|
2427
|
+
sessionId,
|
|
2428
|
+
contentHashHex,
|
|
2429
|
+
sequenceNumber: dedupAfterScreen,
|
|
2430
|
+
correlationId,
|
|
2431
|
+
});
|
|
2432
|
+
return { ok: true, leafIndex: dedupAfterScreen, sequenceNumber: dedupAfterScreen, appendedCount: 0, ...(terminalBlock ? { screenedOut: true } : {}) };
|
|
2433
|
+
}
|
|
2434
|
+
// M8C-ABUSE-1 (cello-unit-reviewer HIGH fix, post-M9INT-1 merge): re-check the size cap here,
|
|
2435
|
+
// in the SAME synchronous window as the dedup re-check above. The original check (before the
|
|
2436
|
+
// screenInbound await) used totals that can go stale: two concurrent ingests for the same
|
|
2437
|
+
// non-contact session — e.g. a live direct arrival racing a recoverParkedFromRelay pull —
|
|
2438
|
+
// could each independently pass the pre-await check using the SAME stale totals, then both
|
|
2439
|
+
// append/hold, jointly exceeding the cap. Symmetric to the dedup fix: everything from here to
|
|
2440
|
+
// the append/hold branch is synchronous, so whichever call resumes first appends/holds before
|
|
2441
|
+
// the second's re-check runs, and the second's freshly-recomputed totals correctly include the
|
|
2442
|
+
// first's contribution.
|
|
2443
|
+
if (!this.isContact(agentName, senderPubkey)) {
|
|
2444
|
+
const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
|
|
2445
|
+
const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
|
|
2446
|
+
if (priorTotal + heldTotal + content.length > ABUSE_MAX_SESSION_RECEIVED_BYTES) {
|
|
2447
|
+
this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
|
|
2448
|
+
sessionId,
|
|
2449
|
+
agentName,
|
|
2450
|
+
senderPubkey,
|
|
2451
|
+
priorTotal,
|
|
2452
|
+
heldTotal,
|
|
2453
|
+
incoming: content.length,
|
|
2454
|
+
cap: ABUSE_MAX_SESSION_RECEIVED_BYTES,
|
|
2455
|
+
correlationId,
|
|
2456
|
+
recheck: true,
|
|
2457
|
+
});
|
|
2458
|
+
return { ok: false, reason: "session_size_limit_exceeded" };
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2125
2461
|
// DOD-MSG-4 (strict in-order gate): the RELAY is the ordering authority. If B holds the
|
|
2126
2462
|
// canonical sequence for this hash (witnessed via leaf_deliver) and it is AHEAD of the next
|
|
2127
2463
|
// expected leaf, HOLD the content rather than append it out of order. The missing in-between
|
|
@@ -2139,18 +2475,22 @@ export class SessionNodeManager {
|
|
|
2139
2475
|
held = new Map();
|
|
2140
2476
|
this.#heldContent.set(key, held);
|
|
2141
2477
|
}
|
|
2142
|
-
|
|
2478
|
+
// A terminal block out of canonical order is held WITHOUT delivery (screenedOut): #releaseHeld
|
|
2479
|
+
// leafs it at its canonical index when the gap fills, but never buffers it for the agent. This
|
|
2480
|
+
// keeps leafIndex === canonicalSeq for screened-out content too (code-review HIGH-1).
|
|
2481
|
+
held.set(canonicalSeq, { content: deliverContent, contentHashHex, correlationId, ...(terminalBlock ? { screenedOut: true } : {}) });
|
|
2143
2482
|
this.#logger.info("session.content.held", {
|
|
2144
2483
|
sessionId,
|
|
2145
2484
|
canonicalSeq,
|
|
2146
2485
|
nextExpected,
|
|
2147
2486
|
gap: canonicalSeq - nextExpected,
|
|
2487
|
+
screenedOut: terminalBlock,
|
|
2148
2488
|
correlationId,
|
|
2149
2489
|
});
|
|
2150
2490
|
// Held content is NOT yet a durable leaf, so it is deliberately NOT acknowledged `persisted`
|
|
2151
2491
|
// (the caller checks `held`). The sender's TTF→park backstop and the recover/dedup path
|
|
2152
2492
|
// guarantee eventual delivery; B never claims persisted for content it only holds in memory.
|
|
2153
|
-
return { ok: true, leafIndex: canonicalSeq, sequenceNumber: canonicalSeq, held: true };
|
|
2493
|
+
return { ok: true, leafIndex: canonicalSeq, sequenceNumber: canonicalSeq, held: true, ...(terminalBlock ? { screenedOut: true } : {}) };
|
|
2154
2494
|
}
|
|
2155
2495
|
if (canonicalSeq !== undefined && canonicalSeq < nextExpected) {
|
|
2156
2496
|
// Contradiction (review finding #2): the witness says this hash belongs BEHIND the current
|
|
@@ -2166,12 +2506,16 @@ export class SessionNodeManager {
|
|
|
2166
2506
|
correlationId,
|
|
2167
2507
|
});
|
|
2168
2508
|
}
|
|
2169
|
-
|
|
2509
|
+
// In-order append. A terminal block leafs the ORIGINAL content hash WITHOUT buffering it for the
|
|
2510
|
+
// agent (screenedOut); a delivered message buffers + leafs via #appendVerifiedContent.
|
|
2511
|
+
const leafIndex = terminalBlock
|
|
2512
|
+
? this.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, correlationId).leafIndex
|
|
2513
|
+
: this.#appendVerifiedContent(agentName, sessionId, deliverContent, contentHashHex, senderPubkey, correlationId).leafIndex;
|
|
2170
2514
|
// A just-appended leaf may unblock held out-of-order arrivals whose turn is now next.
|
|
2171
2515
|
// appendedCount = this leaf + any held leaves released by it, so a caller (recover) can tally the
|
|
2172
2516
|
// leaves ACTUALLY written, not just the directly-ingested one (review #3).
|
|
2173
2517
|
const released = this.#releaseHeld(agentName, sessionId, senderPubkey);
|
|
2174
|
-
return { ok: true, leafIndex, sequenceNumber: leafIndex, appendedCount: 1 + released };
|
|
2518
|
+
return { ok: true, leafIndex, sequenceNumber: leafIndex, appendedCount: 1 + released, ...(terminalBlock ? { screenedOut: true } : {}) };
|
|
2175
2519
|
}
|
|
2176
2520
|
/**
|
|
2177
2521
|
* DOD-MSG-4: record the relay-witnessed canonical sequence for a content hash. The relay is the
|
|
@@ -2229,6 +2573,17 @@ export class SessionNodeManager {
|
|
|
2229
2573
|
sequenceNumber: leafIndex,
|
|
2230
2574
|
correlationId,
|
|
2231
2575
|
});
|
|
2576
|
+
// M8C-MSGWAKE-1: content is now buffered and drainable — fire the doorbell AFTER the push so a
|
|
2577
|
+
// woken cello_receive finds the message. Content-free (agent/session/senderPubkey only). Never
|
|
2578
|
+
// let a listener error escape the content path.
|
|
2579
|
+
try {
|
|
2580
|
+
this.#onContentArrived?.(agentName, sessionId, senderPubkey);
|
|
2581
|
+
}
|
|
2582
|
+
catch (err) {
|
|
2583
|
+
this.#logger.warn("notification.cello_message.dispatch.failed", {
|
|
2584
|
+
sessionId, agentName, reason: err instanceof Error ? err.message : String(err),
|
|
2585
|
+
});
|
|
2586
|
+
}
|
|
2232
2587
|
return { leafIndex };
|
|
2233
2588
|
}
|
|
2234
2589
|
/**
|
|
@@ -2248,11 +2603,19 @@ export class SessionNodeManager {
|
|
|
2248
2603
|
if (!entry)
|
|
2249
2604
|
break;
|
|
2250
2605
|
held.delete(nextExpected);
|
|
2251
|
-
|
|
2606
|
+
// A screened-out (terminal-blocked) held entry leafs at its canonical index but is NEVER
|
|
2607
|
+
// buffered for the agent; a normal held entry buffers + leafs (code-review HIGH-1).
|
|
2608
|
+
if (entry.screenedOut) {
|
|
2609
|
+
this.appendSessionLeaf(agentName, sessionId, "msg", entry.contentHashHex, entry.correlationId);
|
|
2610
|
+
}
|
|
2611
|
+
else {
|
|
2612
|
+
this.#appendVerifiedContent(agentName, sessionId, entry.content, entry.contentHashHex, senderPubkey, entry.correlationId);
|
|
2613
|
+
}
|
|
2252
2614
|
released++;
|
|
2253
2615
|
this.#logger.info("session.content.released", {
|
|
2254
2616
|
sessionId,
|
|
2255
2617
|
sequenceNumber: nextExpected,
|
|
2618
|
+
screenedOut: entry.screenedOut === true,
|
|
2256
2619
|
correlationId: entry.correlationId,
|
|
2257
2620
|
});
|
|
2258
2621
|
if (held.size === 0) {
|
|
@@ -2396,7 +2759,10 @@ export class SessionNodeManager {
|
|
|
2396
2759
|
// store-and-forward so the recipient recovers it (at the witnessed sequence). The durable
|
|
2397
2760
|
// awaiting entry above remains the crash backstop. Carry the retained ordering record (review #1)
|
|
2398
2761
|
// so a TTF-parked entry self-orders on recover, exactly like the direct-dial-fail park.
|
|
2399
|
-
|
|
2762
|
+
// Fire-and-forget: unlike sendContent's live caller, nothing here is awaiting an IPC response
|
|
2763
|
+
// to shape (the TTF timer fires long after cello_send already returned) — the deposit's own
|
|
2764
|
+
// success/failure logging inside #parkContent is the only observability this path needs.
|
|
2765
|
+
void this.#parkContent(agentName, sessionId, hashHex, entry.content, entry.structure1Cbor, entry.structure2Cbor);
|
|
2400
2766
|
}
|
|
2401
2767
|
/**
|
|
2402
2768
|
* Send an unsigned `persisted` delivery ACK back to the sender over the same
|
|
@@ -2418,6 +2784,15 @@ export class SessionNodeManager {
|
|
|
2418
2784
|
correlation_id: correlationId,
|
|
2419
2785
|
});
|
|
2420
2786
|
stream.send(lp.encode.single(frame));
|
|
2787
|
+
// The receiver-side counterpart to the sender's content.delivery.acked: B has acknowledged
|
|
2788
|
+
// this content `persisted`, so the sender stops retrying/parking. Emitted for BOTH a normally
|
|
2789
|
+
// delivered message AND a terminal-screen block (the block is a definitive receipt — the leaf
|
|
2790
|
+
// is recorded, so the sender must stop) — and deliberately NOT for a transient hold.
|
|
2791
|
+
this.#logger.info("content.delivery.ack.sent", {
|
|
2792
|
+
sessionId,
|
|
2793
|
+
contentHash: Buffer.from(contentHash).toString("hex"),
|
|
2794
|
+
correlationId,
|
|
2795
|
+
});
|
|
2421
2796
|
try {
|
|
2422
2797
|
await stream.close();
|
|
2423
2798
|
}
|
|
@@ -2629,7 +3004,7 @@ export class SessionNodeManager {
|
|
|
2629
3004
|
}
|
|
2630
3005
|
// AC-001: carry the sender's correlationId from the frame into the receive
|
|
2631
3006
|
// path so both sides log the same flow id (never re-minted on receipt).
|
|
2632
|
-
const ingest = this.ingestReceivedContent(agentName, sessionId, contentBytes, contentHash, correlationId);
|
|
3007
|
+
const ingest = await this.ingestReceivedContent(agentName, sessionId, contentBytes, contentHash, correlationId);
|
|
2633
3008
|
// AC-001: after the content is durably ingested AND its hash cross-check
|
|
2634
3009
|
// succeeds, emit an unsigned `persisted` delivery ACK back to the sender. A
|
|
2635
3010
|
// rejected ingest (tamper / not-active) produces NO ACK, so the sender's TTF
|