@cello-protocol/daemon 0.0.181 → 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-id-migration.d.ts +9 -0
- package/dist/agent-id-migration.d.ts.map +1 -1
- package/dist/agent-id-migration.js +73 -1
- package/dist/agent-id-migration.js.map +1 -1
- package/dist/agent-selection.d.ts +73 -0
- package/dist/agent-selection.d.ts.map +1 -0
- package/dist/agent-selection.js +80 -0
- package/dist/agent-selection.js.map +1 -0
- 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/assignment-verify.d.ts +62 -0
- package/dist/assignment-verify.d.ts.map +1 -0
- package/dist/assignment-verify.js +185 -0
- package/dist/assignment-verify.js.map +1 -0
- package/dist/backup-restore.d.ts +102 -0
- package/dist/backup-restore.d.ts.map +1 -0
- package/dist/backup-restore.js +340 -0
- package/dist/backup-restore.js.map +1 -0
- package/dist/bin/cello-daemon.js +91 -49
- package/dist/bin/cello-daemon.js.map +1 -1
- package/dist/close-commitment.d.ts +67 -0
- package/dist/close-commitment.d.ts.map +1 -0
- package/dist/close-commitment.js +78 -0
- package/dist/close-commitment.js.map +1 -0
- package/dist/close-session-handler.d.ts +20 -0
- package/dist/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +351 -58
- package/dist/close-session-handler.js.map +1 -1
- package/dist/consortium-bootstrap.d.ts +2 -0
- package/dist/consortium-bootstrap.d.ts.map +1 -1
- package/dist/consortium-bootstrap.js +182 -9
- 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 +323 -21
- package/dist/content-park.js.map +1 -1
- package/dist/daemon.d.ts +13 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +1168 -141
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-session-suspects.d.ts.map +1 -1
- package/dist/delivery-session-suspects.js +5 -0
- package/dist/delivery-session-suspects.js.map +1 -1
- package/dist/directory-auth-posture.d.ts +87 -0
- package/dist/directory-auth-posture.d.ts.map +1 -0
- package/dist/directory-auth-posture.js +134 -0
- package/dist/directory-auth-posture.js.map +1 -0
- package/dist/directory-bootstrap.d.ts +66 -3
- package/dist/directory-bootstrap.d.ts.map +1 -1
- package/dist/directory-bootstrap.js +133 -7
- package/dist/directory-bootstrap.js.map +1 -1
- package/dist/document-delivery-transport.d.ts +22 -2
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +11 -3
- package/dist/document-delivery-transport.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/inbound-sessions.d.ts +31 -1
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +276 -16
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.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/ipc-server.d.ts +5 -1
- package/dist/ipc-server.d.ts.map +1 -1
- package/dist/ipc-server.js +19 -3
- package/dist/ipc-server.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 +153 -0
- package/dist/manifest-validity.d.ts.map +1 -0
- package/dist/manifest-validity.js +268 -0
- package/dist/manifest-validity.js.map +1 -0
- package/dist/notification-handlers.d.ts.map +1 -1
- package/dist/notification-handlers.js +15 -0
- package/dist/notification-handlers.js.map +1 -1
- package/dist/outbound-sessions.d.ts +31 -0
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +217 -17
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/park-envelope.d.ts +95 -1
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +195 -4
- package/dist/park-envelope.js.map +1 -1
- package/dist/refusal-reasons.d.ts +72 -0
- package/dist/refusal-reasons.d.ts.map +1 -0
- package/dist/refusal-reasons.js +69 -0
- package/dist/refusal-reasons.js.map +1 -0
- 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/registration-manager.d.ts.map +1 -1
- package/dist/registration-manager.js +20 -1
- package/dist/registration-manager.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/restart-seal-resolver.d.ts.map +1 -1
- package/dist/restart-seal-resolver.js +6 -0
- package/dist/restart-seal-resolver.js.map +1 -1
- package/dist/retry-queue.d.ts +49 -2
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +55 -10
- package/dist/retry-queue.js.map +1 -1
- package/dist/roster-freshness.d.ts +169 -0
- package/dist/roster-freshness.d.ts.map +1 -0
- package/dist/roster-freshness.js +249 -0
- package/dist/roster-freshness.js.map +1 -0
- package/dist/seal-certificate-pull.d.ts +17 -1
- package/dist/seal-certificate-pull.d.ts.map +1 -1
- package/dist/seal-certificate-pull.js +37 -3
- package/dist/seal-certificate-pull.js.map +1 -1
- package/dist/seal-coordinator.d.ts +17 -1
- package/dist/seal-coordinator.d.ts.map +1 -1
- package/dist/seal-coordinator.js +71 -0
- package/dist/seal-coordinator.js.map +1 -1
- package/dist/seal-failure-store.d.ts +104 -0
- package/dist/seal-failure-store.d.ts.map +1 -0
- package/dist/seal-failure-store.js +126 -0
- package/dist/seal-failure-store.js.map +1 -0
- package/dist/seal-flows.d.ts.map +1 -1
- package/dist/seal-flows.js +35 -6
- package/dist/seal-flows.js.map +1 -1
- package/dist/seal-upgrade.d.ts +20 -5
- package/dist/seal-upgrade.d.ts.map +1 -1
- package/dist/seal-upgrade.js +28 -5
- package/dist/seal-upgrade.js.map +1 -1
- package/dist/session-assignment-parser.d.ts +41 -5
- package/dist/session-assignment-parser.d.ts.map +1 -1
- package/dist/session-assignment-parser.js +77 -14
- package/dist/session-assignment-parser.js.map +1 -1
- package/dist/session-ceremony.d.ts +35 -0
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +97 -0
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-connection-gater.d.ts +46 -7
- package/dist/session-connection-gater.d.ts.map +1 -1
- package/dist/session-connection-gater.js +163 -12
- package/dist/session-connection-gater.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 +149 -8
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +653 -40
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +5938 -374
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-read-handlers.d.ts +27 -0
- package/dist/session-read-handlers.d.ts.map +1 -1
- package/dist/session-read-handlers.js +127 -6
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-relay-client.d.ts +177 -2
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +564 -16
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-salt-agreement.d.ts +338 -0
- package/dist/session-salt-agreement.d.ts.map +1 -0
- package/dist/session-salt-agreement.js +478 -0
- package/dist/session-salt-agreement.js.map +1 -0
- package/dist/signal-submission.d.ts.map +1 -1
- package/dist/signal-submission.js +10 -1
- package/dist/signal-submission.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 +206 -6
- package/dist/signaling-connect.js.map +1 -1
- package/dist/types.d.ts +75 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +10 -0
- package/dist/vocabulary.js.map +1 -1
- package/dist/wire-content-hash.d.ts +68 -0
- package/dist/wire-content-hash.d.ts.map +1 -1
- package/dist/wire-content-hash.js +83 -0
- package/dist/wire-content-hash.js.map +1 -1
- package/package.json +5 -5
package/dist/daemon.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import { mkdir } from "node:fs/promises";
|
|
29
29
|
import { randomUUID } from "node:crypto";
|
|
30
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
30
31
|
import { dirname, join } from "node:path";
|
|
31
32
|
import { loadAgents } from "./agent-loader.js";
|
|
32
33
|
import { RestartSealResolver } from "./restart-seal-resolver.js";
|
|
@@ -35,13 +36,18 @@ import { acquireLock, removeLockIfOwned } from "./lock-file.js";
|
|
|
35
36
|
import { acquireSingletonLock } from "./singleton-lock.js";
|
|
36
37
|
import { createIpcServer } from "./ipc-server.js";
|
|
37
38
|
import { renderForSurface } from "./vocabulary.js";
|
|
39
|
+
import { RandomizedPollScheduler } from "./manifest-poll-scheduler.js";
|
|
40
|
+
import { startManifestValidityWatch, classifyManifestValidity, describeManifestValidity } from "./manifest-validity.js";
|
|
41
|
+
import { describeDirectoryAuth, directoryAuthRequired } from "./directory-auth-posture.js";
|
|
42
|
+
import { SealFailureStore } from "./seal-failure-store.js";
|
|
43
|
+
import { startRosterSweep, classifyRosterReading, describeRosterFreshness, ROSTER_SWEEP_INTERVAL_MS, } from "./roster-freshness.js";
|
|
38
44
|
import { SessionNodeManager, REVIVAL_WINDOW_MS, REVIVAL_BOUND_SWEEP_MS } from "./session-node-manager.js";
|
|
39
45
|
import { registerGatewayConfigHandlers } from "./gateway-config-handlers.js";
|
|
40
46
|
import { RetryQueue } from "./retry-queue.js";
|
|
41
47
|
import { NonceDedupStore } from "./nonce-dedup.js";
|
|
42
48
|
import { ContentParkClient } from "./content-park-client.js";
|
|
43
49
|
import { NotificationDispatcher } from "./notification-dispatcher.js";
|
|
44
|
-
import { createNode, SignalingManager } from "@cello-protocol/transport";
|
|
50
|
+
import { createNode, SignalingManager, } from "@cello-protocol/transport";
|
|
45
51
|
import { createSignalingConnect } from "./signaling-connect.js";
|
|
46
52
|
import { DbRegistrationPersistence, DbIdentityStore } from "./db-identity-store.js";
|
|
47
53
|
import { DbManifestVersionStore } from "./manifest-version-store-db.js";
|
|
@@ -55,7 +61,7 @@ const MAX_SUBMISSION_BODY_CHARS = 4000;
|
|
|
55
61
|
// CELLO-M7-MSG-001 (AC-013/AC-018): the single application content-size cap, enforced
|
|
56
62
|
// at the send point here (the receive point lives in the transport content decode).
|
|
57
63
|
import { sealParkEnvelope } from "./park-envelope.js";
|
|
58
|
-
import { AgentRelayClient, extractErrorMessage } from "./session-relay-client.js";
|
|
64
|
+
import { LEAF_KIND_MSG, AgentRelayClient, extractErrorMessage } from "./session-relay-client.js";
|
|
59
65
|
import { createReconnectDrain } from "./reconnect-drain.js";
|
|
60
66
|
import { resolveCelloEnv, createTransportSelector, isProductionVariant, } from "./transport-composition.js";
|
|
61
67
|
import { whoLabel } from "./who-label.js";
|
|
@@ -66,12 +72,15 @@ import { resolveDirectoryUrl } from "./directory-bootstrap.js";
|
|
|
66
72
|
import { registerContactHandlers } from "./contact-handlers.js";
|
|
67
73
|
import { createSealCoordinator } from "./seal-coordinator.js";
|
|
68
74
|
import { createTelegramDoorbell } from "./telegram-doorbell.js";
|
|
69
|
-
import { registerSessionContentHandlers } from "./session-content-handlers.js";
|
|
75
|
+
import { registerSessionContentHandlers, sentAuthorship } from "./session-content-handlers.js";
|
|
70
76
|
import { createDocumentLayer, agentPublicKeyFromId } from "./document-layer.js";
|
|
71
77
|
import { isDocumentFrame } from "./document-frame-router.js";
|
|
72
78
|
import { INBOUND_INJECTION_BLOCKED } from "@cello-protocol/gateway";
|
|
73
79
|
import { registerDocumentHandlers } from "./document-handlers.js";
|
|
74
|
-
|
|
80
|
+
// `wireContentHash` is no longer imported here: every outbound hash in this file now comes from
|
|
81
|
+
// `SessionNodeManager.contentHashForSession`, which returns the hash and its ALGORITHM together
|
|
82
|
+
// (`DOD-M15-SEALWIRE-1` part B2b). A direct call would be a hash computed without deciding — or
|
|
83
|
+
// recording — how it was made, which is the state that made a version skew look like a tamper.
|
|
75
84
|
import { DocumentPublish } from "./document-publish.js";
|
|
76
85
|
import { createDocumentDeliveryTransport } from "./document-delivery-transport.js";
|
|
77
86
|
import { ReconcileScheduler } from "./document-reconcile-scheduler.js";
|
|
@@ -81,6 +90,9 @@ import { createInboundSessions } from "./inbound-sessions.js";
|
|
|
81
90
|
import { createOutboundSessions } from "./outbound-sessions.js";
|
|
82
91
|
import { registerSessionReadHandlers } from "./session-read-handlers.js";
|
|
83
92
|
import { pullSealCertificate } from "./seal-certificate-pull.js";
|
|
93
|
+
import { createBackup, inspectBackup } from "./backup-restore.js";
|
|
94
|
+
import { resolveCurrentAgentFor } from "./agent-selection.js";
|
|
95
|
+
import { REFUSAL_REASONS, CAPACITY_REASONS } from "./refusal-reasons.js";
|
|
84
96
|
import { revocabilityOf } from "./signal-revocability.js";
|
|
85
97
|
import { registerAgentHandlers } from "./agent-handlers.js";
|
|
86
98
|
import { registerRegisterHandler } from "./register-handler.js";
|
|
@@ -98,6 +110,7 @@ import { countAttendance, ContentTakeLedger } from "./co-attendance.js";
|
|
|
98
110
|
import { isOwnAwayAutoReply, AWAY_AUTO_REPLY_TEXTS, markAsAutoReply, isAutoReplyMarked } from "./away-detection.js";
|
|
99
111
|
import { createDeliveryOpenRegistry } from "./delivery-open-registry.js";
|
|
100
112
|
import { FrontierMismatchStore, renderFrontierMismatch } from "./frontier-mismatch.js";
|
|
113
|
+
import { relayOnlyState } from "./relay-only.js";
|
|
101
114
|
import { decodeCbor } from "@cello-protocol/protocol-types";
|
|
102
115
|
// Minimal no-op KeyProvider stub for session nodes.
|
|
103
116
|
// Session nodes don't need signing keys — libp2p generates its own fresh
|
|
@@ -109,6 +122,20 @@ const SESSION_NODE_KEY_STUB = {
|
|
|
109
122
|
};
|
|
110
123
|
// Production session node factory — wraps createNode from @cello-protocol/transport
|
|
111
124
|
export class ProductionSessionNodeFactory {
|
|
125
|
+
logger;
|
|
126
|
+
/**
|
|
127
|
+
* DOD-M15-IDLE-CONNS-1 — OPTIONAL, and optional for one reason only: this factory is constructed
|
|
128
|
+
* with no arguments in four existing tests, and requiring a logger would turn them into type
|
|
129
|
+
* errors for a unit that has nothing to do with them.
|
|
130
|
+
*
|
|
131
|
+
* The cost is stated rather than hidden: with no logger, a reaped connection is silent, which is
|
|
132
|
+
* exactly the "guard nobody hears" this milestone has found four times. `startDaemon` passes one
|
|
133
|
+
* — that is the production path — and the sweep only runs on nodes this factory builds as
|
|
134
|
+
* receivers, so the silent case is a test-only fixture.
|
|
135
|
+
*/
|
|
136
|
+
constructor(logger) {
|
|
137
|
+
this.logger = logger;
|
|
138
|
+
}
|
|
112
139
|
async createNode(config) {
|
|
113
140
|
// DOD-NAT-REACHABILITY-1: the STANDING RECEIVER — the node that accepts every
|
|
114
141
|
// inbound session — must bind a ROUTABLE interface by default. The old
|
|
@@ -136,15 +163,121 @@ export class ProductionSessionNodeFactory {
|
|
|
136
163
|
listenAddresses: [listenAddr, ...(config.circuitRelayListenAddrs ?? [])],
|
|
137
164
|
...(announce ? { announceAddresses: announce } : {}),
|
|
138
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 } } : {}),
|
|
139
172
|
// Forward the role. After DOD-NAT-REACHABILITY-1, dcutr is on every node
|
|
140
|
-
// type; nodeType's remaining transport effect is the HOP
|
|
141
|
-
// 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).
|
|
142
175
|
nodeType: config.nodeType,
|
|
143
176
|
// DOD-M12B-SESSION-SEED-1: forward the caller's transport seed when it supplied one, so a
|
|
144
177
|
// rebuilt session node returns at the peer id the counterparty already holds. Omitted (rather
|
|
145
178
|
// than passed as undefined) when absent, keeping createNode's "generate a fresh key" default
|
|
146
179
|
// for every node that is not session-scoped.
|
|
147
180
|
...(config.transportPrivateKey ? { transportPrivateKey: config.transportPrivateKey } : {}),
|
|
181
|
+
/**
|
|
182
|
+
* DOD-M15-IDLE-CONNS-1 — the idle sweep is armed on the node that STARTS as a standing
|
|
183
|
+
* receiver.
|
|
184
|
+
*
|
|
185
|
+
* **It is NOT "the standing receiver only", and an earlier version of this comment said so
|
|
186
|
+
* and was false.** `acceptSession` does not build a new node: it moves this same `CelloNode`
|
|
187
|
+
* from `#standingReceivers` into `#activeNodes`, so the interval keeps running after
|
|
188
|
+
* promotion, against the session's own counterparty. Review measured the consequence — the
|
|
189
|
+
* counterparty was hung up mid-conversation and the next send failed with `no_connection`,
|
|
190
|
+
* with nothing anywhere naming the local sweep.
|
|
191
|
+
*
|
|
192
|
+
* That is survivable only because the spared predicate below names the counterparty. The
|
|
193
|
+
* arrangement is deliberate now rather than accidental: one node, one interval, and the gate
|
|
194
|
+
* decides who is off-limits as it narrows.
|
|
195
|
+
*/
|
|
196
|
+
...(isReceiver
|
|
197
|
+
? {
|
|
198
|
+
idleConnectionReaper: {
|
|
199
|
+
/**
|
|
200
|
+
* C3 — THE GUARD IS HEARD. A hang-up that tells nobody is indistinguishable from the
|
|
201
|
+
* thing simply not happening, and review measured what that costs: the operator's
|
|
202
|
+
* next send returns `no_connection`, `session.transport.redial.unavailable` says
|
|
203
|
+
* "every send parks until they re-establish", and not one word in that chain names a
|
|
204
|
+
* `setInterval` on their own machine.
|
|
205
|
+
*
|
|
206
|
+
* WARN and CONTINUE, deliberately: this is resource bounding, not a security event.
|
|
207
|
+
* Nothing is refused and no session state changes — so it is loud, and it does not
|
|
208
|
+
* block (Invariant 2's own distinction).
|
|
209
|
+
*/
|
|
210
|
+
onReaped: (e) => {
|
|
211
|
+
if (e.reason === "never_carried_a_stream") {
|
|
212
|
+
this.logger?.warn("session.node.connection.reaped", {
|
|
213
|
+
sessionId: config.sessionId,
|
|
214
|
+
peerId: e.peerId,
|
|
215
|
+
ageMs: e.ageMs,
|
|
216
|
+
observation: "an inbound connection was hung up after never carrying a stream since it opened",
|
|
217
|
+
impact: "no session state changed and nothing was refused; if this peer returns it must dial again",
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
// A sweep that cannot do its job is a different event and must not read as one that
|
|
222
|
+
// did. `hangUp`'s `invalid_peer_id` is a NAMED reason written so a malformed id is
|
|
223
|
+
// not read as a connection problem — preserved here rather than flattened.
|
|
224
|
+
this.logger?.error("session.node.connection.reap_failed", {
|
|
225
|
+
sessionId: config.sessionId,
|
|
226
|
+
peerId: e.peerId,
|
|
227
|
+
reason: e.reason,
|
|
228
|
+
error: e.error,
|
|
229
|
+
impact: "an idle connection was NOT closed; it continues to hold a slot against the connection cap",
|
|
230
|
+
});
|
|
231
|
+
},
|
|
232
|
+
/**
|
|
233
|
+
* C4 — THE COUNT THE DoD ASKS FOR, and the reason this callback exists at all.
|
|
234
|
+
*
|
|
235
|
+
* The line says *"measure a healthy daemon's connection count first"*, and nothing in
|
|
236
|
+
* the tree reported one — not `cello_status`, not the CLI, not the log. Exposing the
|
|
237
|
+
* CAPS without the COUNT would have been a capability nothing reads, which is this
|
|
238
|
+
* milestone's own "no consumer, no ship". DEBUG because it is a census on a timer,
|
|
239
|
+
* not an event: it exists to be greppable when a cap is finally tuned.
|
|
240
|
+
*/
|
|
241
|
+
onObserved: (c) => {
|
|
242
|
+
this.logger?.debug("transport.connections.observed", {
|
|
243
|
+
sessionId: config.sessionId,
|
|
244
|
+
total: c.total,
|
|
245
|
+
inbound: c.inbound,
|
|
246
|
+
neverSpoke: c.neverSpoke,
|
|
247
|
+
maxConnections: c.maxConnections,
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
: {}),
|
|
253
|
+
}).then((node) => {
|
|
254
|
+
/**
|
|
255
|
+
* SPARE WHAT REACHABILITY DEPENDS ON — two things, and the second was missing.
|
|
256
|
+
*
|
|
257
|
+
* **The reserved relay.** The same list `DOD-M15-FRAME-1`'s eviction sweep spares, for the
|
|
258
|
+
* reason its comment gives: reservation refreshes ride those peers, and hanging one up costs
|
|
259
|
+
* the agent its inbound reachability. A reservation is IDLE BY NATURE between refreshes,
|
|
260
|
+
* which is exactly the shape the sweep hunts.
|
|
261
|
+
*
|
|
262
|
+
* **The peer the gate currently names.** `getAllowedPeerId()` is the admitted dialer before
|
|
263
|
+
* promotion and the counterparty after it. Without this the sweep reaps the one peer the
|
|
264
|
+
* session exists for.
|
|
265
|
+
*
|
|
266
|
+
* READ LIVE on every sweep, never captured: reservations are lost and retaken constantly
|
|
267
|
+
* (2,675 `reservation.lost` in one daemon's log) and `#allowedPeerId` changes at offer,
|
|
268
|
+
* promotion and refusal. A set frozen at build time is wrong within minutes.
|
|
269
|
+
*
|
|
270
|
+
* WHAT IS LEFT TO REAP, stated because a guard with no population is theatre: a peer that was
|
|
271
|
+
* admitted by an offer which was then refused or expired. `closeInbound()` returns
|
|
272
|
+
* `#allowedPeerId` to null, and libp2p never re-runs a gater against a connection that
|
|
273
|
+
* already exists — so that peer stays attached, named by nobody, speaking nothing. That is
|
|
274
|
+
* the connection this unit removes.
|
|
275
|
+
*/
|
|
276
|
+
const gater = config.connectionGater;
|
|
277
|
+
if (isReceiver && gater) {
|
|
278
|
+
node.setIdleReaperSpared((peerId) => gater.isAllowedOutboundPeer(peerId) || gater.getAllowedPeerId() === peerId);
|
|
279
|
+
}
|
|
280
|
+
return node;
|
|
148
281
|
});
|
|
149
282
|
}
|
|
150
283
|
}
|
|
@@ -195,6 +328,43 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
195
328
|
if (manifestProvider && (!manifestRootKeys || !manifestThreshold || manifestThreshold <= 0)) {
|
|
196
329
|
throw new Error("DaemonConfig: manifestProvider requires manifestRootKeys (non-empty) and manifestThreshold (positive integer >= 1)");
|
|
197
330
|
}
|
|
331
|
+
/**
|
|
332
|
+
* DOD-M15-DIRAUTH-1 — an operator can DEMAND directory identity authentication.
|
|
333
|
+
*
|
|
334
|
+
* HERE, under the ADV-006/008 rule above, because this IS pure config validation: both operands
|
|
335
|
+
* are already in hand and it touches nothing.
|
|
336
|
+
*
|
|
337
|
+
* Review F1 caught me putting it ninety lines lower, next to ADV-002, on the reasoning that it
|
|
338
|
+
* "mirrors" it. It does not. ADV-002 sits down there because it MUST — it depends on
|
|
339
|
+
* `verifyStartupManifest`, which depends on the anti-rollback floor in the DB. This depends on
|
|
340
|
+
* nothing, and down there it ran AFTER: the irreversible flat-file → SQLCipher identity migration
|
|
341
|
+
* (which renames and unlinks files), the creation of `sessions.db` and its key, and the sweep that
|
|
342
|
+
* marks every `active` session `interrupted` with `interrupted_by='local'`.
|
|
343
|
+
*
|
|
344
|
+
* So a misconfigured daemon "failed to start" and changed the operator's record on the way out —
|
|
345
|
+
* two live sessions permanently interrupted, attributed to a local cause, by a config check that
|
|
346
|
+
* could have run before anything was touched.
|
|
347
|
+
*/
|
|
348
|
+
if (directoryAuthRequired(process.env) && challengeVerifier === undefined) {
|
|
349
|
+
const url = config.directoryHttpUrl ?? resolveDirectoryUrl(process.env);
|
|
350
|
+
logger.error("directory.auth.required.unavailable", {
|
|
351
|
+
directoryUrl: url,
|
|
352
|
+
impact: "the daemon refused to start rather than connect without directory identity authentication.",
|
|
353
|
+
guidance: "CELLO_REQUIRE_DIRECTORY_AUTH is set, but no challenge verifier could be built for this " +
|
|
354
|
+
"directory URL. Point CELLO_DIRECTORY_URL at a bundled endpoint, or supply a manifest with " +
|
|
355
|
+
"CELLO_CONSORTIUM_MANIFEST plus CELLO_CONSORTIUM_ROOT_KEYS and CELLO_CONSORTIUM_THRESHOLD " +
|
|
356
|
+
"(all three are required together), or set CELLO_REQUIRE_DIRECTORY_AUTH to 0/false/no/off " +
|
|
357
|
+
"to accept the risk.",
|
|
358
|
+
});
|
|
359
|
+
throw new Error(`CELLO_REQUIRE_DIRECTORY_AUTH is set, but directory identity authentication (step 6) cannot ` +
|
|
360
|
+
`be enforced: no challenge verifier was supplied for this daemon. The directory URL is ` +
|
|
361
|
+
`compared against the bundled consortium roster after NORMALISATION (trimmed, trailing slash ` +
|
|
362
|
+
`dropped, lowercased) — so case and a trailing slash are forgiven, but a DNS hostname ` +
|
|
363
|
+
`pointing at exactly the right machine is NOT, which is the usual cause. Either use a bundled ` +
|
|
364
|
+
`endpoint address, or supply a manifest with CELLO_CONSORTIUM_MANIFEST plus ` +
|
|
365
|
+
`CELLO_CONSORTIUM_ROOT_KEYS and CELLO_CONSORTIUM_THRESHOLD (all three are required together), ` +
|
|
366
|
+
`or set CELLO_REQUIRE_DIRECTORY_AUTH to 0/false/no/off to start without step 6.`);
|
|
367
|
+
}
|
|
198
368
|
// ── PERSIST-002: open the encrypted store FIRST (runs the one-time flat-file → SQLCipher migration
|
|
199
369
|
// (AC-006) + creates the agents/manifest_state schema), under the single-instance lock. This must
|
|
200
370
|
// precede the manifest verification below because the manifest version is now stored in the
|
|
@@ -230,7 +400,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
230
400
|
// screening, so a wiring mistake shows up here instead of hiding behind a correct-looking line.
|
|
231
401
|
logger.info("security.gateway.connected", { mode: securityGateway.mode });
|
|
232
402
|
const sessionNodeManager = new SessionNodeManager({
|
|
233
|
-
factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(),
|
|
403
|
+
factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(logger),
|
|
234
404
|
logger,
|
|
235
405
|
dbPath: join(celloDir, "sessions.db"),
|
|
236
406
|
contentTtfMs: config.contentTtfMs,
|
|
@@ -250,6 +420,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
250
420
|
manifestThreshold,
|
|
251
421
|
manifestVersionStore,
|
|
252
422
|
logger,
|
|
423
|
+
// DOD-M15-STALEROSTER-1: the same injected fetch the sweep uses, so the startup probe and the
|
|
424
|
+
// background probe are exercised through one seam rather than one being untestable.
|
|
425
|
+
...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
|
|
253
426
|
});
|
|
254
427
|
// ADV-002: an operator who configures manifestProvider has opted INTO manifest enforcement, so a
|
|
255
428
|
// failed verification is fatal — never a warning we start anyway on.
|
|
@@ -268,7 +441,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
268
441
|
"Check the logs for the specific failure reason (manifest_signature_invalid, manifest_expired, or manifest_version_rollback).");
|
|
269
442
|
}
|
|
270
443
|
// The manifest poll starts only AFTER the refuse above — a refused startup must not leak a timer.
|
|
271
|
-
const { resolveConsortiumRoster, failoverEndpointResolver, getFailoverEndpoint, getUnresolvedNodes, getUnresolvedSweptAt, stopHttpManifestPoll } = createConsortiumRouting({
|
|
444
|
+
const { resolveConsortiumRoster, failoverEndpointResolver, getFailoverEndpoint, getUnresolvedNodes, getUnresolvedSweptAt, getDeclaredNodeCount, stopHttpManifestPoll } = createConsortiumRouting({
|
|
272
445
|
manifestProvider,
|
|
273
446
|
manifestRootKeys,
|
|
274
447
|
manifestThreshold,
|
|
@@ -282,7 +455,96 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
282
455
|
initialUnresolvedNodes: startupUnresolvedNodes,
|
|
283
456
|
initialUnresolvedSweptAt: startupSweptAt,
|
|
284
457
|
logger,
|
|
458
|
+
...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
|
|
459
|
+
});
|
|
460
|
+
/**
|
|
461
|
+
* DOD-M15-STALEROSTER-1 — keep measuring directory reachability even when nothing is wrong.
|
|
462
|
+
*
|
|
463
|
+
* Every existing caller of the sweep is ACTIVITY-driven — ceremonies, session setup,
|
|
464
|
+
* `cello_refresh`, the seal broker. So an IDLE daemon never re-measures, and sitting idle is what
|
|
465
|
+
* a daemon does between conversations: the reading it was seeded with at boot is the reading it
|
|
466
|
+
* still has an hour later. Measured twice, on two machines — node failures from minutes past
|
|
467
|
+
* displayed while `curl` reached all three nodes in 37–184 ms.
|
|
468
|
+
*
|
|
469
|
+
* (An earlier version of this comment said the sweep had ONE caller, the failover path, and that
|
|
470
|
+
* recovering was what stopped the measurement. That was wrong — there are ten — and it is
|
|
471
|
+
* corrected here rather than deleted because believing it is why the concurrent-sweep race in
|
|
472
|
+
* `consortium-bootstrap.ts` went unnoticed until review.)
|
|
473
|
+
*
|
|
474
|
+
* Skipped when there is no manifest provider: there is no node roster to enumerate, so a timer
|
|
475
|
+
* that can only ever re-measure nothing is noise. That case is NOT silent — `cello_status`
|
|
476
|
+
* reports `measurement: "not_configured"` and says why.
|
|
477
|
+
*/
|
|
478
|
+
/**
|
|
479
|
+
* WHERE the held manifest came from, because it decides what the operator can actually DO about
|
|
480
|
+
* an expired one — `DOD-M15-MANIFEST-EXPIRY-LIVE-1` review F5.
|
|
481
|
+
*
|
|
482
|
+
* `EmbeddedManifestProvider` is the compiled-in bundled roster: there is no file to replace and no
|
|
483
|
+
* poll to adopt a replacement, so "rotate the manifest" is not an available action and telling
|
|
484
|
+
* that operator to do it routes them toward the one workaround that silently disables directory
|
|
485
|
+
* identity authentication. Detected by the provider's own constructor rather than by re-reading
|
|
486
|
+
* the env var, so a caller that injects a provider directly is classified by what it IS.
|
|
487
|
+
*/
|
|
488
|
+
const manifestOrigin = manifestProvider?.constructor?.name === "EmbeddedManifestProvider" ? "bundled" : "file";
|
|
489
|
+
/** REVIEW F4: the last sweep failure, surfaced in `cello_status` alongside the log line. */
|
|
490
|
+
let lastRosterSweepError;
|
|
491
|
+
/**
|
|
492
|
+
* DOD-M15-MANIFEST-EXPIRY-LIVE-1 — re-check the trust anchor's validity while the daemon runs.
|
|
493
|
+
*
|
|
494
|
+
* The window is enforced at STARTUP and nowhere else. The manifest poll's expiry check looks at
|
|
495
|
+
* the manifest being FETCHED, never the one held, so a daemon past its expiry keeps polling, keeps
|
|
496
|
+
* correctly refusing expired replacements, and keeps using the lapsed anchor it already has.
|
|
497
|
+
*
|
|
498
|
+
* Rides the roster sweep rather than owning a timer: that tick already fires every 90–180 s on
|
|
499
|
+
* exactly the path where a manifest provider exists.
|
|
500
|
+
*/
|
|
501
|
+
const checkManifestValidity = startManifestValidityWatch({
|
|
502
|
+
getManifest: () => manifestProvider?.getCurrentManifest() ?? null,
|
|
503
|
+
logger,
|
|
285
504
|
});
|
|
505
|
+
const rosterSweepScheduler = manifestProvider
|
|
506
|
+
? config.rosterSweepScheduler ??
|
|
507
|
+
new RandomizedPollScheduler({ minMs: ROSTER_SWEEP_INTERVAL_MS, maxMs: ROSTER_SWEEP_INTERVAL_MS * 2 })
|
|
508
|
+
: undefined;
|
|
509
|
+
const stopRosterSweep = rosterSweepScheduler
|
|
510
|
+
? startRosterSweep({
|
|
511
|
+
scheduler: rosterSweepScheduler,
|
|
512
|
+
// FAST_PROBE is deliberately NOT used here. It exists because the failover resolver runs
|
|
513
|
+
// inside the 10 s signaling wait; nothing waits on this sweep, so it can afford the
|
|
514
|
+
// patient probe and give the more trustworthy answer.
|
|
515
|
+
sweep: async () => {
|
|
516
|
+
/**
|
|
517
|
+
* DOD-M15-MANIFEST-EXPIRY-LIVE-1: the anchor's validity is re-checked on the same tick.
|
|
518
|
+
* BEFORE the probe, so an expired manifest is reported even on a cycle where every node is
|
|
519
|
+
* unreachable and the roster resolve throws.
|
|
520
|
+
*
|
|
521
|
+
* Its OWN try/catch — review F11. Sharing the sweep's error path meant a throw in here
|
|
522
|
+
* would surface as `directory.roster.sweep.failed` AND skip `resolveConsortiumRoster()`
|
|
523
|
+
* entirely: the roster reading would freeze while the operator was pointed at the
|
|
524
|
+
* directory. A manifest-check failure must never be reported as a directory failure, and
|
|
525
|
+
* must never cost the measurement it rides along with.
|
|
526
|
+
*/
|
|
527
|
+
try {
|
|
528
|
+
checkManifestValidity();
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
logger.error("directory.auth.manifest.check.failed", {
|
|
532
|
+
error: err instanceof Error ? err.message : String(err),
|
|
533
|
+
impact: "the manifest validity re-check did not run this cycle. cello_status still computes " +
|
|
534
|
+
"it independently on every read, so the FIELD is unaffected; what is lost is the " +
|
|
535
|
+
"unprompted log line on a transition.",
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
return resolveConsortiumRoster();
|
|
539
|
+
},
|
|
540
|
+
logger,
|
|
541
|
+
// REVIEW F4: the failure reaches the agent's response, not just the log. Without this a
|
|
542
|
+
// sweep failing every cycle is invisible for the first two or three failures, because the
|
|
543
|
+
// reading is still inside its 5-minute freshness bound and reports stale:false.
|
|
544
|
+
onSweepError: (e) => { lastRosterSweepError = e; },
|
|
545
|
+
onSweepSuccess: () => { lastRosterSweepError = undefined; },
|
|
546
|
+
})
|
|
547
|
+
: undefined;
|
|
286
548
|
// DOD-REGISTRY-1: type registry poll — daemon-level, runs even with zero agents.
|
|
287
549
|
// When registryPubkey is configured, the daemon polls GET /registry, verifies the inner
|
|
288
550
|
// Ed25519 signature, and updates the in-memory TypeRegistry. A poll failure never blanks
|
|
@@ -334,6 +596,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
334
596
|
for (const a of loadedAgents) {
|
|
335
597
|
keyProviders.set(a.name, a.keyProvider);
|
|
336
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));
|
|
337
604
|
// Constructed HERE, before ANY boot-time caller. autoRecoverForAgent is invoked from an agent's
|
|
338
605
|
// onConnected and from the seal-upgrade content gate — both of which run long before the IPC
|
|
339
606
|
// handler map exists. Its handlers register later (phase 2), which is what lets this sit up here.
|
|
@@ -544,6 +811,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
544
811
|
onRelayEndpoints: (endpoints) => {
|
|
545
812
|
sessionNodeManager.setDirectoryRelayEndpoints(agentName, endpoints.map((e) => ({ relayPeerId: e.peerId, relayAddrs: e.addrs })));
|
|
546
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
|
+
},
|
|
547
826
|
});
|
|
548
827
|
const mgr = new SignalingManager({
|
|
549
828
|
connect,
|
|
@@ -580,6 +859,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
580
859
|
agentName,
|
|
581
860
|
persistence: getPersistence(agentName),
|
|
582
861
|
agentPubkeyHex,
|
|
862
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F1): the co-sign path must check the root before this
|
|
863
|
+
// agent's key endorses it. Same predicate as the receiving path — one implementation, so the
|
|
864
|
+
// two halves cannot drift about what a mismatch is.
|
|
865
|
+
verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
|
|
583
866
|
keyProvider: agentKeyProvider,
|
|
584
867
|
getNode: entry.getNode,
|
|
585
868
|
getDirectoryEndpoint: getFailoverEndpoint,
|
|
@@ -592,6 +875,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
592
875
|
agentName,
|
|
593
876
|
persistence: getPersistence(agentName),
|
|
594
877
|
agentPubkeyHex,
|
|
878
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F1): the co-sign path must check the root before this
|
|
879
|
+
// agent's key endorses it. Same predicate as the receiving path — one implementation, so the
|
|
880
|
+
// two halves cannot drift about what a mismatch is.
|
|
881
|
+
verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
|
|
595
882
|
keyProvider: agentKeyProvider,
|
|
596
883
|
getNode: entry.getNode,
|
|
597
884
|
getDirectoryEndpoint: getFailoverEndpoint,
|
|
@@ -606,6 +893,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
606
893
|
wireSessionOfferHandler({
|
|
607
894
|
agentName,
|
|
608
895
|
getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agentName),
|
|
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",
|
|
609
900
|
signaling: mgr,
|
|
610
901
|
logger,
|
|
611
902
|
});
|
|
@@ -654,6 +945,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
654
945
|
agentName: agent.name,
|
|
655
946
|
persistence: getPersistence(agent.name),
|
|
656
947
|
agentPubkeyHex: agent.pubkey,
|
|
948
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
|
|
949
|
+
verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
|
|
657
950
|
keyProvider: agent.keyProvider,
|
|
658
951
|
getNode: noSharedDirectoryNode,
|
|
659
952
|
getDirectoryEndpoint: getFailoverEndpoint,
|
|
@@ -665,6 +958,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
665
958
|
agentName: agent.name,
|
|
666
959
|
persistence: getPersistence(agent.name),
|
|
667
960
|
agentPubkeyHex: agent.pubkey,
|
|
961
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
|
|
962
|
+
verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
|
|
668
963
|
keyProvider: agent.keyProvider,
|
|
669
964
|
getNode: noSharedDirectoryNode,
|
|
670
965
|
getDirectoryEndpoint: getFailoverEndpoint,
|
|
@@ -675,6 +970,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
675
970
|
wireSessionOfferHandler({
|
|
676
971
|
agentName: agent.name,
|
|
677
972
|
getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
|
|
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",
|
|
678
976
|
signaling: mgr,
|
|
679
977
|
logger,
|
|
680
978
|
});
|
|
@@ -890,6 +1188,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
890
1188
|
// whether its counterparty is quiet or its sibling was faster. Written at the destructive drain in
|
|
891
1189
|
// session-content-handlers; read at that handler's timeout. Delivery itself is unchanged.
|
|
892
1190
|
const contentTakes = new ContentTakeLedger();
|
|
1191
|
+
/**
|
|
1192
|
+
* In-flight background seal ceremonies — `DOD-M15-CLOSEWAIT-1` review MEDIUM-6.
|
|
1193
|
+
*
|
|
1194
|
+
* The close now answers at commitment and finishes the ceremony detached. Without this the tail
|
|
1195
|
+
* was the ONE background task `stop()` could cut at an arbitrary point, while it cancels or awaits
|
|
1196
|
+
* every other one.
|
|
1197
|
+
*/
|
|
1198
|
+
const backgroundSeals = new Set();
|
|
1199
|
+
/**
|
|
1200
|
+
* DOD-M15-SEAL-FAILED-TERMINAL-1 — the last background seal failure per session.
|
|
1201
|
+
*
|
|
1202
|
+
* In memory on purpose: a restart makes "failed" the WRONG answer, because the boot sweep plus the
|
|
1203
|
+
* restart seal resolver retry the session. A marker whose lifetime is the process matches the
|
|
1204
|
+
* lifetime of the condition it describes.
|
|
1205
|
+
*/
|
|
1206
|
+
const sealFailures = new SealFailureStore();
|
|
893
1207
|
// DOD-AWAY-WRAP-1 AC1: request text is a leave-a-message greeting; agentName is spliced in at
|
|
894
1208
|
// the call site so it names the specific away agent.
|
|
895
1209
|
// DOD-AWAY-ACK-ONESHOT-TEXT-1 (live defect 2026-07-24): the ack must state the one-shot rule —
|
|
@@ -979,9 +1293,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
979
1293
|
// FRONT precisely so [[WRAP]] keeps the end position the counterparty's detector anchors on.
|
|
980
1294
|
const rejectText = markAsAutoReply("This inbox only accepts one message per visit. Closing. [[WRAP]]");
|
|
981
1295
|
const rejectBytes = new TextEncoder().encode(rejectText);
|
|
982
|
-
|
|
1296
|
+
// B2b: one decision point for the hash AND its algorithm — see `contentHashForSession`.
|
|
1297
|
+
const reject = await sessionNodeManager.contentHashForSession(agentName, sessionId, rejectBytes);
|
|
983
1298
|
// Best-effort: a send failure still triggers the seal — we are closing regardless.
|
|
984
|
-
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(
|
|
1299
|
+
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(reject.hash), randomUUID(), LEAF_KIND_MSG, reject.alg);
|
|
985
1300
|
// M12-P13: commit the leaf when the rejection went out OR when it is durably queued —
|
|
986
1301
|
// either way the relay already witnessed its sequence. This caller is the sharpest case
|
|
987
1302
|
// of the three: the seal is initiated immediately below, so a hole here does not merely
|
|
@@ -989,14 +1304,17 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
989
1304
|
// counterparty will still receive content at. The roots then cannot agree, and the
|
|
990
1305
|
// session is unsealable for good.
|
|
991
1306
|
if (sendResult.ok || sendResult.durable) {
|
|
992
|
-
const rejectHashHex = Buffer.from(
|
|
1307
|
+
const rejectHashHex = Buffer.from(reject.hash).toString("hex");
|
|
993
1308
|
// DOD-M12B-INDEX-1: at the relay's position, not the tail. This is the riskiest append
|
|
994
1309
|
// in the codebase for that — the seal is initiated a few lines below, so a leaf at the
|
|
995
1310
|
// wrong index does not merely stall the far side, it seals a tree the counterparty can
|
|
996
1311
|
// never agree with.
|
|
997
|
-
|
|
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));
|
|
998
1316
|
if (placed.placed) {
|
|
999
|
-
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID());
|
|
1317
|
+
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placed.leafIndex, "sent", rejectBytes, randomUUID(), sentAuthorship(sendResult));
|
|
1000
1318
|
}
|
|
1001
1319
|
logger.info("session.away.inbox.oneshot.rejected", { agentName, sessionId, sequenceNumber: placed.placed ? placed.leafIndex : placed.heldAt, committed: placed.placed, queued: !sendResult.ok });
|
|
1002
1320
|
}
|
|
@@ -1015,85 +1333,124 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1015
1333
|
// not have ingested it yet when the seal request arrives over the signaling channel.
|
|
1016
1334
|
// The relay path avoids this entirely — it posts a SEAL ctrl leaf and waits for the
|
|
1017
1335
|
// counterparty to independently co-seal; no bilateral leaf-count comparison needed.
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1336
|
+
/**
|
|
1337
|
+
* DOD-M15-DIVERGE-1 (review HIGH-3) — the gate has to hold on the path with no operator.
|
|
1338
|
+
*
|
|
1339
|
+
* `cello_close_session` refuses a diverged record, but this autonomous path never
|
|
1340
|
+
* consulted `sealReadiness` at all: it read `placed.placed` and discarded
|
|
1341
|
+
* `placed.diverged`, then initiated the seal directly. So "a diverged session is blocked
|
|
1342
|
+
* from sealing" held for the close a human drives and not for the one that runs itself —
|
|
1343
|
+
* which is the worse of the two, because the append two lines above is, by its own
|
|
1344
|
+
* comment, "the riskiest append in the codebase" for exactly this reason.
|
|
1345
|
+
*
|
|
1346
|
+
* THE LOG IS THE SURFACE HERE, and that is not a weakening of Invariant 2. There is no
|
|
1347
|
+
* caller to answer — nothing is awaiting a response on this path — so the log carries the
|
|
1348
|
+
* whole warning rather than half of it.
|
|
1349
|
+
*/
|
|
1350
|
+
const oneshotReadiness = sessionNodeManager.sealReadiness(agentName, sessionId);
|
|
1351
|
+
if (oneshotReadiness.diverged) {
|
|
1352
|
+
logger.warn("session.away.inbox.oneshot.seal_skipped_diverged", {
|
|
1353
|
+
agentName, sessionId,
|
|
1354
|
+
treeSize: oneshotReadiness.treeSize, highWaterSeq: oneshotReadiness.highWaterSeq,
|
|
1355
|
+
impact: "this side's tree parted from the relay's ordering, so the seal was NOT initiated — the session stays closeable by hand, where the operator is told what parted and can compare counts with the counterparty before deciding",
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
else {
|
|
1359
|
+
void (async () => {
|
|
1360
|
+
const correlationId = randomUUID();
|
|
1361
|
+
const sk = sealKey(agentName, sessionId);
|
|
1362
|
+
if (sealInterruptedInProgress.has(sk))
|
|
1363
|
+
return;
|
|
1364
|
+
sealInterruptedInProgress.add(sk);
|
|
1365
|
+
try {
|
|
1366
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F4): SealCompletion is now a union — a refused
|
|
1367
|
+
// certificate resolves the waiter rather than dropping it, so this path must handle it
|
|
1368
|
+
// too rather than treating a refusal as a seal.
|
|
1369
|
+
let resolveSeal;
|
|
1370
|
+
const sealedP = new Promise((r) => { resolveSeal = r; });
|
|
1371
|
+
pendingSealWaiters.set(sk, resolveSeal);
|
|
1372
|
+
const submit = await sessionNodeManager.submitSealLeaf(agentName, sessionId, correlationId);
|
|
1373
|
+
if (!submit.ok && submit.reason !== "responder_seal_already_submitted") {
|
|
1374
|
+
pendingSealWaiters.delete(sk);
|
|
1375
|
+
if (submit.reason === "relay_unavailable") {
|
|
1376
|
+
const fallback = await handleActiveSealFlow(sessionId, record2, correlationId);
|
|
1377
|
+
if (fallback.ok) {
|
|
1378
|
+
logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "signaling_fallback" });
|
|
1379
|
+
}
|
|
1380
|
+
else {
|
|
1381
|
+
logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: fallback.reason, path: "signaling_fallback" });
|
|
1382
|
+
}
|
|
1035
1383
|
}
|
|
1036
1384
|
else {
|
|
1037
|
-
logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason:
|
|
1385
|
+
logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: submit.reason });
|
|
1038
1386
|
}
|
|
1387
|
+
return;
|
|
1039
1388
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1389
|
+
logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "relay" });
|
|
1390
|
+
const bilateralTimeoutMs = Number(process.env["CELLO_SEAL_BILATERAL_TIMEOUT_MS"]) || 660_000;
|
|
1391
|
+
let timer;
|
|
1392
|
+
const timeoutP = new Promise((r) => { timer = setTimeout(() => r(null), bilateralTimeoutMs); });
|
|
1393
|
+
const sealedCompletion = await Promise.race([sealedP, timeoutP]);
|
|
1394
|
+
clearTimeout(timer);
|
|
1395
|
+
pendingSealWaiters.delete(sk);
|
|
1396
|
+
if (sealedCompletion !== null) {
|
|
1397
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F4): a REFUSED certificate resolves the waiter
|
|
1398
|
+
// now rather than being dropped, so this path must tell the two apart — logging a
|
|
1399
|
+
// refusal as "sealed" would be the silent acceptance the whole bullet exists to stop.
|
|
1400
|
+
if ("refused" in sealedCompletion) {
|
|
1401
|
+
logger.error("session.away.inbox.oneshot.seal_refused", {
|
|
1402
|
+
agentName, sessionId, reason: sealedCompletion.reason, detail: sealedCompletion.detail,
|
|
1403
|
+
impact: "the away auto-seal was REFUSED: the directory returned a validly signed root " +
|
|
1404
|
+
"that does not describe this conversation. The session is NOT sealed and nothing " +
|
|
1405
|
+
"was signed with this agent's key.",
|
|
1406
|
+
});
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: sealedCompletion.rootHex });
|
|
1410
|
+
return;
|
|
1042
1411
|
}
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "relay" });
|
|
1046
|
-
const bilateralTimeoutMs = Number(process.env["CELLO_SEAL_BILATERAL_TIMEOUT_MS"]) || 660_000;
|
|
1047
|
-
let timer;
|
|
1048
|
-
const timeoutP = new Promise((r) => { timer = setTimeout(() => r(null), bilateralTimeoutMs); });
|
|
1049
|
-
const sealedCompletion = await Promise.race([sealedP, timeoutP]);
|
|
1050
|
-
clearTimeout(timer);
|
|
1051
|
-
pendingSealWaiters.delete(sk);
|
|
1052
|
-
if (sealedCompletion !== null) {
|
|
1053
|
-
logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: sealedCompletion.rootHex });
|
|
1054
|
-
return;
|
|
1055
|
-
}
|
|
1056
|
-
// Bilateral timeout — escalate to unilateral seal.
|
|
1057
|
-
const escalation = submit.ok
|
|
1058
|
-
? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
|
|
1059
|
-
: submit.reason === "responder_seal_already_submitted" &&
|
|
1060
|
-
typeof submit.reportedRootHex === "string" &&
|
|
1061
|
-
typeof submit.sequenceNumber === "number"
|
|
1412
|
+
// Bilateral timeout — escalate to unilateral seal.
|
|
1413
|
+
const escalation = submit.ok
|
|
1062
1414
|
? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
|
|
1063
|
-
:
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1415
|
+
: submit.reason === "responder_seal_already_submitted" &&
|
|
1416
|
+
typeof submit.reportedRootHex === "string" &&
|
|
1417
|
+
typeof submit.sequenceNumber === "number"
|
|
1418
|
+
? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
|
|
1419
|
+
: null;
|
|
1420
|
+
if (!escalation) {
|
|
1421
|
+
logger.warn("session.away.inbox.oneshot.seal_pending", { agentName, sessionId });
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
// DOD-M12B-SEAL-ESCALATE-DUP-1: THE SHARED ESCALATION, not a second copy.
|
|
1425
|
+
//
|
|
1426
|
+
// This used to be a line-for-line duplicate with its own hardcoded 30 s timeout, and
|
|
1427
|
+
// it missed every refusal the other one gained: an empty carry, a gappy chain, two of
|
|
1428
|
+
// our own ctrl leaves (permanently unsealable), and a bilateral seal already running.
|
|
1429
|
+
// It spent the full timeout on each and then reported `seal_unilateral_timeout` — the
|
|
1430
|
+
// label that names our own wait. Sharing the body is what stops that drifting again.
|
|
1431
|
+
const uni = await runUnilateralEscalation({
|
|
1432
|
+
logger, sessionNodeManager, sendOver, pendingUnilateralWaiters, sealKey,
|
|
1433
|
+
getKeyProvider: (a) => keyProviders.get(a),
|
|
1434
|
+
timeoutMs: UNILATERAL_SEAL_TIMEOUT_MS,
|
|
1435
|
+
}, agentName, sessionId, escalation, correlationId, { refuseOnUnusableCarry: true });
|
|
1436
|
+
if (uni.ok) {
|
|
1437
|
+
logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: uni.sealed_root, sealType: "unilateral" });
|
|
1438
|
+
}
|
|
1439
|
+
else {
|
|
1440
|
+
// CARRY THE GUIDANCE. The four refusals this path just gained come with the
|
|
1441
|
+
// sentence that tells an operator whether to retry or force-abandon — and the log
|
|
1442
|
+
// is this path's ONLY surface, so dropping it leaves a nameable cause with no
|
|
1443
|
+
// action attached. The old reasons had no guidance to lose; these do.
|
|
1444
|
+
logger.warn("session.away.inbox.oneshot.seal_unilateral_failed", {
|
|
1445
|
+
agentName, sessionId, reason: uni.reason, guidance: uni.guidance,
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1082
1448
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
// sentence that tells an operator whether to retry or force-abandon — and the log
|
|
1086
|
-
// is this path's ONLY surface, so dropping it leaves a nameable cause with no
|
|
1087
|
-
// action attached. The old reasons had no guidance to lose; these do.
|
|
1088
|
-
logger.warn("session.away.inbox.oneshot.seal_unilateral_failed", {
|
|
1089
|
-
agentName, sessionId, reason: uni.reason, guidance: uni.guidance,
|
|
1090
|
-
});
|
|
1449
|
+
finally {
|
|
1450
|
+
sealInterruptedInProgress.delete(sealKey(agentName, sessionId));
|
|
1091
1451
|
}
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
sealInterruptedInProgress.delete(sealKey(agentName, sessionId));
|
|
1095
|
-
}
|
|
1096
|
-
})();
|
|
1452
|
+
})();
|
|
1453
|
+
}
|
|
1097
1454
|
}
|
|
1098
1455
|
}
|
|
1099
1456
|
return;
|
|
@@ -1150,8 +1507,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1150
1507
|
? new Uint8Array(awayVerdict.content)
|
|
1151
1508
|
: draftBytes;
|
|
1152
1509
|
const contentBytes = new TextEncoder().encode(markAsAutoReply(new TextDecoder().decode(screenedBytes)));
|
|
1153
|
-
|
|
1154
|
-
const
|
|
1510
|
+
// B2b: one decision point for the hash AND its algorithm — see `contentHashForSession`.
|
|
1511
|
+
const away = await sessionNodeManager.contentHashForSession(agentName, sessionId, contentBytes);
|
|
1512
|
+
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(away.hash), randomUUID(), LEAF_KIND_MSG, away.alg);
|
|
1155
1513
|
if (!sendResult.ok && !sendResult.durable) {
|
|
1156
1514
|
// Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
|
|
1157
1515
|
// away period — clear the guard so the next inbound arrival retries the ack.
|
|
@@ -1165,7 +1523,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1165
1523
|
});
|
|
1166
1524
|
return;
|
|
1167
1525
|
}
|
|
1168
|
-
const contentHashHex = Buffer.from(
|
|
1526
|
+
const contentHashHex = Buffer.from(away.hash).toString("hex");
|
|
1169
1527
|
if (!sendResult.ok) {
|
|
1170
1528
|
// M12-P13 (found live 2026-08-05, M12 Entry 89): the reply is durably queued and already
|
|
1171
1529
|
// owns the sequence the relay witnessed for it, so its leaf MUST be committed here. Without
|
|
@@ -1177,9 +1535,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1177
1535
|
// next arrival would mint a second greeting at a second sequence.
|
|
1178
1536
|
// DOD-M12B-INDEX-1: the queued reply owns the position the relay witnessed for it, and
|
|
1179
1537
|
// that is where its leaf goes.
|
|
1180
|
-
|
|
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));
|
|
1181
1542
|
if (placedQueued.placed) {
|
|
1182
|
-
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID());
|
|
1543
|
+
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedQueued.leafIndex, "sent", contentBytes, randomUUID(), sentAuthorship(sendResult));
|
|
1183
1544
|
}
|
|
1184
1545
|
logger.info("session.away.response.deferred", {
|
|
1185
1546
|
agentName, sessionId, kind, isKnown,
|
|
@@ -1191,9 +1552,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1191
1552
|
}
|
|
1192
1553
|
// DOD-M12B-INDEX-1: the away responder fires while inbound is still arriving, so it is the
|
|
1193
1554
|
// path most likely to have a gap open under it — exactly where a tail append does damage.
|
|
1194
|
-
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));
|
|
1195
1556
|
if (placedReply.placed) {
|
|
1196
|
-
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID());
|
|
1557
|
+
sessionNodeManager.recordTranscriptMessage(agentName, sessionId, placedReply.leafIndex, "sent", contentBytes, randomUUID(), sentAuthorship(sendResult));
|
|
1197
1558
|
}
|
|
1198
1559
|
logger.info("session.away.response.sent", {
|
|
1199
1560
|
agentName, sessionId, kind, isKnown,
|
|
@@ -1382,6 +1743,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1382
1743
|
challengeVerifier,
|
|
1383
1744
|
getManifestVersion: () => verifiedManifestVersion,
|
|
1384
1745
|
loadedAgents,
|
|
1746
|
+
// DOD-M15-ERRSTRING-1: so a session failure can say "and 2 of your 5 directories are
|
|
1747
|
+
// unreachable", which is very often the actual cause and was reported nowhere the operator
|
|
1748
|
+
// was looking.
|
|
1749
|
+
getUnresolvedNodes,
|
|
1750
|
+
getDeclaredNodeCount,
|
|
1385
1751
|
});
|
|
1386
1752
|
// Both use the same SQLite DB as the SessionNodeManager (daemon.db equivalent).
|
|
1387
1753
|
// loadFromDb() must complete BEFORE IPC socket opens (AC-007).
|
|
@@ -1421,6 +1787,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1421
1787
|
// seal depends on — silently, while reporting success.
|
|
1422
1788
|
receiptStore: stores.receiptStore,
|
|
1423
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,
|
|
1424
1793
|
});
|
|
1425
1794
|
});
|
|
1426
1795
|
// DOD-RETRYQ-STRAND-1: a direct-resend row is reachable only by drainSession, which has no
|
|
@@ -1457,15 +1826,20 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1457
1826
|
onPersisted: (agentName, sessionId, contentHashHex) => {
|
|
1458
1827
|
retryQueue.markContentAcked(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"));
|
|
1459
1828
|
},
|
|
1460
|
-
|
|
1461
|
-
|
|
1829
|
+
// `DOD-M15-SEALWIRE-1` B2b-1 review F1 — `contentHashAlg` is the DURABLE WRITER for the column
|
|
1830
|
+
// this unit added. Without it every queued row carries NULL, the crash backstop re-parks a salted
|
|
1831
|
+
// message as sha256, and the recipient refuses it and re-pulls it forever. The commit that added
|
|
1832
|
+
// the column said the producer "passes it"; the producer was passing a value nothing supplied,
|
|
1833
|
+
// because these two hooks were never widened.
|
|
1834
|
+
onTtf: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) => {
|
|
1835
|
+
retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor, contentHashAlg);
|
|
1462
1836
|
},
|
|
1463
1837
|
// M12-P12: same durable destination, different cause — a park deposit the relay refused. The
|
|
1464
1838
|
// TTF timer is already cancelled on this path, so this is the only thing holding the content.
|
|
1465
1839
|
// M12-P13 (review HIGH-1): the enqueue's own answer is returned, never a bare `true`. A dropped
|
|
1466
1840
|
// copy that reports success now buys a committed hash-chain leaf for content that is gone.
|
|
1467
|
-
onParkFailed: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor) => {
|
|
1468
|
-
return retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor);
|
|
1841
|
+
onParkFailed: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) => {
|
|
1842
|
+
return retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor, contentHashAlg);
|
|
1469
1843
|
},
|
|
1470
1844
|
});
|
|
1471
1845
|
// MSG-001-3b (2b): the LIVE content-park deposit. On a not-confirmed send (direct delivery
|
|
@@ -1511,7 +1885,55 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1511
1885
|
logger.warn("session.seal.autoack.broker.unreachable", { agentName, brokerNode, correlationId });
|
|
1512
1886
|
return null;
|
|
1513
1887
|
});
|
|
1514
|
-
|
|
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
|
+
}
|
|
1936
|
+
sessionNodeManager.setContentParkHook(async ({ agentName, sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg }) => {
|
|
1515
1937
|
const node = sessionNodeManager.getStandingReceiverNode();
|
|
1516
1938
|
if (!node) {
|
|
1517
1939
|
const reason = "standing_receiver_unavailable";
|
|
@@ -1545,12 +1967,28 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1545
1967
|
// SEC-1: sealParkEnvelope is the SOLE producer — it signs (sender's K_local, over the
|
|
1546
1968
|
// session/recipient/content binding) and seals in one place, so the two park sites cannot drift
|
|
1547
1969
|
// apart on what gets signed.
|
|
1970
|
+
//
|
|
1971
|
+
// `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
|
|
1972
|
+
// MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
|
|
1973
|
+
// is salted is a fact about the message that was sent; what this side holds now says nothing
|
|
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.
|
|
1548
1983
|
const ciphertext = await sealParkEnvelope({
|
|
1549
1984
|
signer: senderKp,
|
|
1550
1985
|
sessionIdHex: sessionId,
|
|
1551
1986
|
recipientPubkey,
|
|
1552
1987
|
contentHash: contentHashBytes,
|
|
1553
1988
|
content,
|
|
1989
|
+
// The algorithm the DIRECT frame named for this same message. The park copy must claim what
|
|
1990
|
+
// the message actually is, not what this side would choose for it now.
|
|
1991
|
+
contentHashAlg,
|
|
1554
1992
|
structure1Cbor,
|
|
1555
1993
|
structure2Cbor,
|
|
1556
1994
|
});
|
|
@@ -1565,8 +2003,48 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1565
2003
|
logger.info("content.park.deposited", { sessionId, contentHash: contentHashHex, recipientPubkey: recipientPubkeyHex.slice(0, 16) });
|
|
1566
2004
|
return { ok: true };
|
|
1567
2005
|
}
|
|
1568
|
-
logger.warn("content.park.deposit.failed", {
|
|
1569
|
-
|
|
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
|
+
};
|
|
1570
2048
|
});
|
|
1571
2049
|
// CELLO-M7-MSG-001 (AC-004/AC-005, D-d): startup flush of locally-persisted un-acked
|
|
1572
2050
|
// content (the crash backstop). Runs HERE — before the IPC socket opens, consistent
|
|
@@ -1623,12 +2101,28 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1623
2101
|
// empty after a restart, so arrival order there means a wrong leaf index and a divergent tree.
|
|
1624
2102
|
// SEC-1: same sole producer as the live hook — the backstop signs from the persisted
|
|
1625
2103
|
// (sessionId, recipient, contentHash).
|
|
2104
|
+
//
|
|
2105
|
+
// `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
|
|
2106
|
+
// MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
|
|
2107
|
+
// is salted is a fact about the message that was sent; what this side holds now says nothing
|
|
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.
|
|
1626
2117
|
const ciphertext = await sealParkEnvelope({
|
|
1627
2118
|
signer: senderKp,
|
|
1628
2119
|
sessionIdHex: entry.sessionId,
|
|
1629
2120
|
recipientPubkey,
|
|
1630
2121
|
contentHash: contentHashBytes,
|
|
1631
2122
|
content: entry.contentBlob,
|
|
2123
|
+
// The queued row's own record of how it was hashed. `undefined` for a row written before the
|
|
2124
|
+
// column existed, which resolves to `sha256` — exactly what such a row actually used.
|
|
2125
|
+
contentHashAlg: entry.contentHashAlg,
|
|
1632
2126
|
structure1Cbor: entry.structure1Cbor,
|
|
1633
2127
|
structure2Cbor: entry.structure2Cbor,
|
|
1634
2128
|
});
|
|
@@ -1643,7 +2137,12 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1643
2137
|
logger.info("content.park.deposited", { sessionId: entry.sessionId, contentHash: entry.contentHashHex, source: "startup_flush" });
|
|
1644
2138
|
return { parked: true };
|
|
1645
2139
|
}
|
|
1646
|
-
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
|
+
};
|
|
1647
2146
|
};
|
|
1648
2147
|
// Re-park un-acked awaiting content to the relay store-and-forward queue. Runs once pre-IPC
|
|
1649
2148
|
// (the crash backstop) and again per-agent when an agent comes online — because post-DOD-LOOP-1
|
|
@@ -1701,7 +2200,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1701
2200
|
let parkedTotal = 0;
|
|
1702
2201
|
for (const s of sessions) {
|
|
1703
2202
|
try {
|
|
1704
|
-
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
|
+
});
|
|
1705
2220
|
}
|
|
1706
2221
|
catch (err) {
|
|
1707
2222
|
logger.error("content.park.flush.failed", {
|
|
@@ -1789,12 +2304,39 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1789
2304
|
return {
|
|
1790
2305
|
name: a.name,
|
|
1791
2306
|
state,
|
|
2307
|
+
/**
|
|
2308
|
+
* `selected` IS THIS CONNECTION'S VIEW, NOT THE AGENT'S — `DOD-M15-IPCVISIBLE-1` clause 3.
|
|
2309
|
+
*
|
|
2310
|
+
* Every `cello` CLI invocation opens a FRESH connection, which starts with no current
|
|
2311
|
+
* agent. So a client asking about its own state through the CLI always reads `false`, for
|
|
2312
|
+
* an agent it genuinely has selected in another session. Both Andre and a Hermes agent
|
|
2313
|
+
* misread it that way during one investigation, in opposite directions.
|
|
2314
|
+
*
|
|
2315
|
+
* The field name cannot be changed without breaking every reader, so it is ANNOTATED: the
|
|
2316
|
+
* sibling below says whose view this is, and `attended_by` says how many connections hold
|
|
2317
|
+
* this agent at all — which is the question people were actually asking.
|
|
2318
|
+
*/
|
|
1792
2319
|
selected,
|
|
2320
|
+
selected_by_this_connection: selected,
|
|
2321
|
+
attended_by: countAttendance(perConnectionState, a.name),
|
|
1793
2322
|
pubkey: a.pubkey,
|
|
1794
2323
|
// M8B F14 (fix 5): per-agent standing-receiver readiness on the MCP surface
|
|
1795
2324
|
// (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
|
|
1796
2325
|
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
|
|
1797
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
|
+
: {}),
|
|
1798
2340
|
// DOD-COATTEND-VISIBLE-1 AC2: how many sessions are driving this agent, including this
|
|
1799
2341
|
// one. Live, not a high-water mark — it drops when a session disconnects. `selected` says
|
|
1800
2342
|
// whether YOU hold it; this says whether anyone else does too.
|
|
@@ -2005,6 +2547,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2005
2547
|
//
|
|
2006
2548
|
// Omitted entirely when nothing is failing, so a healthy status stays quiet.
|
|
2007
2549
|
...(unresolvedNodesForStatus() ?? {}),
|
|
2550
|
+
// DOD-M15-MANIFEST-EXPIRY-LIVE-1: contributes NOTHING while the manifest is comfortably in
|
|
2551
|
+
// window. A field present on every status read for the years a manifest is valid is furniture,
|
|
2552
|
+
// not a warning, and it teaches the reader to skip the block that matters.
|
|
2553
|
+
...(describeManifestValidity(classifyManifestValidity(manifestProvider?.getCurrentManifest() ?? null, Date.now()), manifestOrigin) ?? {}),
|
|
2554
|
+
// DOD-M15-DIRAUTH-1: the posture is STATED, in both directions. Unlike every other field in
|
|
2555
|
+
// this milestone the healthy case is reported too — the defect is precisely that "enforced"
|
|
2556
|
+
// and "skipped" differ only by the absence of a log line, so an operator must be able to
|
|
2557
|
+
// confirm it is on, not merely fail to find evidence that it is off.
|
|
2558
|
+
...describeDirectoryAuth({
|
|
2559
|
+
verifierPresent: challengeVerifier !== undefined,
|
|
2560
|
+
directoryUrl: directoryHttpUrl ?? resolveDirectoryUrl(process.env),
|
|
2561
|
+
// Review F5: with neither set, `resolveDirectoryUrl` re-picks a RANDOM bundled endpoint on
|
|
2562
|
+
// every call, so there is no configured URL to quote or to blame.
|
|
2563
|
+
urlExplicitlyConfigured: directoryHttpUrl !== undefined || process.env["CELLO_DIRECTORY_URL"] !== undefined,
|
|
2564
|
+
}),
|
|
2008
2565
|
// M8B F14 (fix 5): per-agent standing-receiver readiness, so a deaf agent (online but
|
|
2009
2566
|
// no armed receiver) is visible in cello_status instead of hiding behind the ANY-agent
|
|
2010
2567
|
// aggregate below (kept for backward compatibility).
|
|
@@ -2020,6 +2577,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2020
2577
|
state: agentStateFor(a),
|
|
2021
2578
|
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(a.name),
|
|
2022
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
|
+
: {}),
|
|
2023
2585
|
})),
|
|
2024
2586
|
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
|
|
2025
2587
|
retryQueueDepth: retryQueue.getTotalDepth(),
|
|
@@ -2051,14 +2613,37 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2051
2613
|
// Permissive by design (D12): an agent that exists goes online regardless of directory
|
|
2052
2614
|
// registration state (online-without-registration is an established contract). Returns a
|
|
2053
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
|
+
*/
|
|
2054
2621
|
function startAgentInternal(name) {
|
|
2055
2622
|
const agent = agents.find((a) => a.name === name);
|
|
2056
2623
|
if (!agent || agent.state === "load_failed") {
|
|
2057
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.` };
|
|
2058
2625
|
}
|
|
2059
2626
|
if (onlineAgents.has(name)) {
|
|
2060
|
-
// Idempotent — already online, no event
|
|
2061
|
-
|
|
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
|
+
};
|
|
2062
2647
|
}
|
|
2063
2648
|
onlineAgents.add(name);
|
|
2064
2649
|
// Pressing start clears the deliberate-offline mark — that is what makes the switch reversible.
|
|
@@ -2084,7 +2669,18 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2084
2669
|
void sessionNodeManager.ensureStandingReceiverForAgent(name)
|
|
2085
2670
|
.then(() => flushAwaitingContent(name))
|
|
2086
2671
|
.catch((err) => {
|
|
2087
|
-
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
|
+
});
|
|
2088
2684
|
})
|
|
2089
2685
|
// DOD-MSG-4 (auto-recover-on-reconnect): RECEIVER drains its parked mailbox from every relay it
|
|
2090
2686
|
// has sessions on (symmetric to the sender re-park). Its own stage so a failure is labelled
|
|
@@ -2094,10 +2690,51 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2094
2690
|
.catch((err) => {
|
|
2095
2691
|
logger.warn("content.recover.auto.failed", { agentName: name, stage: "agent_start", error: extractErrorMessage(err) });
|
|
2096
2692
|
});
|
|
2097
|
-
|
|
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
|
+
});
|
|
2098
2721
|
// MCP-002: Broadcast agent_state_changed to ALL connections
|
|
2099
2722
|
notificationDispatcher.dispatchAgentStateChanged(name, "online", "started");
|
|
2100
|
-
|
|
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
|
+
};
|
|
2101
2738
|
}
|
|
2102
2739
|
// M8C-AUTOSTART-1 (F18): resolve which agent an agent-defaulting tool should act on for this
|
|
2103
2740
|
// connection: an explicit { agent } wins; else the connection's current agent; else — when EXACTLY one
|
|
@@ -2117,16 +2754,95 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2117
2754
|
* alice, alice was stopped, and the work lands on bob reporting success. A lost intent is not the
|
|
2118
2755
|
* same as no intent, and it must fail loud (no_current_agent) rather than be guessed at.
|
|
2119
2756
|
*/
|
|
2757
|
+
/**
|
|
2758
|
+
* Which agent this call acts as — `DOD-M15-SELECTION-1`, logic in `agent-selection.ts`.
|
|
2759
|
+
*
|
|
2760
|
+
* It used to end with `if (onlineAgents.size === 1) return [...onlineAgents][0]`, so a connection
|
|
2761
|
+
* that had selected nothing acted as whichever agent happened to be the only one online. On a
|
|
2762
|
+
* shared daemon that can be a DIFFERENT operator's agent, and a live MCP session was being bound
|
|
2763
|
+
* to an identity it never asked for.
|
|
2764
|
+
*
|
|
2765
|
+
* The resolution is now attributable: every path reports how it was reached, and `fallback` — the
|
|
2766
|
+
* one that was invisible — is logged at INFO. `DOD-M15-IPCVISIBLE-1`.
|
|
2767
|
+
*/
|
|
2768
|
+
/**
|
|
2769
|
+
* The fallback notice owed to THIS REQUEST's response — `DOD-M15-SELECTION-1` clause 2.
|
|
2770
|
+
*
|
|
2771
|
+
* ─── Why an AsyncLocalStorage and not a per-connection map ─────────────────────────────────────
|
|
2772
|
+
*
|
|
2773
|
+
* The first cut keyed a `WeakMap` on the per-connection state object, so the notice belonged to
|
|
2774
|
+
* the CONNECTION and was read back at the response boundary. Review found that hands the notice to
|
|
2775
|
+
* whichever response finishes first, and Claude Code issues tool calls in parallel:
|
|
2776
|
+
*
|
|
2777
|
+
* 1. the agent calls `cello_receive` — nothing selected, so the fallback resolves and records
|
|
2778
|
+
* the notice, then the handler BLOCKS for up to 30 s waiting for content;
|
|
2779
|
+
* 2. in the same turn it calls `cello_sessions {agent: "bob"}`, which names an agent explicitly
|
|
2780
|
+
* and never falls back;
|
|
2781
|
+
* 3. `cello_sessions` returns first and takes the notice on its way out.
|
|
2782
|
+
*
|
|
2783
|
+
* Bob's response now says *"no agent was selected, so 'solo' was used"* — false, on the one call
|
|
2784
|
+
* that did name an agent — and `cello_receive`, the call that actually fell back, says nothing.
|
|
2785
|
+
* Exactly inverted.
|
|
2786
|
+
*
|
|
2787
|
+
* A notice is a fact about ONE CALL, so it is stored in that call's async context. The store is
|
|
2788
|
+
* created per request in `renderedHandlers` and dies with it, which also means a handler that
|
|
2789
|
+
* THROWS cannot leave a notice behind to attach itself to some later, unrelated response — the
|
|
2790
|
+
* other half of the same review finding.
|
|
2791
|
+
*/
|
|
2792
|
+
const fallbackNoticeStore = new AsyncLocalStorage();
|
|
2120
2793
|
function resolveCurrentAgent(connState, explicitAgent) {
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2794
|
+
return resolveCurrentAgentFor({
|
|
2795
|
+
connState,
|
|
2796
|
+
onlineAgents,
|
|
2797
|
+
...(explicitAgent !== undefined ? { explicitAgent } : {}),
|
|
2798
|
+
onResolved: (agent, trigger) => {
|
|
2799
|
+
// Only the FALLBACK is announced. `explicit` and `selected` are the ordinary cases and
|
|
2800
|
+
// logging them would bury the one that matters — a signal that fires on the normal case is
|
|
2801
|
+
// not a signal.
|
|
2802
|
+
if (trigger !== "fallback")
|
|
2803
|
+
return;
|
|
2804
|
+
/**
|
|
2805
|
+
* RECORDED FOR THE RESPONSE, not just the log — `DOD-M15-SELECTION-1` clause 2.
|
|
2806
|
+
*
|
|
2807
|
+
* The log tells whoever reads the daemon log. The RESPONSE tells the agent that just acted
|
|
2808
|
+
* as an identity it never selected, which is the one that stops the half-attended state
|
|
2809
|
+
* being read as the protocol dropping messages.
|
|
2810
|
+
*/
|
|
2811
|
+
/**
|
|
2812
|
+
* No `connState` guard. The first cut wrote the notice only `if (agent && connState)`, and
|
|
2813
|
+
* `perConnectionState` is populated at `ipc.connect` — which `withIpc` in the CLI
|
|
2814
|
+
* (`core/cli/src/commands.ts`) does not send. So every plain `cello` invocation fell back
|
|
2815
|
+
* and got a response that said nothing, while the log line below fired regardless: the
|
|
2816
|
+
* clause asks for explicit IN THE RESPONSE, and those callers had it explicit in the log
|
|
2817
|
+
* only. The store is per-REQUEST, so it exists whether or not the connection ever
|
|
2818
|
+
* handshook.
|
|
2819
|
+
*/
|
|
2820
|
+
const store = fallbackNoticeStore.getStore();
|
|
2821
|
+
if (agent && store) {
|
|
2822
|
+
store.notice = {
|
|
2823
|
+
acting_as: agent,
|
|
2824
|
+
agent_selection: "fallback",
|
|
2825
|
+
agent_selection_guidance: `No agent was selected on this connection, so '${agent}' was used because it is the ` +
|
|
2826
|
+
`only one online. This is a per-call subject, NOT an attendance: doorbells route by ` +
|
|
2827
|
+
`the connection's registered agent and this does not set it. Two things follow — ` +
|
|
2828
|
+
`this session will not WAKE on an incoming message even though sending and reading ` +
|
|
2829
|
+
`work, and anyone who opens a session with you is sent an AWAY auto-reply while you ` +
|
|
2830
|
+
`sit here able to answer. Run cello_use_agent to fix both. Naming the agent on each ` +
|
|
2831
|
+
`call is NOT a remedy for either: it settles which agent a call is about and leaves ` +
|
|
2832
|
+
`the connection just as unattended.`,
|
|
2833
|
+
};
|
|
2834
|
+
}
|
|
2835
|
+
logger.info("agent.current.fallback", {
|
|
2836
|
+
agentName: agent,
|
|
2837
|
+
clientType: connState?.clientType ?? "cli",
|
|
2838
|
+
impact: "this call had no selected agent and exactly one was online, so that one was used. It " +
|
|
2839
|
+
"is a per-call subject, NOT an attendance: doorbells route by the connection's " +
|
|
2840
|
+
"registered agent, which this does not set.",
|
|
2841
|
+
guidance: "If this was not the intended agent, name it explicitly, or run cello_use_agent to " +
|
|
2842
|
+
"select one for the connection.",
|
|
2843
|
+
});
|
|
2844
|
+
},
|
|
2845
|
+
});
|
|
2130
2846
|
}
|
|
2131
2847
|
// Agent lifecycle (agent-handlers.ts): create, remove, start, stop, select, list.
|
|
2132
2848
|
registerAgentHandlers({
|
|
@@ -2216,6 +2932,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2216
2932
|
agentName,
|
|
2217
2933
|
persistence: getPersistence(agentName),
|
|
2218
2934
|
agentPubkeyHex: loaded.pubkey,
|
|
2935
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
|
|
2936
|
+
verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
|
|
2219
2937
|
keyProvider: loaded.keyProvider,
|
|
2220
2938
|
getNode: entry.getNode,
|
|
2221
2939
|
getDirectoryEndpoint: getFailoverEndpoint,
|
|
@@ -2260,6 +2978,28 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2260
2978
|
subject_kind: r.subjectKind,
|
|
2261
2979
|
subject: r.subject,
|
|
2262
2980
|
issuer_kind: r.issuerKind,
|
|
2981
|
+
/**
|
|
2982
|
+
* DOD-M15-SAMEOP-FALSEPOS-1: WHO ISSUED IT. The listing carried `subject` — who the signal is
|
|
2983
|
+
* ABOUT — and never who SAID it, so an operator could see "someone endorsed me" and not see
|
|
2984
|
+
* who. For the decision this list exists to support (do I rely on this endorsement?) the
|
|
2985
|
+
* author is the primary fact, and an endorsement nobody can attribute is worth roughly
|
|
2986
|
+
* nothing.
|
|
2987
|
+
*
|
|
2988
|
+
* `issuer_kind` reads like it answers this and does not: it says `"agent"` or `"portal"`, a
|
|
2989
|
+
* category. Elsewhere a field literally named `issuer` holds `"peer-claimed"` /
|
|
2990
|
+
* `"platform-verified"` — also a category. Two fields whose names promise identity, neither
|
|
2991
|
+
* carrying it.
|
|
2992
|
+
*
|
|
2993
|
+
* It also unblocks an investigation this omission stalled: four endorsements in a wallet all
|
|
2994
|
+
* read `same_operator: true`, and **nothing in the response could say whether the stranger's
|
|
2995
|
+
* was among them** — so "a stranger is flagged as self-dealing" and "the stranger's
|
|
2996
|
+
* endorsement never arrived" were indistinguishable from the listing. Different bugs, in
|
|
2997
|
+
* different places, and the field that separates them was already in the row.
|
|
2998
|
+
*
|
|
2999
|
+
* Discloses nothing new: the issuer pubkey is inside the notarized envelope the recipient
|
|
3000
|
+
* already holds and can already present.
|
|
3001
|
+
*/
|
|
3002
|
+
issuer_pubkey: r.issuerPubkey,
|
|
2263
3003
|
status: r.status,
|
|
2264
3004
|
issued_at: r.issuedAt,
|
|
2265
3005
|
expires_at: r.expiresAt,
|
|
@@ -3049,32 +3789,78 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3049
3789
|
});
|
|
3050
3790
|
// ─── MCP-001: cello_status (per-connection perspective) ───
|
|
3051
3791
|
/**
|
|
3052
|
-
* The directory-reachability block for `cello_status
|
|
3792
|
+
* The directory-reachability block for `cello_status`.
|
|
3793
|
+
*
|
|
3794
|
+
* `undefined` — i.e. silence — is reserved for ONE state: a reading taken recently enough to
|
|
3795
|
+
* speak for the present, which found nothing wrong. Every other combination emits, including an
|
|
3796
|
+
* EMPTY node list, because an empty list is not proof of health: it also means nothing has
|
|
3797
|
+
* looked, or looked too long ago to say. `DOD-M15-STALEROSTER-1`.
|
|
3053
3798
|
*
|
|
3054
|
-
*
|
|
3055
|
-
*
|
|
3799
|
+
* So presence of this block does NOT mean nodes are failing. Read `measurement` first —
|
|
3800
|
+
* `current` | `stale` | `never` | `not_configured` — and only then the node list.
|
|
3056
3801
|
*/
|
|
3057
3802
|
function unresolvedNodesForStatus() {
|
|
3058
3803
|
const failures = getUnresolvedNodes();
|
|
3059
|
-
|
|
3804
|
+
/**
|
|
3805
|
+
* DOD-M15-STALEROSTER-1 — the block is now gated on the AGE of the reading, not just on whether
|
|
3806
|
+
* it found anything.
|
|
3807
|
+
*
|
|
3808
|
+
* `if (failures.length === 0) return undefined` made two different states render identically:
|
|
3809
|
+
* "all three nodes answered a moment ago" and "nothing has ever looked". The second is
|
|
3810
|
+
* reachable — `verifyStartupManifest` returns without sweeping when the consortium manifest is
|
|
3811
|
+
* missing, not yet valid, EXPIRED, or rolled back — so a daemon with an expired manifest
|
|
3812
|
+
* reported no directory trouble at all.
|
|
3813
|
+
*
|
|
3814
|
+
* Silence is therefore reserved for the one case that has earned it: a RECENT reading that
|
|
3815
|
+
* found nothing wrong. Every other case says why it cannot make that claim.
|
|
3816
|
+
*/
|
|
3817
|
+
const freshness = describeRosterFreshness(classifyRosterReading(getUnresolvedSweptAt(), Date.now()), {
|
|
3818
|
+
// REVIEW F2: "no manifest configured" is DESIGNED (local dev, the e2e harness, or a
|
|
3819
|
+
// CELLO_DIRECTORY_URL that is not byte-equal to a bundled endpoint) and must not be dressed
|
|
3820
|
+
// as an alarm — it would fire on every local run. It still EMITS, because the line forbids
|
|
3821
|
+
// hiding the field; what differs is what the operator is told.
|
|
3822
|
+
manifestConfigured: manifestProvider !== undefined,
|
|
3823
|
+
...(lastRosterSweepError ? { lastSweepError: lastRosterSweepError } : {}),
|
|
3824
|
+
});
|
|
3825
|
+
if (failures.length === 0 && freshness.measurement === "current")
|
|
3060
3826
|
return undefined;
|
|
3061
3827
|
return {
|
|
3062
3828
|
directory_endpoints_unresolved: {
|
|
3063
|
-
// WHEN this was measured
|
|
3064
|
-
// reads as an ongoing outage: on 2026-08-09
|
|
3065
|
-
//
|
|
3066
|
-
//
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
"
|
|
3077
|
-
|
|
3829
|
+
// WHEN this was measured, and whether that is recent enough to mean anything. Without it the
|
|
3830
|
+
// block asserts the PRESENT, and a transient blip reads as an ongoing outage: on 2026-08-09
|
|
3831
|
+
// all three endpoints failed with ENETUNREACH for under a minute — a network transition on
|
|
3832
|
+
// the operator's machine — and the block went on reporting them unreachable long after they
|
|
3833
|
+
// answered again. True when taken, false when read.
|
|
3834
|
+
...freshness,
|
|
3835
|
+
// DOD-M15-BOOTSTRAP-1: `attempts` distinguishes a node that answered definitively (one
|
|
3836
|
+
// probe — a 404, a bad payload, its configuration) from one that never answered at all
|
|
3837
|
+
// (every probe spent — the path to it). Those call for opposite responses, and without the
|
|
3838
|
+
// count they rendered identically here.
|
|
3839
|
+
nodes: failures.map((f) => ({ node: f.nodeId, endpoint: f.endpoint, reason: f.reason, detail: f.detail, attempts: f.attempts })),
|
|
3840
|
+
guidance: failures.length === 0
|
|
3841
|
+
// The block is present with an EMPTY node list, which before DOD-M15-STALEROSTER-1 could
|
|
3842
|
+
// not happen. Saying "could not resolve these endpoints" here would be a flat lie — there
|
|
3843
|
+
// are no endpoints listed and the point is that nothing was measured. The empty list is
|
|
3844
|
+
// the ABSENCE of a reading, not a clean bill of health, and freshness_guidance above says
|
|
3845
|
+
// which of the two it is.
|
|
3846
|
+
? "This block is present with NO nodes listed, which does not mean the nodes are healthy — " +
|
|
3847
|
+
"it means this reading cannot support that claim. See freshness_guidance above for " +
|
|
3848
|
+
"whether the daemon has never measured, or measured too long ago to speak for the " +
|
|
3849
|
+
"present. Directory reachability is what threshold ceremonies depend on, so an " +
|
|
3850
|
+
"unmeasured roster is an unknown, not an all-clear."
|
|
3851
|
+
: "AS OF checked_at (this is a point-in-time reading, not necessarily now), this daemon could not "
|
|
3852
|
+
+ "resolve these directory endpoints, so the consortium roster was short and " +
|
|
3853
|
+
"threshold ceremonies will fail — sessions surface that as home_node_reports_no_receiver, " +
|
|
3854
|
+
"home_node_not_in_reachable_roster, directory_named_no_home, directory_below_threshold or " +
|
|
3855
|
+
"ceremony_exhausted, none of which name the real cause on their own (DOD-M15-ERRSTRING-1 " +
|
|
3856
|
+
"renamed the first three; they now append this shortfall to their own guidance). " +
|
|
3857
|
+
"Agents can still show 'online': signaling dials multiaddrs and does not need DNS. " +
|
|
3858
|
+
"If reason is dns_error after a directory restart or wake, the resolver is holding a cached " +
|
|
3859
|
+
"negative answer — flush it (macOS: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder). " +
|
|
3860
|
+
"Verify with node -e 'require(\"dns\").lookup(host,console.log)', NOT dig: dig bypasses the " +
|
|
3861
|
+
"cache this daemon is stuck behind, so it reports success while the daemon still fails. "
|
|
3862
|
+
+ "attempts:1 means the node ANSWERED and the answer was unusable — look at that node. "
|
|
3863
|
+
+ "attempts:2+ means it never answered — look at the path to it.",
|
|
3078
3864
|
},
|
|
3079
3865
|
};
|
|
3080
3866
|
}
|
|
@@ -3093,6 +3879,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3093
3879
|
//
|
|
3094
3880
|
// Omitted entirely when nothing is failing, so a healthy status stays quiet.
|
|
3095
3881
|
...(unresolvedNodesForStatus() ?? {}),
|
|
3882
|
+
// DOD-M15-MANIFEST-EXPIRY-LIVE-1: contributes NOTHING while the manifest is comfortably in
|
|
3883
|
+
// window. A field present on every status read for the years a manifest is valid is furniture,
|
|
3884
|
+
// not a warning, and it teaches the reader to skip the block that matters.
|
|
3885
|
+
...(describeManifestValidity(classifyManifestValidity(manifestProvider?.getCurrentManifest() ?? null, Date.now()), manifestOrigin) ?? {}),
|
|
3886
|
+
// DOD-M15-DIRAUTH-1: the posture is STATED, in both directions. Unlike every other field in
|
|
3887
|
+
// this milestone the healthy case is reported too — the defect is precisely that "enforced"
|
|
3888
|
+
// and "skipped" differ only by the absence of a log line, so an operator must be able to
|
|
3889
|
+
// confirm it is on, not merely fail to find evidence that it is off.
|
|
3890
|
+
...describeDirectoryAuth({
|
|
3891
|
+
verifierPresent: challengeVerifier !== undefined,
|
|
3892
|
+
directoryUrl: directoryHttpUrl ?? resolveDirectoryUrl(process.env),
|
|
3893
|
+
// Review F5: with neither set, `resolveDirectoryUrl` re-picks a RANDOM bundled endpoint on
|
|
3894
|
+
// every call, so there is no configured URL to quote or to blame.
|
|
3895
|
+
urlExplicitlyConfigured: directoryHttpUrl !== undefined || process.env["CELLO_DIRECTORY_URL"] !== undefined,
|
|
3896
|
+
}),
|
|
3096
3897
|
agents: getAgentsForConnection(connectionId),
|
|
3097
3898
|
// M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
|
|
3098
3899
|
// the daemon-wide getStatus() surfaces them.
|
|
@@ -3173,6 +3974,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3173
3974
|
// cello_close_session (close-session-handler.ts). Fifteen dependencies — a long list, but a KNOWN
|
|
3174
3975
|
// one, which is the whole difference from a closure over 73 shared locals.
|
|
3175
3976
|
registerCloseSessionHandler({
|
|
3977
|
+
// DOD-M15-CLOSEWAIT-1 review MEDIUM-6: a detached seal tail is registered here so stop() can
|
|
3978
|
+
// drain it, like every other background worker. Self-evicting, so a long-running daemon does not
|
|
3979
|
+
// accumulate settled promises.
|
|
3980
|
+
sealFailures,
|
|
3981
|
+
registerBackgroundSeal: (p) => {
|
|
3982
|
+
backgroundSeals.add(p);
|
|
3983
|
+
void p.finally(() => backgroundSeals.delete(p));
|
|
3984
|
+
},
|
|
3176
3985
|
// M12-P14: the pre-seal readiness gate drains the parked mailbox before judging, so a close
|
|
3177
3986
|
// does not refuse over content the relay is still holding for us.
|
|
3178
3987
|
recoverParkedContent: (agentName, trigger) => autoRecoverForAgent(agentName, trigger),
|
|
@@ -3210,9 +4019,74 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3210
4019
|
handleActiveSealFlow,
|
|
3211
4020
|
resolveConsortiumRoster,
|
|
3212
4021
|
});
|
|
4022
|
+
/**
|
|
4023
|
+
* DOD-M15-BACKUP-1 — export the agent, so losing the machine does not lose the identity.
|
|
4024
|
+
*
|
|
4025
|
+
* `VACUUM INTO` takes a consistent snapshot with the database still open, so this is safe to call
|
|
4026
|
+
* on a running daemon — which matters, because the daemon is what serves the tool.
|
|
4027
|
+
*/
|
|
4028
|
+
handlers.set("cello_backup", async (params, _connectionId) => {
|
|
4029
|
+
const outPath = typeof params?.["path"] === "string" ? params["path"] : "";
|
|
4030
|
+
if (!outPath) {
|
|
4031
|
+
return {
|
|
4032
|
+
ok: false,
|
|
4033
|
+
reason: "missing_path",
|
|
4034
|
+
guidance: "Give an absolute path to write the backup to, e.g. { path: \"/Users/you/cello-agent.cello-backup\" }. " +
|
|
4035
|
+
"It is not written to a default location on purpose: the file contains the key to your agent, " +
|
|
4036
|
+
"so where it lands is a decision you should make deliberately.",
|
|
4037
|
+
};
|
|
4038
|
+
}
|
|
4039
|
+
const res = await createBackup({
|
|
4040
|
+
dbPath: join(celloDir, "sessions.db"),
|
|
4041
|
+
outPath,
|
|
4042
|
+
logger,
|
|
4043
|
+
...(params?.["overwrite"] === true ? { overwrite: true } : {}),
|
|
4044
|
+
});
|
|
4045
|
+
return res.ok
|
|
4046
|
+
? { ok: true, path: res.path, bytes: res.bytes, guidance: res.guidance }
|
|
4047
|
+
: { ok: false, reason: res.reason, guidance: res.guidance };
|
|
4048
|
+
});
|
|
4049
|
+
/**
|
|
4050
|
+
* RESTORE IS REFUSED WHILE THIS DAEMON IS RUNNING, and that is not a limitation to apologise for.
|
|
4051
|
+
*
|
|
4052
|
+
* The daemon holds the database open with a write lock. Overwriting the file underneath an open
|
|
4053
|
+
* SQLite handle risks the handle flushing its own pages back over the restored ones — the restore
|
|
4054
|
+
* appears to succeed and the database is a hybrid of two identities, which is worse than either
|
|
4055
|
+
* failing cleanly.
|
|
4056
|
+
*
|
|
4057
|
+
* The capability itself lives in `backup-restore.ts` (a daemon module, per the DoD), and the CLI
|
|
4058
|
+
* calls it with the daemon stopped. So the honest answer here is the exact sequence, not an
|
|
4059
|
+
* attempt.
|
|
4060
|
+
*/
|
|
4061
|
+
handlers.set("cello_restore", async (params, _connectionId) => {
|
|
4062
|
+
const archivePath = typeof params?.["path"] === "string" ? params["path"] : "";
|
|
4063
|
+
const inspected = archivePath ? await inspectBackup(archivePath) : null;
|
|
4064
|
+
return {
|
|
4065
|
+
ok: false,
|
|
4066
|
+
reason: "daemon_running",
|
|
4067
|
+
...(inspected?.ok
|
|
4068
|
+
? { archive_verified: true, backup_created_at: new Date(inspected.createdAt).toISOString() }
|
|
4069
|
+
: inspected
|
|
4070
|
+
? { archive_verified: false, archive_problem: inspected.reason }
|
|
4071
|
+
: {}),
|
|
4072
|
+
guidance: (inspected?.ok
|
|
4073
|
+
? `That archive is valid (taken ${new Date(inspected.createdAt).toISOString()}). `
|
|
4074
|
+
: inspected
|
|
4075
|
+
? `WARNING: that archive did not validate (${inspected.reason}) — fix that before going further. `
|
|
4076
|
+
: "") +
|
|
4077
|
+
"Restoring REPLACES this machine's agent database, and it cannot be done while the daemon " +
|
|
4078
|
+
"is holding it open — the running daemon could flush its own pages back over the restored " +
|
|
4079
|
+
"ones and leave a database that is half one identity and half another. Stop the daemon " +
|
|
4080
|
+
"first:\n\n cello logout\n cello restore " +
|
|
4081
|
+
(archivePath || "<archive>") +
|
|
4082
|
+
"\n cello login\n\n" +
|
|
4083
|
+
"Anything that happened on this machine since the backup was taken will be gone — restore " +
|
|
4084
|
+
"replaces, it does not merge.",
|
|
4085
|
+
};
|
|
4086
|
+
});
|
|
3213
4087
|
// ─── MCP-001: stubs for tools registered in cello-mcp.ts but not yet implemented ───
|
|
3214
4088
|
// These return not_implemented (same as session tools) so LLMs get consistent guidance.
|
|
3215
|
-
for (const tool of ["
|
|
4089
|
+
for (const tool of ["cello_get_inclusion_proof"]) {
|
|
3216
4090
|
handlers.set(tool, async (_params, _connectionId) => {
|
|
3217
4091
|
return { ok: false, reason: "not_implemented", guidance: `'${tool}' is not yet implemented in the daemon. This feature will be available in a future milestone.` };
|
|
3218
4092
|
});
|
|
@@ -3234,6 +4108,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3234
4108
|
logger,
|
|
3235
4109
|
sessionNodeManager,
|
|
3236
4110
|
loadedAgents,
|
|
4111
|
+
// DOD-M15-CLOSEWAIT-1 review HIGH-2: the SAME predicate cello_status uses, so the two surfaces
|
|
4112
|
+
// cannot disagree about whether a ceremony is in flight. Both maps, because either can be the
|
|
4113
|
+
// one running — pendingSealWaiters is the active close, sealInterruptedInProgress the interrupted.
|
|
4114
|
+
isSealing: (agentName, sessionId) => pendingSealWaiters.has(sealKey(agentName, sessionId)) ||
|
|
4115
|
+
sealInterruptedInProgress.has(sealKey(agentName, sessionId)),
|
|
4116
|
+
// DOD-M15-SEAL-FAILED-TERMINAL-1: the SAME store the close handler writes, so a failure recorded
|
|
4117
|
+
// by the detached tail is the one the receipt surface reads.
|
|
4118
|
+
getSealFailure: (agentName, sessionId) => sealFailures.get(agentName, sessionId),
|
|
3237
4119
|
getConnState: (connectionId) => perConnectionState.get(connectionId),
|
|
3238
4120
|
resolveCurrentAgent,
|
|
3239
4121
|
NO_CURRENT_AGENT_RESPONSE,
|
|
@@ -3308,7 +4190,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3308
4190
|
return { error: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass agentName." };
|
|
3309
4191
|
}
|
|
3310
4192
|
const agentId = sessionNodeManager.resolveAgentId(agentName);
|
|
3311
|
-
|
|
4193
|
+
// The fault-injection IPC seam. It states all three trailing values explicitly rather than
|
|
4194
|
+
// relying on defaults (B2b-1 pass-2 F1): `undefined` for the ordering record and the algorithm
|
|
4195
|
+
// is what this path genuinely has — it injects a bare queued entry — and saying so keeps the
|
|
4196
|
+
// seam honest about what it is producing rather than inheriting whatever the signature assumed.
|
|
4197
|
+
retryQueue.enqueueAwaitingContent(agentId, sessionId, Buffer.from(contentHashHex, "hex"), Buffer.from(contentHex, "hex"), undefined, undefined, undefined);
|
|
3312
4198
|
return { queued: true, awaitingDepth: retryQueue.getAwaitingDepth(agentId, sessionId) };
|
|
3313
4199
|
});
|
|
3314
4200
|
// CELLO-M7-MSG-001: a `persisted` delivery ACK (or a confirmed park) clears the durable
|
|
@@ -3395,7 +4281,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3395
4281
|
const sessionId = params?.sessionId;
|
|
3396
4282
|
if (!agentName || !sessionId)
|
|
3397
4283
|
return { error: "missing_params", guidance: "Provide agentName and sessionId." };
|
|
3398
|
-
|
|
4284
|
+
// The reason is VALIDATED against the closed union rather than cast through it. A test seam
|
|
4285
|
+
// that can inject a reason production cannot produce would let a test prove the inbox handles a
|
|
4286
|
+
// code no refusal path emits — which is the shape DOD-M15-GUARD-HEARD-1 exists to remove, and a
|
|
4287
|
+
// seam is not exempt from it.
|
|
4288
|
+
const asked = params?.reason ?? CAPACITY_REASONS.ABUSE_BOUND_SESSIONS_PER_SENDER;
|
|
4289
|
+
const known = [
|
|
4290
|
+
...Object.values(REFUSAL_REASONS),
|
|
4291
|
+
...Object.values(CAPACITY_REASONS),
|
|
4292
|
+
];
|
|
4293
|
+
if (!known.includes(asked)) {
|
|
4294
|
+
return {
|
|
4295
|
+
error: "unknown_refusal_reason",
|
|
4296
|
+
guidance: `"${asked}" is not a reason any refusal path can emit. Use one of: ${known.join(", ")}. ` +
|
|
4297
|
+
`Seeding an invented reason would test the inbox against a code production never produces.`,
|
|
4298
|
+
};
|
|
4299
|
+
}
|
|
4300
|
+
recordRefusal(agentName, sessionId, params?.counterpartyPubkey ?? "", asked);
|
|
3399
4301
|
return { ok: true };
|
|
3400
4302
|
});
|
|
3401
4303
|
handlers.set("__test_delivery_open_begin", async (params, _connectionId) => {
|
|
@@ -3572,12 +4474,27 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3572
4474
|
if (!handler)
|
|
3573
4475
|
return undefined;
|
|
3574
4476
|
return async (params, connectionId) => {
|
|
3575
|
-
|
|
4477
|
+
/**
|
|
4478
|
+
* ONE request, ONE store — and the fallback notice is spread in BEFORE `renderForSurface`.
|
|
4479
|
+
*
|
|
4480
|
+
* `DOD-M15-SELECTION-1` clause 2 first annotated the response out in `ipc-server.ts`, which
|
|
4481
|
+
* is downstream of this wrapper and therefore downstream of surface rendering. The notice
|
|
4482
|
+
* says *"Run cello_use_agent"*; `isInstructionKey` in `vocabulary.ts` rewrites any key
|
|
4483
|
+
* ending in `guidance`, so that WOULD have become `cello use-agent` for a terminal — but it
|
|
4484
|
+
* arrived after the rewrite had already run. An operator running `cello inbox` was handed a
|
|
4485
|
+
* verb that does not exist in a shell, which is the exact failure the vocabulary layer was
|
|
4486
|
+
* built to prevent. Annotating here puts it back in front of the renderer.
|
|
4487
|
+
*/
|
|
4488
|
+
const store = {};
|
|
4489
|
+
const result = await fallbackNoticeStore.run(store, () => handler(params, connectionId));
|
|
4490
|
+
const annotated = store.notice && result !== null && typeof result === "object" && !Array.isArray(result)
|
|
4491
|
+
? { ...result, ...store.notice }
|
|
4492
|
+
: result;
|
|
3576
4493
|
// Default to "cli": a connection that never sent ipc.connect has no recorded surface, and
|
|
3577
4494
|
// the CLI verb is the safe answer — it is at least a real command an operator can run,
|
|
3578
4495
|
// whereas an MCP tool name is useless in a terminal.
|
|
3579
4496
|
const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
|
|
3580
|
-
return renderForSurface(
|
|
4497
|
+
return renderForSurface(annotated, surface);
|
|
3581
4498
|
};
|
|
3582
4499
|
},
|
|
3583
4500
|
};
|
|
@@ -3828,7 +4745,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3828
4745
|
logger.error("document.delivery.seal_failed", { agent, sessionId, reason: "close_handler_missing", correlationId });
|
|
3829
4746
|
return;
|
|
3830
4747
|
}
|
|
3831
|
-
|
|
4748
|
+
/**
|
|
4749
|
+
* `wait_for_seal: true` — DOD-M15-CLOSEWAIT-1 review MEDIUM-5.
|
|
4750
|
+
*
|
|
4751
|
+
* This is NOT an IPC caller. It is an in-process worker awaiting the close for a session
|
|
4752
|
+
* it opened itself, and the `ok !== true` check below is the only thing that ever reports
|
|
4753
|
+
* a failed document-delivery seal. The new default answers `ok: true` at COMMITMENT, so
|
|
4754
|
+
* without this flag that check could never fire for a ceremony failure again — §16.4's
|
|
4755
|
+
* "the autonomous session still carries the seal" would rest on a detached task nobody
|
|
4756
|
+
* awaited, retried or reported on.
|
|
4757
|
+
*
|
|
4758
|
+
* The whole point of answering early is that a human is watching a terminal. Nobody is
|
|
4759
|
+
* watching this one, so it takes the blocking form — which also stops each delivery
|
|
4760
|
+
* leaving a detached ceremony holding a visiting connection for up to eleven minutes,
|
|
4761
|
+
* overlapping instead of serialising.
|
|
4762
|
+
*/
|
|
4763
|
+
const sealed = (await close({ session_id: sessionId, agent, wait_for_seal: true }, `doc-delivery-${correlationId}`));
|
|
3832
4764
|
if (sealed?.ok !== true) {
|
|
3833
4765
|
// `cello_close_session` has distinct failure codes — session_already_sealed,
|
|
3834
4766
|
// seal_interrupted_*, signaling_reconnecting — and every one of them landed nowhere.
|
|
@@ -3841,7 +4773,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3841
4773
|
// transport asked for 0x04, the composition root threw it away, and every document leaf
|
|
3842
4774
|
// still reached the relay as a MESSAGE. Verified on live traffic: daemon 0.0.145 shipped
|
|
3843
4775
|
// the fix everywhere except here and the wire was unchanged.
|
|
3844
|
-
|
|
4776
|
+
//
|
|
4777
|
+
// ⚠️ AND `contentHashAlg` IS THE SECOND PARAMETER THIS ADAPTER MUST NOT DROP. The note above
|
|
4778
|
+
// is about `leafKind`, which this wrapper silently swallowed while every other caller passed
|
|
4779
|
+
// it — a thin pass-through is exactly where a new argument goes missing, because nothing
|
|
4780
|
+
// about the call site looks wrong afterwards. B2b's failure mode if it happens again: the
|
|
4781
|
+
// document path sends a salted hash labelled `sha256`, and every peer refuses it.
|
|
4782
|
+
sendContent: (agent, sessionId, content, contentHash, correlationId, leafKind, contentHashAlg) => sessionNodeManager.sendContent(agent, sessionId, content, contentHash, correlationId, leafKind, contentHashAlg),
|
|
4783
|
+
contentHashForSession: (agent, sessionId, content) => sessionNodeManager.contentHashForSession(agent, sessionId, content),
|
|
3845
4784
|
// The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
|
|
3846
4785
|
// successful send. See the comment at the call site for why this is delivery-critical and
|
|
3847
4786
|
// not audit bookkeeping.
|
|
@@ -3850,7 +4789,31 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3850
4789
|
// (deliberate — f75ea09), so it obeys the same discipline a message does — including
|
|
3851
4790
|
// being HELD when the position is ahead of the tail, which is why the real frame bytes
|
|
3852
4791
|
// have to travel with it.
|
|
3853
|
-
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);
|
|
3854
4817
|
return { placed: placed.placed, leafIndex: placed.placed ? placed.leafIndex : null };
|
|
3855
4818
|
},
|
|
3856
4819
|
});
|
|
@@ -4012,6 +4975,33 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4012
4975
|
// MCP-001: Clean up per-connection state when a connection disconnects
|
|
4013
4976
|
// MCP-002: Also unregister from notification dispatcher
|
|
4014
4977
|
ipcServer.onDisconnect((connectionId) => {
|
|
4978
|
+
/**
|
|
4979
|
+
* DOD-M15-IPCVISIBLE-1: SAY THAT IT CLOSED, and say what it was attending.
|
|
4980
|
+
*
|
|
4981
|
+
* `daemon.ipc.connected` fired on every open and nothing on close, so a live client and a dead
|
|
4982
|
+
* one that was never cleaned up looked identical in the log. The attended agent is the field
|
|
4983
|
+
* that matters: attendance dropping was silent, and an agent losing its last attendee changes
|
|
4984
|
+
* whether away-messages fire and who receives doorbells — so a session that stopped waking is
|
|
4985
|
+
* diagnosable from the log rather than by guesswork.
|
|
4986
|
+
*/
|
|
4987
|
+
const closing = perConnectionState.get(connectionId);
|
|
4988
|
+
const stillAttending = closing?.currentAgent
|
|
4989
|
+
? countAttendance(perConnectionState, closing.currentAgent) - 1
|
|
4990
|
+
: null;
|
|
4991
|
+
// RETURNED, not logged here — `ipcServer` merges this into its single
|
|
4992
|
+
// `daemon.ipc.disconnected` line. A second line under the same name left neither carrying the
|
|
4993
|
+
// whole picture and doubled every count (review F8).
|
|
4994
|
+
const disconnectContext = {
|
|
4995
|
+
clientType: closing?.clientType ?? "unknown",
|
|
4996
|
+
attendedAgent: closing?.currentAgent ?? null,
|
|
4997
|
+
...(stillAttending !== null ? { remainingAttendance: stillAttending } : {}),
|
|
4998
|
+
...(stillAttending === 0
|
|
4999
|
+
? {
|
|
5000
|
+
impact: "that agent has no attending session left — inbound sessions are now answered with " +
|
|
5001
|
+
"its away message rather than a live reply, and its doorbells reach nobody",
|
|
5002
|
+
}
|
|
5003
|
+
: {}),
|
|
5004
|
+
};
|
|
4015
5005
|
perConnectionState.delete(connectionId);
|
|
4016
5006
|
connectionCursors.delete(connectionId); // M8C-CURSOR-1: cursor is connection-scoped, dies with it
|
|
4017
5007
|
// ...and so is the delivery bookmark (review F1). It is a SEPARATE map from the gate's cursor
|
|
@@ -4044,6 +5034,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4044
5034
|
else
|
|
4045
5035
|
inboundSessionWaiters.delete(agentName);
|
|
4046
5036
|
}
|
|
5037
|
+
return disconnectContext;
|
|
4047
5038
|
});
|
|
4048
5039
|
// Log daemon.login.validation.complete (stub — all unverified until SIGNAL-001)
|
|
4049
5040
|
logger.info("daemon.login.validation.complete", {
|
|
@@ -4068,6 +5059,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4068
5059
|
async function stop(reason) {
|
|
4069
5060
|
clearInterval(reconcileSweepTimer);
|
|
4070
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();
|
|
4071
5067
|
// DOD-M12B-SHUTDOWN-1: clearing the timer only stops the NEXT tick. The pass already running
|
|
4072
5068
|
// walks every agent, and each step dials a peer and opens a session — which is why a daemon
|
|
4073
5069
|
// reported down, with its socket already removed, was still logging `document.reconcile.sweep`
|
|
@@ -4094,6 +5090,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4094
5090
|
if (manifestPollScheduler) {
|
|
4095
5091
|
manifestPollScheduler.cancel();
|
|
4096
5092
|
}
|
|
5093
|
+
// DOD-M15-STALEROSTER-1: same discipline as the manifest poll — the stop function sets the
|
|
5094
|
+
// flag so an in-flight sweep cannot re-arm, and the belt-and-suspenders cancel covers the
|
|
5095
|
+
// scheduler-without-sweep case.
|
|
5096
|
+
stopRosterSweep?.();
|
|
5097
|
+
rosterSweepScheduler?.cancel();
|
|
4097
5098
|
stopRegistryPoll?.();
|
|
4098
5099
|
if (config.registryPollScheduler) {
|
|
4099
5100
|
config.registryPollScheduler.cancel();
|
|
@@ -4103,6 +5104,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4103
5104
|
// outbound work is not draining. Above the `daemon.stopped` log with the other cancels, because
|
|
4104
5105
|
// this is "stop making new work", not "tear down transports".
|
|
4105
5106
|
await restartSealResolver?.stop();
|
|
5107
|
+
/**
|
|
5108
|
+
* DRAIN THE DETACHED SEAL TAILS — review MEDIUM-6, and it sits HERE for the same reason
|
|
5109
|
+
* `restartSealResolver.stop()` does: both are directory ceremonies that must not be cut with
|
|
5110
|
+
* the counterparty holding a commitment this side never acknowledged. Before
|
|
5111
|
+
* `stopAllSignaling()`, because that is what severs the transport underneath them.
|
|
5112
|
+
*
|
|
5113
|
+
* BOUNDED. A ceremony can legitimately wait eleven minutes for a counterparty, and a shutdown
|
|
5114
|
+
* must not. Past the bound they are abandoned deliberately and said out loud — the next boot
|
|
5115
|
+
* resolves them, which is exactly what the restart seal resolver is for.
|
|
5116
|
+
*/
|
|
5117
|
+
if (backgroundSeals.size > 0) {
|
|
5118
|
+
const SHUTDOWN_SEAL_DRAIN_MS = 5_000;
|
|
5119
|
+
logger.info("session.seal.background.draining", { count: backgroundSeals.size, budgetMs: SHUTDOWN_SEAL_DRAIN_MS });
|
|
5120
|
+
const drained = await Promise.race([
|
|
5121
|
+
Promise.allSettled([...backgroundSeals]).then(() => true),
|
|
5122
|
+
new Promise((r) => { const t = setTimeout(() => r(false), SHUTDOWN_SEAL_DRAIN_MS); t.unref?.(); }),
|
|
5123
|
+
]);
|
|
5124
|
+
if (!drained) {
|
|
5125
|
+
logger.warn("session.seal.background.abandoned", {
|
|
5126
|
+
count: backgroundSeals.size,
|
|
5127
|
+
impact: "shutdown did not wait for these seal ceremonies. Each session holds a durable commitment " +
|
|
5128
|
+
"but no receipt yet, and the counterparty may hold a commitment this side never acknowledged.",
|
|
5129
|
+
guidance: "The next daemon start resolves them via the restart seal resolver; no operator action is needed.",
|
|
5130
|
+
});
|
|
5131
|
+
}
|
|
5132
|
+
}
|
|
4106
5133
|
logger.info("daemon.stopped", { pid: process.pid, reason });
|
|
4107
5134
|
// DOD-LOGOUT-EXIT-1: what the teardown actually DID, carried to onStopped so the binary can
|
|
4108
5135
|
// exit non-zero on a dirty stop. Without it a shutdown that threw halfway — sessions never
|