@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
|
@@ -70,6 +70,8 @@ import { openEncryptedDatabase, resolveDbKey, dbKeyPathFor, } from "./sqlcipher-
|
|
|
70
70
|
import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
|
|
71
71
|
import { ensureIdentitySchema } from "./db-identity-store.js";
|
|
72
72
|
import { migrateSessionTablesToAgentId } from "./agent-id-migration.js";
|
|
73
|
+
import { TIER, normalizeTier, isKnownTierValue, tierBoundsFor, DEFAULT_TIER_BOUNDS, migrateContactsAddTierMetadata } from "./contacts-tier-migration.js";
|
|
74
|
+
import { boundSettingKey, settableTierName, isValidSettingKey, awayTierSettingKey, AWAY_DEFAULT_KEY } from "./agent-settings-keys.js";
|
|
73
75
|
import { randomUUID, createHash } from "node:crypto";
|
|
74
76
|
import * as lp from "it-length-prefixed";
|
|
75
77
|
import { decode, Encoder } from "cbor-x";
|
|
@@ -84,15 +86,18 @@ import { RelayReceiptStore } from "./relay-receipt-store.js";
|
|
|
84
86
|
import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
|
|
85
87
|
import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
|
|
86
88
|
const CBOR_ENC = new Encoder({ tagUint8Array: false });
|
|
87
|
-
// M8C-ABUSE-1: persistence bounds
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
89
|
+
// M8C-ABUSE-1 + DOD-TIER-2: persistence bounds. Formerly a binary "known contacts exempt entirely,
|
|
90
|
+
// everyone else 3 sessions / 25 MB"; now TIER-GRADUATED via DEFAULT_TIER_BOUNDS (contacts-tier-
|
|
91
|
+
// migration). The two consts below are kept for back-compat but DERIVE from the grid's UNKNOWN row —
|
|
92
|
+
// the grid is the single source (DOD-TIER-2 AC4), so these can never drift from it.
|
|
93
|
+
/** Anti-drip-feed: cumulative RECEIVED bytes per session at the UNKNOWN tier (= the grid's UNKNOWN
|
|
94
|
+
* byte cap). Higher tiers get more (DEFAULT_TIER_BOUNDS); no tier is unbounded (INV-TIER-BOUND). */
|
|
95
|
+
export const ABUSE_MAX_SESSION_RECEIVED_BYTES = DEFAULT_TIER_BOUNDS[TIER.UNKNOWN].maxBytesPerSession; // 25 MB
|
|
96
|
+
/** Anti-drip-feed via many sessions: active sessions an UNKNOWN counterparty may hold open at once
|
|
97
|
+
* (= the grid's UNKNOWN per-sender cap). */
|
|
98
|
+
export const ABUSE_MAX_SESSIONS_PER_UNKNOWN_SENDER = DEFAULT_TIER_BOUNDS[TIER.UNKNOWN].maxSessionsPerSender; // 3
|
|
99
|
+
/** Anti-swarm: total active sessions from ALL UNKNOWN-tier counterparties combined, per agent. A
|
|
100
|
+
* scalar across the whole unknown pool — not per-tier — so it stays a standalone const. */
|
|
96
101
|
export const ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL = 50;
|
|
97
102
|
// ─── SessionNodeManager ───────────────────────────────────────────────────────
|
|
98
103
|
export class SessionNodeManager {
|
|
@@ -475,6 +480,11 @@ export class SessionNodeManager {
|
|
|
475
480
|
// database it already exists here and is re-keyed in the same transaction; on a fresh one it is
|
|
476
481
|
// absent, is skipped, and RetryQueue then creates it directly in the re-keyed shape.
|
|
477
482
|
migrateSessionTablesToAgentId(this.#db, this.#logger);
|
|
483
|
+
// DOD-TIER-1 (address-book Step 1): give `contacts` its tier metadata (tier / provenance /
|
|
484
|
+
// last_offered_moniker / away_message). Pure ADD COLUMN, no rebuild — so it runs AFTER the
|
|
485
|
+
// agent-id re-key above (it never needs to appear in that migration's pinned DDL) and BEFORE any
|
|
486
|
+
// read below. Idempotent, no column DEFAULT, grandfathers existing contacts to WHITELISTED once.
|
|
487
|
+
migrateContactsAddTierMetadata(this.#db, this.#logger);
|
|
478
488
|
// M8C-TGDOOR-1: daemon-wide Telegram settings (bot token + allowlisted operator chat). A
|
|
479
489
|
// NEW dedicated table — NOT folded into the parked M9-CFG-001 config store, because a bot
|
|
480
490
|
// token has no sensible default (a required credential, unlike AWAY/TTL/CONTACT's real
|
|
@@ -487,6 +497,35 @@ export class SessionNodeManager {
|
|
|
487
497
|
allowlisted_chat_id TEXT NOT NULL,
|
|
488
498
|
updated_at INTEGER NOT NULL
|
|
489
499
|
)
|
|
500
|
+
`);
|
|
501
|
+
// DOD-RENAME-1 (Option C): pending rename notices — one per (agent, contact). A notice is queued
|
|
502
|
+
// when a peer the operator has PERSONALLY NAMED offers a self-declared name that differs from the
|
|
503
|
+
// last one seen; it surfaces through cello_check_notifications (NOT a real-time push) and clears
|
|
504
|
+
// when the operator adopts a name (cello_contact_set_moniker) or removes the contact. Keyed on
|
|
505
|
+
// agent_id (the stable key); the offered name is charset-validated at the wire boundary but still
|
|
506
|
+
// operator-untrusted, so surfaces render it as a quoted CLAIM.
|
|
507
|
+
this.#db.exec(`
|
|
508
|
+
CREATE TABLE IF NOT EXISTS contact_rename_notices (
|
|
509
|
+
agent_id TEXT NOT NULL,
|
|
510
|
+
pubkey TEXT NOT NULL,
|
|
511
|
+
offered_name TEXT NOT NULL,
|
|
512
|
+
noticed_at INTEGER NOT NULL,
|
|
513
|
+
PRIMARY KEY (agent_id, pubkey)
|
|
514
|
+
)
|
|
515
|
+
`);
|
|
516
|
+
// DOD-SETTINGS-1: a daemon-side per-agent settings store for REACHABILITY POLICY (the tier bounds
|
|
517
|
+
// overrides and the per-tier/agent away messages). A generic key-value table on the stable
|
|
518
|
+
// agent_id, in the same SQLCipher DB. Deliberately NOT M9-CFG-001's gateway config store: this is
|
|
519
|
+
// daemon reachability policy, not gateway SCREENING config, and the M9 store is unwired + plaintext.
|
|
520
|
+
// reconcile with DOD-CONFIG-1 later; this is daemon reachability policy, not gateway config.
|
|
521
|
+
this.#db.exec(`
|
|
522
|
+
CREATE TABLE IF NOT EXISTS agent_settings (
|
|
523
|
+
agent_id TEXT NOT NULL,
|
|
524
|
+
key TEXT NOT NULL,
|
|
525
|
+
value TEXT NOT NULL,
|
|
526
|
+
updated_at INTEGER NOT NULL,
|
|
527
|
+
PRIMARY KEY (agent_id, key)
|
|
528
|
+
)
|
|
490
529
|
`);
|
|
491
530
|
// Step 2: Detect interrupted sessions (SIGKILL detection — AC-010).
|
|
492
531
|
// Any 'active' row in a freshly-started daemon is a remnant of a prior
|
|
@@ -658,6 +697,16 @@ export class SessionNodeManager {
|
|
|
658
697
|
.run(this.#requireAgentId(agentName), sessionId, seq);
|
|
659
698
|
this.#logger.info("message.watermark.advanced", { agentName, sessionId, sequence: seq });
|
|
660
699
|
}
|
|
700
|
+
/**
|
|
701
|
+
* The ONE definition of "unread" in this daemon: a RECEIVED transcript row whose sequence is
|
|
702
|
+
* beyond the agent's persisted read watermark. A constant, not a copy-pasted string, so the
|
|
703
|
+
* INBOX unread count and the DOD-CURSOR-DURABLE-1 read-before-write gate can never drift into
|
|
704
|
+
* disagreeing about what "unread" means — the gate deciding one thing while the inbox shows
|
|
705
|
+
* another is precisely the bug this shape prevents. Interpolated SQL only (no user input).
|
|
706
|
+
*/
|
|
707
|
+
static #UNREAD_RECEIVED_WHERE = `
|
|
708
|
+
t.direction = 'received'
|
|
709
|
+
AND t.sequence > COALESCE(w.last_delivered_seq, -1)`;
|
|
661
710
|
/** INBOX-1 (N2): per-session unread summary for an agent — sessions that have RECEIVED transcript
|
|
662
711
|
* messages beyond the read watermark. Content-free (counts + ids + last seq, never message text);
|
|
663
712
|
* a COUNT/MAX query, no decrypt. Only sessions with unread_count > 0 are returned. */
|
|
@@ -672,14 +721,39 @@ export class SessionNodeManager {
|
|
|
672
721
|
LEFT JOIN message_watermarks w
|
|
673
722
|
ON w.agent_id = t.agent_id AND w.session_id = t.session_id
|
|
674
723
|
WHERE t.agent_id = ?
|
|
675
|
-
AND
|
|
676
|
-
AND t.sequence > COALESCE(w.last_delivered_seq, -1)
|
|
724
|
+
AND ${SessionNodeManager.#UNREAD_RECEIVED_WHERE}
|
|
677
725
|
GROUP BY t.session_id
|
|
678
726
|
HAVING unread_count > 0
|
|
679
727
|
ORDER BY t.session_id ASC`)
|
|
680
728
|
.all(this.#requireAgentId(agentName));
|
|
681
729
|
return rows;
|
|
682
730
|
}
|
|
731
|
+
/**
|
|
732
|
+
* DOD-CURSOR-DURABLE-1: how many RECEIVED messages in THIS session the agent has not read —
|
|
733
|
+
* the durable half of the read-before-write gate. Same predicate as getUnreadSummary (shared
|
|
734
|
+
* constant above), scoped to one session.
|
|
735
|
+
*
|
|
736
|
+
* This is DURABLE and PER-AGENT, where the send gate's other authority (the connection cursor) is
|
|
737
|
+
* in-memory and per-connection. It is what lets a stateless client — the `cello` CLI, one process
|
|
738
|
+
* per command — prove it has read the counterparty, which a dead socket's cursor never can.
|
|
739
|
+
*
|
|
740
|
+
* FAILS CLOSED: an uninitialized DB returns a positive count (treated as "unread"), never 0. A 0
|
|
741
|
+
* here unblocks a send; guessing 0 from a broken DB would silently defeat the gate.
|
|
742
|
+
*/
|
|
743
|
+
getUnreadReceivedCount(agentName, sessionId) {
|
|
744
|
+
if (!this.#db)
|
|
745
|
+
return 1; // fail closed — never unblock a send because the DB is unavailable
|
|
746
|
+
const row = this.#db
|
|
747
|
+
.prepare(`SELECT COUNT(*) AS unread_count
|
|
748
|
+
FROM transcript t
|
|
749
|
+
LEFT JOIN message_watermarks w
|
|
750
|
+
ON w.agent_id = t.agent_id AND w.session_id = t.session_id
|
|
751
|
+
WHERE t.agent_id = ?
|
|
752
|
+
AND t.session_id = ?
|
|
753
|
+
AND ${SessionNodeManager.#UNREAD_RECEIVED_WHERE}`)
|
|
754
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
755
|
+
return row ? row.unread_count : 0;
|
|
756
|
+
}
|
|
683
757
|
/** M8C-CONTACT-1: is this pubkey a known contact of this agent? */
|
|
684
758
|
isContact(agentName, pubkey) {
|
|
685
759
|
if (!this.#db)
|
|
@@ -687,12 +761,81 @@ export class SessionNodeManager {
|
|
|
687
761
|
const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), pubkey);
|
|
688
762
|
return row !== undefined;
|
|
689
763
|
}
|
|
764
|
+
/** DOD-TIER-1: the reachability tier for a counterparty of this agent. The RESULT is total — an
|
|
765
|
+
* absent contact row (undefined), a NULL `tier`, or a corrupt out-of-range value all resolve to
|
|
766
|
+
* UNKNOWN via `normalizeTier`, so the return is always in 0..4 and guards the JS `null >= 0`/`0 ||
|
|
767
|
+
* 1`/`grid[99]` traps. It is a SECURITY read (Step 2 gates inbound bounds on it), so it FAILS
|
|
768
|
+
* CLOSED, never open: an uninitialized DB throws (same contract as addContact) rather than
|
|
769
|
+
* silently returning UNKNOWN and admitting a BLOCKED sender; an unresolvable/retired agent name
|
|
770
|
+
* throws via #requireAgentId. Both are invariant violations a caller must surface, not swallow. */
|
|
771
|
+
getTier(agentName, pubkey) {
|
|
772
|
+
// Fail CLOSED: a read that decides whether to admit a sender must not degrade to "unclassified"
|
|
773
|
+
// when it cannot reach the ACL — that would admit a blocked contact. Throw as addContact does.
|
|
774
|
+
if (!this.#db)
|
|
775
|
+
throw new Error(`getTier('${agentName}'): database not initialized`);
|
|
776
|
+
const row = this.#db
|
|
777
|
+
.prepare("SELECT tier FROM contacts WHERE agent_id = ? AND pubkey = ?")
|
|
778
|
+
.get(this.#requireAgentId(agentName), pubkey);
|
|
779
|
+
if (row && row.tier !== null && !isKnownTierValue(row.tier)) {
|
|
780
|
+
// A stored tier outside 0..4 is corruption — surface it. normalizeTier still maps it to the
|
|
781
|
+
// tighter UNKNOWN so the caller is safe, but a silent map would hide a broken row.
|
|
782
|
+
this.#logger.warn("contact.tier.corrupt", { agentName, pubkey, storedTier: row.tier });
|
|
783
|
+
}
|
|
784
|
+
return normalizeTier(row?.tier);
|
|
785
|
+
}
|
|
786
|
+
/** DOD-TIER-4: the DISPLAY/relationship check — is this counterparty a genuine contact (KNOWN or
|
|
787
|
+
* above)? Replaces the old binary `isContact` for behaviour that keyed on "we have a relationship"
|
|
788
|
+
* (e.g. the away-response wording). An UNKNOWN-tier contact (a mere row) is NOT known. */
|
|
789
|
+
isKnown(agentName, pubkey) {
|
|
790
|
+
return this.getTier(agentName, pubkey) >= TIER.KNOWN;
|
|
791
|
+
}
|
|
792
|
+
/** DOD-TIER-4: the POLICY gate — may an inbound session from this counterparty be auto-accepted
|
|
793
|
+
* when the operator is unattended (WHITELISTED or VIP)? The behavioural consumer is the offline
|
|
794
|
+
* relay mailbox (LEAVEMSG-1), out of scope for this unit; defined here as the seam. Being merely
|
|
795
|
+
* KNOWN is NOT enough to auto-accept — whitelisting is the deliberate `cello_contact_set_tier` act. */
|
|
796
|
+
isAutoAccept(agentName, pubkey) {
|
|
797
|
+
return this.getTier(agentName, pubkey) >= TIER.WHITELISTED;
|
|
798
|
+
}
|
|
799
|
+
/** DOD-TIER-BOUNDS-SETTINGS: the effective bound for (agent, tier, field) — a per-agent SETTINGS
|
|
800
|
+
* override if one is set and valid, else the hardcoded grid default (DEFAULT_TIER_BOUNDS). With no
|
|
801
|
+
* settings this is byte-identical to Step 2 (the daemon runs on defaults alone). A stored value
|
|
802
|
+
* that is somehow non-positive/non-finite (should be impossible — validated at SET time) falls back
|
|
803
|
+
* to the grid default rather than removing the bound (INV-TIER-BOUND, defensive). BLOCKED is never
|
|
804
|
+
* settable — it always returns the fixed grid value (0). */
|
|
805
|
+
resolveTierBound(agentName, tier, field) {
|
|
806
|
+
const gridDefault = field === "max_sessions"
|
|
807
|
+
? tierBoundsFor(tier).maxSessionsPerSender
|
|
808
|
+
: tierBoundsFor(tier).maxBytesPerSession;
|
|
809
|
+
const name = settableTierName(tier);
|
|
810
|
+
if (name === null)
|
|
811
|
+
return gridDefault; // BLOCKED or out-of-range — fixed, not overridable
|
|
812
|
+
const raw = this.getSetting(agentName, boundSettingKey(name, field));
|
|
813
|
+
if (raw === null)
|
|
814
|
+
return gridDefault; // unset → default
|
|
815
|
+
const parsed = Number(raw);
|
|
816
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
817
|
+
// Should be impossible (validated at SET time) → a config-integrity failure. Surface it: this
|
|
818
|
+
// reverts a possibly-TIGHTENED bound to the looser default, so a silent revert would hide a real
|
|
819
|
+
// problem. Still fail SAFE (grid default, never unbounded — INV-TIER-BOUND).
|
|
820
|
+
this.#logger.warn("settings.bound.corrupt", { agentName, tier, field, raw });
|
|
821
|
+
return gridDefault;
|
|
822
|
+
}
|
|
823
|
+
return parsed;
|
|
824
|
+
}
|
|
690
825
|
/** M8C-CONTACT-1: pin a contact at add time — idempotent (re-adding an existing contact is a
|
|
691
826
|
* no-op, never refreshes added_at; identity does not get re-resolved). MONIKER-3 AC2: an
|
|
692
827
|
* optional pet name; a NEW non-null moniker on re-add updates it, absence leaves it untouched.
|
|
693
828
|
* THROWS on an invalid moniker — callers validate first; this is the can-never-be-stored
|
|
694
|
-
* backstop (same contract as DbIdentityStore.setMoniker).
|
|
695
|
-
|
|
829
|
+
* backstop (same contract as DbIdentityStore.setMoniker).
|
|
830
|
+
*
|
|
831
|
+
* DOD-TIER-1/4: a NEW row is stamped `tier` (never NULL) and an optional `provenance`
|
|
832
|
+
* ('accepted' | 'initiated' | null). The `tier` defaults to the least-privilege UNKNOWN floor —
|
|
833
|
+
* a caller GRANTS trust by passing a higher tier explicitly. Every production creation path is a
|
|
834
|
+
* deliberate operator action and passes KNOWN (initiate, engage/reply, explicit cello_contact_add
|
|
835
|
+
* — DEC-AB-1). INSERT OR IGNORE means an EXISTING contact is untouched — tier and provenance pin
|
|
836
|
+
* at first add, exactly as `added_at`/`moniker` already do; re-adding never downgrades a contact
|
|
837
|
+
* the operator has since promoted. Raising the tier later is `cello_contact_set_tier`'s job. */
|
|
838
|
+
addContact(agentName, pubkey, moniker, provenance, tier = TIER.UNKNOWN) {
|
|
696
839
|
if (!pubkey)
|
|
697
840
|
return;
|
|
698
841
|
// Review F1: a missing DB handle must FAIL the write loudly — returning silently here let
|
|
@@ -702,10 +845,17 @@ export class SessionNodeManager {
|
|
|
702
845
|
if (moniker !== undefined && moniker !== null && validateMoniker(moniker) === null) {
|
|
703
846
|
throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
|
|
704
847
|
}
|
|
848
|
+
// DOD-TIER-4 (review F3): the stored tier must be a known 0..4 constant — a can-never-be-stored
|
|
849
|
+
// backstop mirroring the moniker validation above. All callers pass a TIER constant; this catches
|
|
850
|
+
// a future caller (or a bad refactor) that would otherwise persist a corrupt tier the read side
|
|
851
|
+
// must then defensively normalize.
|
|
852
|
+
if (!isKnownTierValue(tier)) {
|
|
853
|
+
throw new Error(`invalid contact tier for agent '${agentName}': ${tier} (must be 0..4)`);
|
|
854
|
+
}
|
|
705
855
|
const agentId = this.#requireAgentId(agentName);
|
|
706
856
|
this.#db
|
|
707
|
-
.prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at) VALUES (?, ?, ?)")
|
|
708
|
-
.run(agentId, pubkey, Date.now());
|
|
857
|
+
.prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at, tier, provenance) VALUES (?, ?, ?, ?, ?)")
|
|
858
|
+
.run(agentId, pubkey, Date.now(), tier, provenance ?? null);
|
|
709
859
|
if (moniker !== undefined && moniker !== null) {
|
|
710
860
|
this.#db
|
|
711
861
|
.prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
|
|
@@ -726,13 +876,128 @@ export class SessionNodeManager {
|
|
|
726
876
|
const res = this.#db
|
|
727
877
|
.prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
|
|
728
878
|
.run(moniker, this.#requireAgentId(agentName), pubkey);
|
|
879
|
+
// DOD-RENAME-1: setting the local pet name IS the operator acting on a rename — resolve any
|
|
880
|
+
// pending notice for this contact (whether they adopted the offered name or chose their own).
|
|
881
|
+
if (res.changes > 0)
|
|
882
|
+
this.clearRenameNotice(agentName, pubkey);
|
|
883
|
+
return res.changes > 0;
|
|
884
|
+
}
|
|
885
|
+
/** DOD-AWAY-TIER-1: set (or clear, with null) a contact's per-contact away message. Returns false
|
|
886
|
+
* when no such contact — fail-loud at the caller (same contract as setContactMoniker/setContactTier). */
|
|
887
|
+
setContactAwayMessage(agentName, pubkey, message) {
|
|
888
|
+
if (!this.#db)
|
|
889
|
+
throw new Error(`setContactAwayMessage('${agentName}'): database not initialized`);
|
|
890
|
+
const res = this.#db
|
|
891
|
+
.prepare("UPDATE contacts SET away_message = ? WHERE agent_id = ? AND pubkey = ?")
|
|
892
|
+
.run(message, this.#requireAgentId(agentName), pubkey);
|
|
893
|
+
return res.changes > 0;
|
|
894
|
+
}
|
|
895
|
+
/** DOD-AWAY-TIER-1: resolve the most-specific CUSTOM away text for a counterparty, most-specific
|
|
896
|
+
* first: per-contact `away_message` → per-tier away setting → agent default away setting. Returns
|
|
897
|
+
* null when none is configured, so the CALLER applies the system default (code) — making the full
|
|
898
|
+
* four-level resolution TOTAL. A pure read; the resolved text is screened on the outbound path by
|
|
899
|
+
* the caller like any content (SI — it does not bypass the gateway). */
|
|
900
|
+
resolveAwayMessage(agentName, pubkey) {
|
|
901
|
+
if (!this.#db)
|
|
902
|
+
return null;
|
|
903
|
+
const agentId = this.#requireAgentId(agentName);
|
|
904
|
+
const row = this.#db
|
|
905
|
+
.prepare("SELECT away_message FROM contacts WHERE agent_id = ? AND pubkey = ?")
|
|
906
|
+
.get(agentId, pubkey);
|
|
907
|
+
if (row?.away_message != null) {
|
|
908
|
+
this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: "contact" }); // obs AC
|
|
909
|
+
return row.away_message; // 1. per-contact
|
|
910
|
+
}
|
|
911
|
+
const tierName = settableTierName(this.getTier(agentName, pubkey));
|
|
912
|
+
if (tierName !== null) {
|
|
913
|
+
const tierAway = this.getSetting(agentName, awayTierSettingKey(tierName));
|
|
914
|
+
if (tierAway !== null) {
|
|
915
|
+
this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: "tier" });
|
|
916
|
+
return tierAway; // 2. per-tier
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const agentDefault = this.getSetting(agentName, AWAY_DEFAULT_KEY);
|
|
920
|
+
// 3. agent default, else null → caller applies the system default (code). Level logged HERE.
|
|
921
|
+
this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: agentDefault !== null ? "agent_default" : "system" });
|
|
922
|
+
return agentDefault;
|
|
923
|
+
}
|
|
924
|
+
/** DOD-CONTACT-VIEW-1: set an EXISTING contact's reachability tier. Returns false when no such
|
|
925
|
+
* contact — fail-loud at the caller, never a silent no-op success (same contract as
|
|
926
|
+
* setContactMoniker). The caller validates the tier is a known constant BEFORE calling; this
|
|
927
|
+
* stores whatever it is handed (the handler is the validation boundary). */
|
|
928
|
+
setContactTier(agentName, pubkey, tier) {
|
|
929
|
+
if (!this.#db)
|
|
930
|
+
throw new Error(`setContactTier('${agentName}'): database not initialized`);
|
|
931
|
+
const res = this.#db
|
|
932
|
+
.prepare("UPDATE contacts SET tier = ? WHERE agent_id = ? AND pubkey = ?")
|
|
933
|
+
.run(tier, this.#requireAgentId(agentName), pubkey);
|
|
729
934
|
return res.changes > 0;
|
|
730
935
|
}
|
|
936
|
+
/** DOD-RENAME-1 (Option C): record a self-declared name a peer offered, at the moment the offer is
|
|
937
|
+
* SEEN. The stored local pet name (contacts.moniker) is SACROSANCT — this only ever touches
|
|
938
|
+
* last_offered_moniker and the notice queue, never the moniker (AC2). A rename NOTICE is queued
|
|
939
|
+
* only when the peer is a contact the operator has PERSONALLY NAMED (moniker non-null), a name was
|
|
940
|
+
* seen BEFORE (last_offered_moniker non-null), and the new offer DIFFERS (AC3). The first-ever
|
|
941
|
+
* offer just records the baseline (no notice); a repeat of the same name is idempotent (AC4).
|
|
942
|
+
* Called only when a moniker WAS offered (caller-guarded), so silence never clears the baseline
|
|
943
|
+
* (AC5). Limitation: last_offered_moniker updates only on the RECEIVING side of an offer, so rename
|
|
944
|
+
* detection works only for peers who INITIATE to you — a property, not a bug. */
|
|
945
|
+
recordOfferedMoniker(agentName, pubkey, offered) {
|
|
946
|
+
// Fail CLOSED like getTier/setContactTier: a silent skip here would drop a rename baseline update
|
|
947
|
+
// (and any notice) while the daemon reports healthy — the inbound path always has an open DB.
|
|
948
|
+
if (!this.#db)
|
|
949
|
+
throw new Error(`recordOfferedMoniker('${agentName}'): database not initialized`);
|
|
950
|
+
const agentId = this.#requireAgentId(agentName);
|
|
951
|
+
const row = this.#db
|
|
952
|
+
.prepare("SELECT last_offered_moniker, moniker FROM contacts WHERE agent_id = ? AND pubkey = ?")
|
|
953
|
+
.get(agentId, pubkey);
|
|
954
|
+
if (!row)
|
|
955
|
+
return; // not a contact — no row to hold a baseline or a notice
|
|
956
|
+
if (offered === row.last_offered_moniker)
|
|
957
|
+
return; // idempotent — same name already seen (AC4)
|
|
958
|
+
// A genuine change from a previously-seen name, for a contact the operator has named → notice.
|
|
959
|
+
if (row.last_offered_moniker !== null && row.moniker !== null) {
|
|
960
|
+
this.#db
|
|
961
|
+
.prepare("INSERT OR REPLACE INTO contact_rename_notices (agent_id, pubkey, offered_name, noticed_at) VALUES (?, ?, ?, ?)")
|
|
962
|
+
.run(agentId, pubkey, offered, Date.now());
|
|
963
|
+
// Observability: log the FACT, never the attacker-chosen name (same rule as moniker.rejected).
|
|
964
|
+
this.#logger.info("contact.rename.noticed", { agentName, pubkey });
|
|
965
|
+
}
|
|
966
|
+
this.#db
|
|
967
|
+
.prepare("UPDATE contacts SET last_offered_moniker = ? WHERE agent_id = ? AND pubkey = ?")
|
|
968
|
+
.run(offered, agentId, pubkey);
|
|
969
|
+
}
|
|
970
|
+
/** DOD-RENAME-1: pending rename notices for an agent, oldest first (surfaced in
|
|
971
|
+
* cello_check_notifications — an INBOX pull, never a real-time push). */
|
|
972
|
+
getRenameNotices(agentName) {
|
|
973
|
+
if (!this.#db)
|
|
974
|
+
return [];
|
|
975
|
+
// JOIN the local pet name so the notice can NAME the contact (AC3) — a notice only ever fires for
|
|
976
|
+
// a personally-named contact, so moniker is expected non-null (LEFT JOIN is defensive).
|
|
977
|
+
return this.#db
|
|
978
|
+
.prepare(`SELECT n.pubkey, n.offered_name, n.noticed_at, c.moniker
|
|
979
|
+
FROM contact_rename_notices n
|
|
980
|
+
LEFT JOIN contacts c ON c.agent_id = n.agent_id AND c.pubkey = n.pubkey
|
|
981
|
+
WHERE n.agent_id = ? ORDER BY n.noticed_at ASC`)
|
|
982
|
+
.all(this.#requireAgentId(agentName));
|
|
983
|
+
}
|
|
984
|
+
/** DOD-RENAME-1: clear a pending rename notice — the operator acted (adopted a name or removed the
|
|
985
|
+
* contact). Idempotent (no notice → no-op). Fail-closed on a missing DB, like the writes above. */
|
|
986
|
+
clearRenameNotice(agentName, pubkey) {
|
|
987
|
+
if (!this.#db)
|
|
988
|
+
throw new Error(`clearRenameNotice('${agentName}'): database not initialized`);
|
|
989
|
+
this.#db
|
|
990
|
+
.prepare("DELETE FROM contact_rename_notices WHERE agent_id = ? AND pubkey = ?")
|
|
991
|
+
.run(this.#requireAgentId(agentName), pubkey);
|
|
992
|
+
}
|
|
731
993
|
/** M8C-CONTACT-1: known stays known until explicitly removed. */
|
|
732
994
|
removeContact(agentName, pubkey) {
|
|
733
995
|
if (!this.#db)
|
|
734
996
|
return false;
|
|
735
997
|
const res = this.#db.prepare("DELETE FROM contacts WHERE agent_id = ? AND pubkey = ?").run(this.#requireAgentId(agentName), pubkey);
|
|
998
|
+
// DOD-RENAME-1: a removed contact has no pending rename to resolve.
|
|
999
|
+
if (res.changes > 0)
|
|
1000
|
+
this.clearRenameNotice(agentName, pubkey);
|
|
736
1001
|
return res.changes > 0;
|
|
737
1002
|
}
|
|
738
1003
|
/** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
|
|
@@ -749,13 +1014,23 @@ export class SessionNodeManager {
|
|
|
749
1014
|
.get(this.#requireAgentId(agentName), pubkey);
|
|
750
1015
|
return row?.moniker ?? null;
|
|
751
1016
|
}
|
|
752
|
-
/** M8C-CONTACT-1: list an agent's
|
|
753
|
-
*
|
|
1017
|
+
/** M8C-CONTACT-1 + DOD-CONTACT-VIEW-1: list an agent's contacts, oldest-added first, each with its
|
|
1018
|
+
* pet name (MONIKER-3), tier + provenance (the address-book metadata), and a READ-side LEFT JOIN
|
|
1019
|
+
* against `sessions` for how many SEALED sessions were shared and when they last spoke (MAX
|
|
1020
|
+
* updated_at). No new stored data — a pure read. A contact with no sessions shows 0 / null (never),
|
|
1021
|
+
* not an error. The JOIN is scoped by agent_id so one agent's sessions never bleed into another's. */
|
|
754
1022
|
listContacts(agentName) {
|
|
755
1023
|
if (!this.#db)
|
|
756
1024
|
return [];
|
|
757
1025
|
return this.#db
|
|
758
|
-
.prepare(
|
|
1026
|
+
.prepare(`SELECT c.pubkey, c.added_at, c.moniker, c.tier, c.provenance,
|
|
1027
|
+
COUNT(CASE WHEN s.status = 'sealed' THEN 1 END) AS sealed_count,
|
|
1028
|
+
MAX(s.updated_at) AS last_spoke
|
|
1029
|
+
FROM contacts c
|
|
1030
|
+
LEFT JOIN sessions s ON s.agent_id = c.agent_id AND s.counterparty_pubkey = c.pubkey
|
|
1031
|
+
WHERE c.agent_id = ?
|
|
1032
|
+
GROUP BY c.pubkey, c.added_at, c.moniker, c.tier, c.provenance
|
|
1033
|
+
ORDER BY c.added_at ASC`)
|
|
759
1034
|
.all(this.#requireAgentId(agentName));
|
|
760
1035
|
}
|
|
761
1036
|
/** M8C-ABUSE-1: cumulative RECEIVED byte total for a session (anti-drip-feed accounting). */
|
|
@@ -795,8 +1070,12 @@ export class SessionNodeManager {
|
|
|
795
1070
|
.get(this.#requireAgentId(agentName), counterpartyPubkey);
|
|
796
1071
|
return row.n;
|
|
797
1072
|
}
|
|
798
|
-
/** M8C-ABUSE-1 (anti-swarm): non-terminal sessions this agent holds with
|
|
799
|
-
*
|
|
1073
|
+
/** M8C-ABUSE-1 (anti-swarm) + DOD-TIER-2: non-terminal sessions this agent holds with UNKNOWN-tier
|
|
1074
|
+
* counterparties — the global cap counts across the whole stranger pool. A sender is exempt from
|
|
1075
|
+
* THIS pool iff it is a KNOWN+ contact (tier >= KNOWN); a bare stranger (no row → UNKNOWN) or an
|
|
1076
|
+
* explicitly UNKNOWN-tier contact both count. Keying on `tier >= KNOWN` (bounded to <= VIP so a
|
|
1077
|
+
* corrupt high value cannot grant pool-exemption) replaces the old row-existence proxy, which
|
|
1078
|
+
* would have let a merely-recorded UNKNOWN contact escape the anti-swarm cap. Same
|
|
800
1079
|
* 'interrupted'-status fix as countActiveSessionsForCounterparty above. */
|
|
801
1080
|
countActiveSessionsFromUnknownSenders(agentName) {
|
|
802
1081
|
if (!this.#db)
|
|
@@ -804,23 +1083,36 @@ export class SessionNodeManager {
|
|
|
804
1083
|
const row = this.#db
|
|
805
1084
|
.prepare(`SELECT COUNT(*) AS n FROM sessions s
|
|
806
1085
|
WHERE s.agent_id = ? AND s.status IN ('active', 'interrupted')
|
|
807
|
-
AND NOT EXISTS (
|
|
1086
|
+
AND NOT EXISTS (
|
|
1087
|
+
SELECT 1 FROM contacts c
|
|
1088
|
+
WHERE c.agent_id = s.agent_id AND c.pubkey = s.counterparty_pubkey
|
|
1089
|
+
AND c.tier >= ${TIER.KNOWN} AND c.tier <= ${TIER.VIP}
|
|
1090
|
+
)`)
|
|
808
1091
|
.get(this.#requireAgentId(agentName));
|
|
809
1092
|
return row.n;
|
|
810
1093
|
}
|
|
811
|
-
/** M8C-ABUSE-1: is a NEW inbound session from this counterparty within the
|
|
812
|
-
*
|
|
813
|
-
*
|
|
1094
|
+
/** M8C-ABUSE-1 + DOD-TIER-2/3: is a NEW inbound session from this counterparty within the
|
|
1095
|
+
* acceptance bounds? The per-sender cap is now the sender's TIER cap (DEFAULT_TIER_BOUNDS), not a
|
|
1096
|
+
* flat "3 for strangers, unbounded for contacts". This is where DOD-TIER-3 falls out for free: a
|
|
1097
|
+
* BLOCKED sender's cap is 0, so `perSender (>= 0) >= 0` refuses it through the SAME reason and the
|
|
1098
|
+
* SAME path an over-cap UNKNOWN takes — no separate blocked branch, no distinguishing oracle. The
|
|
1099
|
+
* global anti-swarm cap then applies ONLY to UNKNOWN-tier senders (KNOWN+ are trusted, not part of
|
|
1100
|
+
* the stranger pool; BLOCKED never reaches it). Checked BEFORE accepting a fresh inbound session
|
|
1101
|
+
* (counts reflect sessions already active, not yet counting this one). */
|
|
814
1102
|
checkUnknownSenderAcceptanceBound(agentName, counterpartyPubkey) {
|
|
815
|
-
|
|
816
|
-
|
|
1103
|
+
const tier = this.getTier(agentName, counterpartyPubkey);
|
|
1104
|
+
const perSenderCap = this.resolveTierBound(agentName, tier, "max_sessions");
|
|
817
1105
|
const perSender = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
|
|
818
|
-
if (perSender >=
|
|
1106
|
+
if (perSender >= perSenderCap) {
|
|
819
1107
|
return { ok: false, reason: "abuse_bound_sessions_per_sender" };
|
|
820
1108
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
1109
|
+
// The global stranger cap is only for the UNKNOWN pool. A KNOWN+ sender is past it by trust;
|
|
1110
|
+
// a BLOCKED sender was already refused above (cap 0).
|
|
1111
|
+
if (tier === TIER.UNKNOWN) {
|
|
1112
|
+
const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
|
|
1113
|
+
if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
|
|
1114
|
+
return { ok: false, reason: "abuse_bound_unknown_sessions_global" };
|
|
1115
|
+
}
|
|
824
1116
|
}
|
|
825
1117
|
return { ok: true };
|
|
826
1118
|
}
|
|
@@ -842,6 +1134,42 @@ export class SessionNodeManager {
|
|
|
842
1134
|
ON CONFLICT(id) DO UPDATE SET bot_token = excluded.bot_token, allowlisted_chat_id = excluded.allowlisted_chat_id, updated_at = excluded.updated_at`)
|
|
843
1135
|
.run(botToken, allowlistedChatId, Date.now());
|
|
844
1136
|
}
|
|
1137
|
+
/** DOD-SETTINGS-1: read a per-agent setting, or null if unset. The get-with-default is the CALLER's
|
|
1138
|
+
* job (an unset key falls back to the hardcoded grid/system default — the daemon runs correctly on
|
|
1139
|
+
* defaults alone, AC3). Returns null on a missing DB (settings are always optional). */
|
|
1140
|
+
getSetting(agentName, key) {
|
|
1141
|
+
if (!this.#db)
|
|
1142
|
+
return null;
|
|
1143
|
+
const row = this.#db
|
|
1144
|
+
.prepare("SELECT value FROM agent_settings WHERE agent_id = ? AND key = ?")
|
|
1145
|
+
.get(this.#requireAgentId(agentName), key);
|
|
1146
|
+
return row?.value ?? null;
|
|
1147
|
+
}
|
|
1148
|
+
/** DOD-SETTINGS-1: write a per-agent setting (upsert). Key VALIDATION is the handler's boundary
|
|
1149
|
+
* (isValidSettingKey); value validation for typed settings (finite bounds, etc.) belongs to the
|
|
1150
|
+
* specific consumer. Throws on a missing DB — a write that silently no-ops would be a lie. */
|
|
1151
|
+
setSetting(agentName, key, value) {
|
|
1152
|
+
if (!this.#db)
|
|
1153
|
+
throw new Error(`setSetting('${agentName}'): database not initialized`);
|
|
1154
|
+
// Store-level backstop (review F2): the handler validates the key, but the dual-layer convention
|
|
1155
|
+
// (cf. MONIKER-1) means an unknown key can NEVER be stored — an internal caller that hand-typed a
|
|
1156
|
+
// key instead of using the builders would otherwise persist a setting that never takes effect.
|
|
1157
|
+
if (!isValidSettingKey(key))
|
|
1158
|
+
throw new Error(`invalid_key: '${key}' is not a known setting`);
|
|
1159
|
+
this.#db
|
|
1160
|
+
.prepare(`INSERT INTO agent_settings (agent_id, key, value, updated_at) VALUES (?, ?, ?, ?)
|
|
1161
|
+
ON CONFLICT(agent_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
|
1162
|
+
.run(this.#requireAgentId(agentName), key, value, Date.now());
|
|
1163
|
+
}
|
|
1164
|
+
/** DOD-SETTINGS-1: all explicitly-set settings for an agent (the ones that OVERRIDE a default),
|
|
1165
|
+
* key-sorted. Unset keys are absent — the operator sees only what they changed. */
|
|
1166
|
+
getAllSettings(agentName) {
|
|
1167
|
+
if (!this.#db)
|
|
1168
|
+
return [];
|
|
1169
|
+
return this.#db
|
|
1170
|
+
.prepare("SELECT key, value FROM agent_settings WHERE agent_id = ? ORDER BY key ASC")
|
|
1171
|
+
.all(this.#requireAgentId(agentName));
|
|
1172
|
+
}
|
|
845
1173
|
/** DOD-LOOP-1: whether the given agent has a standing receiver ready (any agent if omitted). */
|
|
846
1174
|
getStandingReceiverReady(agentName) {
|
|
847
1175
|
if (agentName !== undefined)
|
|
@@ -2576,10 +2904,15 @@ export class SessionNodeManager {
|
|
|
2576
2904
|
// seam below (cheap + synchronous — fail fast on volume before spending gateway compute on
|
|
2577
2905
|
// content headed for rejection anyway); both gates are independent and either rejects on its
|
|
2578
2906
|
// own criteria, so ordering between them does not change correctness.
|
|
2579
|
-
|
|
2907
|
+
{
|
|
2908
|
+
// DOD-TIER-2 AC2: the per-session byte cap is the sender's TIER cap (DEFAULT_TIER_BOUNDS),
|
|
2909
|
+
// applied to EVERY sender — no tier is unbounded (INV-TIER-BOUND), so a contact is no longer
|
|
2910
|
+
// "exempt entirely". A stranger (no row → UNKNOWN) keeps the 25 MB cap; KNOWN+ get more.
|
|
2911
|
+
const senderTier = this.getTier(agentName, senderPubkey);
|
|
2912
|
+
const cap = this.resolveTierBound(agentName, senderTier, "max_bytes");
|
|
2580
2913
|
const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
|
|
2581
2914
|
const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
|
|
2582
|
-
if (priorTotal + heldTotal + content.length >
|
|
2915
|
+
if (priorTotal + heldTotal + content.length > cap) {
|
|
2583
2916
|
this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
|
|
2584
2917
|
sessionId,
|
|
2585
2918
|
agentName,
|
|
@@ -2587,7 +2920,8 @@ export class SessionNodeManager {
|
|
|
2587
2920
|
priorTotal,
|
|
2588
2921
|
heldTotal,
|
|
2589
2922
|
incoming: content.length,
|
|
2590
|
-
cap
|
|
2923
|
+
cap,
|
|
2924
|
+
tier: senderTier,
|
|
2591
2925
|
correlationId,
|
|
2592
2926
|
});
|
|
2593
2927
|
return { ok: false, reason: "session_size_limit_exceeded" };
|
|
@@ -2692,10 +3026,16 @@ export class SessionNodeManager {
|
|
|
2692
3026
|
// the append/hold branch is synchronous, so whichever call resumes first appends/holds before
|
|
2693
3027
|
// the second's re-check runs, and the second's freshly-recomputed totals correctly include the
|
|
2694
3028
|
// first's contribution.
|
|
2695
|
-
|
|
3029
|
+
{
|
|
3030
|
+
// DOD-TIER-2 AC2 (re-check): the SAME tier cap as the primary gate above, recomputed in this
|
|
3031
|
+
// synchronous window (the totals can go stale across the screenInbound await). Applied to EVERY
|
|
3032
|
+
// sender — a contact is no longer exempt (INV-TIER-BOUND). Must mirror the primary gate exactly
|
|
3033
|
+
// so a sender can never pass one and fail the other.
|
|
3034
|
+
const senderTier = this.getTier(agentName, senderPubkey);
|
|
3035
|
+
const cap = this.resolveTierBound(agentName, senderTier, "max_bytes");
|
|
2696
3036
|
const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
|
|
2697
3037
|
const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
|
|
2698
|
-
if (priorTotal + heldTotal + content.length >
|
|
3038
|
+
if (priorTotal + heldTotal + content.length > cap) {
|
|
2699
3039
|
this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
|
|
2700
3040
|
sessionId,
|
|
2701
3041
|
agentName,
|
|
@@ -2703,7 +3043,8 @@ export class SessionNodeManager {
|
|
|
2703
3043
|
priorTotal,
|
|
2704
3044
|
heldTotal,
|
|
2705
3045
|
incoming: content.length,
|
|
2706
|
-
cap
|
|
3046
|
+
cap,
|
|
3047
|
+
tier: senderTier,
|
|
2707
3048
|
correlationId,
|
|
2708
3049
|
recheck: true,
|
|
2709
3050
|
});
|