@cello-protocol/daemon 0.0.45 → 0.0.46

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.js CHANGED
@@ -32,6 +32,8 @@ import { loadAgents } from "./agent-loader.js";
32
32
  import { acquireLock, removeLock } from "./lock-file.js";
33
33
  import { createIpcServer } from "./ipc-server.js";
34
34
  import { SessionNodeManager } from "./session-node-manager.js";
35
+ import { TIER, isKnownTierValue } from "./contacts-tier-migration.js";
36
+ import { isValidSettingKey, allSettingKeys, validateSettingValue, AWAY_MESSAGE_MAX_LEN } from "./agent-settings-keys.js";
35
37
  import { classifySession } from "./session-category.js";
36
38
  import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT } from "@cello-protocol/gateway";
37
39
  import { RetryQueue } from "./retry-queue.js";
@@ -827,10 +829,32 @@ export async function startDaemon(config) {
827
829
  // every inbound interaction (promotion now requires operator engagement — an outbound initiate,
828
830
  // a cello_send reply, or an explicit contact add). No subsequent add is coupled to this line's
829
831
  // ordering anymore; it is a plain read of current contact state.
830
- const isKnown = sessionNodeManager.isContact(agentName, record.counterparty_pubkey);
832
+ const isKnown = sessionNodeManager.isKnown(agentName, record.counterparty_pubkey);
831
833
  awayAckSent.add(dedupKey); // guard BEFORE the async send — concurrent arrivals must not double-ack
832
834
  try {
833
- const contentBytes = new TextEncoder().encode(isKnown ? AWAY_TEXT[kind] : STRANGER_TEXT);
835
+ // DOD-AWAY-TIER-1: resolve most-specific-first per-contact away_message per-tier away
836
+ // (settings) → agent default (settings) → the system default (code, per kind). Total.
837
+ const awayText = sessionNodeManager.resolveAwayMessage(agentName, record.counterparty_pubkey)
838
+ ?? (isKnown ? AWAY_TEXT[kind] : STRANGER_TEXT);
839
+ const draftBytes = new TextEncoder().encode(awayText);
840
+ // SI (AWAY-TIER-1): an away message is now operator-configurable, i.e. an outbound DISCLOSURE.
841
+ // Screen it on the outbound path like any content — it does NOT bypass the gateway. A block/warn
842
+ // verdict means it is not sent (the dedup guard stays set — one screen per away period, no spam);
843
+ // a redact verdict sends the ALTERED bytes.
844
+ const awayVerdict = await securityGateway.screenOutbound(draftBytes, {
845
+ direction: "outbound", agentName, sessionId, correlationId: randomUUID(),
846
+ });
847
+ if (awayVerdict.disposition === "block" || awayVerdict.disposition === "warn") {
848
+ logger.info("session.away.response.screened_out", { agentName, sessionId, kind, disposition: awayVerdict.disposition });
849
+ return;
850
+ }
851
+ if (awayVerdict.disposition === "redact" && awayVerdict.content === undefined) {
852
+ logger.error("session.away.response.redact_without_content", { agentName, sessionId, kind });
853
+ return;
854
+ }
855
+ const contentBytes = awayVerdict.disposition === "redact" && awayVerdict.content !== undefined
856
+ ? new Uint8Array(awayVerdict.content)
857
+ : draftBytes;
834
858
  const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
835
859
  const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
836
860
  if (!sendResult.ok) {
@@ -3005,8 +3029,10 @@ export async function startDaemon(config) {
3005
3029
  }
3006
3030
  }
3007
3031
  // M8C-CONTACT-1 (D6): "initiating a session to X adds X" — pin at the pubkey the negotiator
3008
- // actually used (not re-resolved later).
3009
- sessionNodeManager.addContact(agentName, counterpartyPubkey);
3032
+ // actually used (not re-resolved later). DOD-TIER-4 AC3: a deliberate outbound initiate makes the
3033
+ // counterparty KNOWN, provenance 'initiated'. (Not WHITELISTED — auto-accept stays an explicit
3034
+ // cello_contact_set_tier act, design §1.)
3035
+ sessionNodeManager.addContact(agentName, counterpartyPubkey, undefined, "initiated", TIER.KNOWN);
3010
3036
  // AC-007: the session is usable immediately upon (relay) connection — the dcutr
3011
3037
  // upgrade runs in the background and is intentionally NOT awaited here.
3012
3038
  return { ok: true, sessionId, transportMode: result.mode, correlationId };
@@ -4175,10 +4201,11 @@ export async function startDaemon(config) {
4175
4201
  }
4176
4202
  async function acceptInboundAssignment(parsed, agentName, correlationId) {
4177
4203
  try {
4178
- // M8C-ABUSE-1 (anti-drip-feed / anti-swarm): bound acceptance from unknown (non-contact)
4179
- // senders a per-sender cap (many sessions from ONE stranger) and a global cap (many
4180
- // sessions across ALL strangers combined). Known contacts are exempt ("bounded only by
4181
- // disk" — DoD). Checked FIRST, before any standing-receiver work, so a refusal is cheap.
4204
+ // M8C-ABUSE-1 + DOD-TIER-2/3: bound acceptance by the sender's TIER — a per-sender cap that is
4205
+ // the sender's tier cap (BLOCKED 0 refused here, indistinguishably from an over-cap stranger),
4206
+ // plus a global anti-swarm cap that applies only to the UNKNOWN-tier stranger pool. No tier is
4207
+ // unbounded (INV-TIER-BOUND); KNOWN+ are exempt from the GLOBAL pool only, not from their own
4208
+ // cap. Checked FIRST, before any standing-receiver work, so a refusal is cheap.
4182
4209
  // CC-10: reap provably-dead ghosts for THIS agent before counting — otherwise invisible
4183
4210
  // interrupted 0-received sessions (post-restart shape) consume the sender's budget forever
4184
4211
  // and lock the stranger out with no list read ever clearing them. Safe here: abandonSession
@@ -4194,6 +4221,15 @@ export async function startDaemon(config) {
4194
4221
  });
4195
4222
  return;
4196
4223
  }
4224
+ // DOD-RENAME-1 AC1 (review F1 / DEC-AB-4): record the offered name as the rename baseline the
4225
+ // moment the offer passes the acceptance gate — an ALLOWED peer (the bound check let them
4226
+ // through) is tracked even if session formation transiently fails below. Deliberately AFTER the
4227
+ // bound check, not at the raw parse point: a BLOCKED or over-cap peer must NOT be able to drive
4228
+ // the operator's rename baseline or push notices into their inbox. Guarded by offeredMoniker !==
4229
+ // null so a silent offer never clears the baseline (AC5).
4230
+ if (parsed.offeredMoniker !== null) {
4231
+ sessionNodeManager.recordOfferedMoniker(agentName, parsed.participantAPubkeyHex, parsed.offeredMoniker);
4232
+ }
4197
4233
  // M8B F14 (fix 2): KICK creation before polling — an inbound offer arriving while no
4198
4234
  // receiver exists and none is being created must trigger the ensure itself (the doc
4199
4235
  // comment's "retries on demand" made true), instead of polling a creation nobody
@@ -4274,6 +4310,8 @@ export async function startDaemon(config) {
4274
4310
  // DOD-MONIKER-6: scoped to `agentName` — the agent this offer was received FOR.
4275
4311
  if (parsed.offeredMoniker !== null) {
4276
4312
  offeredMonikers.set(offerKey(agentName, parsed.sessionIdHex), parsed.offeredMoniker);
4313
+ // (DOD-RENAME-1's rename-baseline update runs earlier, right after the acceptance bound — see
4314
+ // the recordOfferedMoniker call there. This map is only the session-scoped display material.)
4277
4315
  }
4278
4316
  enqueueInboundSession(agentName, {
4279
4317
  sessionIdHex: parsed.sessionIdHex,
@@ -4290,8 +4328,8 @@ export async function startDaemon(config) {
4290
4328
  // CC-1 (2026-07-07): do NOT auto-add the requester here. Accepting the *connection* must not
4291
4329
  // grant *trust*. The old auto-add promoted any stranger who knocked once to "known" (Level-4
4292
4330
  // fast-track), which defeated BOTH the screening layer AND the ABUSE-1 acceptance caps
4293
- // (checkUnknownSenderAcceptanceBound exempts contacts, so sessions 2+ bypassed the per-sender
4294
- // cap confirmed live 2026-07-07). Promotion to "known" now requires operator engagement only:
4331
+ // (a higher tier raises the per-sender cap, so auto-promoting a stranger would have let their
4332
+ // sessions 2+ ride a larger bound — DOD-TIER-2). Promotion to "known" now requires operator engagement only:
4295
4333
  // an outbound cello_initiate_session (below, ~3137), the operator replying INTO the session via
4296
4334
  // cello_send, or an explicit cello_contact_add. An unattended stranger is never auto-whitelisted.
4297
4335
  // See docs/planning/.../2026-07-07_1700_four-level-screening-policy.md (D21).
@@ -5139,8 +5177,11 @@ export async function startDaemon(config) {
5139
5177
  // auto-adds (that defeated screening + anti-spam). For an OUTBOUND session the counterparty is
5140
5178
  // already a contact (cello_initiate_session added it), so this is an idempotent no-op there; it
5141
5179
  // matters for inbound-originated sessions, where the reply is the trust signal. addContact is
5142
- // INSERT OR IGNORE — it never refreshes added_at.
5143
- sessionNodeManager.addContact(record.agent_name, recipientPubkey);
5180
+ // INSERT OR IGNORE — it never refreshes added_at. DOD-TIER-4 AC3: engaging (a committed reply into
5181
+ // an inbound session I accepted) makes the counterparty KNOWN, provenance 'accepted'. For an
5182
+ // OUTBOUND session the row already exists ('initiated', KNOWN) and INSERT OR IGNORE leaves it
5183
+ // untouched — 'initiated' correctly wins there.
5184
+ sessionNodeManager.addContact(record.agent_name, recipientPubkey, undefined, "accepted", TIER.KNOWN);
5144
5185
  if (modified) {
5145
5186
  logger.info("security.verdict.returned", { disposition: "redact", sessionId, sequenceNumber: leafIndex, correlationId });
5146
5187
  }
@@ -5365,7 +5406,21 @@ export async function startDaemon(config) {
5365
5406
  }));
5366
5407
  const unread = sessionNodeManager.getUnreadSummary(agent);
5367
5408
  const total_unread = unread.reduce((sum, u) => sum + u.unread_count, 0);
5368
- return { agent, pending_session_requests: pending, expired_session_requests: expired, unread, total_unread };
5409
+ // DOD-RENAME-1 AC3: pending rename notices surface HERE (the INBOX pull), never as a real-time
5410
+ // push. The offered name is rendered as an untrusted CLAIM (quoted, with the pubkey) plus the
5411
+ // command to adopt it — the daemon never auto-applies a self-declared name.
5412
+ const rename_notices = sessionNodeManager.getRenameNotices(agent).map((n) => ({
5413
+ pubkey: n.pubkey,
5414
+ your_name_for_them: n.moniker,
5415
+ claimed_name: n.offered_name,
5416
+ noticed_at: n.noticed_at,
5417
+ // Names the contact by the operator's OWN pet name (AC3); the self-declared name is a quoted,
5418
+ // untrusted claim; the adopt command carries its arguments so it is copy-pasteable.
5419
+ notice: `Your contact ${n.moniker !== null ? `"${n.moniker}" ` : ""}(${n.pubkey.slice(0, 16)}…) now calls themselves ` +
5420
+ `"${n.offered_name}" (self-declared — unverified). Adopt it: cello_contact_set_moniker ` +
5421
+ `{ pubkey: "${n.pubkey}", moniker: "${n.offered_name}" }, or ignore.`,
5422
+ }));
5423
+ return { agent, pending_session_requests: pending, expired_session_requests: expired, unread, total_unread, rename_notices };
5369
5424
  });
5370
5425
  const totalUnread = agents.reduce((sum, a) => sum + a.total_unread, 0);
5371
5426
  const totalPending = agents.reduce((sum, a) => sum + a.pending_session_requests.length, 0);
@@ -5413,7 +5468,10 @@ export async function startDaemon(config) {
5413
5468
  };
5414
5469
  }
5415
5470
  }
5416
- sessionNodeManager.addContact(resolved.agent, pubkey, moniker);
5471
+ // DOD-TIER-4 / DEC-AB-1: an explicit cello_contact_add is a deliberate operator vouch → KNOWN
5472
+ // (still not auto-accept; that is a separate cello_contact_set_tier to whitelisted). provenance
5473
+ // stays null — this relationship formed by neither initiating nor accepting a session (AC5).
5474
+ sessionNodeManager.addContact(resolved.agent, pubkey, moniker, null, TIER.KNOWN);
5417
5475
  logger.info("contact.added", { agent: resolved.agent, pubkey });
5418
5476
  if (moniker !== undefined) {
5419
5477
  logger.info("contact.moniker.set", { agentName: resolved.agent, pubkey });
@@ -5449,6 +5507,57 @@ export async function startDaemon(config) {
5449
5507
  logger.info("contact.moniker.set", { agentName: resolved.agent, pubkey, cleared: moniker === null });
5450
5508
  return { ok: true, agent: resolved.agent, pubkey, moniker };
5451
5509
  });
5510
+ // DOD-CONTACT-VIEW-1 AC1: set a contact's reachability tier. Validates the tier is a known constant
5511
+ // (0..4) — an unknown value is REFUSED, never coerced. Emits contact.tier.changed (old→new).
5512
+ handlers.set("cello_contact_set_tier", async (params, connectionId) => {
5513
+ const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5514
+ const tier = typeof params?.tier === "number" ? params.tier : undefined;
5515
+ if (!pubkey || tier === undefined) {
5516
+ return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'tier' (0=blocked, 1=unknown, 2=known, 3=whitelisted, 4=vip)." };
5517
+ }
5518
+ if (!isKnownTierValue(tier)) {
5519
+ return { ok: false, reason: "invalid_tier", guidance: "tier must be one of 0 (blocked), 1 (unknown), 2 (known), 3 (whitelisted), 4 (vip)." };
5520
+ }
5521
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5522
+ if (!resolved.ok)
5523
+ return resolved;
5524
+ const oldTier = sessionNodeManager.getTier(resolved.agent, pubkey);
5525
+ if (!sessionNodeManager.setContactTier(resolved.agent, pubkey, tier)) {
5526
+ return { ok: false, reason: "contact_not_found", guidance: `No contact ${pubkey.slice(0, 16)}… for agent '${resolved.agent}'. Add it first with cello_contact_add.` };
5527
+ }
5528
+ // Only an actual change is an audit event — a no-op re-set must not pollute the trail (review F4).
5529
+ if (oldTier !== tier) {
5530
+ logger.info("contact.tier.changed", { agentName: resolved.agent, pubkey, oldTier, newTier: tier });
5531
+ }
5532
+ return { ok: true, agent: resolved.agent, pubkey, tier };
5533
+ });
5534
+ // DOD-AWAY-TIER-1 AC2: set (or clear, with null) a contact's per-contact away message — the most
5535
+ // specific level of the away-text resolution. Validated as a string-or-null; the text is screened
5536
+ // on the outbound path at send time (SI), never here.
5537
+ handlers.set("cello_contact_set_away", async (params, connectionId) => {
5538
+ const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5539
+ if (!pubkey || !params || !("message" in params)) {
5540
+ return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'message' — a string away text, or null to clear it." };
5541
+ }
5542
+ const rawMessage = params.message === null ? null : typeof params.message === "string" ? params.message : undefined;
5543
+ if (rawMessage === undefined) {
5544
+ return { ok: false, reason: "invalid_message", guidance: "'message' must be a string (the away text) or null (to clear)." };
5545
+ }
5546
+ // Review F2/F3: an empty / whitespace-only message CLEARS (consistent with the CLI + null), never
5547
+ // stores a blank away reply; a valid message is length-bounded.
5548
+ const message = rawMessage !== null && rawMessage.trim().length === 0 ? null : rawMessage;
5549
+ if (message !== null && message.length > AWAY_MESSAGE_MAX_LEN) {
5550
+ return { ok: false, reason: "invalid_message", guidance: `The away message must be <= ${AWAY_MESSAGE_MAX_LEN} characters.` };
5551
+ }
5552
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5553
+ if (!resolved.ok)
5554
+ return resolved;
5555
+ if (!sessionNodeManager.setContactAwayMessage(resolved.agent, pubkey, message)) {
5556
+ return { ok: false, reason: "contact_not_found", guidance: `No contact ${pubkey.slice(0, 16)}… for agent '${resolved.agent}'. Add it first with cello_contact_add.` };
5557
+ }
5558
+ logger.info("contact.away.set", { agentName: resolved.agent, pubkey, cleared: message === null });
5559
+ return { ok: true, agent: resolved.agent, pubkey };
5560
+ });
5452
5561
  handlers.set("cello_contact_remove", async (params, connectionId) => {
5453
5562
  const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
5454
5563
  if (!pubkey) {
@@ -5473,6 +5582,52 @@ export async function startDaemon(config) {
5473
5582
  }));
5474
5583
  return { ok: true, agent: resolved.agent, contacts };
5475
5584
  });
