@cello-protocol/daemon 0.0.45 → 0.0.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-settings-keys.d.ts +52 -0
- package/dist/agent-settings-keys.d.ts.map +1 -0
- package/dist/agent-settings-keys.js +96 -0
- package/dist/agent-settings-keys.js.map +1 -0
- package/dist/contacts-tier-migration.d.ts +90 -0
- package/dist/contacts-tier-migration.d.ts.map +1 -0
- package/dist/contacts-tier-migration.js +149 -0
- package/dist/contacts-tier-migration.js.map +1 -0
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +236 -20
- package/dist/daemon.js.map +1 -1
- package/dist/session-node-manager.d.ts +126 -14
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +377 -36
- package/dist/session-node-manager.js.map +1 -1
- package/package.json +2 -2
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";
|
|
@@ -784,6 +786,24 @@ export async function startDaemon(config) {
|
|
|
784
786
|
cursor += 1;
|
|
785
787
|
advanceConnectionCursor(connectionId, sessionId, cursor);
|
|
786
788
|
}
|
|
789
|
+
/**
|
|
790
|
+
* DOD-CURSOR-DURABLE-1: the same hole-safe walk, applied to the PERSISTED per-(agent, session)
|
|
791
|
+
* read watermark. Used by cello_get_transcript, whose delivery covers BOTH directions.
|
|
792
|
+
*
|
|
793
|
+
* Walks a CONTIGUOUS run from the agent's current watermark, stopping at the first gap — for the
|
|
794
|
+
* identical reason safeCursorAdvance does: leaf indices are contiguous across both directions, so
|
|
795
|
+
* a gap can hide an unread RECEIVED message (e.g. a row that failed to decrypt is absent from
|
|
796
|
+
* `messages`). Advancing past it would mark unseen counterparty content as read and unblock a
|
|
797
|
+
* send that never saw it — defeating the very guarantee this gate exists to enforce. Monotonic:
|
|
798
|
+
* advanceLastDeliveredSeq takes MAX, so this can never lower a watermark.
|
|
799
|
+
*/
|
|
800
|
+
function safeWatermarkAdvance(agentName, sessionId, deliveredSeqs) {
|
|
801
|
+
let frontier = sessionNodeManager.getLastDeliveredSeq(agentName, sessionId);
|
|
802
|
+
while (deliveredSeqs.has(frontier + 1))
|
|
803
|
+
frontier += 1;
|
|
804
|
+
if (frontier >= 0)
|
|
805
|
+
sessionNodeManager.advanceLastDeliveredSeq(agentName, sessionId, frontier);
|
|
806
|
+
}
|
|
787
807
|
// M8C-AWAY-1: away response — an unattended Primary auto-answers session requests + messages
|
|
788
808
|
// with the default transparent away text and queues them (the DoD's own mandated default).
|
|
789
809
|
// CORE ships now; the operator-configurable custom-text / opaque-privacy-mode SWITCH is PARKED
|
|
@@ -827,10 +847,32 @@ export async function startDaemon(config) {
|
|
|
827
847
|
// every inbound interaction (promotion now requires operator engagement — an outbound initiate,
|
|
828
848
|
// a cello_send reply, or an explicit contact add). No subsequent add is coupled to this line's
|
|
829
849
|
// ordering anymore; it is a plain read of current contact state.
|
|
830
|
-
const isKnown = sessionNodeManager.
|
|
850
|
+
const isKnown = sessionNodeManager.isKnown(agentName, record.counterparty_pubkey);
|
|
831
851
|
awayAckSent.add(dedupKey); // guard BEFORE the async send — concurrent arrivals must not double-ack
|
|
832
852
|
try {
|
|
833
|
-
|
|
853
|
+
// DOD-AWAY-TIER-1: resolve most-specific-first — per-contact away_message → per-tier away
|
|
854
|
+
// (settings) → agent default (settings) → the system default (code, per kind). Total.
|
|
855
|
+
const awayText = sessionNodeManager.resolveAwayMessage(agentName, record.counterparty_pubkey)
|
|
856
|
+
?? (isKnown ? AWAY_TEXT[kind] : STRANGER_TEXT);
|
|
857
|
+
const draftBytes = new TextEncoder().encode(awayText);
|
|
858
|
+
// SI (AWAY-TIER-1): an away message is now operator-configurable, i.e. an outbound DISCLOSURE.
|
|
859
|
+
// Screen it on the outbound path like any content — it does NOT bypass the gateway. A block/warn
|
|
860
|
+
// verdict means it is not sent (the dedup guard stays set — one screen per away period, no spam);
|
|
861
|
+
// a redact verdict sends the ALTERED bytes.
|
|
862
|
+
const awayVerdict = await securityGateway.screenOutbound(draftBytes, {
|
|
863
|
+
direction: "outbound", agentName, sessionId, correlationId: randomUUID(),
|
|
864
|
+
});
|
|
865
|
+
if (awayVerdict.disposition === "block" || awayVerdict.disposition === "warn") {
|
|
866
|
+
logger.info("session.away.response.screened_out", { agentName, sessionId, kind, disposition: awayVerdict.disposition });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (awayVerdict.disposition === "redact" && awayVerdict.content === undefined) {
|
|
870
|
+
logger.error("session.away.response.redact_without_content", { agentName, sessionId, kind });
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
const contentBytes = awayVerdict.disposition === "redact" && awayVerdict.content !== undefined
|
|
874
|
+
? new Uint8Array(awayVerdict.content)
|
|
875
|
+
: draftBytes;
|
|
834
876
|
const contentHash = createHash("sha256").update(new Uint8Array([0x00])).update(contentBytes).digest();
|
|
835
877
|
const sendResult = await sessionNodeManager.sendContent(agentName, sessionId, contentBytes, new Uint8Array(contentHash), randomUUID());
|
|
836
878
|
if (!sendResult.ok) {
|
|
@@ -3005,8 +3047,10 @@ export async function startDaemon(config) {
|
|
|
3005
3047
|
}
|
|
3006
3048
|
}
|
|
3007
3049
|
// 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
|
-
|
|
3050
|
+
// actually used (not re-resolved later). DOD-TIER-4 AC3: a deliberate outbound initiate makes the
|
|
3051
|
+
// counterparty KNOWN, provenance 'initiated'. (Not WHITELISTED — auto-accept stays an explicit
|
|
3052
|
+
// cello_contact_set_tier act, design §1.)
|
|
3053
|
+
sessionNodeManager.addContact(agentName, counterpartyPubkey, undefined, "initiated", TIER.KNOWN);
|
|
3010
3054
|
// AC-007: the session is usable immediately upon (relay) connection — the dcutr
|
|
3011
3055
|
// upgrade runs in the background and is intentionally NOT awaited here.
|
|
3012
3056
|
return { ok: true, sessionId, transportMode: result.mode, correlationId };
|
|
@@ -3429,7 +3473,16 @@ export async function startDaemon(config) {
|
|
|
3429
3473
|
// reviewer HIGH finding (aa5928e2/a9099571): recordTranscriptMessage swallows a DB write
|
|
3430
3474
|
// failure without rolling back the tree's leaf count, so a hole in `messages` is possible in
|
|
3431
3475
|
// principle; the contiguous-run walk refuses to vault the cursor past such a hole even here.
|
|
3432
|
-
|
|
3476
|
+
const deliveredSeqs = new Set(messages.map((m) => m.sequence));
|
|
3477
|
+
safeCursorAdvance(connectionId, sessionId, deliveredSeqs);
|
|
3478
|
+
// DOD-CURSOR-DURABLE-1 (AC3): reading the full history IS reading — so this must advance the
|
|
3479
|
+
// PERSISTED watermark too, not just this connection's in-memory cursor. Previously it advanced
|
|
3480
|
+
// only the cursor, which made the gate's own guidance ("call cello_get_transcript, then retry")
|
|
3481
|
+
// a dead end for any stateless client: a fresh process reads the transcript, exits, and the next
|
|
3482
|
+
// process still presents an unread count. Same contiguous-run safety as the cursor — never vault
|
|
3483
|
+
// past a gap (an undecryptable row is absent from `messages`, so the walk stops there and those
|
|
3484
|
+
// messages correctly stay unread).
|
|
3485
|
+
safeWatermarkAdvance(agentName, sessionId, deliveredSeqs);
|
|
3433
3486
|
return { ok: true, session_id: sessionId, messages, undecryptable };
|
|
3434
3487
|
});
|
|
3435
3488
|
// cello_list_sessions: the discovery surface — every persisted session for the
|
|
@@ -4175,10 +4228,11 @@ export async function startDaemon(config) {
|
|
|
4175
4228
|
}
|
|
4176
4229
|
async function acceptInboundAssignment(parsed, agentName, correlationId) {
|
|
4177
4230
|
try {
|
|
4178
|
-
// M8C-ABUSE-1
|
|
4179
|
-
//
|
|
4180
|
-
//
|
|
4181
|
-
//
|
|
4231
|
+
// M8C-ABUSE-1 + DOD-TIER-2/3: bound acceptance by the sender's TIER — a per-sender cap that is
|
|
4232
|
+
// the sender's tier cap (BLOCKED 0 → refused here, indistinguishably from an over-cap stranger),
|
|
4233
|
+
// plus a global anti-swarm cap that applies only to the UNKNOWN-tier stranger pool. No tier is
|
|
4234
|
+
// unbounded (INV-TIER-BOUND); KNOWN+ are exempt from the GLOBAL pool only, not from their own
|
|
4235
|
+
// cap. Checked FIRST, before any standing-receiver work, so a refusal is cheap.
|
|
4182
4236
|
// CC-10: reap provably-dead ghosts for THIS agent before counting — otherwise invisible
|
|
4183
4237
|
// interrupted 0-received sessions (post-restart shape) consume the sender's budget forever
|
|
4184
4238
|
// and lock the stranger out with no list read ever clearing them. Safe here: abandonSession
|
|
@@ -4194,6 +4248,15 @@ export async function startDaemon(config) {
|
|
|
4194
4248
|
});
|
|
4195
4249
|
return;
|
|
4196
4250
|
}
|
|
4251
|
+
// DOD-RENAME-1 AC1 (review F1 / DEC-AB-4): record the offered name as the rename baseline the
|
|
4252
|
+
// moment the offer passes the acceptance gate — an ALLOWED peer (the bound check let them
|
|
4253
|
+
// through) is tracked even if session formation transiently fails below. Deliberately AFTER the
|
|
4254
|
+
// bound check, not at the raw parse point: a BLOCKED or over-cap peer must NOT be able to drive
|
|
4255
|
+
// the operator's rename baseline or push notices into their inbox. Guarded by offeredMoniker !==
|
|
4256
|
+
// null so a silent offer never clears the baseline (AC5).
|
|
4257
|
+
if (parsed.offeredMoniker !== null) {
|
|
4258
|
+
sessionNodeManager.recordOfferedMoniker(agentName, parsed.participantAPubkeyHex, parsed.offeredMoniker);
|
|
4259
|
+
}
|
|
4197
4260
|
// M8B F14 (fix 2): KICK creation before polling — an inbound offer arriving while no
|
|
4198
4261
|
// receiver exists and none is being created must trigger the ensure itself (the doc
|
|
4199
4262
|
// comment's "retries on demand" made true), instead of polling a creation nobody
|
|
@@ -4274,6 +4337,8 @@ export async function startDaemon(config) {
|
|
|
4274
4337
|
// DOD-MONIKER-6: scoped to `agentName` — the agent this offer was received FOR.
|
|
4275
4338
|
if (parsed.offeredMoniker !== null) {
|
|
4276
4339
|
offeredMonikers.set(offerKey(agentName, parsed.sessionIdHex), parsed.offeredMoniker);
|
|
4340
|
+
// (DOD-RENAME-1's rename-baseline update runs earlier, right after the acceptance bound — see
|
|
4341
|
+
// the recordOfferedMoniker call there. This map is only the session-scoped display material.)
|
|
4277
4342
|
}
|
|
4278
4343
|
enqueueInboundSession(agentName, {
|
|
4279
4344
|
sessionIdHex: parsed.sessionIdHex,
|
|
@@ -4290,8 +4355,8 @@ export async function startDaemon(config) {
|
|
|
4290
4355
|
// CC-1 (2026-07-07): do NOT auto-add the requester here. Accepting the *connection* must not
|
|
4291
4356
|
// grant *trust*. The old auto-add promoted any stranger who knocked once to "known" (Level-4
|
|
4292
4357
|
// fast-track), which defeated BOTH the screening layer AND the ABUSE-1 acceptance caps
|
|
4293
|
-
// (
|
|
4294
|
-
//
|
|
4358
|
+
// (a higher tier raises the per-sender cap, so auto-promoting a stranger would have let their
|
|
4359
|
+
// sessions 2+ ride a larger bound — DOD-TIER-2). Promotion to "known" now requires operator engagement only:
|
|
4295
4360
|
// an outbound cello_initiate_session (below, ~3137), the operator replying INTO the session via
|
|
4296
4361
|
// cello_send, or an explicit cello_contact_add. An unattended stranger is never auto-whitelisted.
|
|
4297
4362
|
// See docs/planning/.../2026-07-07_1700_four-level-screening-policy.md (D21).
|
|
@@ -4981,18 +5046,52 @@ export async function startDaemon(config) {
|
|
|
4981
5046
|
// rather than let it send blind — the WhatsApp-group-chat model. Runs BEFORE M9's
|
|
4982
5047
|
// governance-decisions parsing below — an access-control gate should short-circuit before any
|
|
4983
5048
|
// unrelated prep work for a send that may not even be allowed to proceed.
|
|
5049
|
+
// DOD-CURSOR-DURABLE-1: the gate now consults TWO authorities and passes if EITHER says the
|
|
5050
|
+
// caller is caught up.
|
|
5051
|
+
//
|
|
5052
|
+
// 1. the connection cursor (in-memory, per-connection) — today's rule, UNCHANGED. A
|
|
5053
|
+
// long-lived client (the MCP shim, one socket for the whole session) satisfies this
|
|
5054
|
+
// exactly as before, so the shipped single-connection path is byte-for-byte identical.
|
|
5055
|
+
//
|
|
5056
|
+
// 2. the persisted per-(agent, session) read watermark — NEW. A STATELESS client (the `cello`
|
|
5057
|
+
// CLI runs a fresh process, and therefore a fresh connection, per command) always presents
|
|
5058
|
+
// cursor -1, so authority 1 can never be satisfied by it: after the counterparty spoke
|
|
5059
|
+
// once, every CLI send was refused forever, even though the agent had demonstrably read
|
|
5060
|
+
// the message. That made a two-way conversation impossible from bash — and it silently hit
|
|
5061
|
+
// any RECONNECTING MCP client too, whose fresh connectionId also resets the cursor to -1.
|
|
5062
|
+
//
|
|
5063
|
+
// What the gate protects, precisely: an agent must never reply to COUNTERPARTY content nobody
|
|
5064
|
+
// on its side has seen. That guarantee is preserved and is now durable — it survives the socket.
|
|
5065
|
+
//
|
|
5066
|
+
// What it deliberately no longer protects (the approved trade — see
|
|
5067
|
+
// 2026-07-11_cursor-durable-read-before-write-design.md §6, Andre's go): a message this agent
|
|
5068
|
+
// SENT from a DIFFERENT local connection no longer blocks. Two attended windows on one agent
|
|
5069
|
+
// used to gate each other; now they do not, because a locally-authored message is not "unread
|
|
5070
|
+
// counterparty content" — the AGENT wrote it. The principal here is the agent, not the socket,
|
|
5071
|
+
// and the daemon cannot referee which of an operator's own windows a human is looking at.
|
|
4984
5072
|
const currentSeq = record.message_count - 1;
|
|
4985
|
-
const
|
|
4986
|
-
|
|
5073
|
+
const connectionCursor = getConnectionCursor(connectionId, sessionId);
|
|
5074
|
+
const unreadReceived = sessionNodeManager.getUnreadReceivedCount(agentName, sessionId);
|
|
5075
|
+
const caughtUp = connectionCursor >= currentSeq || unreadReceived === 0;
|
|
5076
|
+
if (!caughtUp) {
|
|
4987
5077
|
// M8C-CURSOR-1 (reviewer MEDIUM fix): every sibling rejection in this handler logs; this
|
|
4988
5078
|
// gate must too — a security-relevant control-flow path with no observability is a gap.
|
|
4989
|
-
|
|
5079
|
+
// Both authorities are logged, so the next reader can tell WHICH one refused.
|
|
5080
|
+
logger.warn("session.send.blocked", {
|
|
5081
|
+
sessionId,
|
|
5082
|
+
currentSeq,
|
|
5083
|
+
lastReadSeq: connectionCursor,
|
|
5084
|
+
unreadReceived,
|
|
5085
|
+
connectionId,
|
|
5086
|
+
agentName,
|
|
5087
|
+
});
|
|
4990
5088
|
return {
|
|
4991
5089
|
ok: false,
|
|
4992
5090
|
reason: "session_not_current",
|
|
4993
5091
|
current_seq: currentSeq,
|
|
4994
|
-
last_read_seq:
|
|
4995
|
-
|
|
5092
|
+
last_read_seq: connectionCursor,
|
|
5093
|
+
unread_received: unreadReceived,
|
|
5094
|
+
guidance: `This agent hasn't read ${unreadReceived} received message(s) on session ${sessionId}. Call cello_receive (or cello_get_transcript for the full history) to read them, then retry the send.`,
|
|
4996
5095
|
};
|
|
4997
5096
|
}
|
|
4998
5097
|
// M9-FEED-001 §6: the agent's governance re-send decisions, keyed by the flagId a prior `warn`
|
|
@@ -5139,8 +5238,11 @@ export async function startDaemon(config) {
|
|
|
5139
5238
|
// auto-adds (that defeated screening + anti-spam). For an OUTBOUND session the counterparty is
|
|
5140
5239
|
// already a contact (cello_initiate_session added it), so this is an idempotent no-op there; it
|
|
5141
5240
|
// matters for inbound-originated sessions, where the reply is the trust signal. addContact is
|
|
5142
|
-
// INSERT OR IGNORE — it never refreshes added_at.
|
|
5143
|
-
|
|
5241
|
+
// INSERT OR IGNORE — it never refreshes added_at. DOD-TIER-4 AC3: engaging (a committed reply into
|
|
5242
|
+
// an inbound session I accepted) makes the counterparty KNOWN, provenance 'accepted'. For an
|
|
5243
|
+
// OUTBOUND session the row already exists ('initiated', KNOWN) and INSERT OR IGNORE leaves it
|
|
5244
|
+
// untouched — 'initiated' correctly wins there.
|
|
5245
|
+
sessionNodeManager.addContact(record.agent_name, recipientPubkey, undefined, "accepted", TIER.KNOWN);
|
|
5144
5246
|
if (modified) {
|
|
5145
5247
|
logger.info("security.verdict.returned", { disposition: "redact", sessionId, sequenceNumber: leafIndex, correlationId });
|
|
5146
5248
|
}
|
|
@@ -5365,7 +5467,21 @@ export async function startDaemon(config) {
|
|
|
5365
5467
|
}));
|
|
5366
5468
|
const unread = sessionNodeManager.getUnreadSummary(agent);
|
|
5367
5469
|
const total_unread = unread.reduce((sum, u) => sum + u.unread_count, 0);
|
|
5368
|
-
|
|
5470
|
+
// DOD-RENAME-1 AC3: pending rename notices surface HERE (the INBOX pull), never as a real-time
|
|
5471
|
+
// push. The offered name is rendered as an untrusted CLAIM (quoted, with the pubkey) plus the
|
|
5472
|
+
// command to adopt it — the daemon never auto-applies a self-declared name.
|
|
5473
|
+
const rename_notices = sessionNodeManager.getRenameNotices(agent).map((n) => ({
|
|
5474
|
+
pubkey: n.pubkey,
|
|
5475
|
+
your_name_for_them: n.moniker,
|
|
5476
|
+
claimed_name: n.offered_name,
|
|
5477
|
+
noticed_at: n.noticed_at,
|
|
5478
|
+
// Names the contact by the operator's OWN pet name (AC3); the self-declared name is a quoted,
|
|
5479
|
+
// untrusted claim; the adopt command carries its arguments so it is copy-pasteable.
|
|
5480
|
+
notice: `Your contact ${n.moniker !== null ? `"${n.moniker}" ` : ""}(${n.pubkey.slice(0, 16)}…) now calls themselves ` +
|
|
5481
|
+
`"${n.offered_name}" (self-declared — unverified). Adopt it: cello_contact_set_moniker ` +
|
|
5482
|
+
`{ pubkey: "${n.pubkey}", moniker: "${n.offered_name}" }, or ignore.`,
|
|
5483
|
+
}));
|
|
5484
|
+
return { agent, pending_session_requests: pending, expired_session_requests: expired, unread, total_unread, rename_notices };
|
|
5369
5485
|
});
|
|
5370
5486
|
const totalUnread = agents.reduce((sum, a) => sum + a.total_unread, 0);
|
|
5371
5487
|
const totalPending = agents.reduce((sum, a) => sum + a.pending_session_requests.length, 0);
|
|
@@ -5413,7 +5529,10 @@ export async function startDaemon(config) {
|
|
|
5413
5529
|
};
|
|
5414
5530
|
}
|
|
5415
5531
|
}
|
|
5416
|
-
|
|
5532
|
+
// DOD-TIER-4 / DEC-AB-1: an explicit cello_contact_add is a deliberate operator vouch → KNOWN
|
|
5533
|
+
// (still not auto-accept; that is a separate cello_contact_set_tier to whitelisted). provenance
|
|
5534
|
+
// stays null — this relationship formed by neither initiating nor accepting a session (AC5).
|
|
5535
|
+
sessionNodeManager.addContact(resolved.agent, pubkey, moniker, null, TIER.KNOWN);
|
|
5417
5536
|
logger.info("contact.added", { agent: resolved.agent, pubkey });
|
|
5418
5537
|
if (moniker !== undefined) {
|
|
5419
5538
|
logger.info("contact.moniker.set", { agentName: resolved.agent, pubkey });
|
|
@@ -5449,6 +5568,57 @@ export async function startDaemon(config) {
|
|
|
5449
5568
|
logger.info("contact.moniker.set", { agentName: resolved.agent, pubkey, cleared: moniker === null });
|
|
5450
5569
|
return { ok: true, agent: resolved.agent, pubkey, moniker };
|
|
5451
5570
|
});
|
|
5571
|
+
// DOD-CONTACT-VIEW-1 AC1: set a contact's reachability tier. Validates the tier is a known constant
|
|
5572
|
+
// (0..4) — an unknown value is REFUSED, never coerced. Emits contact.tier.changed (old→new).
|
|
5573
|
+
handlers.set("cello_contact_set_tier", async (params, connectionId) => {
|
|
5574
|
+
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5575
|
+
const tier = typeof params?.tier === "number" ? params.tier : undefined;
|
|
5576
|
+
if (!pubkey || tier === undefined) {
|
|
5577
|
+
return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'tier' (0=blocked, 1=unknown, 2=known, 3=whitelisted, 4=vip)." };
|
|
5578
|
+
}
|
|
5579
|
+
if (!isKnownTierValue(tier)) {
|
|
5580
|
+
return { ok: false, reason: "invalid_tier", guidance: "tier must be one of 0 (blocked), 1 (unknown), 2 (known), 3 (whitelisted), 4 (vip)." };
|
|
5581
|
+
}
|
|
5582
|
+
const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
|
|
5583
|
+
if (!resolved.ok)
|
|
5584
|
+
return resolved;
|
|
5585
|
+
const oldTier = sessionNodeManager.getTier(resolved.agent, pubkey);
|
|
5586
|
+
if (!sessionNodeManager.setContactTier(resolved.agent, pubkey, tier)) {
|
|
5587
|
+
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.` };
|
|
5588
|
+
}
|
|
5589
|
+
// Only an actual change is an audit event — a no-op re-set must not pollute the trail (review F4).
|
|
5590
|
+
if (oldTier !== tier) {
|
|
5591
|
+
logger.info("contact.tier.changed", { agentName: resolved.agent, pubkey, oldTier, newTier: tier });
|
|
5592
|
+
}
|
|
5593
|
+
return { ok: true, agent: resolved.agent, pubkey, tier };
|
|
5594
|
+
});
|
|
5595
|
+
// DOD-AWAY-TIER-1 AC2: set (or clear, with null) a contact's per-contact away message — the most
|
|
5596
|
+
// specific level of the away-text resolution. Validated as a string-or-null; the text is screened
|
|
5597
|
+
// on the outbound path at send time (SI), never here.
|
|
5598
|
+
handlers.set("cello_contact_set_away", async (params, connectionId) => {
|
|
5599
|
+
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5600
|
+
if (!pubkey || !params || !("message" in params)) {
|
|
5601
|
+
return { ok: false, reason: "missing_params", guidance: "Provide 'pubkey' (hex) and 'message' — a string away text, or null to clear it." };
|
|
5602
|
+
}
|
|
5603
|
+
const rawMessage = params.message === null ? null : typeof params.message === "string" ? params.message : undefined;
|
|
5604
|
+
if (rawMessage === undefined) {
|
|
5605
|
+
return { ok: false, reason: "invalid_message", guidance: "'message' must be a string (the away text) or null (to clear)." };
|
|
5606
|
+
}
|
|
5607
|
+
// Review F2/F3: an empty / whitespace-only message CLEARS (consistent with the CLI + null), never
|
|
5608
|
+
// stores a blank away reply; a valid message is length-bounded.
|
|
5609
|
+
const message = rawMessage !== null && rawMessage.trim().length === 0 ? null : rawMessage;
|
|
5610
|
+
if (message !== null && message.length > AWAY_MESSAGE_MAX_LEN) {
|
|
5611
|
+
return { ok: false, reason: "invalid_message", guidance: `The away message must be <= ${AWAY_MESSAGE_MAX_LEN} characters.` };
|
|
5612
|
+
}
|
|
5613
|
+
const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
|
|
5614
|
+
if (!resolved.ok)
|
|
5615
|
+
return resolved;
|
|
5616
|
+
if (!sessionNodeManager.setContactAwayMessage(resolved.agent, pubkey, message)) {
|
|
5617
|
+
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.` };
|
|
5618
|
+
}
|
|
5619
|
+
logger.info("contact.away.set", { agentName: resolved.agent, pubkey, cleared: message === null });
|
|
5620
|
+
return { ok: true, agent: resolved.agent, pubkey };
|
|
5621
|
+
});
|
|
5452
5622
|
handlers.set("cello_contact_remove", async (params, connectionId) => {
|
|
5453
5623
|
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5454
5624
|
if (!pubkey) {
|
|
@@ -5473,6 +5643,52 @@ export async function startDaemon(config) {
|
|
|
5473
5643
|
}));
|
|
5474
5644
|
return { ok: true, agent: resolved.agent, contacts };
|
|
5475
5645
|
});
|
|
5646
|
+
// DOD-SETTINGS-1 AC2: read a per-agent reachability-policy setting (or all set ones). An unset key
|
|
5647
|
+
// returns value:null — the CONSUMER applies the hardcoded default (the daemon runs on defaults alone).
|
|
5648
|
+
handlers.set("cello_settings_get", async (params, connectionId) => {
|
|
5649
|
+
const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
|
|
5650
|
+
if (!resolved.ok)
|
|
5651
|
+
return resolved;
|
|
5652
|
+
// A key was PROVIDED (present and non-null) → it must be a valid string key. Distinguish "key
|
|
5653
|
+
// absent" (list all) from "key present but malformed/unknown" (review F1/F3) — a typo'd read must
|
|
5654
|
+
// NOT masquerade as "unset", the exact invisibility this store exists to prevent.
|
|
5655
|
+
if (params && "key" in params && params.key !== undefined && params.key !== null) {
|
|
5656
|
+
if (typeof params.key !== "string") {
|
|
5657
|
+
return { ok: false, reason: "missing_params", guidance: "'key' must be a string setting key, or omit it to list every set value." };
|
|
5658
|
+
}
|
|
5659
|
+
if (!isValidSettingKey(params.key)) {
|
|
5660
|
+
return { ok: false, reason: "invalid_key", guidance: `Unknown setting key '${params.key}'. Valid keys: ${allSettingKeys().join(", ")}` };
|
|
5661
|
+
}
|
|
5662
|
+
return { ok: true, agent: resolved.agent, key: params.key, value: sessionNodeManager.getSetting(resolved.agent, params.key) };
|
|
5663
|
+
}
|
|
5664
|
+
return { ok: true, agent: resolved.agent, settings: sessionNodeManager.getAllSettings(resolved.agent) };
|
|
5665
|
+
});
|
|
5666
|
+
// DOD-SETTINGS-1 AC2: write a per-agent setting. The KEY is validated against the known namespace —
|
|
5667
|
+
// an unknown key is REFUSED (a typo'd key that persisted would be a setting that never takes effect,
|
|
5668
|
+
// invisible to the operator). Per-key VALUE validation (finite bounds, etc.) lives with the consumer.
|
|
5669
|
+
handlers.set("cello_settings_set", async (params, connectionId) => {
|
|
5670
|
+
const key = typeof params?.key === "string" ? params.key : undefined;
|
|
5671
|
+
const rawValue = params?.value;
|
|
5672
|
+
const value = typeof rawValue === "string" ? rawValue : typeof rawValue === "number" ? String(rawValue) : undefined;
|
|
5673
|
+
if (!key || value === undefined) {
|
|
5674
|
+
return { ok: false, reason: "missing_params", guidance: `Provide 'key' and 'value' (string or number). Valid keys: ${allSettingKeys().join(", ")}` };
|
|
5675
|
+
}
|
|
5676
|
+
if (!isValidSettingKey(key)) {
|
|
5677
|
+
return { ok: false, reason: "invalid_key", guidance: `Unknown setting key '${key}'. Valid keys: ${allSettingKeys().join(", ")}` };
|
|
5678
|
+
}
|
|
5679
|
+
// DOD-TIER-BOUNDS-SETTINGS AC2: a bound value must be a finite positive integer (INV-TIER-BOUND —
|
|
5680
|
+
// a setting cannot REMOVE a bound). Away-text values are free-form.
|
|
5681
|
+
const valueCheck = validateSettingValue(key, value);
|
|
5682
|
+
if (!valueCheck.ok) {
|
|
5683
|
+
return { ok: false, reason: "invalid_value", guidance: valueCheck.reason };
|
|
5684
|
+
}
|
|
5685
|
+
const resolved = resolveContactAgent(perConnectionState.get(connectionId), params);
|
|
5686
|
+
if (!resolved.ok)
|
|
5687
|
+
return resolved;
|
|
5688
|
+
sessionNodeManager.setSetting(resolved.agent, key, value);
|
|
5689
|
+
logger.info("setting.changed", { agentName: resolved.agent, key });
|
|
5690
|
+
return { ok: true, agent: resolved.agent, key, value };
|
|
5691
|
+
});
|
|
5476
5692
|
// MONIKER-1 AC2/AC3: cello_set_moniker — set (or clear, via explicit null) an agent's outbound-name
|
|
5477
5693
|
// override on the agents table. Validated at set-time with the shared MONIKER-0 rule; an invalid
|
|
5478
5694
|
// value is rejected here AND at the store (backstop) — it can never be stored. Local-only: the
|