@cello-protocol/daemon 0.0.182 → 0.0.183
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-settings-keys.d.ts.map +1 -1
- package/dist/agent-settings-keys.js +18 -0
- package/dist/agent-settings-keys.js.map +1 -1
- package/dist/consortium-bootstrap.d.ts.map +1 -1
- package/dist/consortium-bootstrap.js +117 -2
- package/dist/consortium-bootstrap.js.map +1 -1
- package/dist/content-encryption-status.d.ts +97 -0
- package/dist/content-encryption-status.d.ts.map +1 -0
- package/dist/content-encryption-status.js +122 -0
- package/dist/content-encryption-status.js.map +1 -0
- package/dist/content-park-client.d.ts +10 -0
- package/dist/content-park-client.d.ts.map +1 -1
- package/dist/content-park-client.js +84 -3
- package/dist/content-park-client.js.map +1 -1
- package/dist/content-park.d.ts.map +1 -1
- package/dist/content-park.js +184 -13
- package/dist/content-park.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +306 -23
- package/dist/daemon.js.map +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +119 -0
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-layer.d.ts +0 -2
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js.map +1 -1
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +34 -3
- package/dist/initiate-session-handler.js.map +1 -1
- package/dist/manifest-deps.d.ts +0 -11
- package/dist/manifest-deps.d.ts.map +1 -1
- package/dist/manifest-deps.js +28 -2
- package/dist/manifest-deps.js.map +1 -1
- package/dist/manifest-validity.d.ts +2 -0
- package/dist/manifest-validity.d.ts.map +1 -1
- package/dist/manifest-validity.js +1 -1
- package/dist/manifest-validity.js.map +1 -1
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +19 -0
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/park-envelope.d.ts +23 -0
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +76 -0
- package/dist/park-envelope.js.map +1 -1
- package/dist/register-handler.d.ts.map +1 -1
- package/dist/register-handler.js +76 -0
- package/dist/register-handler.js.map +1 -1
- package/dist/relay-only.d.ts +133 -0
- package/dist/relay-only.d.ts.map +1 -0
- package/dist/relay-only.js +183 -0
- package/dist/relay-only.js.map +1 -0
- package/dist/retry-queue.d.ts +26 -1
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +18 -1
- package/dist/retry-queue.js.map +1 -1
- package/dist/session-ceremony.d.ts +6 -0
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +26 -0
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-content-handlers.d.ts +12 -0
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +39 -4
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +247 -4
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +3088 -304
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-read-handlers.d.ts.map +1 -1
- package/dist/session-read-handlers.js +16 -1
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-relay-client.d.ts +116 -0
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +395 -6
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-salt-agreement.d.ts +58 -4
- package/dist/session-salt-agreement.d.ts.map +1 -1
- package/dist/session-salt-agreement.js +110 -3
- package/dist/session-salt-agreement.js.map +1 -1
- package/dist/signaling-connect.d.ts +18 -1
- package/dist/signaling-connect.d.ts.map +1 -1
- package/dist/signaling-connect.js +164 -1
- package/dist/signaling-connect.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire-content-hash.d.ts +15 -3
- package/dist/wire-content-hash.d.ts.map +1 -1
- package/dist/wire-content-hash.js +15 -3
- package/dist/wire-content-hash.js.map +1 -1
- package/package.json +5 -5
package/dist/daemon.js
CHANGED
|
@@ -72,7 +72,7 @@ import { resolveDirectoryUrl } from "./directory-bootstrap.js";
|
|
|
72
72
|
import { registerContactHandlers } from "./contact-handlers.js";
|
|
73
73
|
import { createSealCoordinator } from "./seal-coordinator.js";
|
|
74
74
|
import { createTelegramDoorbell } from "./telegram-doorbell.js";
|
|
75
|
-
import { registerSessionContentHandlers } from "./session-content-handlers.js";
|
|
75
|
+
import { registerSessionContentHandlers, sentAuthorship } from "./session-content-handlers.js";
|
|
76
76
|
import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
|
|
77
77
|
import { isDocumentFrame } from "./document-frame-router.js";
|
|
78
78
|
import { INBOUND_INJECTION_BLOCKED } from "@cello-protocol/gateway";
|
|
@@ -110,6 +110,7 @@ import { countAttendance, ContentTakeLedger } from "./co-attendance.js";
|
|
|
110
110
|
import { isOwnAwayAutoReply, AWAY_AUTO_REPLY_TEXTS, markAsAutoReply, isAutoReplyMarked } from "./away-detection.js";
|
|
111
111
|
import { createDeliveryOpenRegistry } from "./delivery-open-registry.js";
|
|
112
112
|
import { FrontierMismatchStore, renderFrontierMismatch } from "./frontier-mismatch.js";
|
|
113
|
+
import { relayOnlyState } from "./relay-only.js";
|
|
113
114
|
import { decodeCbor } from "@cello-protocol/protocol-types";
|
|
114
115
|
// Minimal no-op KeyProvider stub for session nodes.
|
|
115
116
|
// Session nodes don't need signing keys — libp2p generates its own fresh
|
|
@@ -162,9 +163,15 @@ export class ProductionSessionNodeFactory {
|
|
|
162
163
|
listenAddresses: [listenAddr, ...(config.circuitRelayListenAddrs ?? [])],
|
|
163
164
|
...(announce ? { announceAddresses: announce } : {}),
|
|
164
165
|
connectionGater: config.connectionGater,
|
|
166
|
+
// DOD-M15-RELAYONLY-1: an agent that asked never to be directly reachable must not hole-punch
|
|
167
|
+
// its way to a direct connection. dcutr's job is to UPGRADE a relayed connection, and the
|
|
168
|
+
// INBOUND side starts that upgrade — which is precisely the standing receiver. So filtering
|
|
169
|
+
// what the directory publishes is not enough on its own: the address a peer cannot be TOLD, a
|
|
170
|
+
// hole-punch still REVEALS, and it happens inside libp2p after every assertion has passed.
|
|
171
|
+
...(config.relayOnly === true ? { holePunch: { enabled: false } } : {}),
|
|
165
172
|
// Forward the role. After DOD-NAT-REACHABILITY-1, dcutr is on every node
|
|
166
|
-
// type; nodeType's remaining transport effect is the HOP
|
|
167
|
-
// types never advertise circuit-relay HOP).
|
|
173
|
+
// type unless relay-only turns it off above; nodeType's remaining transport effect is the HOP
|
|
174
|
+
// gate (client types never advertise circuit-relay HOP).
|
|
168
175
|
nodeType: config.nodeType,
|
|
169
176
|
// DOD-M12B-SESSION-SEED-1: forward the caller's transport seed when it supplied one, so a
|
|
170
177
|
// rebuilt session node returns at the peer id the counterparty already holds. Omitted (rather
|
|
@@ -589,6 +596,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
589
596
|
for (const a of loadedAgents) {
|
|
590
597
|
keyProviders.set(a.name, a.keyProvider);
|
|
591
598
|
}
|
|
599
|
+
// DOD-M15-EPHEMERAL-AUTH-1: the session manager signs each session's throwaway key with the
|
|
600
|
+
// agent's identity, so it needs the same providers. Injected here rather than through the
|
|
601
|
+
// constructor because this map is built after the manager exists — the same reason
|
|
602
|
+
// `setParkedDrainHook` is a setter.
|
|
603
|
+
sessionNodeManager.setKeyProviderResolver((agentName) => keyProviders.get(agentName));
|
|
592
604
|
// Constructed HERE, before ANY boot-time caller. autoRecoverForAgent is invoked from an agent's
|
|
593
605
|
// onConnected and from the seal-upgrade content gate — both of which run long before the IPC
|
|
594
606
|
// handler map exists. Its handlers register later (phase 2), which is what lets this sit up here.
|
|
@@ -799,6 +811,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
799
811
|
onRelayEndpoints: (endpoints) => {
|
|
800
812
|
sessionNodeManager.setDirectoryRelayEndpoints(agentName, endpoints.map((e) => ({ relayPeerId: e.peerId, relayAddrs: e.addrs })));
|
|
801
813
|
},
|
|
814
|
+
// DOD-M15-RELAYSLOTS-1: and the credential those relays require. Same frame, same cadence as
|
|
815
|
+
// the endpoints above — every connect and every reconnect, which is what keeps a token that
|
|
816
|
+
// expires within the hour current for a receiver that lives much longer than that.
|
|
817
|
+
onOnlineToken: (token) => {
|
|
818
|
+
sessionNodeManager.setDirectoryOnlineToken(agentName, token);
|
|
819
|
+
},
|
|
820
|
+
// Review M1: and when there is none, WHY — so the operator surface can say "this directory
|
|
821
|
+
// does not know this agent" instead of "check your directory connection" about a connection
|
|
822
|
+
// that just succeeded.
|
|
823
|
+
onOnlineTokenAbsent: (reason) => {
|
|
824
|
+
sessionNodeManager.setDirectoryOnlineTokenAbsent(agentName, reason);
|
|
825
|
+
},
|
|
802
826
|
});
|
|
803
827
|
const mgr = new SignalingManager({
|
|
804
828
|
connect,
|
|
@@ -870,6 +894,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
870
894
|
agentName,
|
|
871
895
|
getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agentName),
|
|
872
896
|
admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agentName, peerId, sessionIdHex),
|
|
897
|
+
// DOD-M15-RELAYONLY-1: lets the handler tell "no addresses because relay-only filtered them"
|
|
898
|
+
// from "no addresses yet", which need opposite answers — a refusal, and the pre-existing path.
|
|
899
|
+
isRelayOnly: () => relayOnlyState((key) => sessionNodeManager.getSetting(agentName, key), sessionNodeManager.hasDatabase()) !== "off",
|
|
873
900
|
signaling: mgr,
|
|
874
901
|
logger,
|
|
875
902
|
});
|
|
@@ -944,6 +971,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
944
971
|
agentName: agent.name,
|
|
945
972
|
getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
|
|
946
973
|
admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agent.name, peerId, sessionIdHex),
|
|
974
|
+
// DOD-M15-RELAYONLY-1 — see the note on the sibling call site above.
|
|
975
|
+
isRelayOnly: () => relayOnlyState((key) => sessionNodeManager.getSetting(agent.name, key), sessionNodeManager.hasDatabase()) !== "off",
|
|
947
976
|
signaling: mgr,
|
|
948
977
|
logger,
|
|
949
978
|
});
|
|
@@ -1280,9 +1309,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1280
1309
|
// in the codebase for that — the seal is initiated a few lines below, so a leaf at the
|
|
1281
1310
|
// wrong index does not merely stall the far side, it seals a tree the counterparty can
|
|
1282
1311
|
// never agree with.
|
|
1283
|
-
|
|
1312
|
+
// The proof travels with the leaf, not only with the transcript row below: when this
|
|
1313
|
+
// append is HELD behind a gap, the `recordTranscriptMessage` call is skipped entirely
|
|
1314
|
+
// and the held entry is the only thing that reaches the row on release.
|
|
1315
|
+
const placed = sessionNodeManager.placeOwnLeaf(agentName, sessionId, rejectHashHex, rejectBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
|
|
1284
1316
|
if (placed.placed) {
|
|
1285
|
-
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID());
|
|
1317
|
+
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID(), sentAuthorship(sendResult));
|
|
1286
1318
|
}
|
|
1287
1319
|
logger.info("session.away.inbox.oneshot.rejected", { agentName, sessionId, sequenceNumber: placed.placed ? placed.leafIndex : placed.heldAt, committed: placed.placed, queued: !sendResult.ok });
|
|
1288
1320
|
}
|
|
@@ -1503,9 +1535,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1503
1535
|
// next arrival would mint a second greeting at a second sequence.
|
|
1504
1536
|
// DOD-M12B-INDEX-1: the queued reply owns the position the relay witnessed for it, and
|
|
1505
1537
|
// that is where its leaf goes.
|
|
1506
|
-
|
|
1538
|
+
// Witnessed and SIGNED — only the direct hand-off failed — so the proof exists and must
|
|
1539
|
+
// reach the leaf. This is the site `sentAuthorship`'s own comment calls dead-by-construction
|
|
1540
|
+
// under an `ok`-gated read; the same reasoning applies to the leaf, not just the row.
|
|
1541
|
+
const placedQueued = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
|
|
1507
1542
|
if (placedQueued.placed) {
|
|
1508
|
-
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID());
|
|
1543
|
+
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID(), sentAuthorship(sendResult));
|
|
1509
1544
|
}
|
|
1510
1545
|
logger.info("session.away.response.deferred", {
|
|
1511
1546
|
agentName, sessionId, kind, isKnown,
|
|
@@ -1517,9 +1552,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1517
1552
|
}
|
|
1518
1553
|
// DOD-M12B-INDEX-1: the away responder fires while inbound is still arriving, so it is the
|
|
1519
1554
|
// path most likely to have a gap open under it — exactly where a tail append does damage.
|
|
1520
|
-
const placedReply = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID());
|
|
1555
|
+
const placedReply = sessionNodeManager.placeOwnLeaf(agentName, sessionId, contentHashHex, contentBytes, sendResult.sequenceNumber, randomUUID(), "msg", sentAuthorship(sendResult));
|
|
1521
1556
|
if (placedReply.placed) {
|
|
1522
|
-
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID());
|
|
1557
|
+
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID(), sentAuthorship(sendResult));
|
|
1523
1558
|
}
|
|
1524
1559
|
logger.info("session.away.response.sent", {
|
|
1525
1560
|
agentName, sessionId, kind, isKnown,
|
|
@@ -1752,6 +1787,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1752
1787
|
// seal depends on — silently, while reporting success.
|
|
1753
1788
|
receiptStore: stores.receiptStore,
|
|
1754
1789
|
sealLeafStore: stores.sealLeafStore,
|
|
1790
|
+
// DOD-M15-RELAYSLOTS-1: the manager owns the current token and hands the accessor down, so
|
|
1791
|
+
// this client reads a fresh one at every auth instead of a snapshot taken here at build time.
|
|
1792
|
+
onlineToken: stores.onlineToken,
|
|
1755
1793
|
});
|
|
1756
1794
|
});
|
|
1757
1795
|
// DOD-RETRYQ-STRAND-1: a direct-resend row is reachable only by drainSession, which has no
|
|
@@ -1847,6 +1885,54 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1847
1885
|
logger.warn("session.seal.autoack.broker.unreachable", { agentName, brokerNode, correlationId });
|
|
1848
1886
|
return null;
|
|
1849
1887
|
});
|
|
1888
|
+
/**
|
|
1889
|
+
* DOD-M15-RELAYABUSE-1: scheduled park-retry timers, tracked so shutdown can clear them.
|
|
1890
|
+
*
|
|
1891
|
+
* ⚠️ Every other daemon timer is cleared in `stop()`; an untracked one lets an in-process restart
|
|
1892
|
+
* leave a stale timer that drains into a torn-down manager. Unref'd already, so it cannot hold the
|
|
1893
|
+
* process open — this is about a clean teardown, not about exiting.
|
|
1894
|
+
*/
|
|
1895
|
+
const parkRetryTimers = new Set();
|
|
1896
|
+
/**
|
|
1897
|
+
* DOD-M15-RELAYABUSE-1: schedule ONE drain at the delay the relay asked for.
|
|
1898
|
+
*
|
|
1899
|
+
* ⚠️ ONE implementation, used by both park paths. The live-send path and the drain path both need
|
|
1900
|
+
* this and a second copy is how the clamp ends up on only one of them — which is exactly the shape
|
|
1901
|
+
* review found here (the number had a consumer on one path and was dropped on the other).
|
|
1902
|
+
*
|
|
1903
|
+
* CLAMPED, because the value comes from another party's software and an unclamped one inverts the
|
|
1904
|
+
* fix: Node's `setTimeout` holds an int32, so a relay reporting `3_000_000_000` overflows it and
|
|
1905
|
+
* fires in about ONE MILLISECOND — re-parking instantly into the limit that just refused, and
|
|
1906
|
+
* printing a `TimeoutOverflowWarning` from a daemon whose convention forbids console output.
|
|
1907
|
+
*/
|
|
1908
|
+
function scheduleParkRetry(retryAfterMs, filterAgentName, source) {
|
|
1909
|
+
const MIN_RETRY_MS = 1_000;
|
|
1910
|
+
const MAX_RETRY_MS = 5 * 60_000;
|
|
1911
|
+
const delay = Math.min(Math.max(retryAfterMs, MIN_RETRY_MS), MAX_RETRY_MS);
|
|
1912
|
+
if (delay !== retryAfterMs) {
|
|
1913
|
+
logger.warn("content.park.retry.clamped", {
|
|
1914
|
+
source,
|
|
1915
|
+
requestedMs: retryAfterMs,
|
|
1916
|
+
usedMs: delay,
|
|
1917
|
+
impact: "the relay asked for a retry delay outside the range this daemon will schedule, so it was " +
|
|
1918
|
+
"clamped — an unclamped value can overflow the timer and fire immediately, which would " +
|
|
1919
|
+
"re-park straight into the limit that just refused",
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
logger.info("content.park.retry.scheduled", { source, delayMs: delay, ...(filterAgentName !== undefined ? { agentName: filterAgentName } : {}) });
|
|
1923
|
+
const timer = setTimeout(() => {
|
|
1924
|
+
parkRetryTimers.delete(timer);
|
|
1925
|
+
void flushAwaitingContent(filterAgentName).catch((err) => {
|
|
1926
|
+
logger.warn("content.park.retry.timer.failed", {
|
|
1927
|
+
source,
|
|
1928
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
1929
|
+
impact: "the scheduled drain threw; the ordinary event triggers (boot, agent start, reconnect) still apply",
|
|
1930
|
+
});
|
|
1931
|
+
});
|
|
1932
|
+
}, delay);
|
|
1933
|
+
timer.unref?.();
|
|
1934
|
+
parkRetryTimers.add(timer);
|
|
1935
|
+
}
|
|
1850
1936
|
sessionNodeManager.setContentParkHook(async ({ agentName, sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg }) => {
|
|
1851
1937
|
const node = sessionNodeManager.getStandingReceiverNode();
|
|
1852
1938
|
if (!node) {
|
|
@@ -1885,8 +1971,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1885
1971
|
// `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
|
|
1886
1972
|
// MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
|
|
1887
1973
|
// is salted is a fact about the message that was sent; what this side holds now says nothing
|
|
1888
|
-
// about it.
|
|
1889
|
-
//
|
|
1974
|
+
// about it.
|
|
1975
|
+
//
|
|
1976
|
+
// ⛔ THIS COMMENT USED TO SAY "Still `sha256` everywhere, because no send path salts yet — the
|
|
1977
|
+
// plumbing is proven carrying the value that cannot break anything, and only then does the value
|
|
1978
|
+
// change." That described B2b, and B2b-2 then CHANGED THE VALUE: a session holding an agreed
|
|
1979
|
+
// salt hashes under `hmac-sha256-salt-v1`, so this path really does carry a salted algorithm
|
|
1980
|
+
// now. Rewritten rather than deleted — a stale reassurance is what `CLAIM-COMMENTS-1` is for,
|
|
1981
|
+
// and on 2026-08-24 a test declared `sha256` on the strength of this sentence and got a tamper
|
|
1982
|
+
// verdict on an honest message.
|
|
1890
1983
|
const ciphertext = await sealParkEnvelope({
|
|
1891
1984
|
signer: senderKp,
|
|
1892
1985
|
sessionIdHex: sessionId,
|
|
@@ -1910,8 +2003,48 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1910
2003
|
logger.info("content.park.deposited", { sessionId, contentHash: contentHashHex, recipientPubkey: recipientPubkeyHex.slice(0, 16) });
|
|
1911
2004
|
return { ok: true };
|
|
1912
2005
|
}
|
|
1913
|
-
logger.warn("content.park.deposit.failed", {
|
|
1914
|
-
|
|
2006
|
+
logger.warn("content.park.deposit.failed", {
|
|
2007
|
+
sessionId,
|
|
2008
|
+
contentHash: contentHashHex,
|
|
2009
|
+
reason: res.reason,
|
|
2010
|
+
...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
|
|
2011
|
+
});
|
|
2012
|
+
/**
|
|
2013
|
+
* DOD-M15-RELAYABUSE-1 — **GIVE THE RELAY'S "WHEN" A CONSUMER.**
|
|
2014
|
+
*
|
|
2015
|
+
* A deferred park is otherwise retried only on EVENTS — boot, agent start, the drain hook, a
|
|
2016
|
+
* signaling reconnect. None of those is coming for a throttle: the relay is healthy, the link
|
|
2017
|
+
* never dropped, and the condition clears on a timer nobody is watching. So the one refusal that
|
|
2018
|
+
* self-heals in about a minute was the one that waited longest, purely because the number the
|
|
2019
|
+
* relay had already computed had no reader.
|
|
2020
|
+
*
|
|
2021
|
+
* Scheduled ONCE per refusal, unref'd so it can never hold the process open, and best-effort:
|
|
2022
|
+
* the existing event triggers remain the guarantee, and this is a shortcut on top of them. It
|
|
2023
|
+
* deliberately does not retry-on-retry — a timer that reschedules itself on failure is a
|
|
2024
|
+
* self-inflicted flood, which is what the limiter exists to stop.
|
|
2025
|
+
*/
|
|
2026
|
+
if (res.retryAfterMs !== undefined && res.retryAfterMs > 0) {
|
|
2027
|
+
/**
|
|
2028
|
+
* ⚠️ CLAMPED, because the number comes from ANOTHER PARTY'S SOFTWARE and an unclamped one is
|
|
2029
|
+
* not merely wrong — it inverts the fix. Node's `setTimeout` holds an int32: a relay reporting
|
|
2030
|
+
* `3_000_000_000` overflows it and the timer fires in about **one millisecond**, re-parking
|
|
2031
|
+
* instantly into the limit that just refused, and printing a `TimeoutOverflowWarning` on
|
|
2032
|
+
* stderr from a daemon whose convention forbids console output. A reported `1` does the same
|
|
2033
|
+
* thing 250 ms later.
|
|
2034
|
+
*
|
|
2035
|
+
* The floor is a second and the ceiling five minutes: long enough that a shortcut is a
|
|
2036
|
+
* shortcut, short enough that it stays one. A clamp is announced rather than silent — a relay
|
|
2037
|
+
* asking us to wait 35 days is a fact an operator wants.
|
|
2038
|
+
*/
|
|
2039
|
+
scheduleParkRetry(res.retryAfterMs, agentName, "send");
|
|
2040
|
+
}
|
|
2041
|
+
return {
|
|
2042
|
+
ok: false,
|
|
2043
|
+
reason: res.reason ?? "relay_deposit_failed",
|
|
2044
|
+
// DOD-M15-RELAYABUSE-1 review MEDIUM-6: the guidance quotes the relay's OWN window instead of
|
|
2045
|
+
// guessing "about a minute", which is a hardcoded assumption about a configurable value.
|
|
2046
|
+
...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
|
|
2047
|
+
};
|
|
1915
2048
|
});
|
|
1916
2049
|
// CELLO-M7-MSG-001 (AC-004/AC-005, D-d): startup flush of locally-persisted un-acked
|
|
1917
2050
|
// content (the crash backstop). Runs HERE — before the IPC socket opens, consistent
|
|
@@ -1972,8 +2105,15 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1972
2105
|
// `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
|
|
1973
2106
|
// MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
|
|
1974
2107
|
// is salted is a fact about the message that was sent; what this side holds now says nothing
|
|
1975
|
-
// about it.
|
|
1976
|
-
//
|
|
2108
|
+
// about it.
|
|
2109
|
+
//
|
|
2110
|
+
// ⛔ THIS COMMENT USED TO SAY "Still `sha256` everywhere, because no send path salts yet — the
|
|
2111
|
+
// plumbing is proven carrying the value that cannot break anything, and only then does the value
|
|
2112
|
+
// change." That described B2b, and B2b-2 then CHANGED THE VALUE: a session holding an agreed
|
|
2113
|
+
// salt hashes under `hmac-sha256-salt-v1`, so this path really does carry a salted algorithm
|
|
2114
|
+
// now. Rewritten rather than deleted — a stale reassurance is what `CLAIM-COMMENTS-1` is for,
|
|
2115
|
+
// and on 2026-08-24 a test declared `sha256` on the strength of this sentence and got a tamper
|
|
2116
|
+
// verdict on an honest message.
|
|
1977
2117
|
const ciphertext = await sealParkEnvelope({
|
|
1978
2118
|
signer: senderKp,
|
|
1979
2119
|
sessionIdHex: entry.sessionId,
|
|
@@ -1997,7 +2137,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1997
2137
|
logger.info("content.park.deposited", { sessionId: entry.sessionId, contentHash: entry.contentHashHex, source: "startup_flush" });
|
|
1998
2138
|
return { parked: true };
|
|
1999
2139
|
}
|
|
2000
|
-
return {
|
|
2140
|
+
return {
|
|
2141
|
+
parked: false,
|
|
2142
|
+
error: res.reason ?? "deposit_failed",
|
|
2143
|
+
// DOD-M15-RELAYABUSE-1 review HIGH-2: carry the relay's own "when" out of the DRAIN path too.
|
|
2144
|
+
...(res.retryAfterMs !== undefined ? { retryAfterMs: res.retryAfterMs } : {}),
|
|
2145
|
+
};
|
|
2001
2146
|
};
|
|
2002
2147
|
// Re-park un-acked awaiting content to the relay store-and-forward queue. Runs once pre-IPC
|
|
2003
2148
|
// (the crash backstop) and again per-agent when an agent comes online — because post-DOD-LOOP-1
|
|
@@ -2055,7 +2200,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2055
2200
|
let parkedTotal = 0;
|
|
2056
2201
|
for (const s of sessions) {
|
|
2057
2202
|
try {
|
|
2058
|
-
parkedTotal += await retryQueue.drainAwaitingToPark(s.agentId, s.sessionId, parkFn)
|
|
2203
|
+
parkedTotal += await retryQueue.drainAwaitingToPark(s.agentId, s.sessionId, parkFn, (retryAfterMs) => {
|
|
2204
|
+
/**
|
|
2205
|
+
* DOD-M15-RELAYABUSE-1 review HIGH-2 — **the drain path can now schedule its own
|
|
2206
|
+
* follow-up, which is what makes the retry work for a BACKLOG rather than one message.**
|
|
2207
|
+
*
|
|
2208
|
+
* Before this, only the live send path heard the relay's delay. With a backlog larger than
|
|
2209
|
+
* one rate-limit window, the drain deposited what the window allowed, every remaining item
|
|
2210
|
+
* was refused, and all of them fell back to waiting for an unrelated reconnect — the exact
|
|
2211
|
+
* condition the retry timer was added to remove.
|
|
2212
|
+
*
|
|
2213
|
+
* ONE timer per pass (the drain reports the largest delay it saw, not one per item), and
|
|
2214
|
+
* it deliberately does not chain beyond that: the next pass schedules the next one only if
|
|
2215
|
+
* it is refused again, so a permanently-full relay costs one timer per window rather than
|
|
2216
|
+
* an accelerating stream of them.
|
|
2217
|
+
*/
|
|
2218
|
+
scheduleParkRetry(retryAfterMs, filterAgentName, "drain");
|
|
2219
|
+
});
|
|
2059
2220
|
}
|
|
2060
2221
|
catch (err) {
|
|
2061
2222
|
logger.error("content.park.flush.failed", {
|
|
@@ -2163,6 +2324,19 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2163
2324
|
// (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
|
|
2164
2325
|
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
|
|
2165
2326
|
standing_receiver_reachability: sessionNodeManager.getStandingReceiverReachability(a.name),
|
|
2327
|
+
/**
|
|
2328
|
+
* DOD-M15-RELAYSLOTS-1: WHY it is not reachable, and what to do about it.
|
|
2329
|
+
*
|
|
2330
|
+
* `standing_receiver_reachability` says `retrying` or `unreachable` and stops there, which
|
|
2331
|
+
* for the person reading it is indistinguishable from the product being broken. The relay
|
|
2332
|
+
* now refuses for reasons someone can act on — no token from a directory yet, too many
|
|
2333
|
+
* sessions still open, this relay is misconfigured — each with a different next step, and
|
|
2334
|
+
* every one of them is wasted if it only reaches a log file. Absent when the last attempt
|
|
2335
|
+
* succeeded.
|
|
2336
|
+
*/
|
|
2337
|
+
...(sessionNodeManager.getStandingReceiverRefusal(a.name)
|
|
2338
|
+
? { standing_receiver_refusal: sessionNodeManager.getStandingReceiverRefusal(a.name) }
|
|
2339
|
+
: {}),
|
|
2166
2340
|
// DOD-COATTEND-VISIBLE-1 AC2: how many sessions are driving this agent, including this
|
|
2167
2341
|
// one. Live, not a high-water mark — it drops when a session disconnects. `selected` says
|
|
2168
2342
|
// whether YOU hold it; this says whether anyone else does too.
|
|
@@ -2403,6 +2577,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2403
2577
|
state: agentStateFor(a),
|
|
2404
2578
|
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
|
|
2405
2579
|
standing_receiver_reachability: sessionNodeManager.getStandingReceiverReachability(a.name),
|
|
2580
|
+
// DOD-M15-RELAYSLOTS-1: the same cause-and-advice on the daemon-wide surface — see the note
|
|
2581
|
+
// on the MCP one above. Two surfaces, one reason to exist.
|
|
2582
|
+
...(sessionNodeManager.getStandingReceiverRefusal(a.name)
|
|
2583
|
+
? { standing_receiver_refusal: sessionNodeManager.getStandingReceiverRefusal(a.name) }
|
|
2584
|
+
: {}),
|
|
2406
2585
|
})),
|
|
2407
2586
|
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
|
|
2408
2587
|
retryQueueDepth: retryQueue.getTotalDepth(),
|
|
@@ -2434,14 +2613,37 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2434
2613
|
// Permissive by design (D12): an agent that exists goes online regardless of directory
|
|
2435
2614
|
// registration state (online-without-registration is an established contract). Returns a
|
|
2436
2615
|
// structured failure so callers can surface agent_start_failed with a real reason + guidance.
|
|
2616
|
+
/**
|
|
2617
|
+
* `standing_receiver` is part of the SUCCESS shape, not an optional extra — `DOD-M15-START-AGENT-
|
|
2618
|
+
* UNAWAITED-1`. A bare `{ ok: true }` claimed the agent was started and reachable when only the
|
|
2619
|
+
* first half was known, and the union makes the two states impossible to conflate at a call site.
|
|
2620
|
+
*/
|
|
2437
2621
|
function startAgentInternal(name) {
|
|
2438
2622
|
const agent = agents.find((a) => a.name === name);
|
|
2439
2623
|
if (!agent || agent.state === "load_failed") {
|
|
2440
2624
|
return { ok: false, reason: "agent_not_found", guidance: `Agent '${name}' does not exist. Run 'cello login' to register agents, or check agent names with cello_agents.` };
|
|
2441
2625
|
}
|
|
2442
2626
|
if (onlineAgents.has(name)) {
|
|
2443
|
-
// Idempotent — already online, no event
|
|
2444
|
-
|
|
2627
|
+
// Idempotent — already online, no event.
|
|
2628
|
+
//
|
|
2629
|
+
// It still reports REAL readiness rather than a bare ok. "Already online" says this daemon
|
|
2630
|
+
// marked the agent online at some earlier moment; it says nothing about whether the receiver
|
|
2631
|
+
// that ensure was firing ever came up. An operator who calls start twice — which is exactly
|
|
2632
|
+
// what someone does when the first one seemed not to work — would otherwise get the most
|
|
2633
|
+
// reassuring answer in the run on the attempt where something is actually wrong.
|
|
2634
|
+
const readyNow = sessionNodeManager.getStandingReceiverInfo(name) !== null;
|
|
2635
|
+
if (readyNow)
|
|
2636
|
+
return { ok: true, standing_receiver: "ready" };
|
|
2637
|
+
const cause = sessionNodeManager.standingReceiverAbsenceReason(name);
|
|
2638
|
+
return {
|
|
2639
|
+
ok: true,
|
|
2640
|
+
standing_receiver: "starting",
|
|
2641
|
+
standing_receiver_cause: cause,
|
|
2642
|
+
guidance: `'${name}' was already online, and its standing receiver is not up (${cause}). Outbound ` +
|
|
2643
|
+
`sends and cello_initiate_session ensure it on demand. An inbound session arriving before ` +
|
|
2644
|
+
`it is ready is refused with 'standing_receiver_unavailable' — this daemon, not the ` +
|
|
2645
|
+
`counterparty. If it stays this way, stop the agent and start it again.`,
|
|
2646
|
+
};
|
|
2445
2647
|
}
|
|
2446
2648
|
onlineAgents.add(name);
|
|
2447
2649
|
// Pressing start clears the deliberate-offline mark — that is what makes the switch reversible.
|
|
@@ -2467,7 +2669,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2467
2669
|
void sessionNodeManager.ensureStandingReceiverForAgent(name)
|
|
2468
2670
|
.then(() => flushAwaitingContent(name))
|
|
2469
2671
|
.catch((err) => {
|
|
2470
|
-
logger.warn("session.standing_receiver.ensure.failed", {
|
|
2672
|
+
logger.warn("session.standing_receiver.ensure.failed", {
|
|
2673
|
+
agentName: name,
|
|
2674
|
+
reason: extractErrorMessage(err),
|
|
2675
|
+
// `DOD-M15-START-AGENT-UNAWAITED-1`. The operator has ALREADY been told `ok: true` — this
|
|
2676
|
+
// handler answered before this promise settled — so nothing corrects that answer if this
|
|
2677
|
+
// is permanent. Say what it costs them here, because this line is the only account.
|
|
2678
|
+
impact: "cello_start_agent already answered ok for this agent, and its standing receiver did not " +
|
|
2679
|
+
"come up. The agent is online to the directory and CANNOT accept an inbound session: a " +
|
|
2680
|
+
"counterparty dialling it is refused standing_receiver_unavailable. Initiate and accept " +
|
|
2681
|
+
"each re-ensure on demand, so this may still recover on the next attempt; if it does not, " +
|
|
2682
|
+
"stop and restart the agent.",
|
|
2683
|
+
});
|
|
2471
2684
|
})
|
|
2472
2685
|
// DOD-MSG-4 (auto-recover-on-reconnect): RECEIVER drains its parked mailbox from every relay it
|
|
2473
2686
|
// has sessions on (symmetric to the sender re-park). Its own stage so a failure is labelled
|
|
@@ -2477,10 +2690,51 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2477
2690
|
.catch((err) => {
|
|
2478
2691
|
logger.warn("content.recover.auto.failed", { agentName: name, stage: "agent_start", error: extractErrorMessage(err) });
|
|
2479
2692
|
});
|
|
2480
|
-
|
|
2693
|
+
/**
|
|
2694
|
+
* `DOD-M15-START-AGENT-UNAWAITED-1` — SAY WHETHER THE AGENT CAN ACTUALLY HEAR YET.
|
|
2695
|
+
*
|
|
2696
|
+
* The ensure above is fire-and-forget and that is deliberate: initiate and accept both ensure on
|
|
2697
|
+
* demand, and awaiting it here would turn a transient network failure into a failed start. **The
|
|
2698
|
+
* defect was never the timing — it was the CLAIM.** `{ ok: true }` with nothing else reads as
|
|
2699
|
+
* "your agent is running and reachable", and a session landing in the window before the receiver
|
|
2700
|
+
* exists is refused `standing_receiver_unavailable` — a precondition on OUR side, surfacing to
|
|
2701
|
+
* the operator as though the counterparty or the directory were at fault.
|
|
2702
|
+
*
|
|
2703
|
+
* ⚠️ **This field is only worth having because it can genuinely say `ready`.** Computed one line
|
|
2704
|
+
* after firing an async ensure, a naive readiness flag would be `starting` on every call — a
|
|
2705
|
+
* field that can never take its other value, which is the same defect as a log line reporting a
|
|
2706
|
+
* verdict its producer cannot have. It escapes that because `ensureStandingReceiverForAgent` is
|
|
2707
|
+
* IDEMPOTENT: an agent that already holds a receiver (a repeat start, or one whose receiver
|
|
2708
|
+
* survived) has one at this instant and reports `ready` truthfully.
|
|
2709
|
+
*
|
|
2710
|
+
* `cause` is read from the same four-way answer the refusal path uses, so the response and the
|
|
2711
|
+
* eventual error agree instead of describing the same state in two vocabularies.
|
|
2712
|
+
*/
|
|
2713
|
+
const receiverReady = sessionNodeManager.getStandingReceiverInfo(name) !== null;
|
|
2714
|
+
const startingCause = receiverReady ? undefined : sessionNodeManager.standingReceiverAbsenceReason(name);
|
|
2715
|
+
logger.info("agent.online", {
|
|
2716
|
+
agentName: name,
|
|
2717
|
+
agentPubkey: agent.pubkey ?? "",
|
|
2718
|
+
standingReceiver: receiverReady ? "ready" : "starting",
|
|
2719
|
+
...(startingCause !== undefined ? { standingReceiverCause: startingCause } : {}),
|
|
2720
|
+
});
|
|
2481
2721
|
// MCP-002: Broadcast agent_state_changed to ALL connections
|
|
2482
2722
|
notificationDispatcher.dispatchAgentStateChanged(name, "online", "started");
|
|
2483
|
-
|
|
2723
|
+
if (receiverReady)
|
|
2724
|
+
return { ok: true, standing_receiver: "ready" };
|
|
2725
|
+
return {
|
|
2726
|
+
ok: true,
|
|
2727
|
+
standing_receiver: "starting",
|
|
2728
|
+
standing_receiver_cause: startingCause,
|
|
2729
|
+
// Invariant: an agent-facing response carries an affordance. Naming the refusal text is the
|
|
2730
|
+
// load-bearing half — an operator who hits it in the next second can otherwise only conclude
|
|
2731
|
+
// the other side is broken.
|
|
2732
|
+
guidance: `'${name}' is online and its standing receiver is still being built. Outbound sends and ` +
|
|
2733
|
+
`cello_initiate_session ensure it on demand, so ordinary use is fine. A session arriving in ` +
|
|
2734
|
+
`the next moment can be refused with 'standing_receiver_unavailable' — that is this daemon ` +
|
|
2735
|
+
`not being ready yet, NOT the counterparty being unreachable. It clears on its own; ` +
|
|
2736
|
+
`cello_status reports the receiver once it is up.`,
|
|
2737
|
+
};
|
|
2484
2738
|
}
|
|
2485
2739
|
// M8C-AUTOSTART-1 (F18): resolve which agent an agent-defaulting tool should act on for this
|
|
2486
2740
|
// connection: an explicit { agent } wins; else the connection's current agent; else — when EXACTLY one
|
|
@@ -4535,7 +4789,31 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4535
4789
|
// (deliberate — f75ea09), so it obeys the same discipline a message does — including
|
|
4536
4790
|
// being HELD when the position is ahead of the tail, which is why the real frame bytes
|
|
4537
4791
|
// have to travel with it.
|
|
4538
|
-
const placed = sessionNodeManager.placeOwnLeaf(agent, sessionId, Buffer.from(contentHash).toString("hex"), frameBytes, assignedSeq, correlationId, "doc"
|
|
4792
|
+
const placed = sessionNodeManager.placeOwnLeaf(agent, sessionId, Buffer.from(contentHash).toString("hex"), frameBytes, assignedSeq, correlationId, "doc",
|
|
4793
|
+
/**
|
|
4794
|
+
* No proof — and the reason I first wrote here was FALSE, which review traced rather
|
|
4795
|
+
* than read.
|
|
4796
|
+
*
|
|
4797
|
+
* ⚠️ IT SAID *"the document transport does not go through `sendContent`, so no Structure-1
|
|
4798
|
+
* was signed."* Both halves are wrong. `document-delivery-transport.ts` calls
|
|
4799
|
+
* `deps.sendContent(...)`, wired straight to `sessionNodeManager.sendContent` a few lines
|
|
4800
|
+
* above this; and `session-relay-client.ts` signs the Structure-1 with no `leafKind` gate
|
|
4801
|
+
* at all, so a `0x04` doc leaf is signed exactly like a message. **A proof exists and is
|
|
4802
|
+
* discarded here.**
|
|
4803
|
+
*
|
|
4804
|
+
* That matters more than a wrong comment usually would, because this unit's whole thesis
|
|
4805
|
+
* is *"`undefined` is a claim the author made rather than one the signature made for
|
|
4806
|
+
* them"* — and the first claim made under the new signature was untrue.
|
|
4807
|
+
*
|
|
4808
|
+
* THE TRUE REASON, which is a better one: **no consumer.** A doc leaf released from a
|
|
4809
|
+
* hold writes no transcript row — `#releaseHeld` skips `recordTranscriptMessage` for
|
|
4810
|
+
* `kind === "doc"` — so there is nothing for the proof to reach. Discarding it is
|
|
4811
|
+
* no-consumer-no-ship, deliberately.
|
|
4812
|
+
*
|
|
4813
|
+
* **If doc rows ever reach the transcript, `appendLeaf` needs an authorship parameter**
|
|
4814
|
+
* and this `undefined` becomes a defect rather than a decision.
|
|
4815
|
+
*/
|
|
4816
|
+
undefined);
|
|
4539
4817
|
return { placed: placed.placed, leafIndex: placed.placed ? placed.leafIndex : null };
|
|
4540
4818
|
},
|
|
4541
4819
|
});
|
|
@@ -4781,6 +5059,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4781
5059
|
async function stop(reason) {
|
|
4782
5060
|
clearInterval(reconcileSweepTimer);
|
|
4783
5061
|
clearInterval(revivalBoundSweepTimer);
|
|
5062
|
+
// DOD-M15-RELAYABUSE-1: scheduled park retries. Unref'd, so they never held the process open —
|
|
5063
|
+
// cleared so an in-process restart cannot leave one draining into a torn-down manager.
|
|
5064
|
+
for (const t of parkRetryTimers)
|
|
5065
|
+
clearTimeout(t);
|
|
5066
|
+
parkRetryTimers.clear();
|
|
4784
5067
|
// DOD-M12B-SHUTDOWN-1: clearing the timer only stops the NEXT tick. The pass already running
|
|
4785
5068
|
// walks every agent, and each step dials a peer and opens a session — which is why a daemon
|
|
4786
5069
|
// reported down, with its socket already removed, was still logging `document.reconcile.sweep`
|