@cello-protocol/daemon 0.0.44 → 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/agent-id-migration.d.ts +61 -0
- package/dist/agent-id-migration.d.ts.map +1 -0
- package/dist/agent-id-migration.js +331 -0
- package/dist/agent-id-migration.js.map +1 -0
- 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 +209 -31
- package/dist/daemon.js.map +1 -1
- package/dist/retry-queue.d.ts +9 -7
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +81 -48
- package/dist/retry-queue.js.map +1 -1
- package/dist/session-node-manager.d.ts +130 -14
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +539 -120
- package/dist/session-node-manager.js.map +1 -1
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.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";
|
|
@@ -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.
|
|
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
|
-
|
|
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) {
|
|
@@ -1450,12 +1474,14 @@ export async function startDaemon(config) {
|
|
|
1450
1474
|
// entry; a TTF expiry records the un-acked content for the crash backstop (the relay
|
|
1451
1475
|
// park deposit itself is added in 3b). Both side effects are best-effort and never
|
|
1452
1476
|
// throw into the content stream handler.
|
|
1477
|
+
// DOD-AGENT-ID-JOINKEY-1: RetryQueue owns an agent-scoped table, so it is handed the STABLE
|
|
1478
|
+
// agent_id. The daemon resolves the operator-facing name ONCE, here at its own boundary.
|
|
1453
1479
|
sessionNodeManager.setAwaitingAckHooks({
|
|
1454
1480
|
onPersisted: (agentName, sessionId, contentHashHex) => {
|
|
1455
|
-
retryQueue.markContentAcked(agentName, sessionId, Buffer.from(contentHashHex, "hex"));
|
|
1481
|
+
retryQueue.markContentAcked(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"));
|
|
1456
1482
|
},
|
|
1457
1483
|
onTtf: (agentName, sessionId, contentHashHex, content) => {
|
|
1458
|
-
retryQueue.enqueueAwaitingContent(agentName, sessionId, Buffer.from(contentHashHex, "hex"), content);
|
|
1484
|
+
retryQueue.enqueueAwaitingContent(sessionNodeManager.resolveAgentId(agentName), sessionId, Buffer.from(contentHashHex, "hex"), content);
|
|
1459
1485
|
},
|
|
1460
1486
|
});
|
|
1461
1487
|
// MSG-001-3b (2b): the LIVE content-park deposit. On a not-confirmed send (direct delivery
|
|
@@ -1508,8 +1534,14 @@ export async function startDaemon(config) {
|
|
|
1508
1534
|
// PERSISTED session state (the in-memory entry is gone after a restart). Same seal + deposit
|
|
1509
1535
|
// as the live hook above; the endpoint + recipient come from the sessions row.
|
|
1510
1536
|
const startupParkFn = async (entry) => {
|
|
1511
|
-
|
|
1512
|
-
|
|
1537
|
+
// The durable row carries the OWNING agent's stable id. Resolve it back to a name for the
|
|
1538
|
+
// name-addressed session/standing-receiver lookups. A retired owner resolves fine and then has no
|
|
1539
|
+
// standing receiver, so the park fails loudly below rather than silently re-parking as someone else.
|
|
1540
|
+
const ownerName = sessionNodeManager.agentNameForId(entry.agentId);
|
|
1541
|
+
if (ownerName === null)
|
|
1542
|
+
return { parked: false, error: "owning_agent_not_found" };
|
|
1543
|
+
const ep = sessionNodeManager.getPersistedRelayEndpoint(ownerName, entry.sessionId);
|
|
1544
|
+
const record = sessionNodeManager.getSessionRecord(ownerName, entry.sessionId);
|
|
1513
1545
|
if (!ep)
|
|
1514
1546
|
return { parked: false, error: "no_persisted_relay_endpoint" };
|
|
1515
1547
|
if (!record?.counterparty_pubkey)
|
|
@@ -1543,18 +1575,23 @@ export async function startDaemon(config) {
|
|
|
1543
1575
|
// Re-park un-acked awaiting content to the relay store-and-forward queue. Runs once pre-IPC
|
|
1544
1576
|
// (the crash backstop) and again per-agent when an agent comes online — because post-DOD-LOOP-1
|
|
1545
1577
|
// the native `startupParkFn` needs the OWNING agent's standing receiver, which exists only once
|
|
1546
|
-
// that agent is online. `
|
|
1578
|
+
// that agent is online. `filterAgentName` scopes the drain to one agent's sessions on the agent-
|
|
1547
1579
|
// online re-run; with no filter it attempts all (the pre-IPC pass / injected-target test path).
|
|
1548
|
-
async function flushAwaitingContent(
|
|
1580
|
+
async function flushAwaitingContent(filterAgentName) {
|
|
1581
|
+
// DOD-AGENT-ID-JOINKEY-1: the queue is keyed by the STABLE agent_id, but the caller (and the
|
|
1582
|
+
// human-readable log) speak the NAME. Resolve once here; log the name, filter by the id.
|
|
1583
|
+
const filterAgentId = filterAgentName !== undefined
|
|
1584
|
+
? sessionNodeManager.resolveAgentId(filterAgentName)
|
|
1585
|
+
: undefined;
|
|
1549
1586
|
const all = retryQueue.getAwaitingSessions();
|
|
1550
|
-
const sessions =
|
|
1587
|
+
const sessions = filterAgentId === undefined
|
|
1551
1588
|
? all
|
|
1552
|
-
: all.filter((s) => s.
|
|
1589
|
+
: all.filter((s) => s.agentId === filterAgentId);
|
|
1553
1590
|
if (sessions.length === 0)
|
|
1554
1591
|
return;
|
|
1555
1592
|
const parkFn = config.contentParkFn ?? startupParkFn;
|
|
1556
1593
|
if (!parkFn) {
|
|
1557
|
-
const pendingCount = sessions.reduce((n, s) => n + retryQueue.getAwaitingDepth(s.
|
|
1594
|
+
const pendingCount = sessions.reduce((n, s) => n + retryQueue.getAwaitingDepth(s.agentId, s.sessionId), 0);
|
|
1558
1595
|
logger.warn("content.park.flush.deferred", {
|
|
1559
1596
|
sessionCount: sessions.length,
|
|
1560
1597
|
pendingCount,
|
|
@@ -1565,7 +1602,7 @@ export async function startDaemon(config) {
|
|
|
1565
1602
|
let parkedTotal = 0;
|
|
1566
1603
|
for (const s of sessions) {
|
|
1567
1604
|
try {
|
|
1568
|
-
parkedTotal += await retryQueue.drainAwaitingToPark(s.
|
|
1605
|
+
parkedTotal += await retryQueue.drainAwaitingToPark(s.agentId, s.sessionId, parkFn);
|
|
1569
1606
|
}
|
|
1570
1607
|
catch (err) {
|
|
1571
1608
|
logger.error("content.park.flush.failed", {
|
|
@@ -1577,7 +1614,7 @@ export async function startDaemon(config) {
|
|
|
1577
1614
|
logger.info("content.park.flush.completed", {
|
|
1578
1615
|
sessionCount: sessions.length,
|
|
1579
1616
|
parkedCount: parkedTotal,
|
|
1580
|
-
...(
|
|
1617
|
+
...(filterAgentName !== undefined ? { agentName: filterAgentName } : {}),
|
|
1581
1618
|
});
|
|
1582
1619
|
}
|
|
1583
1620
|
await flushAwaitingContent();
|
|
@@ -2992,8 +3029,10 @@ export async function startDaemon(config) {
|
|
|
2992
3029
|
}
|
|
2993
3030
|
}
|
|
2994
3031
|
// M8C-CONTACT-1 (D6): "initiating a session to X adds X" — pin at the pubkey the negotiator
|
|
2995
|
-
// actually used (not re-resolved later).
|
|
2996
|
-
|
|
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);
|
|
2997
3036
|
// AC-007: the session is usable immediately upon (relay) connection — the dcutr
|
|
2998
3037
|
// upgrade runs in the background and is intentionally NOT awaited here.
|
|
2999
3038
|
return { ok: true, sessionId, transportMode: result.mode, correlationId };
|
|
@@ -3507,10 +3546,16 @@ export async function startDaemon(config) {
|
|
|
3507
3546
|
}
|
|
3508
3547
|
// DOD-LOOP-1: awaiting content is keyed by the OWNING agent. Prefer an explicit agentName param;
|
|
3509
3548
|
// fall back to the connection's current agent.
|
|
3549
|
+
// DOD-AGENT-ID-JOINKEY-1: the old `?? ""` fell back to an EMPTY-STRING agent, silently merging
|
|
3550
|
+
// every unaddressed caller's awaiting content into one nameless queue. There is no such agent.
|
|
3510
3551
|
const agentName = params?.agentName
|
|
3511
|
-
?? perConnectionState.get(connectionId)?.currentAgent
|
|
3512
|
-
|
|
3513
|
-
|
|
3552
|
+
?? perConnectionState.get(connectionId)?.currentAgent;
|
|
3553
|
+
if (!agentName) {
|
|
3554
|
+
return { error: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass agentName." };
|
|
3555
|
+
}
|
|
3556
|
+
const agentId = sessionNodeManager.resolveAgentId(agentName);
|
|
3557
|
+
retryQueue.enqueueAwaitingContent(agentId, sessionId, Buffer.from(contentHashHex, "hex"), Buffer.from(contentHex, "hex"));
|
|
3558
|
+
return { queued: true, awaitingDepth: retryQueue.getAwaitingDepth(agentId, sessionId) };
|
|
3514
3559
|
});
|
|
3515
3560
|
// CELLO-M7-MSG-001: a `persisted` delivery ACK (or a confirmed park) clears the durable
|
|
3516
3561
|
// awaiting-ACK entry so the startup flush does not re-park already-delivered content.
|
|
@@ -3521,9 +3566,13 @@ export async function startDaemon(config) {
|
|
|
3521
3566
|
return { error: "missing_params", guidance: "Provide sessionId and contentHash (hex)." };
|
|
3522
3567
|
}
|
|
3523
3568
|
const agentName = params?.agentName
|
|
3524
|
-
?? perConnectionState.get(connectionId)?.currentAgent
|
|
3525
|
-
|
|
3526
|
-
|
|
3569
|
+
?? perConnectionState.get(connectionId)?.currentAgent;
|
|
3570
|
+
if (!agentName) {
|
|
3571
|
+
return { error: "no_current_agent", guidance: "Select an agent with cello_use_agent, or pass agentName." };
|
|
3572
|
+
}
|
|
3573
|
+
const agentId = sessionNodeManager.resolveAgentId(agentName);
|
|
3574
|
+
retryQueue.markContentAcked(agentId, sessionId, Buffer.from(contentHashHex, "hex"));
|
|
3575
|
+
return { acked: true, awaitingDepth: retryQueue.getAwaitingDepth(agentId, sessionId) };
|
|
3527
3576
|
});
|
|
3528
3577
|
handlers.set("check_nonce", async (params, _connectionId) => {
|
|
3529
3578
|
const sessionId = params?.sessionId;
|
|
@@ -4152,10 +4201,11 @@ export async function startDaemon(config) {
|
|
|
4152
4201
|
}
|
|
4153
4202
|
async function acceptInboundAssignment(parsed, agentName, correlationId) {
|
|
4154
4203
|
try {
|
|
4155
|
-
// M8C-ABUSE-1
|
|
4156
|
-
//
|
|
4157
|
-
//
|
|
4158
|
-
//
|
|
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.
|
|
4159
4209
|
// CC-10: reap provably-dead ghosts for THIS agent before counting — otherwise invisible
|
|
4160
4210
|
// interrupted 0-received sessions (post-restart shape) consume the sender's budget forever
|
|
4161
4211
|
// and lock the stranger out with no list read ever clearing them. Safe here: abandonSession
|
|
@@ -4171,6 +4221,15 @@ export async function startDaemon(config) {
|
|
|
4171
4221
|
});
|
|
4172
4222
|
return;
|
|
4173
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
|
+
}
|
|
4174
4233
|
// M8B F14 (fix 2): KICK creation before polling — an inbound offer arriving while no
|
|
4175
4234
|
// receiver exists and none is being created must trigger the ensure itself (the doc
|
|
4176
4235
|
// comment's "retries on demand" made true), instead of polling a creation nobody
|
|
@@ -4251,6 +4310,8 @@ export async function startDaemon(config) {
|
|
|
4251
4310
|
// DOD-MONIKER-6: scoped to `agentName` — the agent this offer was received FOR.
|
|
4252
4311
|
if (parsed.offeredMoniker !== null) {
|
|
4253
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.)
|
|
4254
4315
|
}
|
|
4255
4316
|
enqueueInboundSession(agentName, {
|
|
4256
4317
|
sessionIdHex: parsed.sessionIdHex,
|
|
@@ -4267,8 +4328,8 @@ export async function startDaemon(config) {
|
|
|
4267
4328
|
// CC-1 (2026-07-07): do NOT auto-add the requester here. Accepting the *connection* must not
|
|
4268
4329
|
// grant *trust*. The old auto-add promoted any stranger who knocked once to "known" (Level-4
|
|
4269
4330
|
// fast-track), which defeated BOTH the screening layer AND the ABUSE-1 acceptance caps
|
|
4270
|
-
// (
|
|
4271
|
-
//
|
|
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:
|
|
4272
4333
|
// an outbound cello_initiate_session (below, ~3137), the operator replying INTO the session via
|
|
4273
4334
|
// cello_send, or an explicit cello_contact_add. An unattended stranger is never auto-whitelisted.
|
|
4274
4335
|
// See docs/planning/.../2026-07-07_1700_four-level-screening-policy.md (D21).
|
|
@@ -5116,8 +5177,11 @@ export async function startDaemon(config) {
|
|
|
5116
5177
|
// auto-adds (that defeated screening + anti-spam). For an OUTBOUND session the counterparty is
|
|
5117
5178
|
// already a contact (cello_initiate_session added it), so this is an idempotent no-op there; it
|
|
5118
5179
|
// matters for inbound-originated sessions, where the reply is the trust signal. addContact is
|
|
5119
|
-
// INSERT OR IGNORE — it never refreshes added_at.
|
|
5120
|
-
|
|
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);
|
|
5121
5185
|
if (modified) {
|
|
5122
5186
|
logger.info("security.verdict.returned", { disposition: "redact", sessionId, sequenceNumber: leafIndex, correlationId });
|
|
5123
5187
|
}
|
|
@@ -5342,7 +5406,21 @@ export async function startDaemon(config) {
|
|
|
5342
5406
|
}));
|
|
5343
5407
|
const unread = sessionNodeManager.getUnreadSummary(agent);
|
|
5344
5408
|
const total_unread = unread.reduce((sum, u) => sum + u.unread_count, 0);
|
|
5345
|
-
|
|
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 };
|
|
5346
5424
|
});
|
|
5347
5425
|
const totalUnread = agents.reduce((sum, a) => sum + a.total_unread, 0);
|
|
5348
5426
|
const totalPending = agents.reduce((sum, a) => sum + a.pending_session_requests.length, 0);
|
|
@@ -5390,7 +5468,10 @@ export async function startDaemon(config) {
|
|
|
5390
5468
|
};
|
|
5391
5469
|
}
|
|
5392
5470
|
}
|
|
5393
|
-
|
|
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);
|
|
5394
5475
|
logger.info("contact.added", { agent: resolved.agent, pubkey });
|
|
5395
5476
|
if (moniker !== undefined) {
|
|
5396
5477
|
logger.info("contact.moniker.set", { agentName: resolved.agent, pubkey });
|
|
@@ -5426,6 +5507,57 @@ export async function startDaemon(config) {
|
|
|
5426
5507
|
logger.info("contact.moniker.set", { agentName: resolved.agent, pubkey, cleared: moniker === null });
|
|
5427
5508
|
return { ok: true, agent: resolved.agent, pubkey, moniker };
|
|
5428
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
|
+
});
|
|
5429
5561
|
handlers.set("cello_contact_remove", async (params, connectionId) => {
|
|
5430
5562
|
const pubkey = typeof params?.pubkey === "string" ? params.pubkey : undefined;
|
|
5431
5563
|
if (!pubkey) {
|
|
@@ -5450,6 +5582,52 @@ export async function startDaemon(config) {
|
|
|
5450
5582
|
}));
|
|
5451
5583
|
return { ok: true, agent: resolved.agent, contacts };
|
|
5452
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
|
+
});
|
|
5453
5631
|
// MONIKER-1 AC2/AC3: cello_set_moniker — set (or clear, via explicit null) an agent's outbound-name
|
|
5454
5632
|
// override on the agents table. Validated at set-time with the shared MONIKER-0 rule; an invalid
|
|
5455
5633
|
// value is rejected here AND at the store (backstop) — it can never be stored. Local-only: the
|