@cello-protocol/daemon 0.0.181 → 0.0.182
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/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 +65 -7
- package/dist/consortium-bootstrap.js.map +1 -1
- package/dist/content-park.d.ts.map +1 -1
- package/dist/content-park.js +141 -10
- 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 +866 -122
- 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/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/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-validity.d.ts +151 -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 +198 -17
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/park-envelope.d.ts +72 -1
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +119 -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/registration-manager.d.ts.map +1 -1
- package/dist/registration-manager.js +20 -1
- package/dist/registration-manager.js.map +1 -1
- 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 +23 -1
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +37 -9
- 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 +29 -0
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +71 -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.map +1 -1
- package/dist/session-content-handlers.js +110 -4
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +406 -36
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +3025 -245
- 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 +112 -6
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-relay-client.d.ts +61 -2
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +170 -11
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-salt-agreement.d.ts +284 -0
- package/dist/session-salt-agreement.d.ts.map +1 -0
- package/dist/session-salt-agreement.js +371 -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.map +1 -1
- package/dist/signaling-connect.js +42 -5
- package/dist/signaling-connect.js.map +1 -1
- package/dist/types.d.ts +54 -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 +56 -0
- package/dist/wire-content-hash.d.ts.map +1 -1
- package/dist/wire-content-hash.js +71 -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";
|
|
@@ -71,7 +77,10 @@ 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";
|
|
@@ -109,6 +121,20 @@ const SESSION_NODE_KEY_STUB = {
|
|
|
109
121
|
};
|
|
110
122
|
// Production session node factory — wraps createNode from @cello-protocol/transport
|
|
111
123
|
export class ProductionSessionNodeFactory {
|
|
124
|
+
logger;
|
|
125
|
+
/**
|
|
126
|
+
* DOD-M15-IDLE-CONNS-1 — OPTIONAL, and optional for one reason only: this factory is constructed
|
|
127
|
+
* with no arguments in four existing tests, and requiring a logger would turn them into type
|
|
128
|
+
* errors for a unit that has nothing to do with them.
|
|
129
|
+
*
|
|
130
|
+
* The cost is stated rather than hidden: with no logger, a reaped connection is silent, which is
|
|
131
|
+
* exactly the "guard nobody hears" this milestone has found four times. `startDaemon` passes one
|
|
132
|
+
* — that is the production path — and the sweep only runs on nodes this factory builds as
|
|
133
|
+
* receivers, so the silent case is a test-only fixture.
|
|
134
|
+
*/
|
|
135
|
+
constructor(logger) {
|
|
136
|
+
this.logger = logger;
|
|
137
|
+
}
|
|
112
138
|
async createNode(config) {
|
|
113
139
|
// DOD-NAT-REACHABILITY-1: the STANDING RECEIVER — the node that accepts every
|
|
114
140
|
// inbound session — must bind a ROUTABLE interface by default. The old
|
|
@@ -145,6 +171,106 @@ export class ProductionSessionNodeFactory {
|
|
|
145
171
|
// than passed as undefined) when absent, keeping createNode's "generate a fresh key" default
|
|
146
172
|
// for every node that is not session-scoped.
|
|
147
173
|
...(config.transportPrivateKey ? { transportPrivateKey: config.transportPrivateKey } : {}),
|
|
174
|
+
/**
|
|
175
|
+
* DOD-M15-IDLE-CONNS-1 — the idle sweep is armed on the node that STARTS as a standing
|
|
176
|
+
* receiver.
|
|
177
|
+
*
|
|
178
|
+
* **It is NOT "the standing receiver only", and an earlier version of this comment said so
|
|
179
|
+
* and was false.** `acceptSession` does not build a new node: it moves this same `CelloNode`
|
|
180
|
+
* from `#standingReceivers` into `#activeNodes`, so the interval keeps running after
|
|
181
|
+
* promotion, against the session's own counterparty. Review measured the consequence — the
|
|
182
|
+
* counterparty was hung up mid-conversation and the next send failed with `no_connection`,
|
|
183
|
+
* with nothing anywhere naming the local sweep.
|
|
184
|
+
*
|
|
185
|
+
* That is survivable only because the spared predicate below names the counterparty. The
|
|
186
|
+
* arrangement is deliberate now rather than accidental: one node, one interval, and the gate
|
|
187
|
+
* decides who is off-limits as it narrows.
|
|
188
|
+
*/
|
|
189
|
+
...(isReceiver
|
|
190
|
+
? {
|
|
191
|
+
idleConnectionReaper: {
|
|
192
|
+
/**
|
|
193
|
+
* C3 — THE GUARD IS HEARD. A hang-up that tells nobody is indistinguishable from the
|
|
194
|
+
* thing simply not happening, and review measured what that costs: the operator's
|
|
195
|
+
* next send returns `no_connection`, `session.transport.redial.unavailable` says
|
|
196
|
+
* "every send parks until they re-establish", and not one word in that chain names a
|
|
197
|
+
* `setInterval` on their own machine.
|
|
198
|
+
*
|
|
199
|
+
* WARN and CONTINUE, deliberately: this is resource bounding, not a security event.
|
|
200
|
+
* Nothing is refused and no session state changes — so it is loud, and it does not
|
|
201
|
+
* block (Invariant 2's own distinction).
|
|
202
|
+
*/
|
|
203
|
+
onReaped: (e) => {
|
|
204
|
+
if (e.reason === "never_carried_a_stream") {
|
|
205
|
+
this.logger?.warn("session.node.connection.reaped", {
|
|
206
|
+
sessionId: config.sessionId,
|
|
207
|
+
peerId: e.peerId,
|
|
208
|
+
ageMs: e.ageMs,
|
|
209
|
+
observation: "an inbound connection was hung up after never carrying a stream since it opened",
|
|
210
|
+
impact: "no session state changed and nothing was refused; if this peer returns it must dial again",
|
|
211
|
+
});
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
// A sweep that cannot do its job is a different event and must not read as one that
|
|
215
|
+
// did. `hangUp`'s `invalid_peer_id` is a NAMED reason written so a malformed id is
|
|
216
|
+
// not read as a connection problem — preserved here rather than flattened.
|
|
217
|
+
this.logger?.error("session.node.connection.reap_failed", {
|
|
218
|
+
sessionId: config.sessionId,
|
|
219
|
+
peerId: e.peerId,
|
|
220
|
+
reason: e.reason,
|
|
221
|
+
error: e.error,
|
|
222
|
+
impact: "an idle connection was NOT closed; it continues to hold a slot against the connection cap",
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
/**
|
|
226
|
+
* C4 — THE COUNT THE DoD ASKS FOR, and the reason this callback exists at all.
|
|
227
|
+
*
|
|
228
|
+
* The line says *"measure a healthy daemon's connection count first"*, and nothing in
|
|
229
|
+
* the tree reported one — not `cello_status`, not the CLI, not the log. Exposing the
|
|
230
|
+
* CAPS without the COUNT would have been a capability nothing reads, which is this
|
|
231
|
+
* milestone's own "no consumer, no ship". DEBUG because it is a census on a timer,
|
|
232
|
+
* not an event: it exists to be greppable when a cap is finally tuned.
|
|
233
|
+
*/
|
|
234
|
+
onObserved: (c) => {
|
|
235
|
+
this.logger?.debug("transport.connections.observed", {
|
|
236
|
+
sessionId: config.sessionId,
|
|
237
|
+
total: c.total,
|
|
238
|
+
inbound: c.inbound,
|
|
239
|
+
neverSpoke: c.neverSpoke,
|
|
240
|
+
maxConnections: c.maxConnections,
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
: {}),
|
|
246
|
+
}).then((node) => {
|
|
247
|
+
/**
|
|
248
|
+
* SPARE WHAT REACHABILITY DEPENDS ON — two things, and the second was missing.
|
|
249
|
+
*
|
|
250
|
+
* **The reserved relay.** The same list `DOD-M15-FRAME-1`'s eviction sweep spares, for the
|
|
251
|
+
* reason its comment gives: reservation refreshes ride those peers, and hanging one up costs
|
|
252
|
+
* the agent its inbound reachability. A reservation is IDLE BY NATURE between refreshes,
|
|
253
|
+
* which is exactly the shape the sweep hunts.
|
|
254
|
+
*
|
|
255
|
+
* **The peer the gate currently names.** `getAllowedPeerId()` is the admitted dialer before
|
|
256
|
+
* promotion and the counterparty after it. Without this the sweep reaps the one peer the
|
|
257
|
+
* session exists for.
|
|
258
|
+
*
|
|
259
|
+
* READ LIVE on every sweep, never captured: reservations are lost and retaken constantly
|
|
260
|
+
* (2,675 `reservation.lost` in one daemon's log) and `#allowedPeerId` changes at offer,
|
|
261
|
+
* promotion and refusal. A set frozen at build time is wrong within minutes.
|
|
262
|
+
*
|
|
263
|
+
* WHAT IS LEFT TO REAP, stated because a guard with no population is theatre: a peer that was
|
|
264
|
+
* admitted by an offer which was then refused or expired. `closeInbound()` returns
|
|
265
|
+
* `#allowedPeerId` to null, and libp2p never re-runs a gater against a connection that
|
|
266
|
+
* already exists — so that peer stays attached, named by nobody, speaking nothing. That is
|
|
267
|
+
* the connection this unit removes.
|
|
268
|
+
*/
|
|
269
|
+
const gater = config.connectionGater;
|
|
270
|
+
if (isReceiver && gater) {
|
|
271
|
+
node.setIdleReaperSpared((peerId) => gater.isAllowedOutboundPeer(peerId) || gater.getAllowedPeerId() === peerId);
|
|
272
|
+
}
|
|
273
|
+
return node;
|
|
148
274
|
});
|
|
149
275
|
}
|
|
150
276
|
}
|
|
@@ -195,6 +321,43 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
195
321
|
if (manifestProvider && (!manifestRootKeys || !manifestThreshold || manifestThreshold <= 0)) {
|
|
196
322
|
throw new Error("DaemonConfig: manifestProvider requires manifestRootKeys (non-empty) and manifestThreshold (positive integer >= 1)");
|
|
197
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* DOD-M15-DIRAUTH-1 — an operator can DEMAND directory identity authentication.
|
|
326
|
+
*
|
|
327
|
+
* HERE, under the ADV-006/008 rule above, because this IS pure config validation: both operands
|
|
328
|
+
* are already in hand and it touches nothing.
|
|
329
|
+
*
|
|
330
|
+
* Review F1 caught me putting it ninety lines lower, next to ADV-002, on the reasoning that it
|
|
331
|
+
* "mirrors" it. It does not. ADV-002 sits down there because it MUST — it depends on
|
|
332
|
+
* `verifyStartupManifest`, which depends on the anti-rollback floor in the DB. This depends on
|
|
333
|
+
* nothing, and down there it ran AFTER: the irreversible flat-file → SQLCipher identity migration
|
|
334
|
+
* (which renames and unlinks files), the creation of `sessions.db` and its key, and the sweep that
|
|
335
|
+
* marks every `active` session `interrupted` with `interrupted_by='local'`.
|
|
336
|
+
*
|
|
337
|
+
* So a misconfigured daemon "failed to start" and changed the operator's record on the way out —
|
|
338
|
+
* two live sessions permanently interrupted, attributed to a local cause, by a config check that
|
|
339
|
+
* could have run before anything was touched.
|
|
340
|
+
*/
|
|
341
|
+
if (directoryAuthRequired(process.env) && challengeVerifier === undefined) {
|
|
342
|
+
const url = config.directoryHttpUrl ?? resolveDirectoryUrl(process.env);
|
|
343
|
+
logger.error("directory.auth.required.unavailable", {
|
|
344
|
+
directoryUrl: url,
|
|
345
|
+
impact: "the daemon refused to start rather than connect without directory identity authentication.",
|
|
346
|
+
guidance: "CELLO_REQUIRE_DIRECTORY_AUTH is set, but no challenge verifier could be built for this " +
|
|
347
|
+
"directory URL. Point CELLO_DIRECTORY_URL at a bundled endpoint, or supply a manifest with " +
|
|
348
|
+
"CELLO_CONSORTIUM_MANIFEST plus CELLO_CONSORTIUM_ROOT_KEYS and CELLO_CONSORTIUM_THRESHOLD " +
|
|
349
|
+
"(all three are required together), or set CELLO_REQUIRE_DIRECTORY_AUTH to 0/false/no/off " +
|
|
350
|
+
"to accept the risk.",
|
|
351
|
+
});
|
|
352
|
+
throw new Error(`CELLO_REQUIRE_DIRECTORY_AUTH is set, but directory identity authentication (step 6) cannot ` +
|
|
353
|
+
`be enforced: no challenge verifier was supplied for this daemon. The directory URL is ` +
|
|
354
|
+
`compared against the bundled consortium roster after NORMALISATION (trimmed, trailing slash ` +
|
|
355
|
+
`dropped, lowercased) — so case and a trailing slash are forgiven, but a DNS hostname ` +
|
|
356
|
+
`pointing at exactly the right machine is NOT, which is the usual cause. Either use a bundled ` +
|
|
357
|
+
`endpoint address, or supply a manifest with CELLO_CONSORTIUM_MANIFEST plus ` +
|
|
358
|
+
`CELLO_CONSORTIUM_ROOT_KEYS and CELLO_CONSORTIUM_THRESHOLD (all three are required together), ` +
|
|
359
|
+
`or set CELLO_REQUIRE_DIRECTORY_AUTH to 0/false/no/off to start without step 6.`);
|
|
360
|
+
}
|
|
198
361
|
// ── PERSIST-002: open the encrypted store FIRST (runs the one-time flat-file → SQLCipher migration
|
|
199
362
|
// (AC-006) + creates the agents/manifest_state schema), under the single-instance lock. This must
|
|
200
363
|
// precede the manifest verification below because the manifest version is now stored in the
|
|
@@ -230,7 +393,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
230
393
|
// screening, so a wiring mistake shows up here instead of hiding behind a correct-looking line.
|
|
231
394
|
logger.info("security.gateway.connected", { mode: securityGateway.mode });
|
|
232
395
|
const sessionNodeManager = new SessionNodeManager({
|
|
233
|
-
factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(),
|
|
396
|
+
factory: sessionNodeFactory ?? new ProductionSessionNodeFactory(logger),
|
|
234
397
|
logger,
|
|
235
398
|
dbPath: join(celloDir, "sessions.db"),
|
|
236
399
|
contentTtfMs: config.contentTtfMs,
|
|
@@ -250,6 +413,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
250
413
|
manifestThreshold,
|
|
251
414
|
manifestVersionStore,
|
|
252
415
|
logger,
|
|
416
|
+
// DOD-M15-STALEROSTER-1: the same injected fetch the sweep uses, so the startup probe and the
|
|
417
|
+
// background probe are exercised through one seam rather than one being untestable.
|
|
418
|
+
...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
|
|
253
419
|
});
|
|
254
420
|
// ADV-002: an operator who configures manifestProvider has opted INTO manifest enforcement, so a
|
|
255
421
|
// failed verification is fatal — never a warning we start anyway on.
|
|
@@ -268,7 +434,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
268
434
|
"Check the logs for the specific failure reason (manifest_signature_invalid, manifest_expired, or manifest_version_rollback).");
|
|
269
435
|
}
|
|
270
436
|
// 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({
|
|
437
|
+
const { resolveConsortiumRoster, failoverEndpointResolver, getFailoverEndpoint, getUnresolvedNodes, getUnresolvedSweptAt, getDeclaredNodeCount, stopHttpManifestPoll } = createConsortiumRouting({
|
|
272
438
|
manifestProvider,
|
|
273
439
|
manifestRootKeys,
|
|
274
440
|
manifestThreshold,
|
|
@@ -282,7 +448,96 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
282
448
|
initialUnresolvedNodes: startupUnresolvedNodes,
|
|
283
449
|
initialUnresolvedSweptAt: startupSweptAt,
|
|
284
450
|
logger,
|
|
451
|
+
...(config.fetchFn ? { fetchFn: config.fetchFn } : {}),
|
|
285
452
|
});
|
|
453
|
+
/**
|
|
454
|
+
* DOD-M15-STALEROSTER-1 — keep measuring directory reachability even when nothing is wrong.
|
|
455
|
+
*
|
|
456
|
+
* Every existing caller of the sweep is ACTIVITY-driven — ceremonies, session setup,
|
|
457
|
+
* `cello_refresh`, the seal broker. So an IDLE daemon never re-measures, and sitting idle is what
|
|
458
|
+
* a daemon does between conversations: the reading it was seeded with at boot is the reading it
|
|
459
|
+
* still has an hour later. Measured twice, on two machines — node failures from minutes past
|
|
460
|
+
* displayed while `curl` reached all three nodes in 37–184 ms.
|
|
461
|
+
*
|
|
462
|
+
* (An earlier version of this comment said the sweep had ONE caller, the failover path, and that
|
|
463
|
+
* recovering was what stopped the measurement. That was wrong — there are ten — and it is
|
|
464
|
+
* corrected here rather than deleted because believing it is why the concurrent-sweep race in
|
|
465
|
+
* `consortium-bootstrap.ts` went unnoticed until review.)
|
|
466
|
+
*
|
|
467
|
+
* Skipped when there is no manifest provider: there is no node roster to enumerate, so a timer
|
|
468
|
+
* that can only ever re-measure nothing is noise. That case is NOT silent — `cello_status`
|
|
469
|
+
* reports `measurement: "not_configured"` and says why.
|
|
470
|
+
*/
|
|
471
|
+
/**
|
|
472
|
+
* WHERE the held manifest came from, because it decides what the operator can actually DO about
|
|
473
|
+
* an expired one — `DOD-M15-MANIFEST-EXPIRY-LIVE-1` review F5.
|
|
474
|
+
*
|
|
475
|
+
* `EmbeddedManifestProvider` is the compiled-in bundled roster: there is no file to replace and no
|
|
476
|
+
* poll to adopt a replacement, so "rotate the manifest" is not an available action and telling
|
|
477
|
+
* that operator to do it routes them toward the one workaround that silently disables directory
|
|
478
|
+
* identity authentication. Detected by the provider's own constructor rather than by re-reading
|
|
479
|
+
* the env var, so a caller that injects a provider directly is classified by what it IS.
|
|
480
|
+
*/
|
|
481
|
+
const manifestOrigin = manifestProvider?.constructor?.name === "EmbeddedManifestProvider" ? "bundled" : "file";
|
|
482
|
+
/** REVIEW F4: the last sweep failure, surfaced in `cello_status` alongside the log line. */
|
|
483
|
+
let lastRosterSweepError;
|
|
484
|
+
/**
|
|
485
|
+
* DOD-M15-MANIFEST-EXPIRY-LIVE-1 — re-check the trust anchor's validity while the daemon runs.
|
|
486
|
+
*
|
|
487
|
+
* The window is enforced at STARTUP and nowhere else. The manifest poll's expiry check looks at
|
|
488
|
+
* the manifest being FETCHED, never the one held, so a daemon past its expiry keeps polling, keeps
|
|
489
|
+
* correctly refusing expired replacements, and keeps using the lapsed anchor it already has.
|
|
490
|
+
*
|
|
491
|
+
* Rides the roster sweep rather than owning a timer: that tick already fires every 90–180 s on
|
|
492
|
+
* exactly the path where a manifest provider exists.
|
|
493
|
+
*/
|
|
494
|
+
const checkManifestValidity = startManifestValidityWatch({
|
|
495
|
+
getManifest: () => manifestProvider?.getCurrentManifest() ?? null,
|
|
496
|
+
logger,
|
|
497
|
+
});
|
|
498
|
+
const rosterSweepScheduler = manifestProvider
|
|
499
|
+
? config.rosterSweepScheduler ??
|
|
500
|
+
new RandomizedPollScheduler({ minMs: ROSTER_SWEEP_INTERVAL_MS, maxMs: ROSTER_SWEEP_INTERVAL_MS * 2 })
|
|
501
|
+
: undefined;
|
|
502
|
+
const stopRosterSweep = rosterSweepScheduler
|
|
503
|
+
? startRosterSweep({
|
|
504
|
+
scheduler: rosterSweepScheduler,
|
|
505
|
+
// FAST_PROBE is deliberately NOT used here. It exists because the failover resolver runs
|
|
506
|
+
// inside the 10 s signaling wait; nothing waits on this sweep, so it can afford the
|
|
507
|
+
// patient probe and give the more trustworthy answer.
|
|
508
|
+
sweep: async () => {
|
|
509
|
+
/**
|
|
510
|
+
* DOD-M15-MANIFEST-EXPIRY-LIVE-1: the anchor's validity is re-checked on the same tick.
|
|
511
|
+
* BEFORE the probe, so an expired manifest is reported even on a cycle where every node is
|
|
512
|
+
* unreachable and the roster resolve throws.
|
|
513
|
+
*
|
|
514
|
+
* Its OWN try/catch — review F11. Sharing the sweep's error path meant a throw in here
|
|
515
|
+
* would surface as `directory.roster.sweep.failed` AND skip `resolveConsortiumRoster()`
|
|
516
|
+
* entirely: the roster reading would freeze while the operator was pointed at the
|
|
517
|
+
* directory. A manifest-check failure must never be reported as a directory failure, and
|
|
518
|
+
* must never cost the measurement it rides along with.
|
|
519
|
+
*/
|
|
520
|
+
try {
|
|
521
|
+
checkManifestValidity();
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
logger.error("directory.auth.manifest.check.failed", {
|
|
525
|
+
error: err instanceof Error ? err.message : String(err),
|
|
526
|
+
impact: "the manifest validity re-check did not run this cycle. cello_status still computes " +
|
|
527
|
+
"it independently on every read, so the FIELD is unaffected; what is lost is the " +
|
|
528
|
+
"unprompted log line on a transition.",
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
return resolveConsortiumRoster();
|
|
532
|
+
},
|
|
533
|
+
logger,
|
|
534
|
+
// REVIEW F4: the failure reaches the agent's response, not just the log. Without this a
|
|
535
|
+
// sweep failing every cycle is invisible for the first two or three failures, because the
|
|
536
|
+
// reading is still inside its 5-minute freshness bound and reports stale:false.
|
|
537
|
+
onSweepError: (e) => { lastRosterSweepError = e; },
|
|
538
|
+
onSweepSuccess: () => { lastRosterSweepError = undefined; },
|
|
539
|
+
})
|
|
540
|
+
: undefined;
|
|
286
541
|
// DOD-REGISTRY-1: type registry poll — daemon-level, runs even with zero agents.
|
|
287
542
|
// When registryPubkey is configured, the daemon polls GET /registry, verifies the inner
|
|
288
543
|
// Ed25519 signature, and updates the in-memory TypeRegistry. A poll failure never blanks
|
|
@@ -580,6 +835,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
580
835
|
agentName,
|
|
581
836
|
persistence: getPersistence(agentName),
|
|
582
837
|
agentPubkeyHex,
|
|
838
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F1): the co-sign path must check the root before this
|
|
839
|
+
// agent's key endorses it. Same predicate as the receiving path — one implementation, so the
|
|
840
|
+
// two halves cannot drift about what a mismatch is.
|
|
841
|
+
verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
|
|
583
842
|
keyProvider: agentKeyProvider,
|
|
584
843
|
getNode: entry.getNode,
|
|
585
844
|
getDirectoryEndpoint: getFailoverEndpoint,
|
|
@@ -592,6 +851,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
592
851
|
agentName,
|
|
593
852
|
persistence: getPersistence(agentName),
|
|
594
853
|
agentPubkeyHex,
|
|
854
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F1): the co-sign path must check the root before this
|
|
855
|
+
// agent's key endorses it. Same predicate as the receiving path — one implementation, so the
|
|
856
|
+
// two halves cannot drift about what a mismatch is.
|
|
857
|
+
verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
|
|
595
858
|
keyProvider: agentKeyProvider,
|
|
596
859
|
getNode: entry.getNode,
|
|
597
860
|
getDirectoryEndpoint: getFailoverEndpoint,
|
|
@@ -606,6 +869,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
606
869
|
wireSessionOfferHandler({
|
|
607
870
|
agentName,
|
|
608
871
|
getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agentName),
|
|
872
|
+
admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agentName, peerId, sessionIdHex),
|
|
609
873
|
signaling: mgr,
|
|
610
874
|
logger,
|
|
611
875
|
});
|
|
@@ -654,6 +918,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
654
918
|
agentName: agent.name,
|
|
655
919
|
persistence: getPersistence(agent.name),
|
|
656
920
|
agentPubkeyHex: agent.pubkey,
|
|
921
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
|
|
922
|
+
verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
|
|
657
923
|
keyProvider: agent.keyProvider,
|
|
658
924
|
getNode: noSharedDirectoryNode,
|
|
659
925
|
getDirectoryEndpoint: getFailoverEndpoint,
|
|
@@ -665,6 +931,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
665
931
|
agentName: agent.name,
|
|
666
932
|
persistence: getPersistence(agent.name),
|
|
667
933
|
agentPubkeyHex: agent.pubkey,
|
|
934
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
|
|
935
|
+
verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
|
|
668
936
|
keyProvider: agent.keyProvider,
|
|
669
937
|
getNode: noSharedDirectoryNode,
|
|
670
938
|
getDirectoryEndpoint: getFailoverEndpoint,
|
|
@@ -675,6 +943,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
675
943
|
wireSessionOfferHandler({
|
|
676
944
|
agentName: agent.name,
|
|
677
945
|
getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
|
|
946
|
+
admitOfferedDialer: (peerId, sessionIdHex) => sessionNodeManager.admitOfferedDialer(agent.name, peerId, sessionIdHex),
|
|
678
947
|
signaling: mgr,
|
|
679
948
|
logger,
|
|
680
949
|
});
|
|
@@ -890,6 +1159,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
890
1159
|
// whether its counterparty is quiet or its sibling was faster. Written at the destructive drain in
|
|
891
1160
|
// session-content-handlers; read at that handler's timeout. Delivery itself is unchanged.
|
|
892
1161
|
const contentTakes = new ContentTakeLedger();
|
|
1162
|
+
/**
|
|
1163
|
+
* In-flight background seal ceremonies — `DOD-M15-CLOSEWAIT-1` review MEDIUM-6.
|
|
1164
|
+
*
|
|
1165
|
+
* The close now answers at commitment and finishes the ceremony detached. Without this the tail
|
|
1166
|
+
* was the ONE background task `stop()` could cut at an arbitrary point, while it cancels or awaits
|
|
1167
|
+
* every other one.
|
|
1168
|
+
*/
|
|
1169
|
+
const backgroundSeals = new Set();
|
|
1170
|
+
/**
|
|
1171
|
+
* DOD-M15-SEAL-FAILED-TERMINAL-1 — the last background seal failure per session.
|
|
1172
|
+
*
|
|
1173
|
+
* In memory on purpose: a restart makes "failed" the WRONG answer, because the boot sweep plus the
|
|
1174
|
+
* restart seal resolver retry the session. A marker whose lifetime is the process matches the
|
|
1175
|
+
* lifetime of the condition it describes.
|
|
1176
|
+
*/
|
|
1177
|
+
const sealFailures = new SealFailureStore();
|
|
893
1178
|
// DOD-AWAY-WRAP-1 AC1: request text is a leave-a-message greeting; agentName is spliced in at
|
|
894
1179
|
// the call site so it names the specific away agent.
|
|
895
1180
|
// DOD-AWAY-ACK-ONESHOT-TEXT-1 (live defect 2026-07-24): the ack must state the one-shot rule —
|
|
@@ -979,9 +1264,10 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
979
1264
|
// FRONT precisely so [[WRAP]] keeps the end position the counterparty's detector anchors on.
|
|
980
1265
|
const rejectText = markAsAutoReply("This inbox only accepts one message per visit. Closing. [[WRAP]]");
|
|
981
1266
|
const rejectBytes = new TextEncoder().encode(rejectText);
|
|
982
|
-
|
|
1267
|
+
// B2b: one decision point for the hash AND its algorithm — see `contentHashForSession`.
|
|
1268
|
+
const reject = await sessionNodeManager.contentHashForSession(agentName, sessionId, rejectBytes);
|
|
983
1269
|
// Best-effort: a send failure still triggers the seal — we are closing regardless.
|
|
984
|
-
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(
|
|
1270
|
+
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, rejectBytes, new Uint8Array(reject.hash), randomUUID(), LEAF_KIND_MSG, reject.alg);
|
|
985
1271
|
// M12-P13: commit the leaf when the rejection went out OR when it is durably queued —
|
|
986
1272
|
// either way the relay already witnessed its sequence. This caller is the sharpest case
|
|
987
1273
|
// of the three: the seal is initiated immediately below, so a hole here does not merely
|
|
@@ -989,7 +1275,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
989
1275
|
// counterparty will still receive content at. The roots then cannot agree, and the
|
|
990
1276
|
// session is unsealable for good.
|
|
991
1277
|
if (sendResult.ok || sendResult.durable) {
|
|
992
|
-
const rejectHashHex = Buffer.from(
|
|
1278
|
+
const rejectHashHex = Buffer.from(reject.hash).toString("hex");
|
|
993
1279
|
// DOD-M12B-INDEX-1: at the relay's position, not the tail. This is the riskiest append
|
|
994
1280
|
// in the codebase for that — the seal is initiated a few lines below, so a leaf at the
|
|
995
1281
|
// wrong index does not merely stall the far side, it seals a tree the counterparty can
|
|
@@ -1015,85 +1301,124 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1015
1301
|
// not have ingested it yet when the seal request arrives over the signaling channel.
|
|
1016
1302
|
// The relay path avoids this entirely — it posts a SEAL ctrl leaf and waits for the
|
|
1017
1303
|
// 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
|
-
|
|
1304
|
+
/**
|
|
1305
|
+
* DOD-M15-DIVERGE-1 (review HIGH-3) — the gate has to hold on the path with no operator.
|
|
1306
|
+
*
|
|
1307
|
+
* `cello_close_session` refuses a diverged record, but this autonomous path never
|
|
1308
|
+
* consulted `sealReadiness` at all: it read `placed.placed` and discarded
|
|
1309
|
+
* `placed.diverged`, then initiated the seal directly. So "a diverged session is blocked
|
|
1310
|
+
* from sealing" held for the close a human drives and not for the one that runs itself —
|
|
1311
|
+
* which is the worse of the two, because the append two lines above is, by its own
|
|
1312
|
+
* comment, "the riskiest append in the codebase" for exactly this reason.
|
|
1313
|
+
*
|
|
1314
|
+
* THE LOG IS THE SURFACE HERE, and that is not a weakening of Invariant 2. There is no
|
|
1315
|
+
* caller to answer — nothing is awaiting a response on this path — so the log carries the
|
|
1316
|
+
* whole warning rather than half of it.
|
|
1317
|
+
*/
|
|
1318
|
+
const oneshotReadiness = sessionNodeManager.sealReadiness(agentName, sessionId);
|
|
1319
|
+
if (oneshotReadiness.diverged) {
|
|
1320
|
+
logger.warn("session.away.inbox.oneshot.seal_skipped_diverged", {
|
|
1321
|
+
agentName, sessionId,
|
|
1322
|
+
treeSize: oneshotReadiness.treeSize, highWaterSeq: oneshotReadiness.highWaterSeq,
|
|
1323
|
+
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",
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
else {
|
|
1327
|
+
void (async () => {
|
|
1328
|
+
const correlationId = randomUUID();
|
|
1329
|
+
const sk = sealKey(agentName, sessionId);
|
|
1330
|
+
if (sealInterruptedInProgress.has(sk))
|
|
1331
|
+
return;
|
|
1332
|
+
sealInterruptedInProgress.add(sk);
|
|
1333
|
+
try {
|
|
1334
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F4): SealCompletion is now a union — a refused
|
|
1335
|
+
// certificate resolves the waiter rather than dropping it, so this path must handle it
|
|
1336
|
+
// too rather than treating a refusal as a seal.
|
|
1337
|
+
let resolveSeal;
|
|
1338
|
+
const sealedP = new Promise((r) => { resolveSeal = r; });
|
|
1339
|
+
pendingSealWaiters.set(sk, resolveSeal);
|
|
1340
|
+
const submit = await sessionNodeManager.submitSealLeaf(agentName, sessionId, correlationId);
|
|
1341
|
+
if (!submit.ok && submit.reason !== "responder_seal_already_submitted") {
|
|
1342
|
+
pendingSealWaiters.delete(sk);
|
|
1343
|
+
if (submit.reason === "relay_unavailable") {
|
|
1344
|
+
const fallback = await handleActiveSealFlow(sessionId, record2, correlationId);
|
|
1345
|
+
if (fallback.ok) {
|
|
1346
|
+
logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "signaling_fallback" });
|
|
1347
|
+
}
|
|
1348
|
+
else {
|
|
1349
|
+
logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: fallback.reason, path: "signaling_fallback" });
|
|
1350
|
+
}
|
|
1035
1351
|
}
|
|
1036
1352
|
else {
|
|
1037
|
-
logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason:
|
|
1353
|
+
logger.warn("session.away.inbox.oneshot.seal_initiate_failed", { agentName, sessionId, reason: submit.reason });
|
|
1038
1354
|
}
|
|
1355
|
+
return;
|
|
1039
1356
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1357
|
+
logger.info("session.away.inbox.oneshot.seal_initiated", { agentName, sessionId, path: "relay" });
|
|
1358
|
+
const bilateralTimeoutMs = Number(process.env["CELLO_SEAL_BILATERAL_TIMEOUT_MS"]) || 660_000;
|
|
1359
|
+
let timer;
|
|
1360
|
+
const timeoutP = new Promise((r) => { timer = setTimeout(() => r(null), bilateralTimeoutMs); });
|
|
1361
|
+
const sealedCompletion = await Promise.race([sealedP, timeoutP]);
|
|
1362
|
+
clearTimeout(timer);
|
|
1363
|
+
pendingSealWaiters.delete(sk);
|
|
1364
|
+
if (sealedCompletion !== null) {
|
|
1365
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F4): a REFUSED certificate resolves the waiter
|
|
1366
|
+
// now rather than being dropped, so this path must tell the two apart — logging a
|
|
1367
|
+
// refusal as "sealed" would be the silent acceptance the whole bullet exists to stop.
|
|
1368
|
+
if ("refused" in sealedCompletion) {
|
|
1369
|
+
logger.error("session.away.inbox.oneshot.seal_refused", {
|
|
1370
|
+
agentName, sessionId, reason: sealedCompletion.reason, detail: sealedCompletion.detail,
|
|
1371
|
+
impact: "the away auto-seal was REFUSED: the directory returned a validly signed root " +
|
|
1372
|
+
"that does not describe this conversation. The session is NOT sealed and nothing " +
|
|
1373
|
+
"was signed with this agent's key.",
|
|
1374
|
+
});
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: sealedCompletion.rootHex });
|
|
1378
|
+
return;
|
|
1042
1379
|
}
|
|
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"
|
|
1380
|
+
// Bilateral timeout — escalate to unilateral seal.
|
|
1381
|
+
const escalation = submit.ok
|
|
1062
1382
|
? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
|
|
1063
|
-
:
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1383
|
+
: submit.reason === "responder_seal_already_submitted" &&
|
|
1384
|
+
typeof submit.reportedRootHex === "string" &&
|
|
1385
|
+
typeof submit.sequenceNumber === "number"
|
|
1386
|
+
? { reportedRootHex: submit.reportedRootHex, sequenceNumber: submit.sequenceNumber }
|
|
1387
|
+
: null;
|
|
1388
|
+
if (!escalation) {
|
|
1389
|
+
logger.warn("session.away.inbox.oneshot.seal_pending", { agentName, sessionId });
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
// DOD-M12B-SEAL-ESCALATE-DUP-1: THE SHARED ESCALATION, not a second copy.
|
|
1393
|
+
//
|
|
1394
|
+
// This used to be a line-for-line duplicate with its own hardcoded 30 s timeout, and
|
|
1395
|
+
// it missed every refusal the other one gained: an empty carry, a gappy chain, two of
|
|
1396
|
+
// our own ctrl leaves (permanently unsealable), and a bilateral seal already running.
|
|
1397
|
+
// It spent the full timeout on each and then reported `seal_unilateral_timeout` — the
|
|
1398
|
+
// label that names our own wait. Sharing the body is what stops that drifting again.
|
|
1399
|
+
const uni = await runUnilateralEscalation({
|
|
1400
|
+
logger, sessionNodeManager, sendOver, pendingUnilateralWaiters, sealKey,
|
|
1401
|
+
getKeyProvider: (a) => keyProviders.get(a),
|
|
1402
|
+
timeoutMs: UNILATERAL_SEAL_TIMEOUT_MS,
|
|
1403
|
+
}, agentName, sessionId, escalation, correlationId, { refuseOnUnusableCarry: true });
|
|
1404
|
+
if (uni.ok) {
|
|
1405
|
+
logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: uni.sealed_root, sealType: "unilateral" });
|
|
1406
|
+
}
|
|
1407
|
+
else {
|
|
1408
|
+
// CARRY THE GUIDANCE. The four refusals this path just gained come with the
|
|
1409
|
+
// sentence that tells an operator whether to retry or force-abandon — and the log
|
|
1410
|
+
// is this path's ONLY surface, so dropping it leaves a nameable cause with no
|
|
1411
|
+
// action attached. The old reasons had no guidance to lose; these do.
|
|
1412
|
+
logger.warn("session.away.inbox.oneshot.seal_unilateral_failed", {
|
|
1413
|
+
agentName, sessionId, reason: uni.reason, guidance: uni.guidance,
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1067
1416
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
// This used to be a line-for-line duplicate with its own hardcoded 30 s timeout, and
|
|
1071
|
-
// it missed every refusal the other one gained: an empty carry, a gappy chain, two of
|
|
1072
|
-
// our own ctrl leaves (permanently unsealable), and a bilateral seal already running.
|
|
1073
|
-
// It spent the full timeout on each and then reported `seal_unilateral_timeout` — the
|
|
1074
|
-
// label that names our own wait. Sharing the body is what stops that drifting again.
|
|
1075
|
-
const uni = await runUnilateralEscalation({
|
|
1076
|
-
logger, sessionNodeManager, sendOver, pendingUnilateralWaiters, sealKey,
|
|
1077
|
-
getKeyProvider: (a) => keyProviders.get(a),
|
|
1078
|
-
timeoutMs: UNILATERAL_SEAL_TIMEOUT_MS,
|
|
1079
|
-
}, agentName, sessionId, escalation, correlationId, { refuseOnUnusableCarry: true });
|
|
1080
|
-
if (uni.ok) {
|
|
1081
|
-
logger.info("session.away.inbox.oneshot.sealed", { agentName, sessionId, sealedRoot: uni.sealed_root, sealType: "unilateral" });
|
|
1417
|
+
finally {
|
|
1418
|
+
sealInterruptedInProgress.delete(sealKey(agentName, sessionId));
|
|
1082
1419
|
}
|
|
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
|
-
});
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1093
|
-
finally {
|
|
1094
|
-
sealInterruptedInProgress.delete(sealKey(agentName, sessionId));
|
|
1095
|
-
}
|
|
1096
|
-
})();
|
|
1420
|
+
})();
|
|
1421
|
+
}
|
|
1097
1422
|
}
|
|
1098
1423
|
}
|
|
1099
1424
|
return;
|
|
@@ -1150,8 +1475,9 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1150
1475
|
? new Uint8Array(awayVerdict.content)
|
|
1151
1476
|
: draftBytes;
|
|
1152
1477
|
const contentBytes = new TextEncoder().encode(markAsAutoReply(new TextDecoder().decode(screenedBytes)));
|
|
1153
|
-
|
|
1154
|
-
const
|
|
1478
|
+
// B2b: one decision point for the hash AND its algorithm — see `contentHashForSession`.
|
|
1479
|
+
const away = await sessionNodeManager.contentHashForSession(agentName, sessionId, contentBytes);
|
|
1480
|
+
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(away.hash), randomUUID(), LEAF_KIND_MSG, away.alg);
|
|
1155
1481
|
if (!sendResult.ok && !sendResult.durable) {
|
|
1156
1482
|
// Reviewer MEDIUM fix: a transient failure must NOT permanently silence the rest of this
|
|
1157
1483
|
// away period — clear the guard so the next inbound arrival retries the ack.
|
|
@@ -1165,7 +1491,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1165
1491
|
});
|
|
1166
1492
|
return;
|
|
1167
1493
|
}
|
|
1168
|
-
const contentHashHex = Buffer.from(
|
|
1494
|
+
const contentHashHex = Buffer.from(away.hash).toString("hex");
|
|
1169
1495
|
if (!sendResult.ok) {
|
|
1170
1496
|
// M12-P13 (found live 2026-08-05, M12 Entry 89): the reply is durably queued and already
|
|
1171
1497
|
// owns the sequence the relay witnessed for it, so its leaf MUST be committed here. Without
|
|
@@ -1382,6 +1708,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1382
1708
|
challengeVerifier,
|
|
1383
1709
|
getManifestVersion: () => verifiedManifestVersion,
|
|
1384
1710
|
loadedAgents,
|
|
1711
|
+
// DOD-M15-ERRSTRING-1: so a session failure can say "and 2 of your 5 directories are
|
|
1712
|
+
// unreachable", which is very often the actual cause and was reported nowhere the operator
|
|
1713
|
+
// was looking.
|
|
1714
|
+
getUnresolvedNodes,
|
|
1715
|
+
getDeclaredNodeCount,
|
|
1385
1716
|
});
|
|
1386
1717
|
// Both use the same SQLite DB as the SessionNodeManager (daemon.db equivalent).
|
|
1387
1718
|
// loadFromDb() must complete BEFORE IPC socket opens (AC-007).
|
|
@@ -1457,15 +1788,20 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1457
1788
|
onPersisted: (agentName, sessionId, contentHashHex) => {
|
|
1458
1789
|
retryQueue.markContentAcked(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"));
|
|
1459
1790
|
},
|
|
1460
|
-
|
|
1461
|
-
|
|
1791
|
+
// `DOD-M15-SEALWIRE-1` B2b-1 review F1 — `contentHashAlg` is the DURABLE WRITER for the column
|
|
1792
|
+
// this unit added. Without it every queued row carries NULL, the crash backstop re-parks a salted
|
|
1793
|
+
// message as sha256, and the recipient refuses it and re-pulls it forever. The commit that added
|
|
1794
|
+
// the column said the producer "passes it"; the producer was passing a value nothing supplied,
|
|
1795
|
+
// because these two hooks were never widened.
|
|
1796
|
+
onTtf: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) => {
|
|
1797
|
+
retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor, contentHashAlg);
|
|
1462
1798
|
},
|
|
1463
1799
|
// M12-P12: same durable destination, different cause — a park deposit the relay refused. The
|
|
1464
1800
|
// TTF timer is already cancelled on this path, so this is the only thing holding the content.
|
|
1465
1801
|
// M12-P13 (review HIGH-1): the enqueue's own answer is returned, never a bare `true`. A dropped
|
|
1466
1802
|
// 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);
|
|
1803
|
+
onParkFailed: (agentName, sessionId, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg) => {
|
|
1804
|
+
return retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content, structure1Cbor, structure2Cbor, contentHashAlg);
|
|
1469
1805
|
},
|
|
1470
1806
|
});
|
|
1471
1807
|
// MSG-001-3b (2b): the LIVE content-park deposit. On a not-confirmed send (direct delivery
|
|
@@ -1511,7 +1847,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1511
1847
|
logger.warn("session.seal.autoack.broker.unreachable", { agentName, brokerNode, correlationId });
|
|
1512
1848
|
return null;
|
|
1513
1849
|
});
|
|
1514
|
-
sessionNodeManager.setContentParkHook(async ({ agentName, sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor }) => {
|
|
1850
|
+
sessionNodeManager.setContentParkHook(async ({ agentName, sessionId, recipientPubkeyHex, relayPeerId, relayAddrs, contentHashHex, content, structure1Cbor, structure2Cbor, contentHashAlg }) => {
|
|
1515
1851
|
const node = sessionNodeManager.getStandingReceiverNode();
|
|
1516
1852
|
if (!node) {
|
|
1517
1853
|
const reason = "standing_receiver_unavailable";
|
|
@@ -1545,12 +1881,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1545
1881
|
// SEC-1: sealParkEnvelope is the SOLE producer — it signs (sender's K_local, over the
|
|
1546
1882
|
// session/recipient/content binding) and seals in one place, so the two park sites cannot drift
|
|
1547
1883
|
// apart on what gets signed.
|
|
1884
|
+
//
|
|
1885
|
+
// `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
|
|
1886
|
+
// MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
|
|
1887
|
+
// is salted is a fact about the message that was sent; what this side holds now says nothing
|
|
1888
|
+
// about it. Still `sha256` everywhere, because no send path salts yet — the plumbing is proven
|
|
1889
|
+
// carrying the value that cannot break anything, and only then does the value change.
|
|
1548
1890
|
const ciphertext = await sealParkEnvelope({
|
|
1549
1891
|
signer: senderKp,
|
|
1550
1892
|
sessionIdHex: sessionId,
|
|
1551
1893
|
recipientPubkey,
|
|
1552
1894
|
contentHash: contentHashBytes,
|
|
1553
1895
|
content,
|
|
1896
|
+
// The algorithm the DIRECT frame named for this same message. The park copy must claim what
|
|
1897
|
+
// the message actually is, not what this side would choose for it now.
|
|
1898
|
+
contentHashAlg,
|
|
1554
1899
|
structure1Cbor,
|
|
1555
1900
|
structure2Cbor,
|
|
1556
1901
|
});
|
|
@@ -1623,12 +1968,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1623
1968
|
// empty after a restart, so arrival order there means a wrong leaf index and a divergent tree.
|
|
1624
1969
|
// SEC-1: same sole producer as the live hook — the backstop signs from the persisted
|
|
1625
1970
|
// (sessionId, recipient, contentHash).
|
|
1971
|
+
//
|
|
1972
|
+
// `DOD-M15-SEALWIRE-1` PART B2b — the algorithm is threaded through, and it is the value THIS
|
|
1973
|
+
// MESSAGE was hashed under, never one re-derived from the session's current row. Whether a hash
|
|
1974
|
+
// is salted is a fact about the message that was sent; what this side holds now says nothing
|
|
1975
|
+
// about it. Still `sha256` everywhere, because no send path salts yet — the plumbing is proven
|
|
1976
|
+
// carrying the value that cannot break anything, and only then does the value change.
|
|
1626
1977
|
const ciphertext = await sealParkEnvelope({
|
|
1627
1978
|
signer: senderKp,
|
|
1628
1979
|
sessionIdHex: entry.sessionId,
|
|
1629
1980
|
recipientPubkey,
|
|
1630
1981
|
contentHash: contentHashBytes,
|
|
1631
1982
|
content: entry.contentBlob,
|
|
1983
|
+
// The queued row's own record of how it was hashed. `undefined` for a row written before the
|
|
1984
|
+
// column existed, which resolves to `sha256` — exactly what such a row actually used.
|
|
1985
|
+
contentHashAlg: entry.contentHashAlg,
|
|
1632
1986
|
structure1Cbor: entry.structure1Cbor,
|
|
1633
1987
|
structure2Cbor: entry.structure2Cbor,
|
|
1634
1988
|
});
|
|
@@ -1789,7 +2143,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
1789
2143
|
return {
|
|
1790
2144
|
name: a.name,
|
|
1791
2145
|
state,
|
|
2146
|
+
/**
|
|
2147
|
+
* `selected` IS THIS CONNECTION'S VIEW, NOT THE AGENT'S — `DOD-M15-IPCVISIBLE-1` clause 3.
|
|
2148
|
+
*
|
|
2149
|
+
* Every `cello` CLI invocation opens a FRESH connection, which starts with no current
|
|
2150
|
+
* agent. So a client asking about its own state through the CLI always reads `false`, for
|
|
2151
|
+
* an agent it genuinely has selected in another session. Both Andre and a Hermes agent
|
|
2152
|
+
* misread it that way during one investigation, in opposite directions.
|
|
2153
|
+
*
|
|
2154
|
+
* The field name cannot be changed without breaking every reader, so it is ANNOTATED: the
|
|
2155
|
+
* sibling below says whose view this is, and `attended_by` says how many connections hold
|
|
2156
|
+
* this agent at all — which is the question people were actually asking.
|
|
2157
|
+
*/
|
|
1792
2158
|
selected,
|
|
2159
|
+
selected_by_this_connection: selected,
|
|
2160
|
+
attended_by: countAttendance(perConnectionState, a.name),
|
|
1793
2161
|
pubkey: a.pubkey,
|
|
1794
2162
|
// M8B F14 (fix 5): per-agent standing-receiver readiness on the MCP surface
|
|
1795
2163
|
// (cello_status / cello_list_agents), so a deaf agent is visible to the operator.
|
|
@@ -2005,6 +2373,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2005
2373
|
//
|
|
2006
2374
|
// Omitted entirely when nothing is failing, so a healthy status stays quiet.
|
|
2007
2375
|
...(unresolvedNodesForStatus() ?? {}),
|
|
2376
|
+
// DOD-M15-MANIFEST-EXPIRY-LIVE-1: contributes NOTHING while the manifest is comfortably in
|
|
2377
|
+
// window. A field present on every status read for the years a manifest is valid is furniture,
|
|
2378
|
+
// not a warning, and it teaches the reader to skip the block that matters.
|
|
2379
|
+
...(describeManifestValidity(classifyManifestValidity(manifestProvider?.getCurrentManifest() ?? null, Date.now()), manifestOrigin) ?? {}),
|
|
2380
|
+
// DOD-M15-DIRAUTH-1: the posture is STATED, in both directions. Unlike every other field in
|
|
2381
|
+
// this milestone the healthy case is reported too — the defect is precisely that "enforced"
|
|
2382
|
+
// and "skipped" differ only by the absence of a log line, so an operator must be able to
|
|
2383
|
+
// confirm it is on, not merely fail to find evidence that it is off.
|
|
2384
|
+
...describeDirectoryAuth({
|
|
2385
|
+
verifierPresent: challengeVerifier !== undefined,
|
|
2386
|
+
directoryUrl: directoryHttpUrl ?? resolveDirectoryUrl(process.env),
|
|
2387
|
+
// Review F5: with neither set, `resolveDirectoryUrl` re-picks a RANDOM bundled endpoint on
|
|
2388
|
+
// every call, so there is no configured URL to quote or to blame.
|
|
2389
|
+
urlExplicitlyConfigured: directoryHttpUrl !== undefined || process.env["CELLO_DIRECTORY_URL"] !== undefined,
|
|
2390
|
+
}),
|
|
2008
2391
|
// M8B F14 (fix 5): per-agent standing-receiver readiness, so a deaf agent (online but
|
|
2009
2392
|
// no armed receiver) is visible in cello_status instead of hiding behind the ANY-agent
|
|
2010
2393
|
// aggregate below (kept for backward compatibility).
|
|
@@ -2117,16 +2500,95 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2117
2500
|
* alice, alice was stopped, and the work lands on bob reporting success. A lost intent is not the
|
|
2118
2501
|
* same as no intent, and it must fail loud (no_current_agent) rather than be guessed at.
|
|
2119
2502
|
*/
|
|
2503
|
+
/**
|
|
2504
|
+
* Which agent this call acts as — `DOD-M15-SELECTION-1`, logic in `agent-selection.ts`.
|
|
2505
|
+
*
|
|
2506
|
+
* It used to end with `if (onlineAgents.size === 1) return [...onlineAgents][0]`, so a connection
|
|
2507
|
+
* that had selected nothing acted as whichever agent happened to be the only one online. On a
|
|
2508
|
+
* shared daemon that can be a DIFFERENT operator's agent, and a live MCP session was being bound
|
|
2509
|
+
* to an identity it never asked for.
|
|
2510
|
+
*
|
|
2511
|
+
* The resolution is now attributable: every path reports how it was reached, and `fallback` — the
|
|
2512
|
+
* one that was invisible — is logged at INFO. `DOD-M15-IPCVISIBLE-1`.
|
|
2513
|
+
*/
|
|
2514
|
+
/**
|
|
2515
|
+
* The fallback notice owed to THIS REQUEST's response — `DOD-M15-SELECTION-1` clause 2.
|
|
2516
|
+
*
|
|
2517
|
+
* ─── Why an AsyncLocalStorage and not a per-connection map ─────────────────────────────────────
|
|
2518
|
+
*
|
|
2519
|
+
* The first cut keyed a `WeakMap` on the per-connection state object, so the notice belonged to
|
|
2520
|
+
* the CONNECTION and was read back at the response boundary. Review found that hands the notice to
|
|
2521
|
+
* whichever response finishes first, and Claude Code issues tool calls in parallel:
|
|
2522
|
+
*
|
|
2523
|
+
* 1. the agent calls `cello_receive` — nothing selected, so the fallback resolves and records
|
|
2524
|
+
* the notice, then the handler BLOCKS for up to 30 s waiting for content;
|
|
2525
|
+
* 2. in the same turn it calls `cello_sessions {agent: "bob"}`, which names an agent explicitly
|
|
2526
|
+
* and never falls back;
|
|
2527
|
+
* 3. `cello_sessions` returns first and takes the notice on its way out.
|
|
2528
|
+
*
|
|
2529
|
+
* Bob's response now says *"no agent was selected, so 'solo' was used"* — false, on the one call
|
|
2530
|
+
* that did name an agent — and `cello_receive`, the call that actually fell back, says nothing.
|
|
2531
|
+
* Exactly inverted.
|
|
2532
|
+
*
|
|
2533
|
+
* A notice is a fact about ONE CALL, so it is stored in that call's async context. The store is
|
|
2534
|
+
* created per request in `renderedHandlers` and dies with it, which also means a handler that
|
|
2535
|
+
* THROWS cannot leave a notice behind to attach itself to some later, unrelated response — the
|
|
2536
|
+
* other half of the same review finding.
|
|
2537
|
+
*/
|
|
2538
|
+
const fallbackNoticeStore = new AsyncLocalStorage();
|
|
2120
2539
|
function resolveCurrentAgent(connState, explicitAgent) {
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2540
|
+
return resolveCurrentAgentFor({
|
|
2541
|
+
connState,
|
|
2542
|
+
onlineAgents,
|
|
2543
|
+
...(explicitAgent !== undefined ? { explicitAgent } : {}),
|
|
2544
|
+
onResolved: (agent, trigger) => {
|
|
2545
|
+
// Only the FALLBACK is announced. `explicit` and `selected` are the ordinary cases and
|
|
2546
|
+
// logging them would bury the one that matters — a signal that fires on the normal case is
|
|
2547
|
+
// not a signal.
|
|
2548
|
+
if (trigger !== "fallback")
|
|
2549
|
+
return;
|
|
2550
|
+
/**
|
|
2551
|
+
* RECORDED FOR THE RESPONSE, not just the log — `DOD-M15-SELECTION-1` clause 2.
|
|
2552
|
+
*
|
|
2553
|
+
* The log tells whoever reads the daemon log. The RESPONSE tells the agent that just acted
|
|
2554
|
+
* as an identity it never selected, which is the one that stops the half-attended state
|
|
2555
|
+
* being read as the protocol dropping messages.
|
|
2556
|
+
*/
|
|
2557
|
+
/**
|
|
2558
|
+
* No `connState` guard. The first cut wrote the notice only `if (agent && connState)`, and
|
|
2559
|
+
* `perConnectionState` is populated at `ipc.connect` — which `withIpc` in the CLI
|
|
2560
|
+
* (`core/cli/src/commands.ts`) does not send. So every plain `cello` invocation fell back
|
|
2561
|
+
* and got a response that said nothing, while the log line below fired regardless: the
|
|
2562
|
+
* clause asks for explicit IN THE RESPONSE, and those callers had it explicit in the log
|
|
2563
|
+
* only. The store is per-REQUEST, so it exists whether or not the connection ever
|
|
2564
|
+
* handshook.
|
|
2565
|
+
*/
|
|
2566
|
+
const store = fallbackNoticeStore.getStore();
|
|
2567
|
+
if (agent && store) {
|
|
2568
|
+
store.notice = {
|
|
2569
|
+
acting_as: agent,
|
|
2570
|
+
agent_selection: "fallback",
|
|
2571
|
+
agent_selection_guidance: `No agent was selected on this connection, so '${agent}' was used because it is the ` +
|
|
2572
|
+
`only one online. This is a per-call subject, NOT an attendance: doorbells route by ` +
|
|
2573
|
+
`the connection's registered agent and this does not set it. Two things follow — ` +
|
|
2574
|
+
`this session will not WAKE on an incoming message even though sending and reading ` +
|
|
2575
|
+
`work, and anyone who opens a session with you is sent an AWAY auto-reply while you ` +
|
|
2576
|
+
`sit here able to answer. Run cello_use_agent to fix both. Naming the agent on each ` +
|
|
2577
|
+
`call is NOT a remedy for either: it settles which agent a call is about and leaves ` +
|
|
2578
|
+
`the connection just as unattended.`,
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
logger.info("agent.current.fallback", {
|
|
2582
|
+
agentName: agent,
|
|
2583
|
+
clientType: connState?.clientType ?? "cli",
|
|
2584
|
+
impact: "this call had no selected agent and exactly one was online, so that one was used. It " +
|
|
2585
|
+
"is a per-call subject, NOT an attendance: doorbells route by the connection's " +
|
|
2586
|
+
"registered agent, which this does not set.",
|
|
2587
|
+
guidance: "If this was not the intended agent, name it explicitly, or run cello_use_agent to " +
|
|
2588
|
+
"select one for the connection.",
|
|
2589
|
+
});
|
|
2590
|
+
},
|
|
2591
|
+
});
|
|
2130
2592
|
}
|
|
2131
2593
|
// Agent lifecycle (agent-handlers.ts): create, remove, start, stop, select, list.
|
|
2132
2594
|
registerAgentHandlers({
|
|
@@ -2216,6 +2678,8 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2216
2678
|
agentName,
|
|
2217
2679
|
persistence: getPersistence(agentName),
|
|
2218
2680
|
agentPubkeyHex: loaded.pubkey,
|
|
2681
|
+
// DOD-M15-SEALWIRE-1 bullet 2 (review F1): gate the co-signature on the root check.
|
|
2682
|
+
verifyCertifiedRoot: (pub, sid, root, leaves) => sessionNodeManager.verifyCertifiedRoot(pub, sid, root, leaves),
|
|
2219
2683
|
keyProvider: loaded.keyProvider,
|
|
2220
2684
|
getNode: entry.getNode,
|
|
2221
2685
|
getDirectoryEndpoint: getFailoverEndpoint,
|
|
@@ -2260,6 +2724,28 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
2260
2724
|
subject_kind: r.subjectKind,
|
|
2261
2725
|
subject: r.subject,
|
|
2262
2726
|
issuer_kind: r.issuerKind,
|
|
2727
|
+
/**
|
|
2728
|
+
* DOD-M15-SAMEOP-FALSEPOS-1: WHO ISSUED IT. The listing carried `subject` — who the signal is
|
|
2729
|
+
* ABOUT — and never who SAID it, so an operator could see "someone endorsed me" and not see
|
|
2730
|
+
* who. For the decision this list exists to support (do I rely on this endorsement?) the
|
|
2731
|
+
* author is the primary fact, and an endorsement nobody can attribute is worth roughly
|
|
2732
|
+
* nothing.
|
|
2733
|
+
*
|
|
2734
|
+
* `issuer_kind` reads like it answers this and does not: it says `"agent"` or `"portal"`, a
|
|
2735
|
+
* category. Elsewhere a field literally named `issuer` holds `"peer-claimed"` /
|
|
2736
|
+
* `"platform-verified"` — also a category. Two fields whose names promise identity, neither
|
|
2737
|
+
* carrying it.
|
|
2738
|
+
*
|
|
2739
|
+
* It also unblocks an investigation this omission stalled: four endorsements in a wallet all
|
|
2740
|
+
* read `same_operator: true`, and **nothing in the response could say whether the stranger's
|
|
2741
|
+
* was among them** — so "a stranger is flagged as self-dealing" and "the stranger's
|
|
2742
|
+
* endorsement never arrived" were indistinguishable from the listing. Different bugs, in
|
|
2743
|
+
* different places, and the field that separates them was already in the row.
|
|
2744
|
+
*
|
|
2745
|
+
* Discloses nothing new: the issuer pubkey is inside the notarized envelope the recipient
|
|
2746
|
+
* already holds and can already present.
|
|
2747
|
+
*/
|
|
2748
|
+
issuer_pubkey: r.issuerPubkey,
|
|
2263
2749
|
status: r.status,
|
|
2264
2750
|
issued_at: r.issuedAt,
|
|
2265
2751
|
expires_at: r.expiresAt,
|
|
@@ -3049,32 +3535,78 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3049
3535
|
});
|
|
3050
3536
|
// ─── MCP-001: cello_status (per-connection perspective) ───
|
|
3051
3537
|
/**
|
|
3052
|
-
* The directory-reachability block for `cello_status
|
|
3538
|
+
* The directory-reachability block for `cello_status`.
|
|
3539
|
+
*
|
|
3540
|
+
* `undefined` — i.e. silence — is reserved for ONE state: a reading taken recently enough to
|
|
3541
|
+
* speak for the present, which found nothing wrong. Every other combination emits, including an
|
|
3542
|
+
* EMPTY node list, because an empty list is not proof of health: it also means nothing has
|
|
3543
|
+
* looked, or looked too long ago to say. `DOD-M15-STALEROSTER-1`.
|
|
3053
3544
|
*
|
|
3054
|
-
*
|
|
3055
|
-
*
|
|
3545
|
+
* So presence of this block does NOT mean nodes are failing. Read `measurement` first —
|
|
3546
|
+
* `current` | `stale` | `never` | `not_configured` — and only then the node list.
|
|
3056
3547
|
*/
|
|
3057
3548
|
function unresolvedNodesForStatus() {
|
|
3058
3549
|
const failures = getUnresolvedNodes();
|
|
3059
|
-
|
|
3550
|
+
/**
|
|
3551
|
+
* DOD-M15-STALEROSTER-1 — the block is now gated on the AGE of the reading, not just on whether
|
|
3552
|
+
* it found anything.
|
|
3553
|
+
*
|
|
3554
|
+
* `if (failures.length === 0) return undefined` made two different states render identically:
|
|
3555
|
+
* "all three nodes answered a moment ago" and "nothing has ever looked". The second is
|
|
3556
|
+
* reachable — `verifyStartupManifest` returns without sweeping when the consortium manifest is
|
|
3557
|
+
* missing, not yet valid, EXPIRED, or rolled back — so a daemon with an expired manifest
|
|
3558
|
+
* reported no directory trouble at all.
|
|
3559
|
+
*
|
|
3560
|
+
* Silence is therefore reserved for the one case that has earned it: a RECENT reading that
|
|
3561
|
+
* found nothing wrong. Every other case says why it cannot make that claim.
|
|
3562
|
+
*/
|
|
3563
|
+
const freshness = describeRosterFreshness(classifyRosterReading(getUnresolvedSweptAt(), Date.now()), {
|
|
3564
|
+
// REVIEW F2: "no manifest configured" is DESIGNED (local dev, the e2e harness, or a
|
|
3565
|
+
// CELLO_DIRECTORY_URL that is not byte-equal to a bundled endpoint) and must not be dressed
|
|
3566
|
+
// as an alarm — it would fire on every local run. It still EMITS, because the line forbids
|
|
3567
|
+
// hiding the field; what differs is what the operator is told.
|
|
3568
|
+
manifestConfigured: manifestProvider !== undefined,
|
|
3569
|
+
...(lastRosterSweepError ? { lastSweepError: lastRosterSweepError } : {}),
|
|
3570
|
+
});
|
|
3571
|
+
if (failures.length === 0 && freshness.measurement === "current")
|
|
3060
3572
|
return undefined;
|
|
3061
3573
|
return {
|
|
3062
3574
|
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
|
-
|
|
3575
|
+
// WHEN this was measured, and whether that is recent enough to mean anything. Without it the
|
|
3576
|
+
// block asserts the PRESENT, and a transient blip reads as an ongoing outage: on 2026-08-09
|
|
3577
|
+
// all three endpoints failed with ENETUNREACH for under a minute — a network transition on
|
|
3578
|
+
// the operator's machine — and the block went on reporting them unreachable long after they
|
|
3579
|
+
// answered again. True when taken, false when read.
|
|
3580
|
+
...freshness,
|
|
3581
|
+
// DOD-M15-BOOTSTRAP-1: `attempts` distinguishes a node that answered definitively (one
|
|
3582
|
+
// probe — a 404, a bad payload, its configuration) from one that never answered at all
|
|
3583
|
+
// (every probe spent — the path to it). Those call for opposite responses, and without the
|
|
3584
|
+
// count they rendered identically here.
|
|
3585
|
+
nodes: failures.map((f) => ({ node: f.nodeId, endpoint: f.endpoint, reason: f.reason, detail: f.detail, attempts: f.attempts })),
|
|
3586
|
+
guidance: failures.length === 0
|
|
3587
|
+
// The block is present with an EMPTY node list, which before DOD-M15-STALEROSTER-1 could
|
|
3588
|
+
// not happen. Saying "could not resolve these endpoints" here would be a flat lie — there
|
|
3589
|
+
// are no endpoints listed and the point is that nothing was measured. The empty list is
|
|
3590
|
+
// the ABSENCE of a reading, not a clean bill of health, and freshness_guidance above says
|
|
3591
|
+
// which of the two it is.
|
|
3592
|
+
? "This block is present with NO nodes listed, which does not mean the nodes are healthy — " +
|
|
3593
|
+
"it means this reading cannot support that claim. See freshness_guidance above for " +
|
|
3594
|
+
"whether the daemon has never measured, or measured too long ago to speak for the " +
|
|
3595
|
+
"present. Directory reachability is what threshold ceremonies depend on, so an " +
|
|
3596
|
+
"unmeasured roster is an unknown, not an all-clear."
|
|
3597
|
+
: "AS OF checked_at (this is a point-in-time reading, not necessarily now), this daemon could not "
|
|
3598
|
+
+ "resolve these directory endpoints, so the consortium roster was short and " +
|
|
3599
|
+
"threshold ceremonies will fail — sessions surface that as home_node_reports_no_receiver, " +
|
|
3600
|
+
"home_node_not_in_reachable_roster, directory_named_no_home, directory_below_threshold or " +
|
|
3601
|
+
"ceremony_exhausted, none of which name the real cause on their own (DOD-M15-ERRSTRING-1 " +
|
|
3602
|
+
"renamed the first three; they now append this shortfall to their own guidance). " +
|
|
3603
|
+
"Agents can still show 'online': signaling dials multiaddrs and does not need DNS. " +
|
|
3604
|
+
"If reason is dns_error after a directory restart or wake, the resolver is holding a cached " +
|
|
3605
|
+
"negative answer — flush it (macOS: sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder). " +
|
|
3606
|
+
"Verify with node -e 'require(\"dns\").lookup(host,console.log)', NOT dig: dig bypasses the " +
|
|
3607
|
+
"cache this daemon is stuck behind, so it reports success while the daemon still fails. "
|
|
3608
|
+
+ "attempts:1 means the node ANSWERED and the answer was unusable — look at that node. "
|
|
3609
|
+
+ "attempts:2+ means it never answered — look at the path to it.",
|
|
3078
3610
|
},
|
|
3079
3611
|
};
|
|
3080
3612
|
}
|
|
@@ -3093,6 +3625,21 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3093
3625
|
//
|
|
3094
3626
|
// Omitted entirely when nothing is failing, so a healthy status stays quiet.
|
|
3095
3627
|
...(unresolvedNodesForStatus() ?? {}),
|
|
3628
|
+
// DOD-M15-MANIFEST-EXPIRY-LIVE-1: contributes NOTHING while the manifest is comfortably in
|
|
3629
|
+
// window. A field present on every status read for the years a manifest is valid is furniture,
|
|
3630
|
+
// not a warning, and it teaches the reader to skip the block that matters.
|
|
3631
|
+
...(describeManifestValidity(classifyManifestValidity(manifestProvider?.getCurrentManifest() ?? null, Date.now()), manifestOrigin) ?? {}),
|
|
3632
|
+
// DOD-M15-DIRAUTH-1: the posture is STATED, in both directions. Unlike every other field in
|
|
3633
|
+
// this milestone the healthy case is reported too — the defect is precisely that "enforced"
|
|
3634
|
+
// and "skipped" differ only by the absence of a log line, so an operator must be able to
|
|
3635
|
+
// confirm it is on, not merely fail to find evidence that it is off.
|
|
3636
|
+
...describeDirectoryAuth({
|
|
3637
|
+
verifierPresent: challengeVerifier !== undefined,
|
|
3638
|
+
directoryUrl: directoryHttpUrl ?? resolveDirectoryUrl(process.env),
|
|
3639
|
+
// Review F5: with neither set, `resolveDirectoryUrl` re-picks a RANDOM bundled endpoint on
|
|
3640
|
+
// every call, so there is no configured URL to quote or to blame.
|
|
3641
|
+
urlExplicitlyConfigured: directoryHttpUrl !== undefined || process.env["CELLO_DIRECTORY_URL"] !== undefined,
|
|
3642
|
+
}),
|
|
3096
3643
|
agents: getAgentsForConnection(connectionId),
|
|
3097
3644
|
// M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
|
|
3098
3645
|
// the daemon-wide getStatus() surfaces them.
|
|
@@ -3173,6 +3720,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3173
3720
|
// cello_close_session (close-session-handler.ts). Fifteen dependencies — a long list, but a KNOWN
|
|
3174
3721
|
// one, which is the whole difference from a closure over 73 shared locals.
|
|
3175
3722
|
registerCloseSessionHandler({
|
|
3723
|
+
// DOD-M15-CLOSEWAIT-1 review MEDIUM-6: a detached seal tail is registered here so stop() can
|
|
3724
|
+
// drain it, like every other background worker. Self-evicting, so a long-running daemon does not
|
|
3725
|
+
// accumulate settled promises.
|
|
3726
|
+
sealFailures,
|
|
3727
|
+
registerBackgroundSeal: (p) => {
|
|
3728
|
+
backgroundSeals.add(p);
|
|
3729
|
+
void p.finally(() => backgroundSeals.delete(p));
|
|
3730
|
+
},
|
|
3176
3731
|
// M12-P14: the pre-seal readiness gate drains the parked mailbox before judging, so a close
|
|
3177
3732
|
// does not refuse over content the relay is still holding for us.
|
|
3178
3733
|
recoverParkedContent: (agentName, trigger) => autoRecoverForAgent(agentName, trigger),
|
|
@@ -3210,9 +3765,74 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3210
3765
|
handleActiveSealFlow,
|
|
3211
3766
|
resolveConsortiumRoster,
|
|
3212
3767
|
});
|
|
3768
|
+
/**
|
|
3769
|
+
* DOD-M15-BACKUP-1 — export the agent, so losing the machine does not lose the identity.
|
|
3770
|
+
*
|
|
3771
|
+
* `VACUUM INTO` takes a consistent snapshot with the database still open, so this is safe to call
|
|
3772
|
+
* on a running daemon — which matters, because the daemon is what serves the tool.
|
|
3773
|
+
*/
|
|
3774
|
+
handlers.set("cello_backup", async (params, _connectionId) => {
|
|
3775
|
+
const outPath = typeof params?.["path"] === "string" ? params["path"] : "";
|
|
3776
|
+
if (!outPath) {
|
|
3777
|
+
return {
|
|
3778
|
+
ok: false,
|
|
3779
|
+
reason: "missing_path",
|
|
3780
|
+
guidance: "Give an absolute path to write the backup to, e.g. { path: \"/Users/you/cello-agent.cello-backup\" }. " +
|
|
3781
|
+
"It is not written to a default location on purpose: the file contains the key to your agent, " +
|
|
3782
|
+
"so where it lands is a decision you should make deliberately.",
|
|
3783
|
+
};
|
|
3784
|
+
}
|
|
3785
|
+
const res = await createBackup({
|
|
3786
|
+
dbPath: join(celloDir, "sessions.db"),
|
|
3787
|
+
outPath,
|
|
3788
|
+
logger,
|
|
3789
|
+
...(params?.["overwrite"] === true ? { overwrite: true } : {}),
|
|
3790
|
+
});
|
|
3791
|
+
return res.ok
|
|
3792
|
+
? { ok: true, path: res.path, bytes: res.bytes, guidance: res.guidance }
|
|
3793
|
+
: { ok: false, reason: res.reason, guidance: res.guidance };
|
|
3794
|
+
});
|
|
3795
|
+
/**
|
|
3796
|
+
* RESTORE IS REFUSED WHILE THIS DAEMON IS RUNNING, and that is not a limitation to apologise for.
|
|
3797
|
+
*
|
|
3798
|
+
* The daemon holds the database open with a write lock. Overwriting the file underneath an open
|
|
3799
|
+
* SQLite handle risks the handle flushing its own pages back over the restored ones — the restore
|
|
3800
|
+
* appears to succeed and the database is a hybrid of two identities, which is worse than either
|
|
3801
|
+
* failing cleanly.
|
|
3802
|
+
*
|
|
3803
|
+
* The capability itself lives in `backup-restore.ts` (a daemon module, per the DoD), and the CLI
|
|
3804
|
+
* calls it with the daemon stopped. So the honest answer here is the exact sequence, not an
|
|
3805
|
+
* attempt.
|
|
3806
|
+
*/
|
|
3807
|
+
handlers.set("cello_restore", async (params, _connectionId) => {
|
|
3808
|
+
const archivePath = typeof params?.["path"] === "string" ? params["path"] : "";
|
|
3809
|
+
const inspected = archivePath ? await inspectBackup(archivePath) : null;
|
|
3810
|
+
return {
|
|
3811
|
+
ok: false,
|
|
3812
|
+
reason: "daemon_running",
|
|
3813
|
+
...(inspected?.ok
|
|
3814
|
+
? { archive_verified: true, backup_created_at: new Date(inspected.createdAt).toISOString() }
|
|
3815
|
+
: inspected
|
|
3816
|
+
? { archive_verified: false, archive_problem: inspected.reason }
|
|
3817
|
+
: {}),
|
|
3818
|
+
guidance: (inspected?.ok
|
|
3819
|
+
? `That archive is valid (taken ${new Date(inspected.createdAt).toISOString()}). `
|
|
3820
|
+
: inspected
|
|
3821
|
+
? `WARNING: that archive did not validate (${inspected.reason}) — fix that before going further. `
|
|
3822
|
+
: "") +
|
|
3823
|
+
"Restoring REPLACES this machine's agent database, and it cannot be done while the daemon " +
|
|
3824
|
+
"is holding it open — the running daemon could flush its own pages back over the restored " +
|
|
3825
|
+
"ones and leave a database that is half one identity and half another. Stop the daemon " +
|
|
3826
|
+
"first:\n\n cello logout\n cello restore " +
|
|
3827
|
+
(archivePath || "<archive>") +
|
|
3828
|
+
"\n cello login\n\n" +
|
|
3829
|
+
"Anything that happened on this machine since the backup was taken will be gone — restore " +
|
|
3830
|
+
"replaces, it does not merge.",
|
|
3831
|
+
};
|
|
3832
|
+
});
|
|
3213
3833
|
// ─── MCP-001: stubs for tools registered in cello-mcp.ts but not yet implemented ───
|
|
3214
3834
|
// These return not_implemented (same as session tools) so LLMs get consistent guidance.
|
|
3215
|
-
for (const tool of ["
|
|
3835
|
+
for (const tool of ["cello_get_inclusion_proof"]) {
|
|
3216
3836
|
handlers.set(tool, async (_params, _connectionId) => {
|
|
3217
3837
|
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
3838
|
});
|
|
@@ -3234,6 +3854,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3234
3854
|
logger,
|
|
3235
3855
|
sessionNodeManager,
|
|
3236
3856
|
loadedAgents,
|
|
3857
|
+
// DOD-M15-CLOSEWAIT-1 review HIGH-2: the SAME predicate cello_status uses, so the two surfaces
|
|
3858
|
+
// cannot disagree about whether a ceremony is in flight. Both maps, because either can be the
|
|
3859
|
+
// one running — pendingSealWaiters is the active close, sealInterruptedInProgress the interrupted.
|
|
3860
|
+
isSealing: (agentName, sessionId) => pendingSealWaiters.has(sealKey(agentName, sessionId)) ||
|
|
3861
|
+
sealInterruptedInProgress.has(sealKey(agentName, sessionId)),
|
|
3862
|
+
// DOD-M15-SEAL-FAILED-TERMINAL-1: the SAME store the close handler writes, so a failure recorded
|
|
3863
|
+
// by the detached tail is the one the receipt surface reads.
|
|
3864
|
+
getSealFailure: (agentName, sessionId) => sealFailures.get(agentName, sessionId),
|
|
3237
3865
|
getConnState: (connectionId) => perConnectionState.get(connectionId),
|
|
3238
3866
|
resolveCurrentAgent,
|
|
3239
3867
|
NO_CURRENT_AGENT_RESPONSE,
|
|
@@ -3308,7 +3936,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3308
3936
|
return { error: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass agentName." };
|
|
3309
3937
|
}
|
|
3310
3938
|
const agentId = sessionNodeManager.resolveAgentId(agentName);
|
|
3311
|
-
|
|
3939
|
+
// The fault-injection IPC seam. It states all three trailing values explicitly rather than
|
|
3940
|
+
// relying on defaults (B2b-1 pass-2 F1): `undefined` for the ordering record and the algorithm
|
|
3941
|
+
// is what this path genuinely has — it injects a bare queued entry — and saying so keeps the
|
|
3942
|
+
// seam honest about what it is producing rather than inheriting whatever the signature assumed.
|
|
3943
|
+
retryQueue.enqueueAwaitingContent(agentId, sessionId, Buffer.from(contentHashHex, "hex"), Buffer.from(contentHex, "hex"), undefined, undefined, undefined);
|
|
3312
3944
|
return { queued: true, awaitingDepth: retryQueue.getAwaitingDepth(agentId, sessionId) };
|
|
3313
3945
|
});
|
|
3314
3946
|
// CELLO-M7-MSG-001: a `persisted` delivery ACK (or a confirmed park) clears the durable
|
|
@@ -3395,7 +4027,23 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3395
4027
|
const sessionId = params?.sessionId;
|
|
3396
4028
|
if (!agentName || !sessionId)
|
|
3397
4029
|
return { error: "missing_params", guidance: "Provide agentName and sessionId." };
|
|
3398
|
-
|
|
4030
|
+
// The reason is VALIDATED against the closed union rather than cast through it. A test seam
|
|
4031
|
+
// that can inject a reason production cannot produce would let a test prove the inbox handles a
|
|
4032
|
+
// code no refusal path emits — which is the shape DOD-M15-GUARD-HEARD-1 exists to remove, and a
|
|
4033
|
+
// seam is not exempt from it.
|
|
4034
|
+
const asked = params?.reason ?? CAPACITY_REASONS.ABUSE_BOUND_SESSIONS_PER_SENDER;
|
|
4035
|
+
const known = [
|
|
4036
|
+
...Object.values(REFUSAL_REASONS),
|
|
4037
|
+
...Object.values(CAPACITY_REASONS),
|
|
4038
|
+
];
|
|
4039
|
+
if (!known.includes(asked)) {
|
|
4040
|
+
return {
|
|
4041
|
+
error: "unknown_refusal_reason",
|
|
4042
|
+
guidance: `"${asked}" is not a reason any refusal path can emit. Use one of: ${known.join(", ")}. ` +
|
|
4043
|
+
`Seeding an invented reason would test the inbox against a code production never produces.`,
|
|
4044
|
+
};
|
|
4045
|
+
}
|
|
4046
|
+
recordRefusal(agentName, sessionId, params?.counterpartyPubkey ?? "", asked);
|
|
3399
4047
|
return { ok: true };
|
|
3400
4048
|
});
|
|
3401
4049
|
handlers.set("__test_delivery_open_begin", async (params, _connectionId) => {
|
|
@@ -3572,12 +4220,27 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3572
4220
|
if (!handler)
|
|
3573
4221
|
return undefined;
|
|
3574
4222
|
return async (params, connectionId) => {
|
|
3575
|
-
|
|
4223
|
+
/**
|
|
4224
|
+
* ONE request, ONE store — and the fallback notice is spread in BEFORE `renderForSurface`.
|
|
4225
|
+
*
|
|
4226
|
+
* `DOD-M15-SELECTION-1` clause 2 first annotated the response out in `ipc-server.ts`, which
|
|
4227
|
+
* is downstream of this wrapper and therefore downstream of surface rendering. The notice
|
|
4228
|
+
* says *"Run cello_use_agent"*; `isInstructionKey` in `vocabulary.ts` rewrites any key
|
|
4229
|
+
* ending in `guidance`, so that WOULD have become `cello use-agent` for a terminal — but it
|
|
4230
|
+
* arrived after the rewrite had already run. An operator running `cello inbox` was handed a
|
|
4231
|
+
* verb that does not exist in a shell, which is the exact failure the vocabulary layer was
|
|
4232
|
+
* built to prevent. Annotating here puts it back in front of the renderer.
|
|
4233
|
+
*/
|
|
4234
|
+
const store = {};
|
|
4235
|
+
const result = await fallbackNoticeStore.run(store, () => handler(params, connectionId));
|
|
4236
|
+
const annotated = store.notice && result !== null && typeof result === "object" && !Array.isArray(result)
|
|
4237
|
+
? { ...result, ...store.notice }
|
|
4238
|
+
: result;
|
|
3576
4239
|
// Default to "cli": a connection that never sent ipc.connect has no recorded surface, and
|
|
3577
4240
|
// the CLI verb is the safe answer — it is at least a real command an operator can run,
|
|
3578
4241
|
// whereas an MCP tool name is useless in a terminal.
|
|
3579
4242
|
const surface = perConnectionState.get(connectionId)?.clientType === "mcp" ? "mcp" : "cli";
|
|
3580
|
-
return renderForSurface(
|
|
4243
|
+
return renderForSurface(annotated, surface);
|
|
3581
4244
|
};
|
|
3582
4245
|
},
|
|
3583
4246
|
};
|
|
@@ -3828,7 +4491,22 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3828
4491
|
logger.error("document.delivery.seal_failed", { agent, sessionId, reason: "close_handler_missing", correlationId });
|
|
3829
4492
|
return;
|
|
3830
4493
|
}
|
|
3831
|
-
|
|
4494
|
+
/**
|
|
4495
|
+
* `wait_for_seal: true` — DOD-M15-CLOSEWAIT-1 review MEDIUM-5.
|
|
4496
|
+
*
|
|
4497
|
+
* This is NOT an IPC caller. It is an in-process worker awaiting the close for a session
|
|
4498
|
+
* it opened itself, and the `ok !== true` check below is the only thing that ever reports
|
|
4499
|
+
* a failed document-delivery seal. The new default answers `ok: true` at COMMITMENT, so
|
|
4500
|
+
* without this flag that check could never fire for a ceremony failure again — §16.4's
|
|
4501
|
+
* "the autonomous session still carries the seal" would rest on a detached task nobody
|
|
4502
|
+
* awaited, retried or reported on.
|
|
4503
|
+
*
|
|
4504
|
+
* The whole point of answering early is that a human is watching a terminal. Nobody is
|
|
4505
|
+
* watching this one, so it takes the blocking form — which also stops each delivery
|
|
4506
|
+
* leaving a detached ceremony holding a visiting connection for up to eleven minutes,
|
|
4507
|
+
* overlapping instead of serialising.
|
|
4508
|
+
*/
|
|
4509
|
+
const sealed = (await close({ session_id: sessionId, agent, wait_for_seal: true }, `doc-delivery-${correlationId}`));
|
|
3832
4510
|
if (sealed?.ok !== true) {
|
|
3833
4511
|
// `cello_close_session` has distinct failure codes — session_already_sealed,
|
|
3834
4512
|
// seal_interrupted_*, signaling_reconnecting — and every one of them landed nowhere.
|
|
@@ -3841,7 +4519,14 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
3841
4519
|
// transport asked for 0x04, the composition root threw it away, and every document leaf
|
|
3842
4520
|
// still reached the relay as a MESSAGE. Verified on live traffic: daemon 0.0.145 shipped
|
|
3843
4521
|
// the fix everywhere except here and the wire was unchanged.
|
|
3844
|
-
|
|
4522
|
+
//
|
|
4523
|
+
// ⚠️ AND `contentHashAlg` IS THE SECOND PARAMETER THIS ADAPTER MUST NOT DROP. The note above
|
|
4524
|
+
// is about `leafKind`, which this wrapper silently swallowed while every other caller passed
|
|
4525
|
+
// it — a thin pass-through is exactly where a new argument goes missing, because nothing
|
|
4526
|
+
// about the call site looks wrong afterwards. B2b's failure mode if it happens again: the
|
|
4527
|
+
// document path sends a salted hash labelled `sha256`, and every peer refuses it.
|
|
4528
|
+
sendContent: (agent, sessionId, content, contentHash, correlationId, leafKind, contentHashAlg) => sessionNodeManager.sendContent(agent, sessionId, content, contentHash, correlationId, leafKind, contentHashAlg),
|
|
4529
|
+
contentHashForSession: (agent, sessionId, content) => sessionNodeManager.contentHashForSession(agent, sessionId, content),
|
|
3845
4530
|
// The `0x04` doc leaf for a frame WE sent — the same step `cello_send` takes after its own
|
|
3846
4531
|
// successful send. See the comment at the call site for why this is delivery-critical and
|
|
3847
4532
|
// not audit bookkeeping.
|
|
@@ -4012,6 +4697,33 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4012
4697
|
// MCP-001: Clean up per-connection state when a connection disconnects
|
|
4013
4698
|
// MCP-002: Also unregister from notification dispatcher
|
|
4014
4699
|
ipcServer.onDisconnect((connectionId) => {
|
|
4700
|
+
/**
|
|
4701
|
+
* DOD-M15-IPCVISIBLE-1: SAY THAT IT CLOSED, and say what it was attending.
|
|
4702
|
+
*
|
|
4703
|
+
* `daemon.ipc.connected` fired on every open and nothing on close, so a live client and a dead
|
|
4704
|
+
* one that was never cleaned up looked identical in the log. The attended agent is the field
|
|
4705
|
+
* that matters: attendance dropping was silent, and an agent losing its last attendee changes
|
|
4706
|
+
* whether away-messages fire and who receives doorbells — so a session that stopped waking is
|
|
4707
|
+
* diagnosable from the log rather than by guesswork.
|
|
4708
|
+
*/
|
|
4709
|
+
const closing = perConnectionState.get(connectionId);
|
|
4710
|
+
const stillAttending = closing?.currentAgent
|
|
4711
|
+
? countAttendance(perConnectionState, closing.currentAgent) - 1
|
|
4712
|
+
: null;
|
|
4713
|
+
// RETURNED, not logged here — `ipcServer` merges this into its single
|
|
4714
|
+
// `daemon.ipc.disconnected` line. A second line under the same name left neither carrying the
|
|
4715
|
+
// whole picture and doubled every count (review F8).
|
|
4716
|
+
const disconnectContext = {
|
|
4717
|
+
clientType: closing?.clientType ?? "unknown",
|
|
4718
|
+
attendedAgent: closing?.currentAgent ?? null,
|
|
4719
|
+
...(stillAttending !== null ? { remainingAttendance: stillAttending } : {}),
|
|
4720
|
+
...(stillAttending === 0
|
|
4721
|
+
? {
|
|
4722
|
+
impact: "that agent has no attending session left — inbound sessions are now answered with " +
|
|
4723
|
+
"its away message rather than a live reply, and its doorbells reach nobody",
|
|
4724
|
+
}
|
|
4725
|
+
: {}),
|
|
4726
|
+
};
|
|
4015
4727
|
perConnectionState.delete(connectionId);
|
|
4016
4728
|
connectionCursors.delete(connectionId); // M8C-CURSOR-1: cursor is connection-scoped, dies with it
|
|
4017
4729
|
// ...and so is the delivery bookmark (review F1). It is a SEPARATE map from the gate's cursor
|
|
@@ -4044,6 +4756,7 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4044
4756
|
else
|
|
4045
4757
|
inboundSessionWaiters.delete(agentName);
|
|
4046
4758
|
}
|
|
4759
|
+
return disconnectContext;
|
|
4047
4760
|
});
|
|
4048
4761
|
// Log daemon.login.validation.complete (stub — all unverified until SIGNAL-001)
|
|
4049
4762
|
logger.info("daemon.login.validation.complete", {
|
|
@@ -4094,6 +4807,11 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4094
4807
|
if (manifestPollScheduler) {
|
|
4095
4808
|
manifestPollScheduler.cancel();
|
|
4096
4809
|
}
|
|
4810
|
+
// DOD-M15-STALEROSTER-1: same discipline as the manifest poll — the stop function sets the
|
|
4811
|
+
// flag so an in-flight sweep cannot re-arm, and the belt-and-suspenders cancel covers the
|
|
4812
|
+
// scheduler-without-sweep case.
|
|
4813
|
+
stopRosterSweep?.();
|
|
4814
|
+
rosterSweepScheduler?.cancel();
|
|
4097
4815
|
stopRegistryPoll?.();
|
|
4098
4816
|
if (config.registryPollScheduler) {
|
|
4099
4817
|
config.registryPollScheduler.cancel();
|
|
@@ -4103,6 +4821,32 @@ async function startDaemonHoldingLock(config, singletonLock) {
|
|
|
4103
4821
|
// outbound work is not draining. Above the `daemon.stopped` log with the other cancels, because
|
|
4104
4822
|
// this is "stop making new work", not "tear down transports".
|
|
4105
4823
|
await restartSealResolver?.stop();
|
|
4824
|
+
/**
|
|
4825
|
+
* DRAIN THE DETACHED SEAL TAILS — review MEDIUM-6, and it sits HERE for the same reason
|
|
4826
|
+
* `restartSealResolver.stop()` does: both are directory ceremonies that must not be cut with
|
|
4827
|
+
* the counterparty holding a commitment this side never acknowledged. Before
|
|
4828
|
+
* `stopAllSignaling()`, because that is what severs the transport underneath them.
|
|
4829
|
+
*
|
|
4830
|
+
* BOUNDED. A ceremony can legitimately wait eleven minutes for a counterparty, and a shutdown
|
|
4831
|
+
* must not. Past the bound they are abandoned deliberately and said out loud — the next boot
|
|
4832
|
+
* resolves them, which is exactly what the restart seal resolver is for.
|
|
4833
|
+
*/
|
|
4834
|
+
if (backgroundSeals.size > 0) {
|
|
4835
|
+
const SHUTDOWN_SEAL_DRAIN_MS = 5_000;
|
|
4836
|
+
logger.info("session.seal.background.draining", { count: backgroundSeals.size, budgetMs: SHUTDOWN_SEAL_DRAIN_MS });
|
|
4837
|
+
const drained = await Promise.race([
|
|
4838
|
+
Promise.allSettled([...backgroundSeals]).then(() => true),
|
|
4839
|
+
new Promise((r) => { const t = setTimeout(() => r(false), SHUTDOWN_SEAL_DRAIN_MS); t.unref?.(); }),
|
|
4840
|
+
]);
|
|
4841
|
+
if (!drained) {
|
|
4842
|
+
logger.warn("session.seal.background.abandoned", {
|
|
4843
|
+
count: backgroundSeals.size,
|
|
4844
|
+
impact: "shutdown did not wait for these seal ceremonies. Each session holds a durable commitment " +
|
|
4845
|
+
"but no receipt yet, and the counterparty may hold a commitment this side never acknowledged.",
|
|
4846
|
+
guidance: "The next daemon start resolves them via the restart seal resolver; no operator action is needed.",
|
|
4847
|
+
});
|
|
4848
|
+
}
|
|
4849
|
+
}
|
|
4106
4850
|
logger.info("daemon.stopped", { pid: process.pid, reason });
|
|
4107
4851
|
// DOD-LOGOUT-EXIT-1: what the teardown actually DID, carried to onStopped so the binary can
|
|
4108
4852
|
// exit non-zero on a dirty stop. Without it a shutdown that threw halfway — sessions never
|