@cello-protocol/daemon 0.0.32 → 0.0.34
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 +614 -23
- package/dist/daemon.js.map +1 -1
- package/dist/session-node-manager.d.ts +75 -3
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +391 -25
- 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
package/dist/daemon.js
CHANGED
|
@@ -33,8 +33,10 @@ import { acquireLock, removeLock } from "./lock-file.js";
|
|
|
33
33
|
import { createIpcServer } from "./ipc-server.js";
|
|
34
34
|
import { SessionNodeManager } from "./session-node-manager.js";
|
|
35
35
|
import { classifySession } from "./session-category.js";
|
|
36
|
+
import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT } from "@cello-protocol/gateway";
|
|
36
37
|
import { RetryQueue } from "./retry-queue.js";
|
|
37
38
|
import { NonceDedupStore } from "./nonce-dedup.js";
|
|
39
|
+
import { HttpTelegramBotClient } from "./telegram-bot-client.js";
|
|
38
40
|
import { ContentParkClient } from "./content-park-client.js";
|
|
39
41
|
import { NotificationDispatcher } from "./notification-dispatcher.js";
|
|
40
42
|
import { createNode, SignalingManager } from "@cello-protocol/transport";
|
|
@@ -174,8 +176,11 @@ class ProductionSessionNodeFactory {
|
|
|
174
176
|
});
|
|
175
177
|
}
|
|
176
178
|
}
|
|
179
|
+
// M8C-TTL-1: receiver-side session-request TTL. CORE ships the DoD's own 24h default;
|
|
180
|
+
// per-agent configurability is PARKED on M9-CFG-001 (D17 — same pattern as D14/D15/D16).
|
|
181
|
+
export const INBOUND_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
177
182
|
export async function startDaemon(config) {
|
|
178
|
-
const { celloDir, socketPath, lockFilePath, maxConnections, version, logger, manifestProvider, manifestRootKeys, manifestThreshold, manifestVersionStore: injectedManifestVersionStore, manifestPollScheduler, directoryHttpUrl, signalingConnect, challengeVerifier, directoryEndpointResolver, sessionNodeFactory, sessionNegotiator, getRelayCircuitAddress, } = config;
|
|
183
|
+
const { celloDir, socketPath, lockFilePath, maxConnections, version, logger, manifestProvider, manifestRootKeys, manifestThreshold, manifestVersionStore: injectedManifestVersionStore, manifestPollScheduler, directoryHttpUrl, signalingConnect, challengeVerifier, directoryEndpointResolver, sessionNodeFactory, sessionNegotiator, getRelayCircuitAddress, telegramBotClient: injectedTelegramBotClient, } = config;
|
|
179
184
|
// CELLO-M7-TRANSPORT-001: composition-root selection of the transport selector.
|
|
180
185
|
// Driven by CELLO_ENV; fails fast at startup (here, not at first session) when a
|
|
181
186
|
// production environment is missing the required transport dialer (AC-010).
|
|
@@ -203,12 +208,24 @@ export async function startDaemon(config) {
|
|
|
203
208
|
await mkdir(celloDir, { recursive: true });
|
|
204
209
|
await mkdir(dirname(socketPath), { recursive: true });
|
|
205
210
|
await acquireLock(lockFilePath, { pid: process.pid, socketPath, version });
|
|
211
|
+
// M9-CORE-001: one security-gateway client, shared by both seams — the outbound screen in
|
|
212
|
+
// cello_send and the inbound screen inside SessionNodeManager. Absent config falls back to a
|
|
213
|
+
// PassthroughGatewayClient (always-allow), so pre-M9 daemons behave exactly as before while
|
|
214
|
+
// still returning a verdict (SI-001).
|
|
215
|
+
const securityGateway = config.securityGateway ?? new PassthroughGatewayClient();
|
|
216
|
+
// M9-CORE-001 observability: announce the gateway mode at startup. The sidecar socket connects
|
|
217
|
+
// lazily on the first screen, so this records which adapter is wired (sidecar vs the always-allow
|
|
218
|
+
// passthrough default), not a live socket handshake.
|
|
219
|
+
logger.info("security.gateway.connected", {
|
|
220
|
+
mode: config.securityGateway ? "sidecar" : "passthrough",
|
|
221
|
+
});
|
|
206
222
|
const sessionNodeManager = new SessionNodeManager({
|
|
207
223
|
factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(),
|
|
208
224
|
logger,
|
|
209
225
|
dbPath: join(celloDir, "sessions.db"),
|
|
210
226
|
contentTtfMs: config.contentTtfMs,
|
|
211
227
|
autoNatProbers: () => [],
|
|
228
|
+
securityGateway,
|
|
212
229
|
});
|
|
213
230
|
await sessionNodeManager.initialize();
|
|
214
231
|
// AC-008: the manifest version store is DB-backed by default (encrypted manifest_state table). A
|
|
@@ -525,6 +542,14 @@ export async function startDaemon(config) {
|
|
|
525
542
|
logger,
|
|
526
543
|
maxReconnectAttempts: Number.MAX_SAFE_INTEGER,
|
|
527
544
|
maxBackoffMs: 30_000,
|
|
545
|
+
// M8C-RELAYWAKE-1: "check relay on wakeup" — every time this agent's directory signaling
|
|
546
|
+
// reaches 'connected' (the first connect AND every reconnect after a drop), re-drain its
|
|
547
|
+
// parked mailbox from every relay it has session history with. Without this, a message
|
|
548
|
+
// parked while signaling was down (network blip, directory node restart, daemon offline)
|
|
549
|
+
// is only ever discovered at the NEXT agent start, not on the reconnect that actually
|
|
550
|
+
// brings the agent back — this closes that gap. Fire-and-forget; autoRecoverForAgent
|
|
551
|
+
// already catches its own per-relay errors internally.
|
|
552
|
+
onConnected: () => { void autoRecoverForAgent(agentName); },
|
|
528
553
|
});
|
|
529
554
|
const entry = { signaling: mgr, getNode: () => nodeRef };
|
|
530
555
|
perAgentSignaling.set(agentName, entry);
|
|
@@ -721,6 +746,237 @@ export async function startDaemon(config) {
|
|
|
721
746
|
const perConnectionState = new Map();
|
|
722
747
|
// Set of agents currently in "online" state (transitioned via cello_start_agent)
|
|
723
748
|
const onlineAgents = new Set();
|
|
749
|
+
// M8C-CURSOR-1: per-connection, per-session read cursor (read-before-write gating).
|
|
750
|
+
// Distinct from message_watermarks (INBOX-1, per-AGENT delivery watermark, persisted) — this is
|
|
751
|
+
// per-CONNECTION, in-memory only, and intentionally dies with the connection (a fresh connection
|
|
752
|
+
// has read nothing yet, so it must catch up before it may send — the WhatsApp-group-chat model
|
|
753
|
+
// for two attended sessions on one agent). Key = connectionId → sessionId → highest sequence this
|
|
754
|
+
// connection has read (or authored). Absent entry = -1 (nothing read yet).
|
|
755
|
+
const connectionCursors = new Map();
|
|
756
|
+
function getConnectionCursor(connectionId, sessionId) {
|
|
757
|
+
return connectionCursors.get(connectionId)?.get(sessionId) ?? -1;
|
|
758
|
+
}
|
|
759
|
+
function advanceConnectionCursor(connectionId, sessionId, seq) {
|
|
760
|
+
let byId = connectionCursors.get(connectionId);
|
|
761
|
+
if (!byId) {
|
|
762
|
+
byId = new Map();
|
|
763
|
+
connectionCursors.set(connectionId, byId);
|
|
764
|
+
}
|
|
765
|
+
const prior = byId.get(sessionId) ?? -1;
|
|
766
|
+
if (seq > prior)
|
|
767
|
+
byId.set(sessionId, seq); // monotonic — never lowers
|
|
768
|
+
}
|
|
769
|
+
// M8C-CURSOR-1 (cello-unit-reviewer HIGH finding, confirmed by live reproduction): a
|
|
770
|
+
// received-only delivery (since_seq / live-drain cello_receive) must NOT blindly advance the
|
|
771
|
+
// cursor to the max sequence it happened to see — leaf indices are shared and strictly
|
|
772
|
+
// contiguous across BOTH directions (appendSessionLeaf always assigns leafCount, no gaps), so a
|
|
773
|
+
// gap between the connection's actual cursor and that max can hide an unread SENT leaf authored
|
|
774
|
+
// by a DIFFERENT local connection. Advancing past it would silently let this connection send
|
|
775
|
+
// without ever having seen it — defeating the read-before-write guarantee (C4/C5). Only advance
|
|
776
|
+
// through a CONTIGUOUS run of sequence numbers that were actually in this delivery, starting
|
|
777
|
+
// right after the connection's current cursor; stop at the first gap.
|
|
778
|
+
function safeCursorAdvance(connectionId, sessionId, deliveredSeqs) {
|
|
779
|
+
let cursor = getConnectionCursor(connectionId, sessionId);
|
|
780
|
+
while (deliveredSeqs.has(cursor + 1))
|
|
781
|
+
cursor += 1;
|
|
782
|
+
advanceConnectionCursor(connectionId, sessionId, cursor);
|
|
783
|
+
}
|
|
784
|
+
// M8C-AWAY-1: away response — an unattended Primary auto-answers session requests + messages
|
|
785
|
+
// with the default transparent away text and queues them (the DoD's own mandated default).
|
|
786
|
+
// CORE ships now; the operator-configurable custom-text / opaque-privacy-mode SWITCH is PARKED
|
|
787
|
+
// on M9-CFG-001, journaled as D15 (M8C-DECISIONS.md, mirrors D14) — a genuine per-agent operator
|
|
788
|
+
// preference that needs the deferred config store, whereas transparent is already the correct,
|
|
789
|
+
// non-fake default this unit must ship regardless.
|
|
790
|
+
// "Attended" per the design doc (2026-07-01 command-surface discussion, Agent State Model):
|
|
791
|
+
// Primary + a live client session has claimed the agent via use_agent. Scanning
|
|
792
|
+
// perConnectionState is cheap (a handful of connections) and needs no separate tracked state.
|
|
793
|
+
function isAttended(agentName) {
|
|
794
|
+
for (const s of perConnectionState.values())
|
|
795
|
+
if (s.currentAgent === agentName)
|
|
796
|
+
return true;
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
const AWAY_TEXT = {
|
|
800
|
+
request: "Agent is currently away. Your session request has been received and queued.",
|
|
801
|
+
message: "Agent is currently away. Your message has been received and will be read when the operator returns.",
|
|
802
|
+
};
|
|
803
|
+
// M8C-CONTACT-1: "unknown senders learn only 'dispatched' by default" — a single shared,
|
|
804
|
+
// deliberately minimal template regardless of kind, distinct from AWAY-1's richer per-type text.
|
|
805
|
+
const STRANGER_TEXT = "Dispatched.";
|
|
806
|
+
// Coalescing: one away ack per (agent, session, kind) per away period — cleared when the agent
|
|
807
|
+
// becomes attended again (cello_use_agent) so the NEXT away period gets a fresh ack rather than
|
|
808
|
+
// staying silent forever. Known imprecision (journaled, not silent): clearing fires on ANY
|
|
809
|
+
// use_agent selecting this name, even if another connection kept it attended throughout — an
|
|
810
|
+
// edge case, not a correctness gap in the core "queue + one calm ack while genuinely away" promise.
|
|
811
|
+
const awayAckSent = new Set();
|
|
812
|
+
async function sendAwayResponse(agentName, sessionId, kind) {
|
|
813
|
+
if (isAttended(agentName))
|
|
814
|
+
return;
|
|
815
|
+
const dedupKey = `${agentName}:${sessionId}:${kind}`;
|
|
816
|
+
if (awayAckSent.has(dedupKey))
|
|
817
|
+
return;
|
|
818
|
+
const record = sessionNodeManager.getSessionRecord(agentName, sessionId);
|
|
819
|
+
if (!record || record.status !== "active")
|
|
820
|
+
return;
|
|
821
|
+
// M8C-CONTACT-1: this check MUST run synchronously (before any await below) — the caller of
|
|
822
|
+
// sendAwayResponse for a fresh inbound request fires this THEN calls addContact right after
|
|
823
|
+
// (D6: "accepting X's request adds X"), relying on this line executing first so a stranger's
|
|
824
|
+
// VERY FIRST contact is correctly judged unknown, not already-added.
|
|
825
|
+
const isKnown = sessionNodeManager.isContact(agentName, record.counterparty_pubkey);
|
|
826
|
+
awayAckSent.add(dedupKey); // guard BEFORE the async send — concurrent arrivals must not double-ack
|
|
827
|
+
try {
|
|
828
|
+
const contentBytes = new TextEncoder().encode(isKnown ? AWAY_TEXT[kind] : STRANGER_TEXT);
|
|
829
|
+
const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
|
|
830
|
+
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
|
|
831
|
+
if (!sendResult.ok) {
|
|
832
|
+
// Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
|
|
833
|
+
// away period — clear the guard so the next inbound arrival retries the ack.
|
|
834
|
+
awayAckSent.delete(dedupKey);
|
|
835
|
+
logger.warn("session.away.response.failed", { agentName, sessionId, kind, reason: sendResult.reason });
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
839
|
+
const { leafIndex } = sessionNodeManager.appendSessionLeaf(agentName, sessionId, "msg", contentHashHex, randomUUID());
|
|
840
|
+
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, leafIndex, "sent", contentBytes, randomUUID());
|
|
841
|
+
logger.info("session.away.response.sent", { agentName, sessionId, kind, isKnown, sequenceNumber: leafIndex });
|
|
842
|
+
}
|
|
843
|
+
catch (err) {
|
|
844
|
+
// Reviewer MEDIUM fix: same as above — an unexpected throw must not permanently lock out
|
|
845
|
+
// future retries for the rest of this away period.
|
|
846
|
+
awayAckSent.delete(dedupKey);
|
|
847
|
+
logger.warn("session.away.response.failed", { agentName, sessionId, kind, error: err instanceof Error ? err.message : String(err) });
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// M8C-TGDOOR-1: Telegram Mode 1 doorbell — a daemon-owned bot pushes discrete, content-free
|
|
851
|
+
// events (session request / message-waiting / state change) to one allowlisted operator chat.
|
|
852
|
+
// "NO channel machinery" (DoD) — this talks to the Telegram Bot API directly, unrelated to the
|
|
853
|
+
// claude/channel MCP capability from Tiers 1-2. Inert until telegram_settings exist.
|
|
854
|
+
let telegramBotClient = injectedTelegramBotClient ?? null;
|
|
855
|
+
let telegramChatId = null;
|
|
856
|
+
let telegramBotToken = null; // tracked only to detect an actual token CHANGE
|
|
857
|
+
// Generation counter, NOT a shared boolean — a boolean flip-to-false-then-true-again (e.g. a
|
|
858
|
+
// settings update that restarts the poller) would let the OLD loop's `while` condition still
|
|
859
|
+
// read true on its next check, running TWO concurrent loops. Each loop instance captures its
|
|
860
|
+
// OWN generation at start and exits the instant the counter no longer matches (a new start, or
|
|
861
|
+
// shutdown, both just bump it).
|
|
862
|
+
let telegramPollerGeneration = 0;
|
|
863
|
+
let telegramUpdateOffset = 0;
|
|
864
|
+
// Coalescing: ring once for the FIRST message-waiting event since a session was last fully
|
|
865
|
+
// read; cleared on read (cello_receive/since_seq advancing that session's watermark). Session
|
|
866
|
+
// requests and state changes bypass this Set entirely — DoD says they always ring.
|
|
867
|
+
const telegramRungUnread = new Set();
|
|
868
|
+
async function sendTelegramDoorbell(agentName, sessionId, kind, detail) {
|
|
869
|
+
if (!telegramBotClient || !telegramChatId)
|
|
870
|
+
return; // TGDOOR not configured — inert, not an error
|
|
871
|
+
if (kind === "message_waiting") {
|
|
872
|
+
const rungKey = `${agentName}:${sessionId}`;
|
|
873
|
+
if (telegramRungUnread.has(rungKey))
|
|
874
|
+
return; // already rung, still unread — coalesced
|
|
875
|
+
telegramRungUnread.add(rungKey);
|
|
876
|
+
}
|
|
877
|
+
// DOD-INV-CONTENTFREE: `detail` is a fixed, content-free label per kind — NEVER message text.
|
|
878
|
+
const text = `[${agentName} · ${sessionId.slice(0, 8)}] ${detail}`;
|
|
879
|
+
try {
|
|
880
|
+
const result = await telegramBotClient.sendMessage(telegramChatId, text);
|
|
881
|
+
if (!result.ok) {
|
|
882
|
+
logger.warn("telegram.doorbell.send.failed", { agentName, sessionId, kind, reason: result.error });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
logger.info("telegram.doorbell.sent", { agentName, sessionId, kind });
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
logger.warn("telegram.doorbell.send.failed", { agentName, sessionId, kind, error: err instanceof Error ? err.message : String(err) });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
// Read clears the ring (DoD: "ring-once-until-read") — called wherever a receive advances a
|
|
892
|
+
// session's read watermark, mirroring AWAY-1's own dedup-clear-on-attend pattern.
|
|
893
|
+
function clearTelegramRung(agentName, sessionId) {
|
|
894
|
+
telegramRungUnread.delete(`${agentName}:${sessionId}`);
|
|
895
|
+
}
|
|
896
|
+
// Wraps notificationDispatcher.dispatchSessionStateChanged so every call site gets the
|
|
897
|
+
// Telegram state-change doorbell for free (DoD: state changes ALWAYS ring, never coalesced) —
|
|
898
|
+
// one wrapper rather than hooking each of the several existing call sites individually.
|
|
899
|
+
function dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey) {
|
|
900
|
+
notificationDispatcher.dispatchSessionStateChanged(agentName, sessionId, state, counterpartyPubkey);
|
|
901
|
+
void sendTelegramDoorbell(agentName, sessionId, "state_change", `Session ${state}`);
|
|
902
|
+
// Reviewer HIGH fix (a60d68ed): telegramRungUnread had NO cleanup at all — a session that
|
|
903
|
+
// rings once and is never read via cello_receive/since_seq (e.g. the operator only ever uses
|
|
904
|
+
// cello_get_transcript, which does not advance the read watermark) left a permanent entry for
|
|
905
|
+
// the life of the daemon process. Every state change is a natural point to drop it — the
|
|
906
|
+
// worst case if the session is still genuinely active is one possible extra ring later, far
|
|
907
|
+
// preferable to an unbounded leak (the exact class of bug fixed for TTL-1's expired-log at
|
|
908
|
+
// af8a701 in this same milestone).
|
|
909
|
+
clearTelegramRung(agentName, sessionId);
|
|
910
|
+
}
|
|
911
|
+
async function handleInboundTelegramUpdate(update, client) {
|
|
912
|
+
const chatId = update.message?.chat?.id !== undefined ? String(update.message.chat.id) : undefined;
|
|
913
|
+
if (!chatId)
|
|
914
|
+
return;
|
|
915
|
+
if (chatId !== telegramChatId) {
|
|
916
|
+
// D6: any other chat is silently dropped — nothing enters CELLO content paths.
|
|
917
|
+
logger.info("telegram.inbound.rejected", { chatId });
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
logger.info("telegram.inbound.acknowledged", { chatId });
|
|
921
|
+
try {
|
|
922
|
+
await client.sendMessage(chatId, "CELLO doesn't process messages here — this channel only sends you notifications. Use your CELLO client to reply.");
|
|
923
|
+
}
|
|
924
|
+
catch (err) {
|
|
925
|
+
logger.warn("telegram.inbound.ack.failed", { chatId, error: err instanceof Error ? err.message : String(err) });
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
// Single long-lived getUpdates poller (DoD) — one in-flight loop per daemon; started once
|
|
929
|
+
// settings exist, stopped on daemon shutdown. A network hiccup backs off and retries rather
|
|
930
|
+
// than killing the poller (best-effort — the doorbell is a convenience, never load-bearing).
|
|
931
|
+
// Reviewer MEDIUM fix (a60d68ed): the loop previously read the SHARED, reassignable
|
|
932
|
+
// telegramBotClient/telegramUpdateOffset fresh each iteration — only myGeneration was truly
|
|
933
|
+
// loop-local. A settings update mid-await could let a stale response from the OLD client (1)
|
|
934
|
+
// still be processed once (the generation check only blocks the loop's NEXT iteration, not an
|
|
935
|
+
// in-flight call) and (2) stomp telegramUpdateOffset with the OLD bot's update_id numbering,
|
|
936
|
+
// which is meaningless to a new token (Telegram scopes update_id per bot). Fix: capture the
|
|
937
|
+
// client as a PARAMETER (never re-read from the shared variable) and re-check the generation
|
|
938
|
+
// immediately after every await before acting on its result or touching shared state.
|
|
939
|
+
async function runTelegramPollerLoop(myGeneration, myClient) {
|
|
940
|
+
let myOffset = telegramUpdateOffset;
|
|
941
|
+
while (telegramPollerGeneration === myGeneration) {
|
|
942
|
+
try {
|
|
943
|
+
const updates = await myClient.getUpdates(myOffset, 25);
|
|
944
|
+
if (telegramPollerGeneration !== myGeneration)
|
|
945
|
+
break; // superseded while awaiting — drop stale results
|
|
946
|
+
for (const u of updates) {
|
|
947
|
+
myOffset = u.update_id + 1;
|
|
948
|
+
telegramUpdateOffset = myOffset;
|
|
949
|
+
if (u.message)
|
|
950
|
+
void handleInboundTelegramUpdate(u, myClient);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
catch (err) {
|
|
954
|
+
if (telegramPollerGeneration !== myGeneration)
|
|
955
|
+
break; // superseded — don't retry under a dead generation
|
|
956
|
+
logger.warn("telegram.poller.error", { error: err instanceof Error ? err.message : String(err) });
|
|
957
|
+
await new Promise((r) => setTimeout(r, 2000)); // back off before retrying
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
// Always bumps the generation (invalidating any prior loop, which exits on its next check) and
|
|
962
|
+
// starts a fresh one — correct both for the first start AND a settings-change restart.
|
|
963
|
+
function startTelegramPollerIfConfigured() {
|
|
964
|
+
const settings = sessionNodeManager.getTelegramSettings();
|
|
965
|
+
if (!settings)
|
|
966
|
+
return; // not configured — TGDOOR stays inert
|
|
967
|
+
// Reviewer MEDIUM fix: reset the offset on an actual token/chat CHANGE — a prior bot's
|
|
968
|
+
// update_id numbering is meaningless to a new one (Telegram scopes it per bot token).
|
|
969
|
+
if (telegramBotToken !== settings.botToken || telegramChatId !== settings.allowlistedChatId) {
|
|
970
|
+
telegramUpdateOffset = 0;
|
|
971
|
+
}
|
|
972
|
+
telegramBotToken = settings.botToken;
|
|
973
|
+
telegramChatId = settings.allowlistedChatId;
|
|
974
|
+
const client = injectedTelegramBotClient ?? new HttpTelegramBotClient(settings.botToken);
|
|
975
|
+
telegramBotClient = client;
|
|
976
|
+
telegramPollerGeneration += 1;
|
|
977
|
+
void runTelegramPollerLoop(telegramPollerGeneration, client);
|
|
978
|
+
logger.info("telegram.poller.started", {});
|
|
979
|
+
}
|
|
724
980
|
// SessionNodeManager was constructed + initialized at the top of startDaemon (PERSIST-002 — the
|
|
725
981
|
// encrypted store must open before agents load from the `agents` table). Its standing receiver +
|
|
726
982
|
// interrupted-session detection are already ready here, before the IPC socket opens.
|
|
@@ -1135,8 +1391,13 @@ export async function startDaemon(config) {
|
|
|
1135
1391
|
sessionNodeManager.setContentParkHook(async ({ sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor }) => {
|
|
1136
1392
|
const node = sessionNodeManager.getStandingReceiverNode();
|
|
1137
1393
|
if (!node) {
|
|
1138
|
-
|
|
1139
|
-
|
|
1394
|
+
const reason = "standing_receiver_unavailable";
|
|
1395
|
+
logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason });
|
|
1396
|
+
// DOD-LEAVEMSG-1 (reviewer HIGH fix): return the typed failure, never resolve as if this
|
|
1397
|
+
// were a success — #parkContent's caller (sendContent) shapes a live "dispatched to relay"
|
|
1398
|
+
// response from this, and a silently-resolved void here would report a message as safely
|
|
1399
|
+
// parked when nothing was ever deposited.
|
|
1400
|
+
return { ok: false, reason };
|
|
1140
1401
|
}
|
|
1141
1402
|
const recipientPubkey = Buffer.from(recipientPubkeyHex, "hex");
|
|
1142
1403
|
// DOD-MSG-4 (2b): seal the ORDERING ENVELOPE (content + the relay's signed Structure2), not bare
|
|
@@ -1151,10 +1412,10 @@ export async function startDaemon(config) {
|
|
|
1151
1412
|
});
|
|
1152
1413
|
if (res.ok) {
|
|
1153
1414
|
logger.info("content.park.deposited", { sessionId, contentHash: contentHashHex, recipientPubkey: recipientPubkeyHex.slice(0, 16) });
|
|
1415
|
+
return { ok: true };
|
|
1154
1416
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
}
|
|
1417
|
+
logger.warn("content.park.deposit.failed", { sessionId, contentHash: contentHashHex, reason: res.reason });
|
|
1418
|
+
return { ok: false, reason: res.reason ?? "relay_deposit_failed" };
|
|
1158
1419
|
});
|
|
1159
1420
|
// CELLO-M7-MSG-001 (AC-004/AC-005, D-d): startup flush of locally-persisted un-acked
|
|
1160
1421
|
// content (the crash backstop). Runs HERE — before the IPC socket opens, consistent
|
|
@@ -1705,6 +1966,12 @@ export async function startDaemon(config) {
|
|
|
1705
1966
|
}
|
|
1706
1967
|
const fromAgent = connState.currentAgent;
|
|
1707
1968
|
connState.currentAgent = name;
|
|
1969
|
+
// M8C-AWAY-1: this agent just became attended — clear its away-ack dedup entries so the NEXT
|
|
1970
|
+
// away period (after this attended stretch ends) gets a fresh ack instead of staying silent.
|
|
1971
|
+
for (const key of Array.from(awayAckSent)) {
|
|
1972
|
+
if (key.startsWith(`${name}:`))
|
|
1973
|
+
awayAckSent.delete(key);
|
|
1974
|
+
}
|
|
1708
1975
|
// MCP-002: Update dispatcher's routing table and send notification to this connection only
|
|
1709
1976
|
notificationDispatcher.setCurrentAgent(connectionId, name);
|
|
1710
1977
|
notificationDispatcher.dispatchAgentCurrentChanged(connectionId, fromAgent, name);
|
|
@@ -2577,6 +2844,9 @@ export async function startDaemon(config) {
|
|
|
2577
2844
|
});
|
|
2578
2845
|
}
|
|
2579
2846
|
}
|
|
2847
|
+
// M8C-CONTACT-1 (D6): "initiating a session to X adds X" — pin at the pubkey the negotiator
|
|
2848
|
+
// actually used (not re-resolved later).
|
|
2849
|
+
sessionNodeManager.addContact(agentName, counterpartyPubkey);
|
|
2580
2850
|
// AC-007: the session is usable immediately upon (relay) connection — the dcutr
|
|
2581
2851
|
// upgrade runs in the background and is intentionally NOT awaited here.
|
|
2582
2852
|
return { ok: true, sessionId, transportMode: result.mode, correlationId };
|
|
@@ -2965,6 +3235,14 @@ export async function startDaemon(config) {
|
|
|
2965
3235
|
const { messages, undecryptable } = sessionNodeManager.readTranscript(connState.currentAgent, sessionId);
|
|
2966
3236
|
// undecryptable > 0 means some rows failed GCM auth (tamper / wrong key) — surfaced, not hidden,
|
|
2967
3237
|
// so the reader can tell a real gap from an empty transcript.
|
|
3238
|
+
// M8C-CURSOR-1: this is the ONLY reader that covers BOTH directions (sent + received), so it's
|
|
3239
|
+
// the correct general catch-up path — e.g. a second attended connection on the same agent
|
|
3240
|
+
// catching up on a message a DIFFERENT local connection sent (since_seq is received-only and
|
|
3241
|
+
// would never surface it). Route through safeCursorAdvance rather than trusting the raw max —
|
|
3242
|
+
// reviewer HIGH finding (aa5928e2/a9099571): recordTranscriptMessage swallows a DB write
|
|
3243
|
+
// failure without rolling back the tree's leaf count, so a hole in `messages` is possible in
|
|
3244
|
+
// principle; the contiguous-run walk refuses to vault the cursor past such a hole even here.
|
|
3245
|
+
safeCursorAdvance(connectionId, sessionId, new Set(messages.map((m) => m.sequence)));
|
|
2968
3246
|
return { ok: true, session_id: sessionId, messages, undecryptable };
|
|
2969
3247
|
});
|
|
2970
3248
|
// cello_list_sessions: the discovery surface — every persisted session for the
|
|
@@ -3183,13 +3461,25 @@ export async function startDaemon(config) {
|
|
|
3183
3461
|
if (env.structure1Cbor && env.structure2Cbor) {
|
|
3184
3462
|
sessionNodeManager.recordOrderingRecord(recipientAgent.name, e.sessionIdHex, env.structure1Cbor, env.structure2Cbor, contentHashBytes);
|
|
3185
3463
|
}
|
|
3186
|
-
const ingest = sessionNodeManager.ingestReceivedContent(recipientAgent.name, e.sessionIdHex, env.content, contentHashBytes);
|
|
3464
|
+
const ingest = await sessionNodeManager.ingestReceivedContent(recipientAgent.name, e.sessionIdHex, env.content, contentHashBytes);
|
|
3187
3465
|
if (ingest.ok && ingest.held) {
|
|
3188
3466
|
// DOD-MSG-4 (review finding #4): a held entry is NOT yet an appended leaf — its sequence is
|
|
3189
3467
|
// the FUTURE canonical index, not a completed recovery. Do not count it as recovered; log it
|
|
3190
3468
|
// distinctly so the tally reflects leaves actually written, not content still queued in memory.
|
|
3191
3469
|
logger.info("content.recover.held", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, canonicalSeq: ingest.sequenceNumber });
|
|
3192
3470
|
}
|
|
3471
|
+
else if (ingest.ok && ingest.screenedOut) {
|
|
3472
|
+
// M9 (code-review LOW-3): a terminal-screened recovered entry IS durably leafed (so it must be
|
|
3473
|
+
// confirm-deleted, below) but was NEVER delivered to the agent — do not count it as a delivered
|
|
3474
|
+
// recovery, and log it distinctly so observability separates "delivered" from "leafed-but-screened".
|
|
3475
|
+
logger.info("content.recover.screened_out", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, sequenceNumber: ingest.sequenceNumber });
|
|
3476
|
+
try {
|
|
3477
|
+
await client.confirm(node, Buffer.from(recipientPubkey, "hex"), contentHashBytes, kp);
|
|
3478
|
+
}
|
|
3479
|
+
catch (err) {
|
|
3480
|
+
logger.warn("content.recover.confirm.failed", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, error: err instanceof Error ? err.message : String(err) });
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3193
3483
|
else if (ingest.ok) {
|
|
3194
3484
|
// DOD-MSG-4 (review #3): count leaves ACTUALLY written — the directly-ingested leaf PLUS any
|
|
3195
3485
|
// held out-of-order entries this ingest unblocked (appendedCount), not just 1.
|
|
@@ -3284,13 +3574,13 @@ export async function startDaemon(config) {
|
|
|
3284
3574
|
const sessionPeerId = params?.sessionPeerId ?? "";
|
|
3285
3575
|
const correlationId = params?.correlationId ?? "";
|
|
3286
3576
|
logger.info("session.node.created", { sessionId, agentName, sessionPeerId, correlationId });
|
|
3287
|
-
|
|
3577
|
+
dispatchSessionStateChangedWithTelegram(agentName, sessionId, "created", counterpartyPubkey);
|
|
3288
3578
|
}
|
|
3289
3579
|
else if (type === "destroyed") {
|
|
3290
3580
|
const state = params?.state ?? "interrupted";
|
|
3291
3581
|
const reason = params?.reason ?? state;
|
|
3292
3582
|
logger.info("session.node.destroyed", { sessionId, agentName, reason });
|
|
3293
|
-
|
|
3583
|
+
dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey);
|
|
3294
3584
|
}
|
|
3295
3585
|
return { ok: true };
|
|
3296
3586
|
});
|
|
@@ -3305,6 +3595,15 @@ export async function startDaemon(config) {
|
|
|
3305
3595
|
return { error: "missing_params", guidance: "Provide agentName and sessionId." };
|
|
3306
3596
|
}
|
|
3307
3597
|
enqueueInboundSession(agentName, { sessionIdHex, counterpartyPubkeyHex, genesisPrevRootHex: "" });
|
|
3598
|
+
// M8C-TTL-1: let a test backdate the just-enqueued entry's timestamp (simulating age) without
|
|
3599
|
+
// waiting real hours or faking global timers — enqueueInboundSession always stamps Date.now().
|
|
3600
|
+
const enqueuedAtOverride = params?.enqueuedAtOverride;
|
|
3601
|
+
if (enqueuedAtOverride !== undefined) {
|
|
3602
|
+
const q = inboundSessionQueues.get(agentName);
|
|
3603
|
+
const entry = q?.[q.length - 1];
|
|
3604
|
+
if (entry)
|
|
3605
|
+
entry.enqueuedAt = enqueuedAtOverride;
|
|
3606
|
+
}
|
|
3308
3607
|
return { ok: true };
|
|
3309
3608
|
});
|
|
3310
3609
|
// M8C-INBOX-1 (reviewer F1): buffer a received message so a test can drive a live cello_receive
|
|
@@ -3461,6 +3760,9 @@ export async function startDaemon(config) {
|
|
|
3461
3760
|
correlationId,
|
|
3462
3761
|
});
|
|
3463
3762
|
}
|
|
3763
|
+
// Expired requests move HERE (from inboundSessionQueues) rather than vanishing — visible via
|
|
3764
|
+
// cello_check_notifications so the operator can see what they missed, not just silence.
|
|
3765
|
+
const expiredSessionRequests = new Map();
|
|
3464
3766
|
// Per-agent FIFO queue (events accepted while no cello_await_session is blocked) and
|
|
3465
3767
|
// the per-agent list of blocked waiters. Inbound sessions are addressed to a specific
|
|
3466
3768
|
// local agent (participant_b), so both are keyed by agent name.
|
|
@@ -3486,9 +3788,45 @@ export async function startDaemon(config) {
|
|
|
3486
3788
|
return;
|
|
3487
3789
|
}
|
|
3488
3790
|
const q = inboundSessionQueues.get(agentName) ?? [];
|
|
3489
|
-
q.push(event);
|
|
3791
|
+
q.push({ ...event, enqueuedAt: Date.now() }); // M8C-TTL-1: stamp queue entry time
|
|
3490
3792
|
inboundSessionQueues.set(agentName, q);
|
|
3491
3793
|
}
|
|
3794
|
+
// M8C-TTL-1 (reviewer HIGH fix, aed2d71f, D19): expiredSessionRequests is a lasting log, not a
|
|
3795
|
+
// consumed queue (unlike inboundSessionQueues, nothing ever drains it) — a whitelisted CONTACT-1
|
|
3796
|
+
// contact is EXEMPT from ABUSE-1's acceptance bounds ("bounded only by disk"), so they can push
|
|
3797
|
+
// unlimited accepted sessions the operator never claims, each becoming a permanent, unremovable
|
|
3798
|
+
// entry every 24h for the life of the daemon process. Capped the same way STATUS_RESUMABLE_CAP
|
|
3799
|
+
// bounds the interrupted-sessions list — keep only the MOST RECENT N per agent.
|
|
3800
|
+
const EXPIRED_SESSION_REQUESTS_CAP = 20;
|
|
3801
|
+
// M8C-TTL-1: move any queue entries past the TTL into expiredSessionRequests (visible via
|
|
3802
|
+
// INBOX), instead of leaving them to sit forever or vanish silently. Called lazily on every
|
|
3803
|
+
// read of the queue (cello_await_session's immediate-check, cello_check_notifications) rather
|
|
3804
|
+
// than on a timer — no background sweep needed, and it's always correct-as-of-the-read.
|
|
3805
|
+
function reapExpiredInboundSessions(agentName) {
|
|
3806
|
+
const q = inboundSessionQueues.get(agentName);
|
|
3807
|
+
if (!q || q.length === 0)
|
|
3808
|
+
return;
|
|
3809
|
+
const now = Date.now();
|
|
3810
|
+
const live = [];
|
|
3811
|
+
let expiredList = expiredSessionRequests.get(agentName);
|
|
3812
|
+
for (const e of q) {
|
|
3813
|
+
if (e.enqueuedAt !== undefined && now - e.enqueuedAt > INBOUND_SESSION_TTL_MS) {
|
|
3814
|
+
if (!expiredList) {
|
|
3815
|
+
expiredList = [];
|
|
3816
|
+
expiredSessionRequests.set(agentName, expiredList);
|
|
3817
|
+
}
|
|
3818
|
+
expiredList.push({ sessionIdHex: e.sessionIdHex, counterpartyPubkeyHex: e.counterpartyPubkeyHex, expiredAt: now });
|
|
3819
|
+
if (expiredList.length > EXPIRED_SESSION_REQUESTS_CAP) {
|
|
3820
|
+
expiredList.splice(0, expiredList.length - EXPIRED_SESSION_REQUESTS_CAP); // keep newest N
|
|
3821
|
+
}
|
|
3822
|
+
logger.info("session.request.expired", { agentName, sessionId: e.sessionIdHex, enqueuedAt: e.enqueuedAt });
|
|
3823
|
+
}
|
|
3824
|
+
else {
|
|
3825
|
+
live.push(e);
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
inboundSessionQueues.set(agentName, live);
|
|
3829
|
+
}
|
|
3492
3830
|
// CBOR-decoded byte fields arrive as Uint8Array or Buffer; a field may also already be
|
|
3493
3831
|
// a hex string. Hex strings are lowercased so the case-sensitive agent-pubkey match
|
|
3494
3832
|
// (agents store lowercase hex) cannot silently miss (review L2).
|
|
@@ -3614,6 +3952,20 @@ export async function startDaemon(config) {
|
|
|
3614
3952
|
}
|
|
3615
3953
|
async function acceptInboundAssignment(parsed, agentName, correlationId) {
|
|
3616
3954
|
try {
|
|
3955
|
+
// M8C-ABUSE-1 (anti-drip-feed / anti-swarm): bound acceptance from unknown (non-contact)
|
|
3956
|
+
// senders — a per-sender cap (many sessions from ONE stranger) and a global cap (many
|
|
3957
|
+
// sessions across ALL strangers combined). Known contacts are exempt ("bounded only by
|
|
3958
|
+
// disk" — DoD). Checked FIRST, before any standing-receiver work, so a refusal is cheap.
|
|
3959
|
+
const bound = sessionNodeManager.checkUnknownSenderAcceptanceBound(agentName, parsed.participantAPubkeyHex);
|
|
3960
|
+
if (!bound.ok) {
|
|
3961
|
+
logger.warn("session.inbound.accept.failed", {
|
|
3962
|
+
sessionId: parsed.sessionIdHex,
|
|
3963
|
+
agentName,
|
|
3964
|
+
reason: bound.reason,
|
|
3965
|
+
correlationId,
|
|
3966
|
+
});
|
|
3967
|
+
return;
|
|
3968
|
+
}
|
|
3617
3969
|
// M8B F14 (fix 2): KICK creation before polling — an inbound offer arriving while no
|
|
3618
3970
|
// receiver exists and none is being created must trigger the ensure itself (the doc
|
|
3619
3971
|
// comment's "retries on demand" made true), instead of polling a creation nobody
|
|
@@ -3682,7 +4034,17 @@ export async function startDaemon(config) {
|
|
|
3682
4034
|
counterpartyPubkeyHex: parsed.participantAPubkeyHex,
|
|
3683
4035
|
genesisPrevRootHex,
|
|
3684
4036
|
});
|
|
3685
|
-
|
|
4037
|
+
dispatchSessionStateChangedWithTelegram(agentName, parsed.sessionIdHex, "created", parsed.participantAPubkeyHex);
|
|
4038
|
+
// M8C-TGDOOR-1: session requests ALWAYS ring (DoD) — no coalescing, unlike message-waiting.
|
|
4039
|
+
void sendTelegramDoorbell(agentName, parsed.sessionIdHex, "session_request", "New session request");
|
|
4040
|
+
// M8C-AWAY-1: an unattended agent auto-acks a fresh inbound session request. Fire-and-forget
|
|
4041
|
+
// (sendAwayResponse never throws) — best-effort, must not delay/block acceptance completion.
|
|
4042
|
+
// ORDER MATTERS: sendAwayResponse's isContact check runs synchronously (before any await) as
|
|
4043
|
+
// soon as it's invoked, so calling addContact on the NEXT line still lets a stranger's very
|
|
4044
|
+
// first contact be judged correctly unknown (M8C-CONTACT-1 D6: "accepting X's request adds X"
|
|
4045
|
+
// — they become known immediately after, for every subsequent interaction).
|
|
4046
|
+
void sendAwayResponse(agentName, parsed.sessionIdHex, "request");
|
|
4047
|
+
sessionNodeManager.addContact(agentName, parsed.participantAPubkeyHex);
|
|
3686
4048
|
}
|
|
3687
4049
|
finally {
|
|
3688
4050
|
inboundInFlight.delete(parsed.sessionIdHex);
|
|
@@ -3882,6 +4244,7 @@ export async function startDaemon(config) {
|
|
|
3882
4244
|
counterparty_pubkey: e.counterpartyPubkeyHex,
|
|
3883
4245
|
genesis_prev_root: e.genesisPrevRootHex,
|
|
3884
4246
|
});
|
|
4247
|
+
reapExpiredInboundSessions(agentName); // M8C-TTL-1: don't hand back a stale expired entry
|
|
3885
4248
|
const queued = inboundSessionQueues.get(agentName);
|
|
3886
4249
|
if (queued && queued.length > 0) {
|
|
3887
4250
|
return toResponse(queued.shift());
|
|
@@ -4326,6 +4689,42 @@ export async function startDaemon(config) {
|
|
|
4326
4689
|
if (record.status !== "active") {
|
|
4327
4690
|
return { ok: false, reason: "session_not_active", guidance: `Session is '${record.status}', not active. Content can only be sent on an active session. If it is interrupted, call cello_close_session to seal it.` };
|
|
4328
4691
|
}
|
|
4692
|
+
// M8C-CURSOR-1: read-before-write gate. current_seq is the tree's highest leaf index
|
|
4693
|
+
// (message_count is kept in sync with leafCount on every append, both directions — DAEMON-004
|
|
4694
|
+
// finding #2), so it reflects EVERY message in the session regardless of which connection sent
|
|
4695
|
+
// or received it. If this connection hasn't read up to current_seq (e.g. a second attended
|
|
4696
|
+
// session on the same agent that hasn't polled since the other connection's last send), refuse
|
|
4697
|
+
// rather than let it send blind — the WhatsApp-group-chat model. Runs BEFORE M9's
|
|
4698
|
+
// governance-decisions parsing below — an access-control gate should short-circuit before any
|
|
4699
|
+
// unrelated prep work for a send that may not even be allowed to proceed.
|
|
4700
|
+
const currentSeq = record.message_count - 1;
|
|
4701
|
+
const lastReadSeq = getConnectionCursor(connectionId, sessionId);
|
|
4702
|
+
if (lastReadSeq < currentSeq) {
|
|
4703
|
+
// M8C-CURSOR-1 (reviewer MEDIUM fix): every sibling rejection in this handler logs; this
|
|
4704
|
+
// gate must too — a security-relevant control-flow path with no observability is a gap.
|
|
4705
|
+
logger.warn("session.send.blocked", { sessionId, currentSeq, lastReadSeq, connectionId });
|
|
4706
|
+
return {
|
|
4707
|
+
ok: false,
|
|
4708
|
+
reason: "session_not_current",
|
|
4709
|
+
current_seq: currentSeq,
|
|
4710
|
+
last_read_seq: lastReadSeq,
|
|
4711
|
+
guidance: `This connection hasn't caught up on session ${sessionId} — ${currentSeq - lastReadSeq} message(s) unread (this may include messages authored by another connection on this same agent). Call cello_get_transcript to read the full history (covers both sent and received), then retry the send.`,
|
|
4712
|
+
};
|
|
4713
|
+
}
|
|
4714
|
+
// M9-FEED-001 §6: the agent's governance re-send decisions, keyed by the flagId a prior `warn`
|
|
4715
|
+
// returned. Optional; validated shape only (the gateway re-scans + applies them, INV-4). A
|
|
4716
|
+
// malformed map is ignored rather than failing the send (the gateway will just re-warn).
|
|
4717
|
+
const rawDecisions = params?.governance_decisions;
|
|
4718
|
+
let governanceDecisions;
|
|
4719
|
+
if (rawDecisions && typeof rawDecisions === "object" && !Array.isArray(rawDecisions)) {
|
|
4720
|
+
const valid = {};
|
|
4721
|
+
for (const [k, v] of Object.entries(rawDecisions)) {
|
|
4722
|
+
if (v === "redact" || v === "allow_once" || v === "allow_always")
|
|
4723
|
+
valid[k] = v;
|
|
4724
|
+
}
|
|
4725
|
+
if (Object.keys(valid).length > 0)
|
|
4726
|
+
governanceDecisions = valid;
|
|
4727
|
+
}
|
|
4329
4728
|
const correlationId = randomUUID();
|
|
4330
4729
|
const contentBytes = new TextEncoder().encode(contentStr);
|
|
4331
4730
|
// CELLO-M7-MSG-001 (AC-013/AC-018/AC-021): enforce the 1 MB application content cap
|
|
@@ -4346,17 +4745,74 @@ export async function startDaemon(config) {
|
|
|
4346
4745
|
guidance: `This message is ${contentBytes.length} bytes, over the ${MAX_CONTENT_BYTES}-byte (1 MB) per-message content cap. Split it into multiple messages each under the cap, or use the large-object/file transfer path for large payloads (not cello_send). Nothing was sent and the session is still active — retry with smaller content.`,
|
|
4347
4746
|
};
|
|
4348
4747
|
}
|
|
4349
|
-
const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
|
|
4350
|
-
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
4351
4748
|
const recipientPubkey = record.counterparty_pubkey;
|
|
4352
|
-
|
|
4749
|
+
// M9 outbound screening seam (INV-5/SI-001). Screen BEFORE anything reaches the wire. The
|
|
4750
|
+
// gateway verdict drives the four cello_send outcomes (M9-FEED-001): block / warn → NOT sent;
|
|
4751
|
+
// allow → sent as-is; redact → sent in ALTERED form. A configured-but-unreachable gateway fails
|
|
4752
|
+
// closed (block, gateway_unavailable), so a screening outage can never let content out ungated.
|
|
4753
|
+
const outboundVerdict = await securityGateway.screenOutbound(contentBytes, {
|
|
4754
|
+
direction: "outbound",
|
|
4755
|
+
agentName: record.agent_name,
|
|
4756
|
+
sessionId,
|
|
4757
|
+
correlationId,
|
|
4758
|
+
...(governanceDecisions !== undefined ? { governanceDecisions } : {}),
|
|
4759
|
+
});
|
|
4760
|
+
if (outboundVerdict.disposition === "block") {
|
|
4761
|
+
if (outboundVerdict.reason === GOVERNANCE_TIMEOUT) {
|
|
4762
|
+
logger.error("security.gateway.timeout", { sessionId, reason: outboundVerdict.reason, correlationId });
|
|
4763
|
+
}
|
|
4764
|
+
else if (outboundVerdict.reason === GATEWAY_UNAVAILABLE) {
|
|
4765
|
+
logger.error("security.gateway.unavailable", { direction: "outbound", reason: outboundVerdict.reason, correlationId });
|
|
4766
|
+
}
|
|
4767
|
+
else {
|
|
4768
|
+
logger.info("security.verdict.returned", { disposition: "block", sessionId, reason: outboundVerdict.reason, correlationId });
|
|
4769
|
+
}
|
|
4770
|
+
return {
|
|
4771
|
+
ok: false,
|
|
4772
|
+
reason: outboundVerdict.reason ?? "blocked_by_governance",
|
|
4773
|
+
guidance: outboundVerdict.guidance ??
|
|
4774
|
+
"This message was blocked by the security gateway and was NOT sent. The session is still active.",
|
|
4775
|
+
blocks: (outboundVerdict.events ?? []).filter((e) => e.disposition === "block"),
|
|
4776
|
+
};
|
|
4777
|
+
}
|
|
4778
|
+
if (outboundVerdict.disposition === "warn") {
|
|
4779
|
+
logger.info("security.verdict.returned", { disposition: "warn", sessionId, correlationId });
|
|
4780
|
+
return {
|
|
4781
|
+
ok: false,
|
|
4782
|
+
reason: "governance_warn",
|
|
4783
|
+
guidance: outboundVerdict.guidance ??
|
|
4784
|
+
"This message was held for a governance decision and was NOT sent. Re-send the same content with a " +
|
|
4785
|
+
"governance_decisions map ({flagId: redact | allow_once | allow_always}) to resolve each flagged item.",
|
|
4786
|
+
flags: (outboundVerdict.events ?? []).filter((e) => e.disposition === "warn"),
|
|
4787
|
+
};
|
|
4788
|
+
}
|
|
4789
|
+
// FAIL-CLOSED (code-review MED): a `redact` verdict MUST carry the redacted content. If it ever
|
|
4790
|
+
// arrives without it, sending the original `contentBytes` would leak the pre-redaction draft — the
|
|
4791
|
+
// one place M9 could fail OPEN. Treat it as a block, never an allow-original. (Unreachable today:
|
|
4792
|
+
// the gateway always includes content on redact; this is the defensive floor.)
|
|
4793
|
+
if (outboundVerdict.disposition === "redact" && outboundVerdict.content === undefined) {
|
|
4794
|
+
logger.error("security.verdict.redact_without_content", { sessionId, correlationId });
|
|
4795
|
+
return {
|
|
4796
|
+
ok: false,
|
|
4797
|
+
reason: "redact_without_content",
|
|
4798
|
+
guidance: "The security gateway returned a redact verdict without the redacted content. To avoid " +
|
|
4799
|
+
"leaking the original, nothing was sent. This is a gateway fault — check the gateway logs and retry.",
|
|
4800
|
+
};
|
|
4801
|
+
}
|
|
4802
|
+
// allow or redact → send. On redact the ALTERED bytes are what go on the wire AND what the leaf
|
|
4803
|
+
// hash binds — the transcript records what was actually sent, not the pre-redaction draft.
|
|
4804
|
+
const modified = outboundVerdict.disposition === "redact" && outboundVerdict.content !== undefined;
|
|
4805
|
+
const sendBytes = modified ? new Uint8Array(outboundVerdict.content) : contentBytes;
|
|
4806
|
+
const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(sendBytes).digest();
|
|
4807
|
+
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
4808
|
+
const sendResult = await sessionNodeManager.sendContent(record.agent_name, sessionId, sendBytes, new Uint8Array(contentHash), correlationId);
|
|
4353
4809
|
if (!sendResult.ok) {
|
|
4354
4810
|
// DB-001 / dead-channel contract: never silently drop, never desync. Preserve
|
|
4355
4811
|
// the content in the durable retry_queue so it is retried on reconnect, and
|
|
4356
4812
|
// surface a named, diagnosable failure.
|
|
4357
4813
|
const nonce = randomUUID();
|
|
4358
4814
|
try {
|
|
4359
|
-
retryQueue.enqueue(sessionId, new TextEncoder().encode(nonce),
|
|
4815
|
+
retryQueue.enqueue(sessionId, new TextEncoder().encode(nonce), sendBytes);
|
|
4360
4816
|
}
|
|
4361
4817
|
catch (err) {
|
|
4362
4818
|
logger.error("session.content.queue.failed", {
|
|
@@ -4378,11 +4834,46 @@ export async function startDaemon(config) {
|
|
|
4378
4834
|
guidance: "The content could not be delivered over the session stream right now. It has been queued in the durable retry queue and will be retried when the counterparty reconnects. The session remains usable — check cello_list_connections for the counterparty's status.",
|
|
4379
4835
|
};
|
|
4380
4836
|
}
|
|
4381
|
-
// Delivered
|
|
4837
|
+
// Delivered directly OR dispatched to relay (DOD-LEAVEMSG-1) — either way the content is now
|
|
4838
|
+
// part of the daemon-owned tree: the relay witness (R1) already assigned it a sequence before
|
|
4839
|
+
// direct delivery was even attempted, so a parked message occupies the SAME leaf position it
|
|
4840
|
+
// would have taken if delivered live. Append once, for both outcomes.
|
|
4382
4841
|
const { leafIndex, newRootHex } = sessionNodeManager.appendSessionLeaf(record.agent_name, sessionId, "msg", contentHashHex, correlationId);
|
|
4383
4842
|
// DOD-LOG-1: persist the readable SENT plaintext to the durable transcript, keyed by the
|
|
4384
4843
|
// canonical leaf sequence so it joins the committed hash chain (survives restart).
|
|
4385
|
-
|
|
4844
|
+
// M9 merge fix: use sendBytes (the ALTERED bytes on a redact verdict), never the pre-redaction
|
|
4845
|
+
// contentBytes — the leaf hash above already binds sendBytes; the transcript must match what
|
|
4846
|
+
// actually went on the wire, not the pre-redaction draft (M9's own stated seam invariant).
|
|
4847
|
+
sessionNodeManager.recordTranscriptMessage(record.agent_name, sessionId, leafIndex, "sent", sendBytes, correlationId);
|
|
4848
|
+
// M8C-CURSOR-1: the sender authored this leaf — advance ITS OWN cursor so it doesn't get
|
|
4849
|
+
// blocked by session_not_current on its own just-sent message.
|
|
4850
|
+
advanceConnectionCursor(connectionId, sessionId, leafIndex);
|
|
4851
|
+
void newRootHex;
|
|
4852
|
+
if (modified) {
|
|
4853
|
+
logger.info("security.verdict.returned", { disposition: "redact", sessionId, sequenceNumber: leafIndex, correlationId });
|
|
4854
|
+
}
|
|
4855
|
+
if (!sendResult.delivered) {
|
|
4856
|
+
// DOD-LEAVEMSG-1 (sender half): direct delivery failed but the sealed, hashed content was
|
|
4857
|
+
// successfully deposited at the relay (pickup_queue) — this is a SUCCESS outcome, not a
|
|
4858
|
+
// failure. The recipient's daemon pulls it via RELAYWAKE on next reconnect. Reporting this
|
|
4859
|
+
// as ok:false (the pre-LEAVEMSG-1 behavior) misrepresented an in-flight message as lost.
|
|
4860
|
+
logger.info("session.content.dispatched_to_relay", {
|
|
4861
|
+
sessionId,
|
|
4862
|
+
recipientPubkey,
|
|
4863
|
+
contentHashHex,
|
|
4864
|
+
sequenceNumber: leafIndex,
|
|
4865
|
+
correlationId,
|
|
4866
|
+
});
|
|
4867
|
+
return {
|
|
4868
|
+
ok: true,
|
|
4869
|
+
sequence_number: leafIndex,
|
|
4870
|
+
delivered: false,
|
|
4871
|
+
reason: "dispatched_to_relay",
|
|
4872
|
+
modified,
|
|
4873
|
+
guidance: "The counterparty is not directly reachable right now, so this message was sealed and dispatched to relay store-and-forward. It will be delivered the next time the counterparty's daemon reconnects — no further action is needed.",
|
|
4874
|
+
...(modified ? { transformations: (outboundVerdict.events ?? []).filter((e) => e.disposition === "redact") } : {}),
|
|
4875
|
+
};
|
|
4876
|
+
}
|
|
4386
4877
|
logger.info("session.content.sent", {
|
|
4387
4878
|
sessionId,
|
|
4388
4879
|
recipientPubkey,
|
|
@@ -4390,8 +4881,14 @@ export async function startDaemon(config) {
|
|
|
4390
4881
|
sequenceNumber: leafIndex,
|
|
4391
4882
|
correlationId,
|
|
4392
4883
|
});
|
|
4393
|
-
|
|
4394
|
-
|
|
4884
|
+
return {
|
|
4885
|
+
ok: true,
|
|
4886
|
+
sequence_number: leafIndex,
|
|
4887
|
+
delivered: true,
|
|
4888
|
+
modified,
|
|
4889
|
+
// On a redact, tell the agent exactly what was transformed (the §6 sender-side surface).
|
|
4890
|
+
...(modified ? { transformations: (outboundVerdict.events ?? []).filter((e) => e.disposition === "redact") } : {}),
|
|
4891
|
+
};
|
|
4395
4892
|
});
|
|
4396
4893
|
// ─── CELLO-M7-DAEMON-004 / F1-a: cello_receive (BLOCKING, session-scoped) ────────
|
|
4397
4894
|
// F1-a fix: the daemon port had dropped the blocking receive (the handler was a
|
|
@@ -4432,8 +4929,14 @@ export async function startDaemon(config) {
|
|
|
4432
4929
|
const received = messages.filter((m) => m.direction === "received" && m.sequence > sinceSeq);
|
|
4433
4930
|
if (received.length > 0) {
|
|
4434
4931
|
// readTranscript is ordered by sequence ASC → the last is the max.
|
|
4435
|
-
|
|
4932
|
+
const maxSeq = received[received.length - 1].sequence;
|
|
4933
|
+
sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, maxSeq);
|
|
4934
|
+
clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
|
|
4436
4935
|
}
|
|
4936
|
+
// M8C-CURSOR-1 (reviewer HIGH fix): only advance through the CONTIGUOUS run this batch
|
|
4937
|
+
// actually delivered — if a sent leaf from another local connection sits in a gap, this
|
|
4938
|
+
// correctly refuses to advance past it (cello_get_transcript is still required to catch up).
|
|
4939
|
+
safeCursorAdvance(connectionId, sessionId, new Set(received.map((m) => m.sequence)));
|
|
4437
4940
|
logger.info("session.receive.since_seq", { sessionId, agentName, since_seq: sinceSeq, count: received.length });
|
|
4438
4941
|
return {
|
|
4439
4942
|
ok: true,
|
|
@@ -4454,6 +4957,11 @@ export async function startDaemon(config) {
|
|
|
4454
4957
|
// M8C-INBOX-1 (N3): delivery marks read — advance the persisted read watermark so this
|
|
4455
4958
|
// message no longer counts as unread in cello_check_notifications. Monotonic (never lowers).
|
|
4456
4959
|
sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, entry.sequenceNumber);
|
|
4960
|
+
clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
|
|
4961
|
+
// M8C-CURSOR-1 (reviewer HIGH fix): a single delivered message only proves THIS sequence
|
|
4962
|
+
// was read — safeCursorAdvance refuses to vault past a gap (e.g. an unread sent leaf from
|
|
4963
|
+
// another local connection) even though this specific sequence number is now known.
|
|
4964
|
+
safeCursorAdvance(connectionId, sessionId, new Set([entry.sequenceNumber]));
|
|
4457
4965
|
return {
|
|
4458
4966
|
ok: true,
|
|
4459
4967
|
content: Buffer.from(entry.contentHex, "hex").toString("utf8"),
|
|
@@ -4527,19 +5035,91 @@ export async function startDaemon(config) {
|
|
|
4527
5035
|
agentNames = [current];
|
|
4528
5036
|
}
|
|
4529
5037
|
const agents = agentNames.map((agent) => {
|
|
5038
|
+
reapExpiredInboundSessions(agent); // M8C-TTL-1: expired ones surface below, not as "pending"
|
|
4530
5039
|
const pending = (inboundSessionQueues.get(agent) ?? []).map((e) => ({
|
|
4531
5040
|
session_id: e.sessionIdHex,
|
|
4532
5041
|
from: e.counterpartyPubkeyHex,
|
|
4533
5042
|
}));
|
|
5043
|
+
// M8C-TTL-1: expired requests stay VISIBLE (not silently dropped) — the operator can see
|
|
5044
|
+
// what they missed rather than a request just vanishing from the pending list.
|
|
5045
|
+
const expired = (expiredSessionRequests.get(agent) ?? []).map((e) => ({
|
|
5046
|
+
session_id: e.sessionIdHex,
|
|
5047
|
+
from: e.counterpartyPubkeyHex,
|
|
5048
|
+
expired_at: e.expiredAt,
|
|
5049
|
+
}));
|
|
4534
5050
|
const unread = sessionNodeManager.getUnreadSummary(agent);
|
|
4535
5051
|
const total_unread = unread.reduce((sum, u) => sum + u.unread_count, 0);
|
|
4536
|
-
return { agent, pending_session_requests: pending, unread, total_unread };
|
|
5052
|
+
return { agent, pending_session_requests: pending, expired_session_requests: expired, unread, total_unread };
|
|
4537
5053
|
});
|
|
4538
5054
|
const totalUnread = agents.reduce((sum, a) => sum + a.total_unread, 0);
|
|
4539
5055
|
const totalPending = agents.reduce((sum, a) => sum + a.pending_session_requests.length, 0);
|
|
4540
|
-
|
|
5056
|
+
// M8C-TTL-1 (reviewer finding, D19): surface expired-log size so unbounded growth (were the
|
|
5057
|
+
// cap ever removed or misconfigured) would be visible here, not just in an internal Map.
|
|
5058
|
+
const totalExpired = agents.reduce((sum, a) => sum + a.expired_session_requests.length, 0);
|
|
5059
|
+
logger.info("inbox.checked", { connectionId, scope, agentCount: agents.length, totalUnread, totalPending, totalExpired });
|
|
4541
5060
|
return { ok: true, scope, agents };
|
|
4542
5061
|
});
|
|
5062
|
+
// M8C-CONTACT-1: cello contact add/remove/list [--agent <name>]. All three resolve the target
|
|
5063
|
+
// agent the same way — an explicit params.agent, else this connection's current/sole-online
|
|
5064
|
+
// agent (F18) — so a CLI/AI operator gets the same no_current_agent guidance INBOX already uses.
|
|
5065
|
+
function resolveContactAgent(connState, params) {
|
|
5066
|
+
const explicit = typeof params?.agent === "string" ? params.agent : undefined;
|
|
5067
|
+
if (explicit)
|
|
5068
|
+
return { ok: true, agent: explicit };
|
|
5069
|
+
const current = resolveCurrentAgent(connState);
|
|
5070
|
+
if (!current) {
|
|
5071
|
+
return {
|
|
5072
|
+
ok: false,
|
|
5073
|
+
reason: "no_current_agent",
|
|
5074
|
+
guidance: "No current agent for this connection. Pass --agent <name>, or call cello_use_agent to select one first.",
|
|
5075
|
+
};
|
|
5076
|
+
}
|
|
5077
|
+
return { ok: true, agent: current };
|
|
5078
|
+
}
|
|
5079
|
+
handlers.set("cello_contact_add", async (params, connectionId) => {
|
|
5080
|
+
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5081
|
+
if (!pubkey) {
|
|
5082
|
+
return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to add." };
|
|
5083
|
+
}
|
|
5084
|
+
const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
|
|
5085
|
+
if (!resolved.ok)
|
|
5086
|
+
return resolved;
|
|
5087
|
+
sessionNodeManager.addContact(resolved.agent, pubkey);
|
|
5088
|
+
logger.info("contact.added", { agent: resolved.agent, pubkey });
|
|
5089
|
+
return { ok: true, agent: resolved.agent, pubkey };
|
|
5090
|
+
});
|
|
5091
|
+
handlers.set("cello_contact_remove", async (params, connectionId) => {
|
|
5092
|
+
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5093
|
+
if (!pubkey) {
|
|
5094
|
+
return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to remove." };
|
|
5095
|
+
}
|
|
5096
|
+
const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
|
|
5097
|
+
if (!resolved.ok)
|
|
5098
|
+
return resolved;
|
|
5099
|
+
const removed = sessionNodeManager.removeContact(resolved.agent, pubkey);
|
|
5100
|
+
logger.info("contact.removed", { agent: resolved.agent, pubkey, removed });
|
|
5101
|
+
return { ok: true, agent: resolved.agent, pubkey, removed };
|
|
5102
|
+
});
|
|
5103
|
+
handlers.set("cello_contact_list", async (params, connectionId) => {
|
|
5104
|
+
const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
|
|
5105
|
+
if (!resolved.ok)
|
|
5106
|
+
return resolved;
|
|
5107
|
+
const contacts = sessionNodeManager.listContacts(resolved.agent);
|
|
5108
|
+
return { ok: true, agent: resolved.agent, contacts };
|
|
5109
|
+
});
|
|
5110
|
+
// M8C-TGDOOR-1: cello_telegram_set_token — persist the daemon-wide bot token + allowlisted
|
|
5111
|
+
// operator chat ID, then start the poller immediately (no restart needed).
|
|
5112
|
+
handlers.set("cello_telegram_set_token", async (params, _connectionId) => {
|
|
5113
|
+
const botToken = typeof params?.bot_token === "string" ? params.bot_token : undefined;
|
|
5114
|
+
const chatId = typeof params?.allowlisted_chat_id === "string" ? params.allowlisted_chat_id : undefined;
|
|
5115
|
+
if (!botToken || !chatId) {
|
|
5116
|
+
return { ok: false, reason: "missing_params", guidance: "Provide 'bot_token' and 'allowlisted_chat_id'." };
|
|
5117
|
+
}
|
|
5118
|
+
sessionNodeManager.setTelegramSettings(botToken, chatId);
|
|
5119
|
+
startTelegramPollerIfConfigured(); // always bumps the generation — restarts even if already running
|
|
5120
|
+
logger.info("telegram.settings.updated", {});
|
|
5121
|
+
return { ok: true };
|
|
5122
|
+
});
|
|
4543
5123
|
let shutdownPromise = null;
|
|
4544
5124
|
handlers.set("shutdown", async (_params, _connectionId) => {
|
|
4545
5125
|
if (!shutdownPromise) {
|
|
@@ -4573,18 +5153,23 @@ export async function startDaemon(config) {
|
|
|
4573
5153
|
// used because the dispatcher is constructed AFTER the SessionNodeManager
|
|
4574
5154
|
// (it depends on the IPC server), so constructor injection would be circular.
|
|
4575
5155
|
sessionNodeManager.setOnSessionStateChanged((agentName, sessionId, state, counterpartyPubkey) => {
|
|
4576
|
-
|
|
5156
|
+
dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey);
|
|
4577
5157
|
});
|
|
4578
5158
|
// M8C-MSGWAKE-1 (channel stage 2): per-message wake — a verified inbound message fires a
|
|
4579
5159
|
// content-free `cello_message` doorbell to the current-agent connection(s). The shim's generic
|
|
4580
5160
|
// bridge (WAKE) forwards it to a live --channels session as notifications/claude/channel.
|
|
4581
5161
|
sessionNodeManager.setOnContentArrived((agentName, sessionId, senderPubkey) => {
|
|
4582
5162
|
notificationDispatcher.dispatchCelloMessage(agentName, sessionId, senderPubkey);
|
|
5163
|
+
// M8C-AWAY-1: an unattended agent auto-acks an inbound message on an existing session.
|
|
5164
|
+
void sendAwayResponse(agentName, sessionId, "message");
|
|
5165
|
+
// M8C-TGDOOR-1: message-waiting — coalesced (ring-once-until-read) inside sendTelegramDoorbell.
|
|
5166
|
+
void sendTelegramDoorbell(agentName, sessionId, "message_waiting", "New message waiting");
|
|
4583
5167
|
});
|
|
4584
5168
|
// MCP-001: Clean up per-connection state when a connection disconnects
|
|
4585
5169
|
// MCP-002: Also unregister from notification dispatcher
|
|
4586
5170
|
ipcServer.onDisconnect((connectionId) => {
|
|
4587
5171
|
perConnectionState.delete(connectionId);
|
|
5172
|
+
connectionCursors.delete(connectionId); // M8C-CURSOR-1: cursor is connection-scoped, dies with it
|
|
4588
5173
|
notificationDispatcher.unregisterConnection(connectionId);
|
|
4589
5174
|
// Seam 2 (review H2): evict any cello_await_session waiters owned by this connection.
|
|
4590
5175
|
// Otherwise enqueueInboundSession would hand the next inbound session to a closed
|
|
@@ -4622,6 +5207,9 @@ export async function startDaemon(config) {
|
|
|
4622
5207
|
// lifecycle is the daemon's, not any agent connection's — so it polls even with zero agents.
|
|
4623
5208
|
// Graceful shutdown
|
|
4624
5209
|
async function stop(reason) {
|
|
5210
|
+
// M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
|
|
5211
|
+
// the generation so the running loop's while-condition fails on its next check.
|
|
5212
|
+
telegramPollerGeneration += 1;
|
|
4625
5213
|
// CELLO-M7-CONN-001: stop the HTTP manifest poll (sets the stopped flag so an in-flight
|
|
4626
5214
|
// tick cannot re-arm, and cancels the scheduler). Belt-and-suspenders cancel for the
|
|
4627
5215
|
// no-poll case (scheduler present but poll not started).
|
|
@@ -4648,6 +5236,9 @@ export async function startDaemon(config) {
|
|
|
4648
5236
|
function getAutoNatService() {
|
|
4649
5237
|
return autoNatService;
|
|
4650
5238
|
}
|
|
5239
|
+
// M8C-TGDOOR-1: cold-capable — start the poller if a token was already configured from a
|
|
5240
|
+
// prior run, without waiting for any agent to come online or any client to attach.
|
|
5241
|
+
startTelegramPollerIfConfigured();
|
|
4651
5242
|
return { stop, getStatus, getSessionNodeManager, getDirectoryNode, getTransportSelector, getAutoNatService };
|
|
4652
5243
|
}
|
|
4653
5244
|
//# sourceMappingURL=daemon.js.map
|