@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
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.
|
|
@@ -1705,6 +1961,12 @@ export async function startDaemon(config) {
|
|
|
1705
1961
|
}
|
|
1706
1962
|
const fromAgent = connState.currentAgent;
|
|
1707
1963
|
connState.currentAgent = name;
|
|
1964
|
+
// M8C-AWAY-1: this agent just became attended — clear its away-ack dedup entries so the NEXT
|
|
1965
|
+
// away period (after this attended stretch ends) gets a fresh ack instead of staying silent.
|
|
1966
|
+
for (const key of Array.from(awayAckSent)) {
|
|
1967
|
+
if (key.startsWith(`${name}:`))
|
|
1968
|
+
awayAckSent.delete(key);
|
|
1969
|
+
}
|
|
1708
1970
|
// MCP-002: Update dispatcher's routing table and send notification to this connection only
|
|
1709
1971
|
notificationDispatcher.setCurrentAgent(connectionId, name);
|
|
1710
1972
|
notificationDispatcher.dispatchAgentCurrentChanged(connectionId, fromAgent, name);
|
|
@@ -2577,6 +2839,9 @@ export async function startDaemon(config) {
|
|
|
2577
2839
|
});
|
|
2578
2840
|
}
|
|
2579
2841
|
}
|
|
2842
|
+
// M8C-CONTACT-1 (D6): "initiating a session to X adds X" — pin at the pubkey the negotiator
|
|
2843
|
+
// actually used (not re-resolved later).
|
|
2844
|
+
sessionNodeManager.addContact(agentName, counterpartyPubkey);
|
|
2580
2845
|
// AC-007: the session is usable immediately upon (relay) connection — the dcutr
|
|
2581
2846
|
// upgrade runs in the background and is intentionally NOT awaited here.
|
|
2582
2847
|
return { ok: true, sessionId, transportMode: result.mode, correlationId };
|
|
@@ -2965,6 +3230,14 @@ export async function startDaemon(config) {
|
|
|
2965
3230
|
const { messages, undecryptable } = sessionNodeManager.readTranscript(connState.currentAgent, sessionId);
|
|
2966
3231
|
// undecryptable > 0 means some rows failed GCM auth (tamper / wrong key) — surfaced, not hidden,
|
|
2967
3232
|
// so the reader can tell a real gap from an empty transcript.
|
|
3233
|
+
// M8C-CURSOR-1: this is the ONLY reader that covers BOTH directions (sent + received), so it's
|
|
3234
|
+
// the correct general catch-up path — e.g. a second attended connection on the same agent
|
|
3235
|
+
// catching up on a message a DIFFERENT local connection sent (since_seq is received-only and
|
|
3236
|
+
// would never surface it). Route through safeCursorAdvance rather than trusting the raw max —
|
|
3237
|
+
// reviewer HIGH finding (aa5928e2/a9099571): recordTranscriptMessage swallows a DB write
|
|
3238
|
+
// failure without rolling back the tree's leaf count, so a hole in `messages` is possible in
|
|
3239
|
+
// principle; the contiguous-run walk refuses to vault the cursor past such a hole even here.
|
|
3240
|
+
safeCursorAdvance(connectionId, sessionId, new Set(messages.map((m) => m.sequence)));
|
|
2968
3241
|
return { ok: true, session_id: sessionId, messages, undecryptable };
|
|
2969
3242
|
});
|
|
2970
3243
|
// cello_list_sessions: the discovery surface — every persisted session for the
|
|
@@ -3183,13 +3456,25 @@ export async function startDaemon(config) {
|
|
|
3183
3456
|
if (env.structure1Cbor && env.structure2Cbor) {
|
|
3184
3457
|
sessionNodeManager.recordOrderingRecord(recipientAgent.name, e.sessionIdHex, env.structure1Cbor, env.structure2Cbor, contentHashBytes);
|
|
3185
3458
|
}
|
|
3186
|
-
const ingest = sessionNodeManager.ingestReceivedContent(recipientAgent.name, e.sessionIdHex, env.content, contentHashBytes);
|
|
3459
|
+
const ingest = await sessionNodeManager.ingestReceivedContent(recipientAgent.name, e.sessionIdHex, env.content, contentHashBytes);
|
|
3187
3460
|
if (ingest.ok && ingest.held) {
|
|
3188
3461
|
// DOD-MSG-4 (review finding #4): a held entry is NOT yet an appended leaf — its sequence is
|
|
3189
3462
|
// the FUTURE canonical index, not a completed recovery. Do not count it as recovered; log it
|
|
3190
3463
|
// distinctly so the tally reflects leaves actually written, not content still queued in memory.
|
|
3191
3464
|
logger.info("content.recover.held", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, canonicalSeq: ingest.sequenceNumber });
|
|
3192
3465
|
}
|
|
3466
|
+
else if (ingest.ok && ingest.screenedOut) {
|
|
3467
|
+
// M9 (code-review LOW-3): a terminal-screened recovered entry IS durably leafed (so it must be
|
|
3468
|
+
// confirm-deleted, below) but was NEVER delivered to the agent — do not count it as a delivered
|
|
3469
|
+
// recovery, and log it distinctly so observability separates "delivered" from "leafed-but-screened".
|
|
3470
|
+
logger.info("content.recover.screened_out", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, sequenceNumber: ingest.sequenceNumber });
|
|
3471
|
+
try {
|
|
3472
|
+
await client.confirm(node, Buffer.from(recipientPubkey, "hex"), contentHashBytes, kp);
|
|
3473
|
+
}
|
|
3474
|
+
catch (err) {
|
|
3475
|
+
logger.warn("content.recover.confirm.failed", { sessionId: e.sessionIdHex, contentHash: e.contentHashHex, error: err instanceof Error ? err.message : String(err) });
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3193
3478
|
else if (ingest.ok) {
|
|
3194
3479
|
// DOD-MSG-4 (review #3): count leaves ACTUALLY written — the directly-ingested leaf PLUS any
|
|
3195
3480
|
// held out-of-order entries this ingest unblocked (appendedCount), not just 1.
|
|
@@ -3284,13 +3569,13 @@ export async function startDaemon(config) {
|
|
|
3284
3569
|
const sessionPeerId = params?.sessionPeerId ?? "";
|
|
3285
3570
|
const correlationId = params?.correlationId ?? "";
|
|
3286
3571
|
logger.info("session.node.created", { sessionId, agentName, sessionPeerId, correlationId });
|
|
3287
|
-
|
|
3572
|
+
dispatchSessionStateChangedWithTelegram(agentName, sessionId, "created", counterpartyPubkey);
|
|
3288
3573
|
}
|
|
3289
3574
|
else if (type === "destroyed") {
|
|
3290
3575
|
const state = params?.state ?? "interrupted";
|
|
3291
3576
|
const reason = params?.reason ?? state;
|
|
3292
3577
|
logger.info("session.node.destroyed", { sessionId, agentName, reason });
|
|
3293
|
-
|
|
3578
|
+
dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey);
|
|
3294
3579
|
}
|
|
3295
3580
|
return { ok: true };
|
|
3296
3581
|
});
|
|
@@ -3305,6 +3590,15 @@ export async function startDaemon(config) {
|
|
|
3305
3590
|
return { error: "missing_params", guidance: "Provide agentName and sessionId." };
|
|
3306
3591
|
}
|
|
3307
3592
|
enqueueInboundSession(agentName, { sessionIdHex, counterpartyPubkeyHex, genesisPrevRootHex: "" });
|
|
3593
|
+
// M8C-TTL-1: let a test backdate the just-enqueued entry's timestamp (simulating age) without
|
|
3594
|
+
// waiting real hours or faking global timers — enqueueInboundSession always stamps Date.now().
|
|
3595
|
+
const enqueuedAtOverride = params?.enqueuedAtOverride;
|
|
3596
|
+
if (enqueuedAtOverride !== undefined) {
|
|
3597
|
+
const q = inboundSessionQueues.get(agentName);
|
|
3598
|
+
const entry = q?.[q.length - 1];
|
|
3599
|
+
if (entry)
|
|
3600
|
+
entry.enqueuedAt = enqueuedAtOverride;
|
|
3601
|
+
}
|
|
3308
3602
|
return { ok: true };
|
|
3309
3603
|
});
|
|
3310
3604
|
// M8C-INBOX-1 (reviewer F1): buffer a received message so a test can drive a live cello_receive
|
|
@@ -3461,6 +3755,9 @@ export async function startDaemon(config) {
|
|
|
3461
3755
|
correlationId,
|
|
3462
3756
|
});
|
|
3463
3757
|
}
|
|
3758
|
+
// Expired requests move HERE (from inboundSessionQueues) rather than vanishing — visible via
|
|
3759
|
+
// cello_check_notifications so the operator can see what they missed, not just silence.
|
|
3760
|
+
const expiredSessionRequests = new Map();
|
|
3464
3761
|
// Per-agent FIFO queue (events accepted while no cello_await_session is blocked) and
|
|
3465
3762
|
// the per-agent list of blocked waiters. Inbound sessions are addressed to a specific
|
|
3466
3763
|
// local agent (participant_b), so both are keyed by agent name.
|
|
@@ -3486,9 +3783,45 @@ export async function startDaemon(config) {
|
|
|
3486
3783
|
return;
|
|
3487
3784
|
}
|
|
3488
3785
|
const q = inboundSessionQueues.get(agentName) ?? [];
|
|
3489
|
-
q.push(event);
|
|
3786
|
+
q.push({ ...event, enqueuedAt: Date.now() }); // M8C-TTL-1: stamp queue entry time
|
|
3490
3787
|
inboundSessionQueues.set(agentName, q);
|
|
3491
3788
|
}
|
|
3789
|
+
// M8C-TTL-1 (reviewer HIGH fix, aed2d71f, D19): expiredSessionRequests is a lasting log, not a
|
|
3790
|
+
// consumed queue (unlike inboundSessionQueues, nothing ever drains it) — a whitelisted CONTACT-1
|
|
3791
|
+
// contact is EXEMPT from ABUSE-1's acceptance bounds ("bounded only by disk"), so they can push
|
|
3792
|
+
// unlimited accepted sessions the operator never claims, each becoming a permanent, unremovable
|
|
3793
|
+
// entry every 24h for the life of the daemon process. Capped the same way STATUS_RESUMABLE_CAP
|
|
3794
|
+
// bounds the interrupted-sessions list — keep only the MOST RECENT N per agent.
|
|
3795
|
+
const EXPIRED_SESSION_REQUESTS_CAP = 20;
|
|
3796
|
+
// M8C-TTL-1: move any queue entries past the TTL into expiredSessionRequests (visible via
|
|
3797
|
+
// INBOX), instead of leaving them to sit forever or vanish silently. Called lazily on every
|
|
3798
|
+
// read of the queue (cello_await_session's immediate-check, cello_check_notifications) rather
|
|
3799
|
+
// than on a timer — no background sweep needed, and it's always correct-as-of-the-read.
|
|
3800
|
+
function reapExpiredInboundSessions(agentName) {
|
|
3801
|
+
const q = inboundSessionQueues.get(agentName);
|
|
3802
|
+
if (!q || q.length === 0)
|
|
3803
|
+
return;
|
|
3804
|
+
const now = Date.now();
|
|
3805
|
+
const live = [];
|
|
3806
|
+
let expiredList = expiredSessionRequests.get(agentName);
|
|
3807
|
+
for (const e of q) {
|
|
3808
|
+
if (e.enqueuedAt !== undefined && now - e.enqueuedAt > INBOUND_SESSION_TTL_MS) {
|
|
3809
|
+
if (!expiredList) {
|
|
3810
|
+
expiredList = [];
|
|
3811
|
+
expiredSessionRequests.set(agentName, expiredList);
|
|
3812
|
+
}
|
|
3813
|
+
expiredList.push({ sessionIdHex: e.sessionIdHex, counterpartyPubkeyHex: e.counterpartyPubkeyHex, expiredAt: now });
|
|
3814
|
+
if (expiredList.length > EXPIRED_SESSION_REQUESTS_CAP) {
|
|
3815
|
+
expiredList.splice(0, expiredList.length - EXPIRED_SESSION_REQUESTS_CAP); // keep newest N
|
|
3816
|
+
}
|
|
3817
|
+
logger.info("session.request.expired", { agentName, sessionId: e.sessionIdHex, enqueuedAt: e.enqueuedAt });
|
|
3818
|
+
}
|
|
3819
|
+
else {
|
|
3820
|
+
live.push(e);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3823
|
+
inboundSessionQueues.set(agentName, live);
|
|
3824
|
+
}
|
|
3492
3825
|
// CBOR-decoded byte fields arrive as Uint8Array or Buffer; a field may also already be
|
|
3493
3826
|
// a hex string. Hex strings are lowercased so the case-sensitive agent-pubkey match
|
|
3494
3827
|
// (agents store lowercase hex) cannot silently miss (review L2).
|
|
@@ -3614,6 +3947,20 @@ export async function startDaemon(config) {
|
|
|
3614
3947
|
}
|
|
3615
3948
|
async function acceptInboundAssignment(parsed, agentName, correlationId) {
|
|
3616
3949
|
try {
|
|
3950
|
+
// M8C-ABUSE-1 (anti-drip-feed / anti-swarm): bound acceptance from unknown (non-contact)
|
|
3951
|
+
// senders — a per-sender cap (many sessions from ONE stranger) and a global cap (many
|
|
3952
|
+
// sessions across ALL strangers combined). Known contacts are exempt ("bounded only by
|
|
3953
|
+
// disk" — DoD). Checked FIRST, before any standing-receiver work, so a refusal is cheap.
|
|
3954
|
+
const bound = sessionNodeManager.checkUnknownSenderAcceptanceBound(agentName, parsed.participantAPubkeyHex);
|
|
3955
|
+
if (!bound.ok) {
|
|
3956
|
+
logger.warn("session.inbound.accept.failed", {
|
|
3957
|
+
sessionId: parsed.sessionIdHex,
|
|
3958
|
+
agentName,
|
|
3959
|
+
reason: bound.reason,
|
|
3960
|
+
correlationId,
|
|
3961
|
+
});
|
|
3962
|
+
return;
|
|
3963
|
+
}
|
|
3617
3964
|
// M8B F14 (fix 2): KICK creation before polling — an inbound offer arriving while no
|
|
3618
3965
|
// receiver exists and none is being created must trigger the ensure itself (the doc
|
|
3619
3966
|
// comment's "retries on demand" made true), instead of polling a creation nobody
|
|
@@ -3682,7 +4029,17 @@ export async function startDaemon(config) {
|
|
|
3682
4029
|
counterpartyPubkeyHex: parsed.participantAPubkeyHex,
|
|
3683
4030
|
genesisPrevRootHex,
|
|
3684
4031
|
});
|
|
3685
|
-
|
|
4032
|
+
dispatchSessionStateChangedWithTelegram(agentName, parsed.sessionIdHex, "created", parsed.participantAPubkeyHex);
|
|
4033
|
+
// M8C-TGDOOR-1: session requests ALWAYS ring (DoD) — no coalescing, unlike message-waiting.
|
|
4034
|
+
void sendTelegramDoorbell(agentName, parsed.sessionIdHex, "session_request", "New session request");
|
|
4035
|
+
// M8C-AWAY-1: an unattended agent auto-acks a fresh inbound session request. Fire-and-forget
|
|
4036
|
+
// (sendAwayResponse never throws) — best-effort, must not delay/block acceptance completion.
|
|
4037
|
+
// ORDER MATTERS: sendAwayResponse's isContact check runs synchronously (before any await) as
|
|
4038
|
+
// soon as it's invoked, so calling addContact on the NEXT line still lets a stranger's very
|
|
4039
|
+
// first contact be judged correctly unknown (M8C-CONTACT-1 D6: "accepting X's request adds X"
|
|
4040
|
+
// — they become known immediately after, for every subsequent interaction).
|
|
4041
|
+
void sendAwayResponse(agentName, parsed.sessionIdHex, "request");
|
|
4042
|
+
sessionNodeManager.addContact(agentName, parsed.participantAPubkeyHex);
|
|
3686
4043
|
}
|
|
3687
4044
|
finally {
|
|
3688
4045
|
inboundInFlight.delete(parsed.sessionIdHex);
|
|
@@ -3882,6 +4239,7 @@ export async function startDaemon(config) {
|
|
|
3882
4239
|
counterparty_pubkey: e.counterpartyPubkeyHex,
|
|
3883
4240
|
genesis_prev_root: e.genesisPrevRootHex,
|
|
3884
4241
|
});
|
|
4242
|
+
reapExpiredInboundSessions(agentName); // M8C-TTL-1: don't hand back a stale expired entry
|
|
3885
4243
|
const queued = inboundSessionQueues.get(agentName);
|
|
3886
4244
|
if (queued && queued.length > 0) {
|
|
3887
4245
|
return toResponse(queued.shift());
|
|
@@ -4326,6 +4684,42 @@ export async function startDaemon(config) {
|
|
|
4326
4684
|
if (record.status !== "active") {
|
|
4327
4685
|
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
4686
|
}
|
|
4687
|
+
// M8C-CURSOR-1: read-before-write gate. current_seq is the tree's highest leaf index
|
|
4688
|
+
// (message_count is kept in sync with leafCount on every append, both directions — DAEMON-004
|
|
4689
|
+
// finding #2), so it reflects EVERY message in the session regardless of which connection sent
|
|
4690
|
+
// or received it. If this connection hasn't read up to current_seq (e.g. a second attended
|
|
4691
|
+
// session on the same agent that hasn't polled since the other connection's last send), refuse
|
|
4692
|
+
// rather than let it send blind — the WhatsApp-group-chat model. Runs BEFORE M9's
|
|
4693
|
+
// governance-decisions parsing below — an access-control gate should short-circuit before any
|
|
4694
|
+
// unrelated prep work for a send that may not even be allowed to proceed.
|
|
4695
|
+
const currentSeq = record.message_count - 1;
|
|
4696
|
+
const lastReadSeq = getConnectionCursor(connectionId, sessionId);
|
|
4697
|
+
if (lastReadSeq < currentSeq) {
|
|
4698
|
+
// M8C-CURSOR-1 (reviewer MEDIUM fix): every sibling rejection in this handler logs; this
|
|
4699
|
+
// gate must too — a security-relevant control-flow path with no observability is a gap.
|
|
4700
|
+
logger.warn("session.send.blocked", { sessionId, currentSeq, lastReadSeq, connectionId });
|
|
4701
|
+
return {
|
|
4702
|
+
ok: false,
|
|
4703
|
+
reason: "session_not_current",
|
|
4704
|
+
current_seq: currentSeq,
|
|
4705
|
+
last_read_seq: lastReadSeq,
|
|
4706
|
+
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.`,
|
|
4707
|
+
};
|
|
4708
|
+
}
|
|
4709
|
+
// M9-FEED-001 §6: the agent's governance re-send decisions, keyed by the flagId a prior `warn`
|
|
4710
|
+
// returned. Optional; validated shape only (the gateway re-scans + applies them, INV-4). A
|
|
4711
|
+
// malformed map is ignored rather than failing the send (the gateway will just re-warn).
|
|
4712
|
+
const rawDecisions = params?.governance_decisions;
|
|
4713
|
+
let governanceDecisions;
|
|
4714
|
+
if (rawDecisions && typeof rawDecisions === "object" && !Array.isArray(rawDecisions)) {
|
|
4715
|
+
const valid = {};
|
|
4716
|
+
for (const [k, v] of Object.entries(rawDecisions)) {
|
|
4717
|
+
if (v === "redact" || v === "allow_once" || v === "allow_always")
|
|
4718
|
+
valid[k] = v;
|
|
4719
|
+
}
|
|
4720
|
+
if (Object.keys(valid).length > 0)
|
|
4721
|
+
governanceDecisions = valid;
|
|
4722
|
+
}
|
|
4329
4723
|
const correlationId = randomUUID();
|
|
4330
4724
|
const contentBytes = new TextEncoder().encode(contentStr);
|
|
4331
4725
|
// CELLO-M7-MSG-001 (AC-013/AC-018/AC-021): enforce the 1 MB application content cap
|
|
@@ -4346,17 +4740,74 @@ export async function startDaemon(config) {
|
|
|
4346
4740
|
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
4741
|
};
|
|
4348
4742
|
}
|
|
4349
|
-
const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
|
|
4350
|
-
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
4351
4743
|
const recipientPubkey = record.counterparty_pubkey;
|
|
4352
|
-
|
|
4744
|
+
// M9 outbound screening seam (INV-5/SI-001). Screen BEFORE anything reaches the wire. The
|
|
4745
|
+
// gateway verdict drives the four cello_send outcomes (M9-FEED-001): block / warn → NOT sent;
|
|
4746
|
+
// allow → sent as-is; redact → sent in ALTERED form. A configured-but-unreachable gateway fails
|
|
4747
|
+
// closed (block, gateway_unavailable), so a screening outage can never let content out ungated.
|
|
4748
|
+
const outboundVerdict = await securityGateway.screenOutbound(contentBytes, {
|
|
4749
|
+
direction: "outbound",
|
|
4750
|
+
agentName: record.agent_name,
|
|
4751
|
+
sessionId,
|
|
4752
|
+
correlationId,
|
|
4753
|
+
...(governanceDecisions !== undefined ? { governanceDecisions } : {}),
|
|
4754
|
+
});
|
|
4755
|
+
if (outboundVerdict.disposition === "block") {
|
|
4756
|
+
if (outboundVerdict.reason === GOVERNANCE_TIMEOUT) {
|
|
4757
|
+
logger.error("security.gateway.timeout", { sessionId, reason: outboundVerdict.reason, correlationId });
|
|
4758
|
+
}
|
|
4759
|
+
else if (outboundVerdict.reason === GATEWAY_UNAVAILABLE) {
|
|
4760
|
+
logger.error("security.gateway.unavailable", { direction: "outbound", reason: outboundVerdict.reason, correlationId });
|
|
4761
|
+
}
|
|
4762
|
+
else {
|
|
4763
|
+
logger.info("security.verdict.returned", { disposition: "block", sessionId, reason: outboundVerdict.reason, correlationId });
|
|
4764
|
+
}
|
|
4765
|
+
return {
|
|
4766
|
+
ok: false,
|
|
4767
|
+
reason: outboundVerdict.reason ?? "blocked_by_governance",
|
|
4768
|
+
guidance: outboundVerdict.guidance ??
|
|
4769
|
+
"This message was blocked by the security gateway and was NOT sent. The session is still active.",
|
|
4770
|
+
blocks: (outboundVerdict.events ?? []).filter((e) => e.disposition === "block"),
|
|
4771
|
+
};
|
|
4772
|
+
}
|
|
4773
|
+
if (outboundVerdict.disposition === "warn") {
|
|
4774
|
+
logger.info("security.verdict.returned", { disposition: "warn", sessionId, correlationId });
|
|
4775
|
+
return {
|
|
4776
|
+
ok: false,
|
|
4777
|
+
reason: "governance_warn",
|
|
4778
|
+
guidance: outboundVerdict.guidance ??
|
|
4779
|
+
"This message was held for a governance decision and was NOT sent. Re-send the same content with a " +
|
|
4780
|
+
"governance_decisions map ({flagId: redact | allow_once | allow_always}) to resolve each flagged item.",
|
|
4781
|
+
flags: (outboundVerdict.events ?? []).filter((e) => e.disposition === "warn"),
|
|
4782
|
+
};
|
|
4783
|
+
}
|
|
4784
|
+
// FAIL-CLOSED (code-review MED): a `redact` verdict MUST carry the redacted content. If it ever
|
|
4785
|
+
// arrives without it, sending the original `contentBytes` would leak the pre-redaction draft — the
|
|
4786
|
+
// one place M9 could fail OPEN. Treat it as a block, never an allow-original. (Unreachable today:
|
|
4787
|
+
// the gateway always includes content on redact; this is the defensive floor.)
|
|
4788
|
+
if (outboundVerdict.disposition === "redact" && outboundVerdict.content === undefined) {
|
|
4789
|
+
logger.error("security.verdict.redact_without_content", { sessionId, correlationId });
|
|
4790
|
+
return {
|
|
4791
|
+
ok: false,
|
|
4792
|
+
reason: "redact_without_content",
|
|
4793
|
+
guidance: "The security gateway returned a redact verdict without the redacted content. To avoid " +
|
|
4794
|
+
"leaking the original, nothing was sent. This is a gateway fault — check the gateway logs and retry.",
|
|
4795
|
+
};
|
|
4796
|
+
}
|
|
4797
|
+
// allow or redact → send. On redact the ALTERED bytes are what go on the wire AND what the leaf
|
|
4798
|
+
// hash binds — the transcript records what was actually sent, not the pre-redaction draft.
|
|
4799
|
+
const modified = outboundVerdict.disposition === "redact" && outboundVerdict.content !== undefined;
|
|
4800
|
+
const sendBytes = modified ? new Uint8Array(outboundVerdict.content) : contentBytes;
|
|
4801
|
+
const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(sendBytes).digest();
|
|
4802
|
+
const contentHashHex = Buffer.from(contentHash).toString("hex");
|
|
4803
|
+
const sendResult = await sessionNodeManager.sendContent(record.agent_name, sessionId, sendBytes, new Uint8Array(contentHash), correlationId);
|
|
4353
4804
|
if (!sendResult.ok) {
|
|
4354
4805
|
// DB-001 / dead-channel contract: never silently drop, never desync. Preserve
|
|
4355
4806
|
// the content in the durable retry_queue so it is retried on reconnect, and
|
|
4356
4807
|
// surface a named, diagnosable failure.
|
|
4357
4808
|
const nonce = randomUUID();
|
|
4358
4809
|
try {
|
|
4359
|
-
retryQueue.enqueue(sessionId, new TextEncoder().encode(nonce),
|
|
4810
|
+
retryQueue.enqueue(sessionId, new TextEncoder().encode(nonce), sendBytes);
|
|
4360
4811
|
}
|
|
4361
4812
|
catch (err) {
|
|
4362
4813
|
logger.error("session.content.queue.failed", {
|
|
@@ -4378,11 +4829,46 @@ export async function startDaemon(config) {
|
|
|
4378
4829
|
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
4830
|
};
|
|
4380
4831
|
}
|
|
4381
|
-
// Delivered
|
|
4832
|
+
// Delivered directly OR dispatched to relay (DOD-LEAVEMSG-1) — either way the content is now
|
|
4833
|
+
// part of the daemon-owned tree: the relay witness (R1) already assigned it a sequence before
|
|
4834
|
+
// direct delivery was even attempted, so a parked message occupies the SAME leaf position it
|
|
4835
|
+
// would have taken if delivered live. Append once, for both outcomes.
|
|
4382
4836
|
const { leafIndex, newRootHex } = sessionNodeManager.appendSessionLeaf(record.agent_name, sessionId, "msg", contentHashHex, correlationId);
|
|
4383
4837
|
// DOD-LOG-1: persist the readable SENT plaintext to the durable transcript, keyed by the
|
|
4384
4838
|
// canonical leaf sequence so it joins the committed hash chain (survives restart).
|
|
4385
|
-
|
|
4839
|
+
// M9 merge fix: use sendBytes (the ALTERED bytes on a redact verdict), never the pre-redaction
|
|
4840
|
+
// contentBytes — the leaf hash above already binds sendBytes; the transcript must match what
|
|
4841
|
+
// actually went on the wire, not the pre-redaction draft (M9's own stated seam invariant).
|
|
4842
|
+
sessionNodeManager.recordTranscriptMessage(record.agent_name, sessionId, leafIndex, "sent", sendBytes, correlationId);
|
|
4843
|
+
// M8C-CURSOR-1: the sender authored this leaf — advance ITS OWN cursor so it doesn't get
|
|
4844
|
+
// blocked by session_not_current on its own just-sent message.
|
|
4845
|
+
advanceConnectionCursor(connectionId, sessionId, leafIndex);
|
|
4846
|
+
void newRootHex;
|
|
4847
|
+
if (modified) {
|
|
4848
|
+
logger.info("security.verdict.returned", { disposition: "redact", sessionId, sequenceNumber: leafIndex, correlationId });
|
|
4849
|
+
}
|
|
4850
|
+
if (!sendResult.delivered) {
|
|
4851
|
+
// DOD-LEAVEMSG-1 (sender half): direct delivery failed but the sealed, hashed content was
|
|
4852
|
+
// successfully deposited at the relay (pickup_queue) — this is a SUCCESS outcome, not a
|
|
4853
|
+
// failure. The recipient's daemon pulls it via RELAYWAKE on next reconnect. Reporting this
|
|
4854
|
+
// as ok:false (the pre-LEAVEMSG-1 behavior) misrepresented an in-flight message as lost.
|
|
4855
|
+
logger.info("session.content.dispatched_to_relay", {
|
|
4856
|
+
sessionId,
|
|
4857
|
+
recipientPubkey,
|
|
4858
|
+
contentHashHex,
|
|
4859
|
+
sequenceNumber: leafIndex,
|
|
4860
|
+
correlationId,
|
|
4861
|
+
});
|
|
4862
|
+
return {
|
|
4863
|
+
ok: true,
|
|
4864
|
+
sequence_number: leafIndex,
|
|
4865
|
+
delivered: false,
|
|
4866
|
+
reason: "dispatched_to_relay",
|
|
4867
|
+
modified,
|
|
4868
|
+
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.",
|
|
4869
|
+
...(modified ? { transformations: (outboundVerdict.events ?? []).filter((e) => e.disposition === "redact") } : {}),
|
|
4870
|
+
};
|
|
4871
|
+
}
|
|
4386
4872
|
logger.info("session.content.sent", {
|
|
4387
4873
|
sessionId,
|
|
4388
4874
|
recipientPubkey,
|
|
@@ -4390,8 +4876,14 @@ export async function startDaemon(config) {
|
|
|
4390
4876
|
sequenceNumber: leafIndex,
|
|
4391
4877
|
correlationId,
|
|
4392
4878
|
});
|
|
4393
|
-
|
|
4394
|
-
|
|
4879
|
+
return {
|
|
4880
|
+
ok: true,
|
|
4881
|
+
sequence_number: leafIndex,
|
|
4882
|
+
delivered: true,
|
|
4883
|
+
modified,
|
|
4884
|
+
// On a redact, tell the agent exactly what was transformed (the §6 sender-side surface).
|
|
4885
|
+
...(modified ? { transformations: (outboundVerdict.events ?? []).filter((e) => e.disposition === "redact") } : {}),
|
|
4886
|
+
};
|
|
4395
4887
|
});
|
|
4396
4888
|
// ─── CELLO-M7-DAEMON-004 / F1-a: cello_receive (BLOCKING, session-scoped) ────────
|
|
4397
4889
|
// F1-a fix: the daemon port had dropped the blocking receive (the handler was a
|
|
@@ -4418,6 +4910,36 @@ export async function startDaemon(config) {
|
|
|
4418
4910
|
if (record.agent_name !== agentName) {
|
|
4419
4911
|
return { ok: false, reason: "session_not_owned", guidance: "This session belongs to a different agent. Call cello_use_agent to switch to the agent that owns it, then retry." };
|
|
4420
4912
|
}
|
|
4913
|
+
// M8C-SINCESEQ-1: stateless catch-up. When since_seq is provided, return a BATCH of received
|
|
4914
|
+
// transcript messages with sequence > since_seq (durable transcript, not the ephemeral buffer —
|
|
4915
|
+
// so concurrent arrivals don't shift what a given since_seq returns; no replay race). Replaces
|
|
4916
|
+
// the cello_get_transcript workaround for away-then-return. Received-direction only (the messages
|
|
4917
|
+
// you'd have gotten live). Advances the read watermark (delivery marks read — clears INBOX
|
|
4918
|
+
// unread). A distinct early branch: the plain (no since_seq) receive is entirely unchanged.
|
|
4919
|
+
const rawSince = params?.since_seq;
|
|
4920
|
+
if (typeof rawSince === "number" && Number.isFinite(rawSince)) {
|
|
4921
|
+
const sinceSeq = rawSince;
|
|
4922
|
+
const from = record.counterparty_pubkey;
|
|
4923
|
+
const { messages } = sessionNodeManager.readTranscript(agentName, sessionId);
|
|
4924
|
+
const received = messages.filter((m) => m.direction === "received" && m.sequence > sinceSeq);
|
|
4925
|
+
if (received.length > 0) {
|
|
4926
|
+
// readTranscript is ordered by sequence ASC → the last is the max.
|
|
4927
|
+
const maxSeq = received[received.length - 1].sequence;
|
|
4928
|
+
sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, maxSeq);
|
|
4929
|
+
clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
|
|
4930
|
+
}
|
|
4931
|
+
// M8C-CURSOR-1 (reviewer HIGH fix): only advance through the CONTIGUOUS run this batch
|
|
4932
|
+
// actually delivered — if a sent leaf from another local connection sits in a gap, this
|
|
4933
|
+
// correctly refuses to advance past it (cello_get_transcript is still required to catch up).
|
|
4934
|
+
safeCursorAdvance(connectionId, sessionId, new Set(received.map((m) => m.sequence)));
|
|
4935
|
+
logger.info("session.receive.since_seq", { sessionId, agentName, since_seq: sinceSeq, count: received.length });
|
|
4936
|
+
return {
|
|
4937
|
+
ok: true,
|
|
4938
|
+
since_seq: sinceSeq,
|
|
4939
|
+
count: received.length,
|
|
4940
|
+
messages: received.map((m) => ({ sequence: m.sequence, content: m.text, from })),
|
|
4941
|
+
};
|
|
4942
|
+
}
|
|
4421
4943
|
const rawTimeout = params?.timeout_ms;
|
|
4422
4944
|
const timeoutMs = typeof rawTimeout === "number" && Number.isFinite(rawTimeout) && rawTimeout >= 0
|
|
4423
4945
|
? rawTimeout
|
|
@@ -4430,6 +4952,11 @@ export async function startDaemon(config) {
|
|
|
4430
4952
|
// M8C-INBOX-1 (N3): delivery marks read — advance the persisted read watermark so this
|
|
4431
4953
|
// message no longer counts as unread in cello_check_notifications. Monotonic (never lowers).
|
|
4432
4954
|
sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, entry.sequenceNumber);
|
|
4955
|
+
clearTelegramRung(agentName, sessionId); // M8C-TGDOOR-1: read clears the ring
|
|
4956
|
+
// M8C-CURSOR-1 (reviewer HIGH fix): a single delivered message only proves THIS sequence
|
|
4957
|
+
// was read — safeCursorAdvance refuses to vault past a gap (e.g. an unread sent leaf from
|
|
4958
|
+
// another local connection) even though this specific sequence number is now known.
|
|
4959
|
+
safeCursorAdvance(connectionId, sessionId, new Set([entry.sequenceNumber]));
|
|
4433
4960
|
return {
|
|
4434
4961
|
ok: true,
|
|
4435
4962
|
content: Buffer.from(entry.contentHex, "hex").toString("utf8"),
|
|
@@ -4503,19 +5030,91 @@ export async function startDaemon(config) {
|
|
|
4503
5030
|
agentNames = [current];
|
|
4504
5031
|
}
|
|
4505
5032
|
const agents = agentNames.map((agent) => {
|
|
5033
|
+
reapExpiredInboundSessions(agent); // M8C-TTL-1: expired ones surface below, not as "pending"
|
|
4506
5034
|
const pending = (inboundSessionQueues.get(agent) ?? []).map((e) => ({
|
|
4507
5035
|
session_id: e.sessionIdHex,
|
|
4508
5036
|
from: e.counterpartyPubkeyHex,
|
|
4509
5037
|
}));
|
|
5038
|
+
// M8C-TTL-1: expired requests stay VISIBLE (not silently dropped) — the operator can see
|
|
5039
|
+
// what they missed rather than a request just vanishing from the pending list.
|
|
5040
|
+
const expired = (expiredSessionRequests.get(agent) ?? []).map((e) => ({
|
|
5041
|
+
session_id: e.sessionIdHex,
|
|
5042
|
+
from: e.counterpartyPubkeyHex,
|
|
5043
|
+
expired_at: e.expiredAt,
|
|
5044
|
+
}));
|
|
4510
5045
|
const unread = sessionNodeManager.getUnreadSummary(agent);
|
|
4511
5046
|
const total_unread = unread.reduce((sum, u) => sum + u.unread_count, 0);
|
|
4512
|
-
return { agent, pending_session_requests: pending, unread, total_unread };
|
|
5047
|
+
return { agent, pending_session_requests: pending, expired_session_requests: expired, unread, total_unread };
|
|
4513
5048
|
});
|
|
4514
5049
|
const totalUnread = agents.reduce((sum, a) => sum + a.total_unread, 0);
|
|
4515
5050
|
const totalPending = agents.reduce((sum, a) => sum + a.pending_session_requests.length, 0);
|
|
4516
|
-
|
|
5051
|
+
// M8C-TTL-1 (reviewer finding, D19): surface expired-log size so unbounded growth (were the
|
|
5052
|
+
// cap ever removed or misconfigured) would be visible here, not just in an internal Map.
|
|
5053
|
+
const totalExpired = agents.reduce((sum, a) => sum + a.expired_session_requests.length, 0);
|
|
5054
|
+
logger.info("inbox.checked", { connectionId, scope, agentCount: agents.length, totalUnread, totalPending, totalExpired });
|
|
4517
5055
|
return { ok: true, scope, agents };
|
|
4518
5056
|
});
|
|
5057
|
+
// M8C-CONTACT-1: cello contact add/remove/list [--agent <name>]. All three resolve the target
|
|
5058
|
+
// agent the same way — an explicit params.agent, else this connection's current/sole-online
|
|
5059
|
+
// agent (F18) — so a CLI/AI operator gets the same no_current_agent guidance INBOX already uses.
|
|
5060
|
+
function resolveContactAgent(connState, params) {
|
|
5061
|
+
const explicit = typeof params?.agent === "string" ? params.agent : undefined;
|
|
5062
|
+
if (explicit)
|
|
5063
|
+
return { ok: true, agent: explicit };
|
|
5064
|
+
const current = resolveCurrentAgent(connState);
|
|
5065
|
+
if (!current) {
|
|
5066
|
+
return {
|
|
5067
|
+
ok: false,
|
|
5068
|
+
reason: "no_current_agent",
|
|
5069
|
+
guidance: "No current agent for this connection. Pass --agent <name>, or call cello_use_agent to select one first.",
|
|
5070
|
+
};
|
|
5071
|
+
}
|
|
5072
|
+
return { ok: true, agent: current };
|
|
5073
|
+
}
|
|
5074
|
+
handlers.set("cello_contact_add", async (params, connectionId) => {
|
|
5075
|
+
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5076
|
+
if (!pubkey) {
|
|
5077
|
+
return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to add." };
|
|
5078
|
+
}
|
|
5079
|
+
const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
|
|
5080
|
+
if (!resolved.ok)
|
|
5081
|
+
return resolved;
|
|
5082
|
+
sessionNodeManager.addContact(resolved.agent, pubkey);
|
|
5083
|
+
logger.info("contact.added", { agent: resolved.agent, pubkey });
|
|
5084
|
+
return { ok: true, agent: resolved.agent, pubkey };
|
|
5085
|
+
});
|
|
5086
|
+
handlers.set("cello_contact_remove", async (params, connectionId) => {
|
|
5087
|
+
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5088
|
+
if (!pubkey) {
|
|
5089
|
+
return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) — the contact to remove." };
|
|
5090
|
+
}
|
|
5091
|
+
const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
|
|
5092
|
+
if (!resolved.ok)
|
|
5093
|
+
return resolved;
|
|
5094
|
+
const removed = sessionNodeManager.removeContact(resolved.agent, pubkey);
|
|
5095
|
+
logger.info("contact.removed", { agent: resolved.agent, pubkey, removed });
|
|
5096
|
+
return { ok: true, agent: resolved.agent, pubkey, removed };
|
|
5097
|
+
});
|
|
5098
|
+
handlers.set("cello_contact_list", async (params, connectionId) => {
|
|
5099
|
+
const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
|
|
5100
|
+
if (!resolved.ok)
|
|
5101
|
+
return resolved;
|
|
5102
|
+
const contacts = sessionNodeManager.listContacts(resolved.agent);
|
|
5103
|
+
return { ok: true, agent: resolved.agent, contacts };
|
|
5104
|
+
});
|
|
5105
|
+
// M8C-TGDOOR-1: cello_telegram_set_token — persist the daemon-wide bot token + allowlisted
|
|
5106
|
+
// operator chat ID, then start the poller immediately (no restart needed).
|
|
5107
|
+
handlers.set("cello_telegram_set_token", async (params, _connectionId) => {
|
|
5108
|
+
const botToken = typeof params?.bot_token === "string" ? params.bot_token : undefined;
|
|
5109
|
+
const chatId = typeof params?.allowlisted_chat_id === "string" ? params.allowlisted_chat_id : undefined;
|
|
5110
|
+
if (!botToken || !chatId) {
|
|
5111
|
+
return { ok: false, reason: "missing_params", guidance: "Provide 'bot_token' and 'allowlisted_chat_id'." };
|
|
5112
|
+
}
|
|
5113
|
+
sessionNodeManager.setTelegramSettings(botToken, chatId);
|
|
5114
|
+
startTelegramPollerIfConfigured(); // always bumps the generation — restarts even if already running
|
|
5115
|
+
logger.info("telegram.settings.updated", {});
|
|
5116
|
+
return { ok: true };
|
|
5117
|
+
});
|
|
4519
5118
|
let shutdownPromise = null;
|
|
4520
5119
|
handlers.set("shutdown", async (_params, _connectionId) => {
|
|
4521
5120
|
if (!shutdownPromise) {
|
|
@@ -4549,12 +5148,23 @@ export async function startDaemon(config) {
|
|
|
4549
5148
|
// used because the dispatcher is constructed AFTER the SessionNodeManager
|
|
4550
5149
|
// (it depends on the IPC server), so constructor injection would be circular.
|
|
4551
5150
|
sessionNodeManager.setOnSessionStateChanged((agentName, sessionId, state, counterpartyPubkey) => {
|
|
4552
|
-
|
|
5151
|
+
dispatchSessionStateChangedWithTelegram(agentName, sessionId, state, counterpartyPubkey);
|
|
5152
|
+
});
|
|
5153
|
+
// M8C-MSGWAKE-1 (channel stage 2): per-message wake — a verified inbound message fires a
|
|
5154
|
+
// content-free `cello_message` doorbell to the current-agent connection(s). The shim's generic
|
|
5155
|
+
// bridge (WAKE) forwards it to a live --channels session as notifications/claude/channel.
|
|
5156
|
+
sessionNodeManager.setOnContentArrived((agentName, sessionId, senderPubkey) => {
|
|
5157
|
+
notificationDispatcher.dispatchCelloMessage(agentName, sessionId, senderPubkey);
|
|
5158
|
+
// M8C-AWAY-1: an unattended agent auto-acks an inbound message on an existing session.
|
|
5159
|
+
void sendAwayResponse(agentName, sessionId, "message");
|
|
5160
|
+
// M8C-TGDOOR-1: message-waiting — coalesced (ring-once-until-read) inside sendTelegramDoorbell.
|
|
5161
|
+
void sendTelegramDoorbell(agentName, sessionId, "message_waiting", "New message waiting");
|
|
4553
5162
|
});
|
|
4554
5163
|
// MCP-001: Clean up per-connection state when a connection disconnects
|
|
4555
5164
|
// MCP-002: Also unregister from notification dispatcher
|
|
4556
5165
|
ipcServer.onDisconnect((connectionId) => {
|
|
4557
5166
|
perConnectionState.delete(connectionId);
|
|
5167
|
+
connectionCursors.delete(connectionId); // M8C-CURSOR-1: cursor is connection-scoped, dies with it
|
|
4558
5168
|
notificationDispatcher.unregisterConnection(connectionId);
|
|
4559
5169
|
// Seam 2 (review H2): evict any cello_await_session waiters owned by this connection.
|
|
4560
5170
|
// Otherwise enqueueInboundSession would hand the next inbound session to a closed
|
|
@@ -4592,6 +5202,9 @@ export async function startDaemon(config) {
|
|
|
4592
5202
|
// lifecycle is the daemon's, not any agent connection's — so it polls even with zero agents.
|
|
4593
5203
|
// Graceful shutdown
|
|
4594
5204
|
async function stop(reason) {
|
|
5205
|
+
// M8C-TGDOOR-1: stop the single long-lived getUpdates poller (no-op if never started) — bump
|
|
5206
|
+
// the generation so the running loop's while-condition fails on its next check.
|
|
5207
|
+
telegramPollerGeneration += 1;
|
|
4595
5208
|
// CELLO-M7-CONN-001: stop the HTTP manifest poll (sets the stopped flag so an in-flight
|
|
4596
5209
|
// tick cannot re-arm, and cancels the scheduler). Belt-and-suspenders cancel for the
|
|
4597
5210
|
// no-poll case (scheduler present but poll not started).
|
|
@@ -4618,6 +5231,9 @@ export async function startDaemon(config) {
|
|
|
4618
5231
|
function getAutoNatService() {
|
|
4619
5232
|
return autoNatService;
|
|
4620
5233
|
}
|
|
5234
|
+
// M8C-TGDOOR-1: cold-capable — start the poller if a token was already configured from a
|
|
5235
|
+
// prior run, without waiting for any agent to come online or any client to attach.
|
|
5236
|
+
startTelegramPollerIfConfigured();
|
|
4621
5237
|
return { stop, getStatus, getSessionNodeManager, getDirectoryNode, getTransportSelector, getAutoNatService };
|
|
4622
5238
|
}
|
|
4623
5239
|
//# sourceMappingURL=daemon.js.map
|