5585
+ // DOD-SETTINGS-1 AC2: read a per-agent reachability-policy setting (or all set ones). An unset key
5586
+ // returns value:null — the CONSUMER applies the hardcoded default (the daemon runs on defaults alone).
5587
+ handlers.set("cello_settings_get", async (params, connectionId) => {
5588
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5589
+ if (!resolved.ok)
5590
+ return resolved;
5591
+ // A key was PROVIDED (present and non-null) → it must be a valid string key. Distinguish "key
5592
+ // absent" (list all) from "key present but malformed/unknown" (review F1/F3) — a typo'd read must
5593
+ // NOT masquerade as "unset", the exact invisibility this store exists to prevent.
5594
+ if (params && "key" in params && params.key !== undefined && params.key !== null) {
5595
+ if (typeof params.key !== "string") {
5596
+ return { ok: false, reason: "missing_params", guidance: "'key' must be a string setting key, or omit it to list every set value." };
5597
+ }
5598
+ if (!isValidSettingKey(params.key)) {
5599
+ return { ok: false, reason: "invalid_key", guidance: `Unknown setting key '${params.key}'. Valid keys: ${allSettingKeys().join(", ")}` };
5600
+ }
5601
+ return { ok: true, agent: resolved.agent, key: params.key, value: sessionNodeManager.getSetting(resolved.agent, params.key) };
5602
+ }
5603
+ return { ok: true, agent: resolved.agent, settings: sessionNodeManager.getAllSettings(resolved.agent) };
5604
+ });
5605
+ // DOD-SETTINGS-1 AC2: write a per-agent setting. The KEY is validated against the known namespace —
5606
+ // an unknown key is REFUSED (a typo'd key that persisted would be a setting that never takes effect,
5607
+ // invisible to the operator). Per-key VALUE validation (finite bounds, etc.) lives with the consumer.
5608
+ handlers.set("cello_settings_set", async (params, connectionId) => {
5609
+ const key = typeof params?.key === "string" ? params.key : undefined;
5610
+ const rawValue = params?.value;
5611
+ const value = typeof rawValue === "string" ? rawValue : typeof rawValue === "number" ? String(rawValue) : undefined;
5612
+ if (!key || value === undefined) {
5613
+ return { ok: false, reason: "missing_params", guidance: `Provide 'key' and 'value' (string or number). Valid keys: ${allSettingKeys().join(", ")}` };
5614
+ }
5615
+ if (!isValidSettingKey(key)) {
5616
+ return { ok: false, reason: "invalid_key", guidance: `Unknown setting key '${key}'. Valid keys: ${allSettingKeys().join(", ")}` };
5617
+ }
5618
+ // DOD-TIER-BOUNDS-SETTINGS AC2: a bound value must be a finite positive integer (INV-TIER-BOUND —
5619
+ // a setting cannot REMOVE a bound). Away-text values are free-form.
5620
+ const valueCheck = validateSettingValue(key, value);
5621
+ if (!valueCheck.ok) {
5622
+ return { ok: false, reason: "invalid_value", guidance: valueCheck.reason };
5623
+ }
5624
+ const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
5625
+ if (!resolved.ok)
5626
+ return resolved;
5627
+ sessionNodeManager.setSetting(resolved.agent, key, value);
5628
+ logger.info("setting.changed", { agentName: resolved.agent, key });
5629
+ return { ok: true, agent: resolved.agent, key, value };
5630
+ });
5476
5631
  // MONIKER-1 AC2/AC3: cello_set_moniker — set (or clear, via explicit null) an agent's outbound-name
5477
5632
  // override on the agents table. Validated at set-time with the shared MONIKER-0 rule; an invalid
5478
5633
  // value is rejected here AND at the store (backstop) — it can never be stored. Local-only: the