@cello-protocol/daemon 0.0.10 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +410 -186
- package/dist/daemon.js.map +1 -1
- package/dist/db-identity-store.d.ts +37 -9
- package/dist/db-identity-store.d.ts.map +1 -1
- package/dist/db-identity-store.js +186 -54
- package/dist/db-identity-store.js.map +1 -1
- package/dist/http-manifest-poll.d.ts +67 -0
- package/dist/http-manifest-poll.d.ts.map +1 -0
- package/dist/http-manifest-poll.js +143 -0
- package/dist/http-manifest-poll.js.map +1 -0
- package/dist/identity-migration.js +14 -8
- package/dist/identity-migration.js.map +1 -1
- package/dist/session-assignment-parser.d.ts.map +1 -1
- package/dist/session-assignment-parser.js +1 -0
- package/dist/session-assignment-parser.js.map +1 -1
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +11 -2
- package/dist/session-node-manager.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +4 -4
package/dist/daemon.js
CHANGED
|
@@ -46,13 +46,15 @@ import { verify as ed25519Verify, sealToRecipient, generateKLocalSeed, InMemoryK
|
|
|
46
46
|
import { attemptSealUpgrade as attemptSealUpgradeImpl, verifyUpgradeConfirmedCert } from "./seal-upgrade.js";
|
|
47
47
|
// CELLO-M7-MSG-001 (AC-013/AC-018): the single application content-size cap, enforced
|
|
48
48
|
// at the send point here (the receive point lives in the transport content decode).
|
|
49
|
-
import { MAX_CONTENT_BYTES, computeGenesisPrevRoot } from "@cello-protocol/protocol-types";
|
|
49
|
+
import { MAX_CONTENT_BYTES, computeGenesisPrevRoot, buildAgentRevocationTbs } from "@cello-protocol/protocol-types";
|
|
50
50
|
import { resolveCelloEnv, createTransportSelector, isProductionVariant, } from "./transport-composition.js";
|
|
51
51
|
import { selectAdvertisedAddress } from "./transport-selector.js";
|
|
52
52
|
import { parseSessionAssignment, sessionRequestErrorReason } from "./session-assignment-parser.js";
|
|
53
53
|
import { wireSessionCeremonyHandler, wireSessionOfferHandler, wireSealCeremonyHandler, verifyUnilateralCertificate, verifyBilateralSealCertificate } from "./session-ceremony.js";
|
|
54
54
|
import { reDeriveFrontiers, findInflatedFrontier } from "./seal-frontier-verify.js";
|
|
55
55
|
import { LocalAutoNatStub } from "@cello-protocol/transport";
|
|
56
|
+
import { startHttpManifestPoll } from "./http-manifest-poll.js";
|
|
57
|
+
import { resolveDirectoryUrl } from "./directory-bootstrap.js";
|
|
56
58
|
/**
|
|
57
59
|
* M7-SESSION-001 (H-1): canonical byte encoding of a SEAL-INTERRUPTED leaf for
|
|
58
60
|
* Ed25519 signing/verification. Field order is fixed and deterministic. Both the
|
|
@@ -170,7 +172,7 @@ class ProductionSessionNodeFactory {
|
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
export async function startDaemon(config) {
|
|
173
|
-
const { celloDir, socketPath, lockFilePath, maxConnections, version, logger, manifestProvider, manifestRootKeys, manifestThreshold, manifestVersionStore: injectedManifestVersionStore, manifestPollScheduler, signalingConnect, challengeVerifier, directoryEndpointResolver, sessionNodeFactory, sessionNegotiator, getRelayCircuitAddress, } = config;
|
|
175
|
+
const { celloDir, socketPath, lockFilePath, maxConnections, version, logger, manifestProvider, manifestRootKeys, manifestThreshold, manifestVersionStore: injectedManifestVersionStore, manifestPollScheduler, directoryHttpUrl, signalingConnect, challengeVerifier, directoryEndpointResolver, sessionNodeFactory, sessionNegotiator, getRelayCircuitAddress, } = config;
|
|
174
176
|
// CELLO-M7-TRANSPORT-001: composition-root selection of the transport selector.
|
|
175
177
|
// Driven by CELLO_ENV; fails fast at startup (here, not at first session) when a
|
|
176
178
|
// production environment is missing the required transport dialer (AC-010).
|
|
@@ -283,6 +285,24 @@ export async function startDaemon(config) {
|
|
|
283
285
|
throw new Error("Manifest verification failed. The daemon cannot start with an unverified manifest when manifestProvider is configured. " +
|
|
284
286
|
"Check the logs for the specific failure reason (manifest_signature_invalid, manifest_expired, or manifest_version_rollback).");
|
|
285
287
|
}
|
|
288
|
+
// CELLO-M7-CONN-001 (DOD-CONN-3): start the daemon-level HTTP manifest poll. It fetches
|
|
289
|
+
// ${directoryHttpUrl}/manifest, verifies the threshold signature against the locally-pinned
|
|
290
|
+
// root keys, applies anti-rollback + expiry, and adopts a newer manifest — independent of any
|
|
291
|
+
// agent identity, running even with ZERO agents (the deleted keystone could not). Gated on the
|
|
292
|
+
// same manifest deps as the old signaling poll; off on the M6 backward-compat path (no scheduler).
|
|
293
|
+
let stopHttpManifestPoll;
|
|
294
|
+
if (manifestPollScheduler && manifestProvider && manifestRootKeys && manifestRootKeys.length > 0 && manifestThreshold && manifestThreshold >= 1) {
|
|
295
|
+
stopHttpManifestPoll = startHttpManifestPoll({
|
|
296
|
+
scheduler: manifestPollScheduler,
|
|
297
|
+
directoryUrl: directoryHttpUrl ?? resolveDirectoryUrl(process.env),
|
|
298
|
+
manifestProvider,
|
|
299
|
+
manifestVersionStore,
|
|
300
|
+
rootKeys: manifestRootKeys,
|
|
301
|
+
threshold: manifestThreshold,
|
|
302
|
+
logger,
|
|
303
|
+
mintCorrelationId: () => randomUUID(),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
286
306
|
// Load agent identities from the encrypted `agents` table (PERSIST-002 AC-007 — one path).
|
|
287
307
|
// (The encrypted store + lock were established above, before manifest verification.)
|
|
288
308
|
const { loaded: loadedAgents, failed: failedAgents } = await loadAgents(sessionNodeManager.getDb(), logger);
|
|
@@ -336,111 +356,63 @@ export async function startDaemon(config) {
|
|
|
336
356
|
const defaultConnect = async () => {
|
|
337
357
|
throw new Error("directory_signaling_not_configured");
|
|
338
358
|
};
|
|
339
|
-
// M7
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
359
|
+
// CELLO-M7-CONN-001 (DOD-CONN-1): the keystone is DELETED. There is no shared directory
|
|
360
|
+
// connection borrowing the "primary" agent's identity. In PRODUCTION every agent operates its
|
|
361
|
+
// OWN directory signaling connection authenticated as itself (getAgentSignaling / signalingFor);
|
|
362
|
+
// removing any agent tears down only that agent's own connection, so the daemon never holds a
|
|
363
|
+
// connection authenticated as a removed agent (the Demo1 stranding bug). A single SHARED manager
|
|
364
|
+
// exists ONLY for the in-process test / backward-compat path (a single injected signalingConnect,
|
|
365
|
+
// no per-agent isolation) — in production it is undefined.
|
|
345
366
|
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// order (agent-loader) is platform-dependent and unsorted, which would otherwise
|
|
350
|
-
// let the authenticating identity change between daemon restarts.
|
|
351
|
-
// CELLO-M7-ONBOARD-001: `primaryAgent` is mutable so the FIRST agent created at runtime
|
|
352
|
-
// (cello_create_agent on a fresh install) can be elected as the keystone identity — otherwise the
|
|
353
|
-
// directory door stays `reconnecting` forever until a restart. getAuthIdentity reads it on every
|
|
354
|
-
// reconnect attempt, so electing it makes the already-running reconnect loop authenticate + connect.
|
|
355
|
-
let primaryAgent = [...loadedAgents].sort((a, b) => a.name.localeCompare(b.name))[0];
|
|
356
|
-
const getAuthIdentity = () => {
|
|
357
|
-
if (!primaryAgent)
|
|
358
|
-
return null;
|
|
359
|
-
return { keyProvider: primaryAgent.keyProvider, pubkeyHex: primaryAgent.pubkey };
|
|
360
|
-
};
|
|
361
|
-
// Production builds signalingConnect from the bootstrap resolver + agent identity.
|
|
362
|
-
// Tests inject signalingConnect directly (takes precedence). Neither → defaultConnect
|
|
363
|
-
// (DAEMON-001 backward-compat). challengeVerifier is left to the caller: when absent,
|
|
364
|
-
// step-6 directory verification is skipped — the M6 path that connected and ran the
|
|
365
|
-
// full DKG/seal pipeline.
|
|
366
|
-
// M7 Action 2: the daemon holds a reference to the live directory-facing node so
|
|
367
|
-
// registration's FROST DKG (NetworkDirectoryNode) — and future ceremonies/seal — can
|
|
368
|
-
// open streams to the directory on the SAME node. createSignalingConnect sets it via
|
|
369
|
-
// publishNode on a successful connect and clears it (null) when the stream closes.
|
|
370
|
-
// Consumers MUST gate use on signalingManager.status === "connected".
|
|
367
|
+
// M7 Action 2: the daemon holds a reference to a live directory-facing node so registration's
|
|
368
|
+
// FROST DKG can open streams on the SAME node. In production each per-agent manager publishes its
|
|
369
|
+
// OWN node (getAgentSignaling); this shared ref is the test-path node only.
|
|
371
370
|
let directoryNode = null;
|
|
372
371
|
const getDirectoryNode = () => directoryNode;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
})
|
|
385
|
-
: defaultConnect);
|
|
386
|
-
// H1: a long-running daemon must ride out directory outages — notably the
|
|
387
|
-
// 25-30 min multi-region directory deploy. The transport default of 10 reconnect
|
|
388
|
-
// attempts (~5 min with default backoff) transitions the manager to terminal
|
|
389
|
-
// "lost" mid-deploy, with no public way to re-enter the loop — the daemon would
|
|
390
|
-
// never recover without a cello logout/login. Use an effectively-unbounded attempt
|
|
391
|
-
// budget with a capped backoff so it keeps retrying and reconnects within
|
|
392
|
-
// ~maxBackoffMs of the directory returning. (Availability is a first-class invariant.)
|
|
393
|
-
//
|
|
394
|
-
// L3: challengeVerifier is NOT passed here — the dialer (createSignalingConnect)
|
|
395
|
-
// performs step-6 verification itself, matching #doOpen. The manager's copy would
|
|
396
|
-
// be dead (processStep5Frame is only invoked inside connect()).
|
|
397
|
-
const signalingManager = new SignalingManager({
|
|
398
|
-
connect: resolvedConnect,
|
|
399
|
-
logger,
|
|
400
|
-
maxReconnectAttempts: Number.MAX_SAFE_INTEGER,
|
|
401
|
-
maxBackoffMs: 30_000,
|
|
402
|
-
// DOD-AUTH-2: the keystone manager (primary agent's directory door) carries the
|
|
403
|
-
// manifest-poll deps so it re-polls the directory on its live stream and adopts a
|
|
404
|
-
// newer signed manifest. The SAME shared manifestProvider instance the startup load
|
|
405
|
-
// + challengeVerifier use, so an adopted manifest updates the cache step-6 reads from.
|
|
406
|
-
// All optional — undefined on the M6 backward-compat path → polling is simply off.
|
|
407
|
-
pollScheduler: manifestPollScheduler,
|
|
408
|
-
manifestProvider,
|
|
409
|
-
manifestVersionStore,
|
|
410
|
-
rootKeys: manifestRootKeys,
|
|
411
|
-
threshold: manifestThreshold,
|
|
412
|
-
});
|
|
372
|
+
// H1: a long-running daemon must ride out directory outages — notably the 25-30 min multi-region
|
|
373
|
+
// directory deploy. Use an effectively-unbounded reconnect budget with a capped backoff so each
|
|
374
|
+
// connection keeps retrying and reconnects within ~maxBackoffMs of the directory returning.
|
|
375
|
+
const sharedSignaling = directoryEndpointResolver
|
|
376
|
+
? undefined
|
|
377
|
+
: new SignalingManager({
|
|
378
|
+
connect: signalingConnect ?? defaultConnect,
|
|
379
|
+
logger,
|
|
380
|
+
maxReconnectAttempts: Number.MAX_SAFE_INTEGER,
|
|
381
|
+
maxBackoffMs: 30_000,
|
|
382
|
+
});
|
|
413
383
|
const perAgentSignaling = new Map();
|
|
414
384
|
/**
|
|
415
|
-
* Return the directory signaling stream for `agentName`, authenticated as that
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
* manager when no production bootstrap resolver is configured (in-process tests
|
|
419
|
-
* inject a single `signalingConnect` and never exercise the per-agent path).
|
|
385
|
+
* Return the directory signaling stream for `agentName`, authenticated as that agent.
|
|
386
|
+
* Production: a dedicated per-agent manager (created + cached on first use). Test /
|
|
387
|
+
* backward-compat path (no directoryEndpointResolver): the single shared manager.
|
|
420
388
|
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* regression (before this, a non-primary agent could not register at all); closing it
|
|
428
|
-
* is SPINE-5, which attaches the session inbound handlers per-agent. Tracked in the
|
|
429
|
-
* M7 build journal + DoD SPINE-5 scope note.
|
|
389
|
+
* CONN-001 (DOD-CONN-2): the per-agent manager wires BOTH registration AND inbound session
|
|
390
|
+
* handlers (session_assignment / seal_interrupted_request) via wirePerAgentSessionInbound,
|
|
391
|
+
* so a non-primary agent RECEIVES inbound sessions on its own stream — closing the prior
|
|
392
|
+
* SPINE-5 gap where only the primary (keystone) received them. Earlier scope note (now
|
|
393
|
+
* resolved): inbound handlers used to be attached to the keystone only; they are now
|
|
394
|
+
* attached per-agent here via wirePerAgentSessionInbound.
|
|
430
395
|
*/
|
|
431
396
|
function getAgentSignaling(agentName, agentKeyProvider, agentPubkeyHex) {
|
|
432
|
-
|
|
433
|
-
|
|
397
|
+
// CONN-001: test / backward-compat path — a single shared manager (no per-agent isolation;
|
|
398
|
+
// a single injected signalingConnect). In production sharedSignaling is undefined and every
|
|
399
|
+
// agent builds its own dedicated manager below.
|
|
400
|
+
if (sharedSignaling) {
|
|
401
|
+
return { signaling: sharedSignaling, getNode: getDirectoryNode };
|
|
434
402
|
}
|
|
435
403
|
const existing = perAgentSignaling.get(agentName);
|
|
436
404
|
if (existing)
|
|
437
405
|
return existing;
|
|
406
|
+
// Unreachable in practice: sharedSignaling is defined iff directoryEndpointResolver is absent, and
|
|
407
|
+
// the sharedSignaling branch above already returned. This narrows the resolver for the type-checker
|
|
408
|
+
// and is a defensive guard.
|
|
438
409
|
if (!directoryEndpointResolver) {
|
|
439
|
-
|
|
410
|
+
throw new Error("getAgentSignaling: no directory endpoint resolver configured (and no shared manager)");
|
|
440
411
|
}
|
|
412
|
+
const resolver = directoryEndpointResolver;
|
|
441
413
|
let nodeRef = null;
|
|
442
414
|
const connect = createSignalingConnect({
|
|
443
|
-
getDirectoryEndpoint:
|
|
415
|
+
getDirectoryEndpoint: resolver,
|
|
444
416
|
getAuthIdentity: () => ({ keyProvider: agentKeyProvider, pubkeyHex: agentPubkeyHex }),
|
|
445
417
|
logger,
|
|
446
418
|
challengeVerifier,
|
|
@@ -496,6 +468,9 @@ export async function startDaemon(config) {
|
|
|
496
468
|
signaling: mgr,
|
|
497
469
|
logger,
|
|
498
470
|
});
|
|
471
|
+
// CELLO-M7-CONN-001 (DOD-CONN-2): inbound session_assignment + seal_interrupted_request
|
|
472
|
+
// on THIS agent's own stream, so a non-primary agent receives inbound sessions (SPINE-5).
|
|
473
|
+
wirePerAgentSessionInbound(mgr);
|
|
499
474
|
return entry;
|
|
500
475
|
}
|
|
501
476
|
/** Resolve once `mgr` reaches "connected", or false on timeout. */
|
|
@@ -510,9 +485,9 @@ export async function startDaemon(config) {
|
|
|
510
485
|
* Stop and forget a dedicated per-agent signaling manager. Called when an agent's
|
|
511
486
|
* registration fails terminally — otherwise the lazily-created manager (and its
|
|
512
487
|
* libp2p node + effectively-unbounded reconnect loop) would keep reconnecting forever
|
|
513
|
-
* for an agent that is not registered/online. No-op
|
|
514
|
-
*
|
|
515
|
-
*
|
|
488
|
+
* for an agent that is not registered/online. No-op on the test path (the shared manager is not
|
|
489
|
+
* stored in perAgentSignaling) and for agents with no dedicated manager. On a later retry,
|
|
490
|
+
* getAgentSignaling re-creates it.
|
|
516
491
|
*/
|
|
517
492
|
async function dropAgentSignaling(agentName) {
|
|
518
493
|
const entry = perAgentSignaling.get(agentName);
|
|
@@ -522,19 +497,16 @@ export async function startDaemon(config) {
|
|
|
522
497
|
await entry.signaling.stop();
|
|
523
498
|
logger.info("agent.signaling.dropped", { agentName });
|
|
524
499
|
}
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
// ceremony would time out. CELLO-M7-ONBOARD-001: extracted into a function so it can also be wired
|
|
529
|
-
// when the first agent is elected at runtime (cello_create_agent), not only at startup.
|
|
530
|
-
function wireKeystonePrimary(agent) {
|
|
500
|
+
// CONN-001: wire a manager's session/seal/offer handlers for `agent`. Used ONLY for the
|
|
501
|
+
// in-process test path's shared manager — production wires these PER-AGENT in getAgentSignaling.
|
|
502
|
+
function wireSharedHandlers(agent, mgr) {
|
|
531
503
|
wireSessionCeremonyHandler({
|
|
532
504
|
agentName: agent.name,
|
|
533
505
|
persistence: getPersistence(agent.name),
|
|
534
506
|
agentPubkeyHex: agent.pubkey,
|
|
535
507
|
getNode: getDirectoryNode,
|
|
536
508
|
getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
|
|
537
|
-
signaling:
|
|
509
|
+
signaling: mgr,
|
|
538
510
|
logger,
|
|
539
511
|
});
|
|
540
512
|
wireSealCeremonyHandler({
|
|
@@ -543,27 +515,83 @@ export async function startDaemon(config) {
|
|
|
543
515
|
agentPubkeyHex: agent.pubkey,
|
|
544
516
|
getNode: getDirectoryNode,
|
|
545
517
|
getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
|
|
546
|
-
signaling:
|
|
518
|
+
signaling: mgr,
|
|
547
519
|
logger,
|
|
548
520
|
});
|
|
549
521
|
wireSessionOfferHandler({
|
|
550
522
|
agentName: agent.name,
|
|
551
523
|
getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
|
|
552
|
-
signaling:
|
|
524
|
+
signaling: mgr,
|
|
553
525
|
logger,
|
|
554
526
|
});
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
527
|
+
registerSessionSealedListener(mgr, agent.name, agent.pubkey);
|
|
528
|
+
registerUnilateralConfirmedListener(mgr, agent.name, agent.pubkey);
|
|
529
|
+
registerUnilateralUpgradeListener(mgr, agent.name, agent.pubkey);
|
|
530
|
+
}
|
|
531
|
+
// CONN-001: the directory signaling manager that OWNS operations for `agentName`. In production
|
|
532
|
+
// that is the agent's OWN per-agent manager (authenticated as itself); on the in-process test
|
|
533
|
+
// path it is the single shared manager (agentName ignored). Returns undefined only when the agent
|
|
534
|
+
// is not loaded in production (e.g. removed mid-flow) — callers treat that as a send failure.
|
|
535
|
+
function signalingFor(agentName) {
|
|
536
|
+
if (sharedSignaling)
|
|
537
|
+
return sharedSignaling;
|
|
538
|
+
const kp = keyProviders.get(agentName);
|
|
539
|
+
const agent = loadedAgents.find((a) => a.name === agentName);
|
|
540
|
+
if (!kp || !agent)
|
|
541
|
+
return undefined;
|
|
542
|
+
return getAgentSignaling(agentName, kp, agent.pubkey).signaling;
|
|
543
|
+
}
|
|
544
|
+
// CONN-001: send a frame over the OWNING agent's directory manager (per-agent in production, the
|
|
545
|
+
// shared manager in tests). If the agent has no manager (e.g. removed mid-flow), return a send
|
|
546
|
+
// failure rather than throw — callers already branch on `!result.ok`.
|
|
547
|
+
async function sendOver(agentName, frame) {
|
|
548
|
+
const mgr = signalingFor(agentName);
|
|
549
|
+
if (!mgr)
|
|
550
|
+
return { ok: false, reason: "directory_unreachable" };
|
|
551
|
+
return mgr.sendRaw(frame);
|
|
552
|
+
}
|
|
553
|
+
// CONN-001: aggregate directory signaling status for `cello status`. Test path → the shared
|
|
554
|
+
// manager's status. Production → connected if ANY online agent's connection is connected; else
|
|
555
|
+
// reconnecting if any per-agent connection exists; else disconnected (no agents online → no
|
|
556
|
+
// connections, which is correct — there is no shared connection to be "reconnecting").
|
|
557
|
+
function directorySignalingStatus() {
|
|
558
|
+
if (sharedSignaling)
|
|
559
|
+
return sharedSignaling.status;
|
|
560
|
+
const managers = [...perAgentSignaling.values()];
|
|
561
|
+
// CONN-001 (fallback-finder MED): a single daemon-level field cannot fully represent N independent
|
|
562
|
+
// per-agent connections, but it must NOT show "connected" while any agent is severed (that would
|
|
563
|
+
// mask a partial directory outage — the severed agent silently never receives inbound). So report
|
|
564
|
+
// "connected" ONLY when every per-agent manager is connected; otherwise "reconnecting" (degraded or
|
|
565
|
+
// none). No managers (no agent online) → "reconnecting", matching pre-CONN-001 fresh-install
|
|
566
|
+
// behavior. (Per-agent connection state is the future faithful surface.)
|
|
567
|
+
if (managers.length === 0)
|
|
568
|
+
return "reconnecting";
|
|
569
|
+
return managers.every((m) => m.signaling.status === "connected") ? "connected" : "reconnecting";
|
|
570
|
+
}
|
|
571
|
+
// CONN-001: stop every directory signaling connection on shutdown — the shared test manager AND
|
|
572
|
+
// every per-agent manager — so no reconnect loop is orphaned past shutdown.
|
|
573
|
+
async function stopAllSignaling() {
|
|
574
|
+
if (sharedSignaling) {
|
|
575
|
+
try {
|
|
576
|
+
await sharedSignaling.stop();
|
|
577
|
+
}
|
|
578
|
+
catch { /* best-effort */ }
|
|
579
|
+
}
|
|
580
|
+
for (const entry of perAgentSignaling.values()) {
|
|
581
|
+
try {
|
|
582
|
+
await entry.signaling.stop();
|
|
583
|
+
}
|
|
584
|
+
catch { /* best-effort */ }
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// CONN-001: test path only — wire the shared manager's session/seal handlers for the first loaded
|
|
588
|
+
// agent. Production wires them per-agent (getAgentSignaling). There is NO keystone identity to
|
|
589
|
+
// elect and no re-election machinery: the shared test manager is never removed, and in production
|
|
590
|
+
// a fresh create-agent brings up that agent's OWN connection (no shared door to elect into).
|
|
591
|
+
if (sharedSignaling && loadedAgents.length > 0) {
|
|
592
|
+
const first = [...loadedAgents].sort((a, b) => a.name.localeCompare(b.name))[0];
|
|
593
|
+
wireSharedHandlers(first, sharedSignaling);
|
|
564
594
|
}
|
|
565
|
-
if (primaryAgent)
|
|
566
|
-
wireKeystonePrimary(primaryAgent);
|
|
567
595
|
// Per-connection state: tracks which agent is "current" for each IPC connection.
|
|
568
596
|
// Key = connectionId (assigned by IPC server), Value = current agent name or null.
|
|
569
597
|
const perConnectionState = new Map();
|
|
@@ -878,7 +906,7 @@ export async function startDaemon(config) {
|
|
|
878
906
|
const interrupted_sessions = buildInterruptedSessions();
|
|
879
907
|
return {
|
|
880
908
|
daemon: "running",
|
|
881
|
-
directory_signaling:
|
|
909
|
+
directory_signaling: directorySignalingStatus(),
|
|
882
910
|
agents,
|
|
883
911
|
connections,
|
|
884
912
|
standing_receiver_ready: sessionNodeManager.getStandingReceiverReady(),
|
|
@@ -917,6 +945,18 @@ export async function startDaemon(config) {
|
|
|
917
945
|
return { ok: true };
|
|
918
946
|
}
|
|
919
947
|
onlineAgents.add(name);
|
|
948
|
+
// CELLO-M7-CONN-001 (DOD-CONN-2, code-review HIGH): the "online" transition establishes THIS
|
|
949
|
+
// agent's OWN directory signaling connection (the documented getAgentSignaling "online" trigger),
|
|
950
|
+
// so the directory has a stream to push inbound session_assignment / seal_interrupted_request to.
|
|
951
|
+
// Without this, a started receiver agent sitting in cello_await_session (notably after a daemon
|
|
952
|
+
// restart, where login does NOT auto-start agents) would have no stream and never receive inbound —
|
|
953
|
+
// a regression of the pre-CONN-001 keystone, which connected the primary at startup. Lazy +
|
|
954
|
+
// idempotent (getAgentSignaling reuses an existing manager); the test path returns the shared one.
|
|
955
|
+
const startKp = keyProviders.get(name);
|
|
956
|
+
if (startKp && agent.pubkey) {
|
|
957
|
+
getAgentSignaling(name, startKp, agent.pubkey);
|
|
958
|
+
logger.info("agent.directory.connection.initiated", { agentName: name, agentPubkey: agent.pubkey });
|
|
959
|
+
}
|
|
920
960
|
// DOD-LOOP-1: each online agent gets its OWN standing receiver, so two agents on one daemon
|
|
921
961
|
// (loopback) never contend for a single one. Fire-and-forget (initiate/accept also ensure on
|
|
922
962
|
// demand); never let it throw out of the handler. Once the SR is up, re-park any of THIS
|
|
@@ -951,37 +991,33 @@ export async function startDaemon(config) {
|
|
|
951
991
|
return { ok: false, reason: "invalid_agent_name", guidance: "Provide a 'name' (1-64 chars: letters, digits, '-' or '_') for the new agent." };
|
|
952
992
|
}
|
|
953
993
|
const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
|
|
954
|
-
if (store.
|
|
994
|
+
if (store.hasActiveAgent(name) || agents.some((a) => a.name === name)) {
|
|
955
995
|
return { ok: false, reason: "agent_already_exists", guidance: `Agent '${name}' already exists. Choose a different name, or see cello_list_agents.` };
|
|
956
996
|
}
|
|
957
997
|
let pubkeyHex;
|
|
998
|
+
let agentId;
|
|
958
999
|
try {
|
|
959
1000
|
const seed = generateKLocalSeed();
|
|
960
1001
|
const keyProvider = new InMemoryKeyProvider(seed);
|
|
961
1002
|
pubkeyHex = Buffer.from(await keyProvider.getPublicKey()).toString("hex");
|
|
962
|
-
// SI-001: createAgent stores the seed in the encrypted DB and logs only the pubkey.
|
|
963
|
-
store.createAgent(name, seed, pubkeyHex);
|
|
1003
|
+
// SI-001: createAgent stores the seed in the encrypted DB and logs only the pubkey + agent_id.
|
|
1004
|
+
agentId = store.createAgent(name, seed, pubkeyHex);
|
|
964
1005
|
// Runtime-add: make the agent immediately registrable/usable (the register handler resolves
|
|
965
1006
|
// identity from keyProviders/loadedAgents; per-agent signaling is created lazily on register).
|
|
966
1007
|
keyProviders.set(name, keyProvider);
|
|
967
1008
|
const loaded = { name, pubkey: pubkeyHex, keyProvider };
|
|
968
1009
|
loadedAgents.push(loaded);
|
|
969
1010
|
agents.push({ name, state: "registered", pubkey: pubkeyHex });
|
|
970
|
-
// CELLO-M7-ONBOARD-001: on a
|
|
971
|
-
//
|
|
972
|
-
//
|
|
973
|
-
//
|
|
974
|
-
// restart
|
|
975
|
-
//
|
|
976
|
-
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
|
|
980
|
-
if (!primaryAgent) {
|
|
981
|
-
primaryAgent = loaded;
|
|
982
|
-
wireKeystonePrimary(loaded);
|
|
983
|
-
logger.info("directory.keystone.elected", { agentName: name, agentPubkey: pubkeyHex });
|
|
984
|
-
}
|
|
1011
|
+
// CELLO-M7-CONN-001 (DOD-CONN-1, supersedes ONBOARD-001 keystone election): on a fresh install
|
|
1012
|
+
// the daemon started with zero agents, so there were no directory connections. Bring up THIS
|
|
1013
|
+
// agent's OWN directory connection now (production: getAgentSignaling creates + connects a
|
|
1014
|
+
// dedicated manager authenticated as this agent; the directory door becomes active with no
|
|
1015
|
+
// restart). There is no shared keystone to elect into. In the test path getAgentSignaling returns
|
|
1016
|
+
// the already-present shared manager (no-op).
|
|
1017
|
+
getAgentSignaling(name, keyProvider, pubkeyHex);
|
|
1018
|
+
// "initiated" not "established": getAgentSignaling starts the connection but does not await it
|
|
1019
|
+
// (the SignalingManager emits directory.signaling.connected when it actually authenticates).
|
|
1020
|
+
logger.info("agent.directory.connection.initiated", { agentName: name, agentPubkey: pubkeyHex });
|
|
985
1021
|
}
|
|
986
1022
|
catch (err) {
|
|
987
1023
|
logger.error("persist.identity.persist.failed", { agentName: name, error: err instanceof Error ? err.message : String(err) });
|
|
@@ -989,8 +1025,172 @@ export async function startDaemon(config) {
|
|
|
989
1025
|
}
|
|
990
1026
|
// Creation is not an online/offline transition — the agent appears (cello_list_agents) but is
|
|
991
1027
|
// not online until cello_start_agent. Just record it.
|
|
992
|
-
logger.info("agent.created", { agentName: name, agentPubkey: pubkeyHex });
|
|
993
|
-
return { ok: true, name, pubkey: pubkeyHex };
|
|
1028
|
+
logger.info("agent.created", { agentName: name, agentId, agentPubkey: pubkeyHex });
|
|
1029
|
+
return { ok: true, name, pubkey: pubkeyHex, agentId };
|
|
1030
|
+
});
|
|
1031
|
+
// ─── CELLO-M7-REMOVE-001 (DOD-REMOVE-1): cello_remove_agent handler ───
|
|
1032
|
+
// RETIRE-AND-KEEP: flip the agent's local row to state='retired' (its row, keys, and history are
|
|
1033
|
+
// KEPT for accountability — never hard-deleted, SI-002) and FREE the human name for reuse. Purge the
|
|
1034
|
+
// retired identity from the live runtime so it stops operating and the name is immediately available
|
|
1035
|
+
// to a NEW `cello_create_agent`. One-way. (DEC-4: the signed DIRECTORY revocation is DOD-REMOVE-2 —
|
|
1036
|
+
// not built here; this unit is the local record shape only.)
|
|
1037
|
+
// CELLO-M7-REMOVE-001 (DOD-REMOVE-2): build + self-sign an agent revocation and submit it to the
|
|
1038
|
+
// directory on the agent's K_local-authenticated signaling stream. Self-authorized — the directory
|
|
1039
|
+
// verifies the signature against the agent's registered K_local before appending. Best-effort: returns
|
|
1040
|
+
// { recorded:false, reason } if the directory is unreachable / rejects (DB-001 — the caller still
|
|
1041
|
+
// applies the one-way local retire and surfaces a distinct status).
|
|
1042
|
+
async function submitAgentRevocation(opts) {
|
|
1043
|
+
const { agentName, signer, kLocalPubkeyHex, regAgentId } = opts;
|
|
1044
|
+
const epochId = "";
|
|
1045
|
+
const reason = "voluntary";
|
|
1046
|
+
const revokedAt = Date.now();
|
|
1047
|
+
const tbs = buildAgentRevocationTbs(regAgentId, kLocalPubkeyHex, epochId, reason, revokedAt);
|
|
1048
|
+
const sigHex = Buffer.from(await signer.sign(tbs)).toString("hex");
|
|
1049
|
+
// If the agent has no live signaling (e.g. a re-push of an already-retired agent), getAgentSignaling
|
|
1050
|
+
// lazily creates a dedicated manager — drop it again afterwards so we don't leak a reconnect loop.
|
|
1051
|
+
const hadSignaling = perAgentSignaling.has(agentName);
|
|
1052
|
+
const { signaling } = getAgentSignaling(agentName, signer, kLocalPubkeyHex);
|
|
1053
|
+
// createdSignaling is true only when this call lazily created a NEW per-agent manager (production);
|
|
1054
|
+
// on the shared test path perAgentSignaling stays empty, so it is false and dropAgentSignaling no-ops.
|
|
1055
|
+
const createdSignaling = !hadSignaling && perAgentSignaling.has(agentName);
|
|
1056
|
+
try {
|
|
1057
|
+
const connected = await waitForSignalingConnected(signaling, 10_000);
|
|
1058
|
+
if (!connected)
|
|
1059
|
+
return { recorded: false, reason: "directory_unreachable" };
|
|
1060
|
+
let resolveFrame;
|
|
1061
|
+
const pending = new Promise((r) => { resolveFrame = r; });
|
|
1062
|
+
// Match the reply by agent_id so a revocation on a shared signaling stream is never cross-wired.
|
|
1063
|
+
const unregister = signaling.registerInboundHandler((frame) => {
|
|
1064
|
+
const t = frame["type"];
|
|
1065
|
+
if ((t === "agent_revocation_ack" || t === "agent_revocation_error") && frame["agent_id"] === regAgentId)
|
|
1066
|
+
resolveFrame(frame);
|
|
1067
|
+
});
|
|
1068
|
+
try {
|
|
1069
|
+
const sent = await signaling.sendRaw({ type: "revoke_agent", agent_id: regAgentId, epoch_id: epochId, reason, revoked_at: revokedAt, signature: sigHex });
|
|
1070
|
+
if (!sent.ok)
|
|
1071
|
+
return { recorded: false, reason: sent.reason ?? "directory_unreachable" };
|
|
1072
|
+
let timer;
|
|
1073
|
+
const timeoutP = new Promise((r) => { timer = setTimeout(() => r({ type: "__timeout__" }), 15_000); });
|
|
1074
|
+
const frame = await Promise.race([pending, timeoutP]);
|
|
1075
|
+
clearTimeout(timer);
|
|
1076
|
+
if (frame["type"] === "__timeout__")
|
|
1077
|
+
return { recorded: false, reason: "timeout" };
|
|
1078
|
+
if (frame["type"] === "agent_revocation_error")
|
|
1079
|
+
return { recorded: false, reason: String(frame["reason"] ?? "rejected") };
|
|
1080
|
+
logger.info("agent.revocation.submitted", { agentName, agentId: regAgentId });
|
|
1081
|
+
return { recorded: true };
|
|
1082
|
+
}
|
|
1083
|
+
finally {
|
|
1084
|
+
unregister();
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
finally {
|
|
1088
|
+
if (createdSignaling) {
|
|
1089
|
+
await dropAgentSignaling(agentName).catch((err) => {
|
|
1090
|
+
logger.warn("agent.revocation.signaling_teardown_failed", { agentName, error: err instanceof Error ? err.message : String(err) });
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
handlers.set("cello_remove_agent", async (params, _connectionId) => {
|
|
1096
|
+
const name = params?.name;
|
|
1097
|
+
if (!name || typeof name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
|
|
1098
|
+
return { ok: false, reason: "invalid_agent_name", guidance: "Provide the 'name' of the agent to remove (1-64 chars: letters, digits, '-' or '_')." };
|
|
1099
|
+
}
|
|
1100
|
+
const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
|
|
1101
|
+
// The active row (a fresh removal) OR the most-recent retired row (a DB-001 re-push). Captured BEFORE
|
|
1102
|
+
// any retire — the active-only accessors filter retired rows out once state is flipped.
|
|
1103
|
+
const target = store.getAgentForRevocation(name);
|
|
1104
|
+
if (!target) {
|
|
1105
|
+
return { ok: false, reason: "agent_not_found", guidance: `No agent named '${name}'. Check cello_list_agents.` };
|
|
1106
|
+
}
|
|
1107
|
+
const wasActive = target.state !== "retired";
|
|
1108
|
+
const agentId = target.localAgentId;
|
|
1109
|
+
// An already-retired agent that was never registered has nothing to do — no local retire (one-way,
|
|
1110
|
+
// already done) and no directory revocation to push. Treat a repeat removal as agent_not_found (a
|
|
1111
|
+
// tombstone is never re-retired). A retired agent WITH a directory id falls through to the DB-001
|
|
1112
|
+
// re-push below.
|
|
1113
|
+
if (!wasActive && !target.regAgentId) {
|
|
1114
|
+
return { ok: false, reason: "agent_not_found", guidance: `No active agent named '${name}'. Removal is one-way; '${name}' is already retired.` };
|
|
1115
|
+
}
|
|
1116
|
+
// DOD-REMOVE-2: build + self-sign + submit the directory revocation. Done for a fresh removal AND a
|
|
1117
|
+
// re-push of an already-retired agent (DB-001). Skipped only if the agent was never registered (no
|
|
1118
|
+
// directory-known id to revoke). The signer is re-derived from the kept K_local seed, so it works
|
|
1119
|
+
// even for an already-retired agent whose runtime keyProvider was purged.
|
|
1120
|
+
let directoryRevocation = "skipped";
|
|
1121
|
+
let revocationReason;
|
|
1122
|
+
if (target.regAgentId) {
|
|
1123
|
+
const signer = new InMemoryKeyProvider(target.kLocalSeed);
|
|
1124
|
+
const kLocalPubkeyHex = Buffer.from(await signer.getPublicKey()).toString("hex");
|
|
1125
|
+
const res = await submitAgentRevocation({ agentName: name, signer, kLocalPubkeyHex, regAgentId: target.regAgentId });
|
|
1126
|
+
directoryRevocation = res.recorded ? "recorded" : "deferred";
|
|
1127
|
+
revocationReason = res.reason;
|
|
1128
|
+
if (!res.recorded) {
|
|
1129
|
+
logger.error("agent.removal.failed", { agentName: name, error: res.reason ?? "directory_unreachable" });
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
logger.info("agent.revocation.recorded", { agentId: target.regAgentId });
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
else if (target.state === "registered") {
|
|
1136
|
+
// Anomaly (fallback-finder MEDIUM): the agent is locally marked registered but has no
|
|
1137
|
+
// directory-known id, so the revocation cannot be pushed. Do NOT report the benign "never
|
|
1138
|
+
// registered" — surface it loudly so a registered-but-unrevocable agent is visible.
|
|
1139
|
+
revocationReason = "registered_without_directory_id";
|
|
1140
|
+
logger.error("agent.removal.failed", { agentName: name, error: "registered_without_directory_id" });
|
|
1141
|
+
}
|
|
1142
|
+
// Local retire (one-way) + runtime purge — only for a fresh removal. An already-retired re-push does
|
|
1143
|
+
// not re-retire (and has nothing loaded to purge).
|
|
1144
|
+
if (wasActive) {
|
|
1145
|
+
store.retireAgent(name);
|
|
1146
|
+
// Tear down the retired identity's live runtime so it can no longer receive or re-authenticate.
|
|
1147
|
+
// AWAITED + LOGGED on failure (review HIGH-1 / fallback-finder MEDIUM): a teardown that didn't
|
|
1148
|
+
// happen must be visible.
|
|
1149
|
+
if (onlineAgents.has(name)) {
|
|
1150
|
+
onlineAgents.delete(name);
|
|
1151
|
+
await sessionNodeManager.removeStandingReceiverForAgent(name).catch((err) => {
|
|
1152
|
+
logger.warn("agent.removal.receiver_teardown_failed", { agentName: name, agentId, error: err instanceof Error ? err.message : String(err) });
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
// Stop+forget the retired agent's dedicated per-agent signaling manager (review HIGH-1).
|
|
1156
|
+
await dropAgentSignaling(name).catch((err) => {
|
|
1157
|
+
logger.warn("agent.removal.signaling_teardown_failed", { agentName: name, agentId, error: err instanceof Error ? err.message : String(err) });
|
|
1158
|
+
});
|
|
1159
|
+
keyProviders.delete(name);
|
|
1160
|
+
const li = loadedAgents.findIndex((a) => a.name === name);
|
|
1161
|
+
if (li >= 0)
|
|
1162
|
+
loadedAgents.splice(li, 1);
|
|
1163
|
+
const ai = agents.findIndex((a) => a.name === name);
|
|
1164
|
+
if (ai >= 0)
|
|
1165
|
+
agents.splice(ai, 1);
|
|
1166
|
+
// CELLO-M7-CONN-001 (DOD-CONN-1): no keystone to clear — the agent's OWN per-agent directory
|
|
1167
|
+
// connection was already torn down above (dropAgentSignaling). Removing any agent disturbs only
|
|
1168
|
+
// its own connection; no other agent's connection, and no shared "keystone", is affected. This is
|
|
1169
|
+
// the fix for the Demo1 bug (the keystone lingered authenticated as the removed agent).
|
|
1170
|
+
logger.info("agent.directory.connection.dropped", { agentName: name, agentId, reason: "agent_removed" });
|
|
1171
|
+
// Drop the retired agent as any connection's current agent.
|
|
1172
|
+
for (const [connId, state] of perConnectionState) {
|
|
1173
|
+
if (state.currentAgent === name) {
|
|
1174
|
+
state.currentAgent = null;
|
|
1175
|
+
notificationDispatcher.setCurrentAgent(connId, null);
|
|
1176
|
+
notificationDispatcher.dispatchAgentCurrentChanged(connId, name, null);
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
// agent.removal.retired (observability): never log key material — only the name + agent_id.
|
|
1180
|
+
logger.info("agent.removal.retired", { agentName: name, agentId });
|
|
1181
|
+
notificationDispatcher.dispatchAgentStateChanged(name, "offline", "removed");
|
|
1182
|
+
}
|
|
1183
|
+
const baseLine = wasActive
|
|
1184
|
+
? "Agent retired. This is one-way: its identity and history are kept for accountability, but it can no longer connect. The name is now free to reuse with cello create-agent."
|
|
1185
|
+
: "Agent was already retired locally.";
|
|
1186
|
+
const dirLine = directoryRevocation === "recorded"
|
|
1187
|
+
? " A signed revocation was recorded at the directory — peers will see it as revoked."
|
|
1188
|
+
: directoryRevocation === "deferred"
|
|
1189
|
+
? ` The directory could NOT be reached to record the revocation (${revocationReason ?? "directory_unreachable"}) — peers do not yet see it as revoked. Re-run 'cello remove-agent ${name}' when the directory is reachable to push it.`
|
|
1190
|
+
: revocationReason === "registered_without_directory_id"
|
|
1191
|
+
? " WARNING: this agent appears registered but has no directory id recorded locally, so its revocation could NOT be pushed — peers may still see it as reachable. Check the daemon logs (agent.removal.failed)."
|
|
1192
|
+
: " It was never registered with a directory, so there is no directory revocation to record.";
|
|
1193
|
+
return { ok: true, name, agentId, oneWay: true, directoryRevocation, guidance: baseLine + dirLine };
|
|
994
1194
|
});
|
|
995
1195
|
// ─── MCP-001: cello_stop_agent handler ───
|
|
996
1196
|
handlers.set("cello_stop_agent", async (params, _connectionId) => {
|
|
@@ -1121,8 +1321,8 @@ export async function startDaemon(config) {
|
|
|
1121
1321
|
const directoryEndpoint = { peer_id: ep.peerId, multiaddrs: [ep.multiaddr] };
|
|
1122
1322
|
// Multi-agent: register over THIS agent's own directory signaling stream (authed
|
|
1123
1323
|
// as this agent), so the directory routes its dkg_complete/register_success back
|
|
1124
|
-
// to it.
|
|
1125
|
-
//
|
|
1324
|
+
// to it. CONN-001: every agent has its own dedicated stream (no shared keystone). The
|
|
1325
|
+
// DKG's FROST streams open on this agent's directory node.
|
|
1126
1326
|
const agentRecord = loadedAgents.find((a) => a.name === name);
|
|
1127
1327
|
const agentPubkeyHex = agentRecord?.pubkey ?? Buffer.from(await keyProvider.getPublicKey()).toString("hex");
|
|
1128
1328
|
const { signaling: agentSignaling, getNode: agentGetNode } = getAgentSignaling(name, keyProvider, agentPubkeyHex);
|
|
@@ -1205,9 +1405,9 @@ export async function startDaemon(config) {
|
|
|
1205
1405
|
// Prevents duplicate concurrent seal-interrupted attempts for the same (agent, session) (AC-011).
|
|
1206
1406
|
const sealInterruptedInProgress = new Set();
|
|
1207
1407
|
const pendingSealWaiters = new Map();
|
|
1208
|
-
// M7 DOD-SPINE-7: register the session_sealed completion handler on a signaling
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1408
|
+
// M7 DOD-SPINE-7: register the session_sealed completion handler on a signaling manager — per-agent
|
|
1409
|
+
// in production (the directory routes session_sealed to the session-owning agent's authenticated
|
|
1410
|
+
// stream), or the shared manager on the test path. Function declaration so getAgentSignaling
|
|
1211
1411
|
// (defined earlier, called at runtime) can wire it per-agent.
|
|
1212
1412
|
function registerSessionSealedListener(signaling, agentName, agentPubkeyHex) {
|
|
1213
1413
|
return signaling.registerInboundHandler((frame) => {
|
|
@@ -1490,7 +1690,7 @@ export async function startDaemon(config) {
|
|
|
1490
1690
|
handlers.set("cello_status", async (_params, connectionId) => {
|
|
1491
1691
|
return {
|
|
1492
1692
|
daemon: "running",
|
|
1493
|
-
directory_signaling:
|
|
1693
|
+
directory_signaling: directorySignalingStatus(),
|
|
1494
1694
|
agents: getAgentsForConnection(connectionId),
|
|
1495
1695
|
connections,
|
|
1496
1696
|
// M-1 PULL: live MCP clients must see interrupted sessions too, exactly as
|
|
@@ -1692,7 +1892,7 @@ export async function startDaemon(config) {
|
|
|
1692
1892
|
};
|
|
1693
1893
|
}
|
|
1694
1894
|
// DB-001: signaling stream reconnecting
|
|
1695
|
-
if (record.status === "interrupted" &&
|
|
1895
|
+
if (record.status === "interrupted" && signalingFor(record.agent_name)?.status === "reconnecting") {
|
|
1696
1896
|
return {
|
|
1697
1897
|
ok: false,
|
|
1698
1898
|
reason: "signaling_reconnecting",
|
|
@@ -1792,7 +1992,7 @@ export async function startDaemon(config) {
|
|
|
1792
1992
|
let resolveUni;
|
|
1793
1993
|
const uniP = new Promise((r) => { resolveUni = r; });
|
|
1794
1994
|
pendingUnilateralWaiters.set(sessionId, resolveUni);
|
|
1795
|
-
const sent = await
|
|
1995
|
+
const sent = await sendOver(record.agent_name, {
|
|
1796
1996
|
type: "seal_unilateral",
|
|
1797
1997
|
session_id: new Uint8Array(Buffer.from(sessionId, "hex")),
|
|
1798
1998
|
reported_root: new Uint8Array(Buffer.from(submit.reportedRootHex, "hex")),
|
|
@@ -2221,13 +2421,24 @@ export async function startDaemon(config) {
|
|
|
2221
2421
|
return;
|
|
2222
2422
|
}
|
|
2223
2423
|
const reject = async (reason) => {
|
|
2224
|
-
|
|
2424
|
+
// CONN-001: send the rejection over the LOCAL responder agent's own stream (the agent whose
|
|
2425
|
+
// pubkey is counterpartyPubkey — the stream this request arrived on). If unresolved, sendOver
|
|
2426
|
+
// reports a send failure rather than throwing.
|
|
2427
|
+
const sent = await sendOver(agents.find((a) => a.pubkey === counterpartyPubkey)?.name ?? "", {
|
|
2225
2428
|
type: "seal_interrupted_rejection",
|
|
2226
2429
|
sessionId,
|
|
2227
2430
|
initiatorPubkey,
|
|
2228
2431
|
reason,
|
|
2229
2432
|
});
|
|
2230
|
-
|
|
2433
|
+
// fallback-finder LOW: don't log the rejection as delivered when the send failed (e.g. no local
|
|
2434
|
+
// agent for counterpartyPubkey, or a transient send error) — the counterparty would otherwise
|
|
2435
|
+
// appear rejected but only ever see a timeout.
|
|
2436
|
+
if (sent.ok) {
|
|
2437
|
+
logger.warn("session.interrupted.request.rejected", { sessionId, reason, correlationId });
|
|
2438
|
+
}
|
|
2439
|
+
else {
|
|
2440
|
+
logger.warn("session.interrupted.request.rejection.send.failed", { sessionId, reason, sendReason: sent.reason, correlationId });
|
|
2441
|
+
}
|
|
2231
2442
|
};
|
|
2232
2443
|
// DOD-LOOP-1: resolve the addressed local agent FIRST — the composite (agent, session_id) key
|
|
2233
2444
|
// needs it. The request must be addressed to one of our agents (counterpartyPubkey is OUR
|
|
@@ -2306,7 +2517,7 @@ export async function startDaemon(config) {
|
|
|
2306
2517
|
nonce,
|
|
2307
2518
|
sealInterruptedLeaf: ownLeaf,
|
|
2308
2519
|
};
|
|
2309
|
-
const sendResult = await
|
|
2520
|
+
const sendResult = await sendOver(localAgent.name, ack);
|
|
2310
2521
|
if (!sendResult.ok) {
|
|
2311
2522
|
logger.error("session.interrupted.ack.send.failed", {
|
|
2312
2523
|
sessionId,
|
|
@@ -2323,13 +2534,6 @@ export async function startDaemon(config) {
|
|
|
2323
2534
|
correlationId,
|
|
2324
2535
|
});
|
|
2325
2536
|
}
|
|
2326
|
-
// Register the persistent responder. This is a REAL registered handler (not a
|
|
2327
|
-
// test-only path): it fires for every inbound seal_interrupted_request.
|
|
2328
|
-
signalingManager.registerInboundHandler((frame) => {
|
|
2329
|
-
if (frame["type"] !== "seal_interrupted_request")
|
|
2330
|
-
return;
|
|
2331
|
-
void handleInboundSealInterruptedRequest(frame);
|
|
2332
|
-
});
|
|
2333
2537
|
// Per-agent FIFO queue (events accepted while no cello_await_session is blocked) and
|
|
2334
2538
|
// the per-agent list of blocked waiters. Inbound sessions are addressed to a specific
|
|
2335
2539
|
// local agent (participant_b), so both are keyed by agent name.
|
|
@@ -2629,17 +2833,34 @@ export async function startDaemon(config) {
|
|
|
2629
2833
|
});
|
|
2630
2834
|
});
|
|
2631
2835
|
}
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
//
|
|
2638
|
-
//
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2836
|
+
// CELLO-M7-CONN-001 (DOD-CONN-2): wire the inbound session/seal responders onto a GIVEN
|
|
2837
|
+
// signaling manager. Called for EVERY agent's own per-agent manager (via getAgentSignaling)
|
|
2838
|
+
// so each agent receives inbound session_assignment + seal_interrupted_request on its OWN
|
|
2839
|
+
// authenticated stream — closing the SPINE-5 gap where only the primary (whose stream WAS
|
|
2840
|
+
// the keystone) received them. A frame arrives on exactly one agent's stream, so there is no
|
|
2841
|
+
// double-dispatch; each handler resolves the local agent internally and that resolution
|
|
2842
|
+
// matches the stream the directory routed the frame to.
|
|
2843
|
+
function wirePerAgentSessionInbound(mgr) {
|
|
2844
|
+
mgr.registerInboundHandler((frame) => {
|
|
2845
|
+
if (frame["type"] !== "seal_interrupted_request")
|
|
2846
|
+
return;
|
|
2847
|
+
void handleInboundSealInterruptedRequest(frame);
|
|
2848
|
+
});
|
|
2849
|
+
mgr.registerInboundHandler((frame) => {
|
|
2850
|
+
if (frame["type"] !== "session_assignment")
|
|
2851
|
+
return;
|
|
2852
|
+
handleInboundSessionAssignment(frame);
|
|
2853
|
+
});
|
|
2854
|
+
}
|
|
2855
|
+
// CONN-001: test path only — the shared manager's inbound session/seal responders. Production
|
|
2856
|
+
// wires them per-agent in getAgentSignaling (each agent receives on its own stream).
|
|
2857
|
+
if (sharedSignaling)
|
|
2858
|
+
wirePerAgentSessionInbound(sharedSignaling);
|
|
2859
|
+
// M7 DOD-SPINE-7 / CELLO-M7-CONN-001: the seal-completion listeners (session_sealed /
|
|
2860
|
+
// seal_unilateral_confirmed / seal_unilateral_notification) are registered PER-AGENT inside
|
|
2861
|
+
// getAgentSignaling (the directory routes each over the session-owning agent's authenticated
|
|
2862
|
+
// stream), and on the shared manager for the test path via wireSharedHandlers. No keystone, no
|
|
2863
|
+
// runtime election — a fresh-install agent gets them when it comes online (create/start).
|
|
2643
2864
|
// cello_await_session — the counterparty's blocking pull for the next inbound session.
|
|
2644
2865
|
// Returns immediately if one is already queued for the current agent (FIFO), otherwise
|
|
2645
2866
|
// blocks until one arrives or timeout_ms elapses. Response shape matches the established
|
|
@@ -2693,13 +2914,18 @@ export async function startDaemon(config) {
|
|
|
2693
2914
|
// out). Extracted so the two flows wait identically — the directory pass-through
|
|
2694
2915
|
// routing (directory-node.ts) is the only wired transport for this exchange.
|
|
2695
2916
|
const SEAL_INTERRUPTED_TIMEOUT_MS = 30_000;
|
|
2696
|
-
function awaitSealAck(sessionId) {
|
|
2917
|
+
function awaitSealAck(sessionId, mgr) {
|
|
2697
2918
|
return new Promise((resolve) => {
|
|
2919
|
+
// CONN-001: await the ack on the OWNING agent's own stream (per-agent in prod, shared in test).
|
|
2920
|
+
if (!mgr) {
|
|
2921
|
+
resolve({ type: "timeout" });
|
|
2922
|
+
return;
|
|
2923
|
+
}
|
|
2698
2924
|
const timeoutHandle = setTimeout(() => {
|
|
2699
2925
|
unregister();
|
|
2700
2926
|
resolve({ type: "timeout" });
|
|
2701
2927
|
}, SEAL_INTERRUPTED_TIMEOUT_MS);
|
|
2702
|
-
const unregister =
|
|
2928
|
+
const unregister = mgr.registerInboundHandler((frame) => {
|
|
2703
2929
|
if (frame.type !== "seal_interrupted_ack" && frame.type !== "seal_interrupted_rejection") {
|
|
2704
2930
|
return;
|
|
2705
2931
|
}
|
|
@@ -2745,7 +2971,7 @@ export async function startDaemon(config) {
|
|
|
2745
2971
|
? sessionNodeManager.getSessionTreeRootHex(record.agent_name, sessionId)
|
|
2746
2972
|
: merkleRootAtInterruption;
|
|
2747
2973
|
// DB-001: check signaling status before attempting to send
|
|
2748
|
-
if (
|
|
2974
|
+
if (signalingFor(record.agent_name)?.status === "reconnecting") {
|
|
2749
2975
|
logger.error("session.interrupted.seal.failed", {
|
|
2750
2976
|
sessionId,
|
|
2751
2977
|
agentName: record.agent_name,
|
|
@@ -2791,7 +3017,7 @@ export async function startDaemon(config) {
|
|
|
2791
3017
|
merkleRootAtInterruption: effectiveRoot,
|
|
2792
3018
|
nonce,
|
|
2793
3019
|
};
|
|
2794
|
-
const sendResult = await
|
|
3020
|
+
const sendResult = await sendOver(record.agent_name, request);
|
|
2795
3021
|
if (!sendResult.ok) {
|
|
2796
3022
|
logger.error("session.interrupted.seal.failed", {
|
|
2797
3023
|
sessionId,
|
|
@@ -2807,7 +3033,7 @@ export async function startDaemon(config) {
|
|
|
2807
3033
|
};
|
|
2808
3034
|
}
|
|
2809
3035
|
// Wait for counterparty ack/rejection via the shared signaling await machinery.
|
|
2810
|
-
const ackResult = await awaitSealAck(sessionId);
|
|
3036
|
+
const ackResult = await awaitSealAck(sessionId, signalingFor(record.agent_name));
|
|
2811
3037
|
if (ackResult.type === "timeout") {
|
|
2812
3038
|
logger.error("session.interrupted.seal.failed", {
|
|
2813
3039
|
sessionId,
|
|
@@ -2915,7 +3141,7 @@ export async function startDaemon(config) {
|
|
|
2915
3141
|
const myPubkeyHex = agent?.pubkey ?? "";
|
|
2916
3142
|
const kp = keyProviders.get(record.agent_name);
|
|
2917
3143
|
// DB-002: never initiate a partial seal while signaling is reconnecting.
|
|
2918
|
-
if (
|
|
3144
|
+
if (signalingFor(record.agent_name)?.status === "reconnecting") {
|
|
2919
3145
|
return {
|
|
2920
3146
|
ok: false,
|
|
2921
3147
|
reason: "signaling_reconnecting",
|
|
@@ -2977,7 +3203,7 @@ export async function startDaemon(config) {
|
|
|
2977
3203
|
// Submit the SEAL request over the directory signaling stream (the SAME wired
|
|
2978
3204
|
// pass-through the interrupted-seal flow uses) and AWAIT the counterparty's
|
|
2979
3205
|
// bilateral ack. We never report success on a fire-and-forget send.
|
|
2980
|
-
const sendResult = await
|
|
3206
|
+
const sendResult = await sendOver(record.agent_name, {
|
|
2981
3207
|
type: "seal_interrupted_request",
|
|
2982
3208
|
sessionId,
|
|
2983
3209
|
initiatorPubkey: myPubkeyHex,
|
|
@@ -2995,14 +3221,14 @@ export async function startDaemon(config) {
|
|
|
2995
3221
|
});
|
|
2996
3222
|
return {
|
|
2997
3223
|
ok: false,
|
|
2998
|
-
reason: sendResult.reason,
|
|
3224
|
+
reason: sendResult.reason ?? "directory_unreachable",
|
|
2999
3225
|
guidance: "guidance" in sendResult && typeof sendResult.guidance === "string"
|
|
3000
3226
|
? sendResult.guidance
|
|
3001
3227
|
: "The seal could not be submitted to the directory. Retry once cello status shows directory_signaling connected.",
|
|
3002
3228
|
};
|
|
3003
3229
|
}
|
|
3004
3230
|
// Wait for the counterparty's bilateral ack (or rejection / timeout).
|
|
3005
|
-
const ackResult = await awaitSealAck(sessionId);
|
|
3231
|
+
const ackResult = await awaitSealAck(sessionId, signalingFor(record.agent_name));
|
|
3006
3232
|
if (ackResult.type === "timeout" || ackResult.type === "seal_interrupted_rejection") {
|
|
3007
3233
|
const reason = ackResult.type === "timeout" ? "seal_counterparty_unavailable" : "seal_rejected_by_counterparty";
|
|
3008
3234
|
logger.error("session.seal.initiate.failed", {
|
|
@@ -3263,25 +3489,23 @@ export async function startDaemon(config) {
|
|
|
3263
3489
|
agentCount: loadedAgents.length,
|
|
3264
3490
|
manifestVerified,
|
|
3265
3491
|
});
|
|
3266
|
-
// M7-
|
|
3267
|
-
//
|
|
3268
|
-
//
|
|
3269
|
-
// and adopts a newer signed manifest (handleManifestPollResponse). No separate wiring
|
|
3270
|
-
// needed here; poll lifecycle = the keystone connection lifecycle.
|
|
3492
|
+
// CELLO-M7-CONN-001 (DOD-CONN-3): background manifest polling runs daemon-level over
|
|
3493
|
+
// unauthenticated HTTP (startHttpManifestPoll above), NOT over a signaling stream. Its
|
|
3494
|
+
// lifecycle is the daemon's, not any agent connection's — so it polls even with zero agents.
|
|
3271
3495
|
// Graceful shutdown
|
|
3272
3496
|
async function stop(reason) {
|
|
3273
|
-
//
|
|
3497
|
+
// CELLO-M7-CONN-001: stop the HTTP manifest poll (sets the stopped flag so an in-flight
|
|
3498
|
+
// tick cannot re-arm, and cancels the scheduler). Belt-and-suspenders cancel for the
|
|
3499
|
+
// no-poll case (scheduler present but poll not started).
|
|
3500
|
+
stopHttpManifestPoll?.();
|
|
3274
3501
|
if (manifestPollScheduler) {
|
|
3275
3502
|
manifestPollScheduler.cancel();
|
|
3276
3503
|
}
|
|
3277
3504
|
logger.info("daemon.stopped", { pid: process.pid, reason });
|
|
3278
|
-
//
|
|
3279
|
-
|
|
3280
|
-
//
|
|
3281
|
-
|
|
3282
|
-
for (const entry of perAgentSignaling.values()) {
|
|
3283
|
-
await entry.signaling.stop();
|
|
3284
|
-
}
|
|
3505
|
+
// CONN-001 (code-review LOW): stopAllSignaling() stops the shared manager AND every per-agent
|
|
3506
|
+
// manager (best-effort). The previously-separate per-agent stop loop here was redundant and
|
|
3507
|
+
// unguarded (would abort the rest of shutdown if stop() ever threw on a second call) — removed.
|
|
3508
|
+
await stopAllSignaling();
|
|
3285
3509
|
// Gracefully mark active sessions interrupted (AC-009) before stopping IPC
|
|
3286
3510
|
await sessionNodeManager.gracefulShutdown();
|
|
3287
3511
|
await ipcServer.stop();
|