@cello-protocol/daemon 0.0.10 → 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.
@@ -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,CAm6G7E"}
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";
@@ -527,8 +527,14 @@ export async function startDaemon(config) {
527
527
  // handlers must be wired on the keystone manager — otherwise a primary-agent initiator's session
528
528
  // ceremony would time out. CELLO-M7-ONBOARD-001: extracted into a function so it can also be wired
529
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).
530
535
  function wireKeystonePrimary(agent) {
531
- wireSessionCeremonyHandler({
536
+ const unregister = [];
537
+ unregister.push(wireSessionCeremonyHandler({
532
538
  agentName: agent.name,
533
539
  persistence: getPersistence(agent.name),
534
540
  agentPubkeyHex: agent.pubkey,
@@ -536,8 +542,8 @@ export async function startDaemon(config) {
536
542
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
537
543
  signaling: signalingManager,
538
544
  logger,
539
- });
540
- wireSealCeremonyHandler({
545
+ }));
546
+ unregister.push(wireSealCeremonyHandler({
541
547
  agentName: agent.name,
542
548
  persistence: getPersistence(agent.name),
543
549
  agentPubkeyHex: agent.pubkey,
@@ -545,25 +551,34 @@ export async function startDaemon(config) {
545
551
  getDirectoryEndpoint: async () => (directoryEndpointResolver ? (await directoryEndpointResolver()) ?? null : null),
546
552
  signaling: signalingManager,
547
553
  logger,
548
- });
549
- wireSessionOfferHandler({
554
+ }));
555
+ unregister.push(wireSessionOfferHandler({
550
556
  agentName: agent.name,
551
557
  getStandingReceiverEndpoint: () => sessionNodeManager.getStandingReceiverInfo(agent.name),
552
558
  signaling: signalingManager,
553
559
  logger,
554
- });
560
+ }));
555
561
  // The primary closes its OWN sessions over the keystone stream, so the directory routes its
556
562
  // session_sealed / seal_unilateral_confirmed / seal_unilateral_notification frames there. These
557
563
  // seal-completion listeners MUST be registered for the keystone primary (startup OR runtime
558
564
  // election) — otherwise the close waiter never resolves and the seal silently never finalizes
559
565
  // (review HIGH: previously these were a separate startup-only block, skipped on a fresh install).
560
566
  // The register* functions are hoisted declarations within startDaemon, so callable here.
561
- registerSessionSealedListener(signalingManager, agent.name, agent.pubkey);
562
- registerUnilateralConfirmedListener(signalingManager, agent.name, agent.pubkey);
563
- registerUnilateralUpgradeListener(signalingManager, agent.name, agent.pubkey);
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
+ } };
564
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;
565
580
  if (primaryAgent)
566
- wireKeystonePrimary(primaryAgent);
581
+ keystoneDispose = wireKeystonePrimary(primaryAgent);
567
582
  // Per-connection state: tracks which agent is "current" for each IPC connection.
568
583
  // Key = connectionId (assigned by IPC server), Value = current agent name or null.
569
584
  const perConnectionState = new Map();
@@ -951,16 +966,17 @@ export async function startDaemon(config) {
951
966
  return { ok: false, reason: "invalid_agent_name", guidance: "Provide a 'name' (1-64 chars: letters, digits, '-' or '_') for the new agent." };
952
967
  }
953
968
  const store = new DbIdentityStore(sessionNodeManager.getDb(), logger);
954
- if (store.hasAgent(name) || agents.some((a) => a.name === name)) {
969
+ if (store.hasActiveAgent(name) || agents.some((a) => a.name === name)) {
955
970
  return { ok: false, reason: "agent_already_exists", guidance: `Agent '${name}' already exists. Choose a different name, or see cello_list_agents.` };
956
971
  }
957
972
  let pubkeyHex;
973
+ let agentId;
958
974
  try {
959
975
  const seed = generateKLocalSeed();
960
976
  const keyProvider = new InMemoryKeyProvider(seed);
961
977
  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);
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);
964
980
  // Runtime-add: make the agent immediately registrable/usable (the register handler resolves
965
981
  // identity from keyProviders/loadedAgents; per-agent signaling is created lazily on register).
966
982
  keyProviders.set(name, keyProvider);
@@ -978,8 +994,10 @@ export async function startDaemon(config) {
978
994
  // demoted agent gets fully wired via the per-agent path on restart), and not worth runtime
979
995
  // re-election (un-wire/re-wire) churn.
980
996
  if (!primaryAgent) {
997
+ // Dispose any prior keystone wiring before re-electing, so listeners never stack (REMOVE-001).
998
+ keystoneDispose?.();
981
999
  primaryAgent = loaded;
982
- wireKeystonePrimary(loaded);
1000
+ keystoneDispose = wireKeystonePrimary(loaded);
983
1001
  logger.info("directory.keystone.elected", { agentName: name, agentPubkey: pubkeyHex });
984
1002
  }
985
1003
  }
@@ -989,8 +1007,173 @@ export async function startDaemon(config) {
989
1007
  }
990
1008
  // Creation is not an online/offline transition — the agent appears (cello_list_agents) but is
991
1009
  // 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 };
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 };
994
1177
  });
995
1178
  // ─── MCP-001: cello_stop_agent handler ───
996
1179
  handlers.set("cello_stop_agent", async (params, _connectionId) => {