@cello-protocol/daemon 0.0.9 → 0.0.11
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 +247 -46
- 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/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/package.json +4 -4
package/dist/daemon.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EAKrB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAK/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAoB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAM/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAmInF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;;;;;OAQG;IACH,gBAAgB,IAAI,SAAS,GAAG,IAAI,CAAC;IACrC;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;CACtC;AAyCD,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,
|
|
1
|
+
{"version":3,"file":"daemon.d.ts","sourceRoot":"","sources":["../src/daemon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,OAAO,KAAK,EACV,YAAY,EACZ,oBAAoB,EAKrB,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAK/D,OAAO,EAAoD,KAAK,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAoB7G,OAAO,KAAK,EAAE,kBAAkB,EAA+C,MAAM,yBAAyB,CAAC;AAM/G,OAAO,EAAoB,KAAK,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAmInF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,SAAS,IAAI,oBAAoB,CAAC;IAClC;;;;OAIG;IACH,qBAAqB,IAAI,kBAAkB,CAAC;IAC5C;;;;;;;;OAQG;IACH,gBAAgB,IAAI,SAAS,GAAG,IAAI,CAAC;IACrC;;;;OAIG;IACH,oBAAoB,IAAI,kBAAkB,CAAC;IAC3C;;;OAGG;IACH,iBAAiB,IAAI,eAAe,CAAC;CACtC;AAyCD,wBAAsB,WAAW,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,CAulH7E"}
|
package/dist/daemon.js
CHANGED
|
@@ -46,7 +46,7 @@ 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";
|
|
@@ -348,7 +348,11 @@ export async function startDaemon(config) {
|
|
|
348
348
|
// L4: sort by name so the "primary" agent is STABLE across restarts — readdir
|
|
349
349
|
// order (agent-loader) is platform-dependent and unsorted, which would otherwise
|
|
350
350
|
// let the authenticating identity change between daemon restarts.
|
|
351
|
-
|
|
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];
|
|
352
356
|
const getAuthIdentity = () => {
|
|
353
357
|
if (!primaryAgent)
|
|
354
358
|
return null;
|
|
@@ -518,36 +522,63 @@ export async function startDaemon(config) {
|
|
|
518
522
|
await entry.signaling.stop();
|
|
519
523
|
logger.info("agent.signaling.dropped", { agentName });
|
|
520
524
|
}
|
|
521
|
-
// DOD-SPINE-5: the PRIMARY agent registers + initiates
|
|
522
|
-
// stream (not a per-agent one), so
|
|
523
|
-
// manager
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
525
|
+
// DOD-SPINE-5/7: the PRIMARY agent registers + initiates + coordinates the session/seal FROST
|
|
526
|
+
// ceremonies over the keystone signaling stream (not a per-agent one), so its ceremony/seal/offer
|
|
527
|
+
// handlers must be wired on the keystone manager — otherwise a primary-agent initiator's session
|
|
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
|
+
// Returns a disposer that UNREGISTERS the six keystone listeners. Each wire*/register* helper returns
|
|
531
|
+
// its own unregister fn; the keystone manager is shared and never stopped, so (unlike the per-agent
|
|
532
|
+
// managers, which unregister implicitly on stop) these MUST be unregistered explicitly when the
|
|
533
|
+
// primary is cleared or re-elected — otherwise removing-then-recreating the primary STACKS a second
|
|
534
|
+
// full set of listeners on the shared stream, double-firing every ceremony/seal frame (review HIGH).
|
|
535
|
+
function wireKeystonePrimary(agent) {
|
|
536
|
+
const unregister = [];
|
|
537
|
+
unregister.push(wireSessionCeremonyHandler({
|
|
538
|
+
agentName: agent.name,
|
|
539
|
+
persistence: getPersistence(agent.name),
|
|
540
|
+
agentPubkeyHex: agent.pubkey,
|
|
529
541
|
getNode: getDirectoryNode,
|
|
530
542
|
getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
|
|
531
543
|
signaling: signalingManager,
|
|
532
544
|
logger,
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
agentPubkeyHex: primaryAgent.pubkey,
|
|
545
|
+
}));
|
|
546
|
+
unregister.push(wireSealCeremonyHandler({
|
|
547
|
+
agentName: agent.name,
|
|
548
|
+
persistence: getPersistence(agent.name),
|
|
549
|
+
agentPubkeyHex: agent.pubkey,
|
|
539
550
|
getNode: getDirectoryNode,
|
|
540
551
|
getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
|
|
541
552
|
signaling: signalingManager,
|
|
542
553
|
logger,
|
|
543
|
-
});
|
|
544
|
-
wireSessionOfferHandler({
|
|
545
|
-
agentName:
|
|
546
|
-
getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(
|
|
554
|
+
}));
|
|
555
|
+
unregister.push(wireSessionOfferHandler({
|
|
556
|
+
agentName: agent.name,
|
|
557
|
+
getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
|
|
547
558
|
signaling: signalingManager,
|
|
548
559
|
logger,
|
|
549
|
-
});
|
|
560
|
+
}));
|
|
561
|
+
// The primary closes its OWN sessions over the keystone stream, so the directory routes its
|
|
562
|
+
// session_sealed / seal_unilateral_confirmed / seal_unilateral_notification frames there. These
|
|
563
|
+
// seal-completion listeners MUST be registered for the keystone primary (startup OR runtime
|
|
564
|
+
// election) — otherwise the close waiter never resolves and the seal silently never finalizes
|
|
565
|
+
// (review HIGH: previously these were a separate startup-only block, skipped on a fresh install).
|
|
566
|
+
// The register* functions are hoisted declarations within startDaemon, so callable here.
|
|
567
|
+
unregister.push(registerSessionSealedListener(signalingManager, agent.name, agent.pubkey));
|
|
568
|
+
unregister.push(registerUnilateralConfirmedListener(signalingManager, agent.name, agent.pubkey));
|
|
569
|
+
unregister.push(registerUnilateralUpgradeListener(signalingManager, agent.name, agent.pubkey));
|
|
570
|
+
return () => { for (const u of unregister) {
|
|
571
|
+
try {
|
|
572
|
+
u();
|
|
573
|
+
}
|
|
574
|
+
catch { /* unregister is best-effort */ }
|
|
575
|
+
} };
|
|
550
576
|
}
|
|
577
|
+
// Disposer for the currently-wired keystone primary listeners (CELLO-M7-REMOVE-001): call it before
|
|
578
|
+
// clearing or re-electing the primary so the shared stream never carries a stale/duplicate set.
|
|
579
|
+
let keystoneDispose;
|
|
580
|
+
if (primaryAgent)
|
|
581
|
+
keystoneDispose = wireKeystonePrimary(primaryAgent);
|
|
551
582
|
// Per-connection state: tracks which agent is "current" for each IPC connection.
|
|
552
583
|
// Key = connectionId (assigned by IPC server), Value = current agent name or null.
|
|
553
584
|
const perConnectionState = new Map();
|
|
@@ -935,21 +966,40 @@ export async function startDaemon(config) {
|
|
|
935
966
|
return { ok: false, reason: "invalid_agent_name", guidance: "Provide a 'name' (1-64 chars: letters, digits, '-' or '_') for the new agent." };
|
|
936
967
|
}
|
|
937
968
|
const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
|
|
938
|
-
if (store.
|
|
969
|
+
if (store.hasActiveAgent(name) || agents.some((a) => a.name === name)) {
|
|
939
970
|
return { ok: false, reason: "agent_already_exists", guidance: `Agent '${name}' already exists. Choose a different name, or see cello_list_agents.` };
|
|
940
971
|
}
|
|
941
972
|
let pubkeyHex;
|
|
973
|
+
let agentId;
|
|
942
974
|
try {
|
|
943
975
|
const seed = generateKLocalSeed();
|
|
944
976
|
const keyProvider = new InMemoryKeyProvider(seed);
|
|
945
977
|
pubkeyHex = Buffer.from(await keyProvider.getPublicKey()).toString("hex");
|
|
946
|
-
// SI-001: createAgent stores the seed in the encrypted DB and logs only the pubkey.
|
|
947
|
-
store.createAgent(name, seed, pubkeyHex);
|
|
978
|
+
// SI-001: createAgent stores the seed in the encrypted DB and logs only the pubkey + agent_id.
|
|
979
|
+
agentId = store.createAgent(name, seed, pubkeyHex);
|
|
948
980
|
// Runtime-add: make the agent immediately registrable/usable (the register handler resolves
|
|
949
981
|
// identity from keyProviders/loadedAgents; per-agent signaling is created lazily on register).
|
|
950
982
|
keyProviders.set(name, keyProvider);
|
|
951
|
-
|
|
983
|
+
const loaded = { name, pubkey: pubkeyHex, keyProvider };
|
|
984
|
+
loadedAgents.push(loaded);
|
|
952
985
|
agents.push({ name, state: "registered", pubkey: pubkeyHex });
|
|
986
|
+
// CELLO-M7-ONBOARD-001: on a FRESH install the daemon started with no agents, so there was no
|
|
987
|
+
// keystone identity and the directory door is stuck `reconnecting`. Elect this first agent as the
|
|
988
|
+
// keystone primary + wire its ceremony/seal handlers; the running reconnect loop then
|
|
989
|
+
// authenticates with it (getAuthIdentity now returns it) and connects on its next attempt — no
|
|
990
|
+
// restart needed. Only the FIRST agent is elected (loadedAgents is empty here, so it is also the
|
|
991
|
+
// name-sort min). NOTE (accepted LOW, review): if the operator then creates a lexicographically
|
|
992
|
+
// SMALLER second agent before any restart, the keystone primary stays this one until the next
|
|
993
|
+
// restart, where it re-derives to sort[0] — benign (each agent authenticates its own stream; the
|
|
994
|
+
// demoted agent gets fully wired via the per-agent path on restart), and not worth runtime
|
|
995
|
+
// re-election (un-wire/re-wire) churn.
|
|
996
|
+
if (!primaryAgent) {
|
|
997
|
+
// Dispose any prior keystone wiring before re-electing, so listeners never stack (REMOVE-001).
|
|
998
|
+
keystoneDispose?.();
|
|
999
|
+
primaryAgent = loaded;
|
|
1000
|
+
keystoneDispose = wireKeystonePrimary(loaded);
|
|
1001
|
+
logger.info("directory.keystone.elected", { agentName: name, agentPubkey: pubkeyHex });
|
|
1002
|
+
}
|
|
953
1003
|
}
|
|
954
1004
|
catch (err) {
|
|
955
1005
|
logger.error("persist.identity.persist.failed", { agentName: name, error: err instanceof Error ? err.message : String(err) });
|
|
@@ -957,8 +1007,173 @@ export async function startDaemon(config) {
|
|
|
957
1007
|
}
|
|
958
1008
|
// Creation is not an online/offline transition — the agent appears (cello_list_agents) but is
|
|
959
1009
|
// not online until cello_start_agent. Just record it.
|
|
960
|
-
logger.info("agent.created", { agentName: name, agentPubkey: pubkeyHex });
|
|
961
|
-
return { ok: true, name, pubkey: pubkeyHex };
|
|
1010
|
+
logger.info("agent.created", { agentName: name, agentId, agentPubkey: pubkeyHex });
|
|
1011
|
+
return { ok: true, name, pubkey: pubkeyHex, agentId };
|
|
1012
|
+
});
|
|
1013
|
+
// ─── CELLO-M7-REMOVE-001 (DOD-REMOVE-1): cello_remove_agent handler ───
|
|
1014
|
+
// RETIRE-AND-KEEP: flip the agent's local row to state='retired' (its row, keys, and history are
|
|
1015
|
+
// KEPT for accountability — never hard-deleted, SI-002) and FREE the human name for reuse. Purge the
|
|
1016
|
+
// retired identity from the live runtime so it stops operating and the name is immediately available
|
|
1017
|
+
// to a NEW `cello_create_agent`. One-way. (DEC-4: the signed DIRECTORY revocation is DOD-REMOVE-2 —
|
|
1018
|
+
// not built here; this unit is the local record shape only.)
|
|
1019
|
+
// CELLO-M7-REMOVE-001 (DOD-REMOVE-2): build + self-sign an agent revocation and submit it to the
|
|
1020
|
+
// directory on the agent's K_local-authenticated signaling stream. Self-authorized — the directory
|
|
1021
|
+
// verifies the signature against the agent's registered K_local before appending. Best-effort: returns
|
|
1022
|
+
// { recorded:false, reason } if the directory is unreachable / rejects (DB-001 — the caller still
|
|
1023
|
+
// applies the one-way local retire and surfaces a distinct status).
|
|
1024
|
+
async function submitAgentRevocation(opts) {
|
|
1025
|
+
const { agentName, signer, kLocalPubkeyHex, regAgentId } = opts;
|
|
1026
|
+
const epochId = "";
|
|
1027
|
+
const reason = "voluntary";
|
|
1028
|
+
const revokedAt = Date.now();
|
|
1029
|
+
const tbs = buildAgentRevocationTbs(regAgentId, kLocalPubkeyHex, epochId, reason, revokedAt);
|
|
1030
|
+
const sigHex = Buffer.from(await signer.sign(tbs)).toString("hex");
|
|
1031
|
+
// If the agent has no live signaling (e.g. a re-push of an already-retired agent), getAgentSignaling
|
|
1032
|
+
// lazily creates a dedicated manager — drop it again afterwards so we don't leak a reconnect loop.
|
|
1033
|
+
const hadSignaling = perAgentSignaling.has(agentName) || primaryAgent?.name === agentName;
|
|
1034
|
+
const { signaling } = getAgentSignaling(agentName, signer, kLocalPubkeyHex);
|
|
1035
|
+
const createdSignaling = !hadSignaling && primaryAgent?.name !== agentName;
|
|
1036
|
+
try {
|
|
1037
|
+
const connected = await waitForSignalingConnected(signaling, 10_000);
|
|
1038
|
+
if (!connected)
|
|
1039
|
+
return { recorded: false, reason: "directory_unreachable" };
|
|
1040
|
+
let resolveFrame;
|
|
1041
|
+
const pending = new Promise((r) => { resolveFrame = r; });
|
|
1042
|
+
// Match the reply by agent_id so a revocation on the shared keystone is never cross-wired.
|
|
1043
|
+
const unregister = signaling.registerInboundHandler((frame) => {
|
|
1044
|
+
const t = frame["type"];
|
|
1045
|
+
if ((t === "agent_revocation_ack" || t === "agent_revocation_error") && frame["agent_id"] === regAgentId)
|
|
1046
|
+
resolveFrame(frame);
|
|
1047
|
+
});
|
|
1048
|
+
try {
|
|
1049
|
+
const sent = await signaling.sendRaw({ type: "revoke_agent", agent_id: regAgentId, epoch_id: epochId, reason, revoked_at: revokedAt, signature: sigHex });
|
|
1050
|
+
if (!sent.ok)
|
|
1051
|
+
return { recorded: false, reason: sent.reason ?? "directory_unreachable" };
|
|
1052
|
+
let timer;
|
|
1053
|
+
const timeoutP = new Promise((r) => { timer = setTimeout(() => r({ type: "__timeout__" }), 15_000); });
|
|
1054
|
+
const frame = await Promise.race([pending, timeoutP]);
|
|
1055
|
+
clearTimeout(timer);
|
|
1056
|
+
if (frame["type"] === "__timeout__")
|
|
1057
|
+
return { recorded: false, reason: "timeout" };
|
|
1058
|
+
if (frame["type"] === "agent_revocation_error")
|
|
1059
|
+
return { recorded: false, reason: String(frame["reason"] ?? "rejected") };
|
|
1060
|
+
logger.info("agent.revocation.submitted", { agentName, agentId: regAgentId });
|
|
1061
|
+
return { recorded: true };
|
|
1062
|
+
}
|
|
1063
|
+
finally {
|
|
1064
|
+
unregister();
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
finally {
|
|
1068
|
+
if (createdSignaling) {
|
|
1069
|
+
await dropAgentSignaling(agentName).catch((err) => {
|
|
1070
|
+
logger.warn("agent.revocation.signaling_teardown_failed", { agentName, error: err instanceof Error ? err.message : String(err) });
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
handlers.set("cello_remove_agent", async (params, _connectionId) => {
|
|
1076
|
+
const name = params?.name;
|
|
1077
|
+
if (!name || typeof name !== "string" || !/^[a-zA-Z0-9_-]{1,64}$/.test(name)) {
|
|
1078
|
+
return { ok: false, reason: "invalid_agent_name", guidance: "Provide the 'name' of the agent to remove (1-64 chars: letters, digits, '-' or '_')." };
|
|
1079
|
+
}
|
|
1080
|
+
const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
|
|
1081
|
+
// The active row (a fresh removal) OR the most-recent retired row (a DB-001 re-push). Captured BEFORE
|
|
1082
|
+
// any retire — the active-only accessors filter retired rows out once state is flipped.
|
|
1083
|
+
const target = store.getAgentForRevocation(name);
|
|
1084
|
+
if (!target) {
|
|
1085
|
+
return { ok: false, reason: "agent_not_found", guidance: `No agent named '${name}'. Check cello_list_agents.` };
|
|
1086
|
+
}
|
|
1087
|
+
const wasActive = target.state !== "retired";
|
|
1088
|
+
const agentId = target.localAgentId;
|
|
1089
|
+
// An already-retired agent that was never registered has nothing to do — no local retire (one-way,
|
|
1090
|
+
// already done) and no directory revocation to push. Treat a repeat removal as agent_not_found (a
|
|
1091
|
+
// tombstone is never re-retired). A retired agent WITH a directory id falls through to the DB-001
|
|
1092
|
+
// re-push below.
|
|
1093
|
+
if (!wasActive && !target.regAgentId) {
|
|
1094
|
+
return { ok: false, reason: "agent_not_found", guidance: `No active agent named '${name}'. Removal is one-way; '${name}' is already retired.` };
|
|
1095
|
+
}
|
|
1096
|
+
// DOD-REMOVE-2: build + self-sign + submit the directory revocation. Done for a fresh removal AND a
|
|
1097
|
+
// re-push of an already-retired agent (DB-001). Skipped only if the agent was never registered (no
|
|
1098
|
+
// directory-known id to revoke). The signer is re-derived from the kept K_local seed, so it works
|
|
1099
|
+
// even for an already-retired agent whose runtime keyProvider was purged.
|
|
1100
|
+
let directoryRevocation = "skipped";
|
|
1101
|
+
let revocationReason;
|
|
1102
|
+
if (target.regAgentId) {
|
|
1103
|
+
const signer = new InMemoryKeyProvider(target.kLocalSeed);
|
|
1104
|
+
const kLocalPubkeyHex = Buffer.from(await signer.getPublicKey()).toString("hex");
|
|
1105
|
+
const res = await submitAgentRevocation({ agentName: name, signer, kLocalPubkeyHex, regAgentId: target.regAgentId });
|
|
1106
|
+
directoryRevocation = res.recorded ? "recorded" : "deferred";
|
|
1107
|
+
revocationReason = res.reason;
|
|
1108
|
+
if (!res.recorded) {
|
|
1109
|
+
logger.error("agent.removal.failed", { agentName: name, error: res.reason ?? "directory_unreachable" });
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
logger.info("agent.revocation.recorded", { agentId: target.regAgentId });
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
else if (target.state === "registered") {
|
|
1116
|
+
// Anomaly (fallback-finder MEDIUM): the agent is locally marked registered but has no
|
|
1117
|
+
// directory-known id, so the revocation cannot be pushed. Do NOT report the benign "never
|
|
1118
|
+
// registered" — surface it loudly so a registered-but-unrevocable agent is visible.
|
|
1119
|
+
revocationReason = "registered_without_directory_id";
|
|
1120
|
+
logger.error("agent.removal.failed", { agentName: name, error: "registered_without_directory_id" });
|
|
1121
|
+
}
|
|
1122
|
+
// Local retire (one-way) + runtime purge — only for a fresh removal. An already-retired re-push does
|
|
1123
|
+
// not re-retire (and has nothing loaded to purge).
|
|
1124
|
+
if (wasActive) {
|
|
1125
|
+
store.retireAgent(name);
|
|
1126
|
+
// Tear down the retired identity's live runtime so it can no longer receive or re-authenticate.
|
|
1127
|
+
// AWAITED + LOGGED on failure (review HIGH-1 / fallback-finder MEDIUM): a teardown that didn't
|
|
1128
|
+
// happen must be visible.
|
|
1129
|
+
if (onlineAgents.has(name)) {
|
|
1130
|
+
onlineAgents.delete(name);
|
|
1131
|
+
await sessionNodeManager.removeStandingReceiverForAgent(name).catch((err) => {
|
|
1132
|
+
logger.warn("agent.removal.receiver_teardown_failed", { agentName: name, agentId, error: err instanceof Error ? err.message : String(err) });
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
// Stop+forget the retired agent's dedicated per-agent signaling manager (review HIGH-1).
|
|
1136
|
+
await dropAgentSignaling(name).catch((err) => {
|
|
1137
|
+
logger.warn("agent.removal.signaling_teardown_failed", { agentName: name, agentId, error: err instanceof Error ? err.message : String(err) });
|
|
1138
|
+
});
|
|
1139
|
+
keyProviders.delete(name);
|
|
1140
|
+
const li = loadedAgents.findIndex((a) => a.name === name);
|
|
1141
|
+
if (li >= 0)
|
|
1142
|
+
loadedAgents.splice(li, 1);
|
|
1143
|
+
const ai = agents.findIndex((a) => a.name === name);
|
|
1144
|
+
if (ai >= 0)
|
|
1145
|
+
agents.splice(ai, 1);
|
|
1146
|
+
// If the retired agent was the directory keystone, UNWIRE its keystone listeners + clear it
|
|
1147
|
+
// (review HIGH-2: the listeners were never unregistered → double-wire on re-election).
|
|
1148
|
+
if (primaryAgent && primaryAgent.name === name) {
|
|
1149
|
+
keystoneDispose?.();
|
|
1150
|
+
keystoneDispose = undefined;
|
|
1151
|
+
primaryAgent = undefined;
|
|
1152
|
+
logger.info("directory.keystone.cleared", { agentName: name, agentId });
|
|
1153
|
+
}
|
|
1154
|
+
// Drop the retired agent as any connection's current agent.
|
|
1155
|
+
for (const [connId, state] of perConnectionState) {
|
|
1156
|
+
if (state.currentAgent === name) {
|
|
1157
|
+
state.currentAgent = null;
|
|
1158
|
+
notificationDispatcher.setCurrentAgent(connId, null);
|
|
1159
|
+
notificationDispatcher.dispatchAgentCurrentChanged(connId, name, null);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
// agent.removal.retired (observability): never log key material — only the name + agent_id.
|
|
1163
|
+
logger.info("agent.removal.retired", { agentName: name, agentId });
|
|
1164
|
+
notificationDispatcher.dispatchAgentStateChanged(name, "offline", "removed");
|
|
1165
|
+
}
|
|
1166
|
+
const baseLine = wasActive
|
|
1167
|
+
? "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."
|
|
1168
|
+
: "Agent was already retired locally.";
|
|
1169
|
+
const dirLine = directoryRevocation === "recorded"
|
|
1170
|
+
? " A signed revocation was recorded at the directory — peers will see it as revoked."
|
|
1171
|
+
: directoryRevocation === "deferred"
|
|
1172
|
+
? ` 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.`
|
|
1173
|
+
: revocationReason === "registered_without_directory_id"
|
|
1174
|
+
? " 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)."
|
|
1175
|
+
: " It was never registered with a directory, so there is no directory revocation to record.";
|
|
1176
|
+
return { ok: true, name, agentId, oneWay: true, directoryRevocation, guidance: baseLine + dirLine };
|
|
962
1177
|
});
|
|
963
1178
|
// ─── MCP-001: cello_stop_agent handler ───
|
|
964
1179
|
handlers.set("cello_stop_agent", async (params, _connectionId) => {
|
|
@@ -2603,25 +2818,11 @@ export async function startDaemon(config) {
|
|
|
2603
2818
|
handleInboundSessionAssignment(frame);
|
|
2604
2819
|
});
|
|
2605
2820
|
// M7 DOD-SPINE-7: session_sealed listener. The directory delivers this over the SESSION-OWNING
|
|
2606
|
-
//
|
|
2607
|
-
//
|
|
2608
|
-
//
|
|
2609
|
-
//
|
|
2610
|
-
//
|
|
2611
|
-
// needs the primary agent's name/pubkey to verify the seal signature (legibility-TBS-binding), and
|
|
2612
|
-
// with no agents there are no keystone sessions to seal anyway.
|
|
2613
|
-
if (primaryAgent) {
|
|
2614
|
-
registerSessionSealedListener(signalingManager, primaryAgent.name, primaryAgent.pubkey);
|
|
2615
|
-
// SESSION-002: the keystone counterpart for the unilateral certificate listener — the
|
|
2616
|
-
// primary agent closes over the keystone stream, so the directory routes its
|
|
2617
|
-
// seal_unilateral_confirmed there (mirrors the session_sealed keystone listener above).
|
|
2618
|
-
registerUnilateralConfirmedListener(signalingManager, primaryAgent.name, primaryAgent.pubkey);
|
|
2619
|
-
// DOD-UP-1: the keystone counterpart for the absent-party upgrade listener. The directory
|
|
2620
|
-
// PUSHES the queued seal_unilateral_notification during the keystone's auth/reconnect drain —
|
|
2621
|
-
// BEFORE any cello_start_agent runs — so the handler MUST be registered here at startup, not only
|
|
2622
|
-
// in startAgent, or B (the returning absent party) would miss its own ratification trigger.
|
|
2623
|
-
registerUnilateralUpgradeListener(signalingManager, primaryAgent.name, primaryAgent.pubkey);
|
|
2624
|
-
}
|
|
2821
|
+
// CELLO-M7-ONBOARD-001: the keystone primary's seal-completion listeners (session_sealed /
|
|
2822
|
+
// seal_unilateral_confirmed / seal_unilateral_notification) are now registered inside
|
|
2823
|
+
// wireKeystonePrimary — at startup for a loaded primary AND at runtime when the first agent is
|
|
2824
|
+
// elected (cello_create_agent on a fresh install). They must NOT be a separate startup-only block,
|
|
2825
|
+
// or a fresh-install primary's seals would silently never finalize (review HIGH).
|
|
2625
2826
|
// cello_await_session — the counterparty's blocking pull for the next inbound session.
|
|
2626
2827
|
// Returns immediately if one is already queued for the current agent (FIFO), otherwise
|
|
2627
2828
|
// blocks until one arrives or timeout_ms elapses. Response shape matches the established
|