@cello-protocol/daemon 0.0.45 → 0.0.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +169 -14
- package/dist/daemon.js.map +1 -1
- package/dist/session-node-manager.d.ts +113 -14
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +340 -34
- package/dist/session-node-manager.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
@@ -687,12 +726,81 @@ export class SessionNodeManager {
|
|
|
687
726
|
const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), pubkey);
|
|
688
727
|
return row !== undefined;
|
|
689
728
|
}
|
|
729
|
+
/** DOD-TIER-1: the reachability tier for a counterparty of this agent. The RESULT is total — an
|
|
730
|
+
* absent contact row (undefined), a NULL `tier`, or a corrupt out-of-range value all resolve to
|
|
731
|
+
* UNKNOWN via `normalizeTier`, so the return is always in 0..4 and guards the JS `null >= 0`/`0 ||
|
|
732
|
+
* 1`/`grid[99]` traps. It is a SECURITY read (Step 2 gates inbound bounds on it), so it FAILS
|
|
733
|
+
* CLOSED, never open: an uninitialized DB throws (same contract as addContact) rather than
|
|
734
|
+
* silently returning UNKNOWN and admitting a BLOCKED sender; an unresolvable/retired agent name
|
|
735
|
+
* throws via #requireAgentId. Both are invariant violations a caller must surface, not swallow. */
|
|
736
|
+
getTier(agentName, pubkey) {
|
|
737
|
+
// Fail CLOSED: a read that decides whether to admit a sender must not degrade to "unclassified"
|
|
738
|
+
// when it cannot reach the ACL — that would admit a blocked contact. Throw as addContact does.
|
|
739
|
+
if (!this.#db)
|
|
740
|
+
throw new Error(`getTier('${agentName}'): database not initialized`);
|
|
741
|
+
const row = this.#db
|
|
742
|
+
.prepare("SELECT tier FROM contacts WHERE agent_id = ? AND pubkey = ?")
|
|
743
|
+
.get(this.#requireAgentId(agentName), pubkey);
|
|
744
|
+
if (row && row.tier !== null && !isKnownTierValue(row.tier)) {
|
|
745
|
+
// A stored tier outside 0..4 is corruption — surface it. normalizeTier still maps it to the
|
|
746
|
+
// tighter UNKNOWN so the caller is safe, but a silent map would hide a broken row.
|
|
747
|
+
this.#logger.warn("contact.tier.corrupt", { agentName, pubkey, storedTier: row.tier });
|
|
748
|
+
}
|
|
749
|
+
return normalizeTier(row?.tier);
|
|
750
|
+
}
|
|
751
|
+
/** DOD-TIER-4: the DISPLAY/relationship check — is this counterparty a genuine contact (KNOWN or
|
|
752
|
+
* above)? Replaces the old binary `isContact` for behaviour that keyed on "we have a relationship"
|
|
753
|
+
* (e.g. the away-response wording). An UNKNOWN-tier contact (a mere row) is NOT known. */
|
|
754
|
+
isKnown(agentName, pubkey) {
|
|
755
|
+
return this.getTier(agentName, pubkey) >= TIER.KNOWN;
|
|
756
|
+
}
|
|
757
|
+
/** DOD-TIER-4: the POLICY gate — may an inbound session from this counterparty be auto-accepted
|
|
758
|
+
* when the operator is unattended (WHITELISTED or VIP)? The behavioural consumer is the offline
|
|
759
|
+
* relay mailbox (LEAVEMSG-1), out of scope for this unit; defined here as the seam. Being merely
|
|
760
|
+
* KNOWN is NOT enough to auto-accept — whitelisting is the deliberate `cello_contact_set_tier` act. */
|
|
761
|
+
isAutoAccept(agentName, pubkey) {
|
|
762
|
+
return this.getTier(agentName, pubkey) >= TIER.WHITELISTED;
|
|
763
|
+
}
|
|
764
|
+
/** DOD-TIER-BOUNDS-SETTINGS: the effective bound for (agent, tier, field) — a per-agent SETTINGS
|
|
765
|
+
* override if one is set and valid, else the hardcoded grid default (DEFAULT_TIER_BOUNDS). With no
|
|
766
|
+
* settings this is byte-identical to Step 2 (the daemon runs on defaults alone). A stored value
|
|
767
|
+
* that is somehow non-positive/non-finite (should be impossible — validated at SET time) falls back
|
|
768
|
+
* to the grid default rather than removing the bound (INV-TIER-BOUND, defensive). BLOCKED is never
|
|
769
|
+
* settable — it always returns the fixed grid value (0). */
|
|
770
|
+
resolveTierBound(agentName, tier, field) {
|
|
771
|
+
const gridDefault = field === "max_sessions"
|
|
772
|
+
? tierBoundsFor(tier).maxSessionsPerSender
|
|
773
|
+
: tierBoundsFor(tier).maxBytesPerSession;
|
|
774
|
+
const name = settableTierName(tier);
|
|
775
|
+
if (name === null)
|
|
776
|
+
return gridDefault; // BLOCKED or out-of-range — fixed, not overridable
|
|
777
|
+
const raw = this.getSetting(agentName, boundSettingKey(name, field));
|
|
778
|
+
if (raw === null)
|
|
779
|
+
return gridDefault; // unset → default
|
|
780
|
+
const parsed = Number(raw);
|
|
781
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
782
|
+
// Should be impossible (validated at SET time) → a config-integrity failure. Surface it: this
|
|
783
|
+
// reverts a possibly-TIGHTENED bound to the looser default, so a silent revert would hide a real
|
|
784
|
+
// problem. Still fail SAFE (grid default, never unbounded — INV-TIER-BOUND).
|
|
785
|
+
this.#logger.warn("settings.bound.corrupt", { agentName, tier, field, raw });
|
|
786
|
+
return gridDefault;
|
|
787
|
+
}
|
|
788
|
+
return parsed;
|
|
789
|
+
}
|
|
690
790
|
/** M8C-CONTACT-1: pin a contact at add time — idempotent (re-adding an existing contact is a
|
|
691
791
|
* no-op, never refreshes added_at; identity does not get re-resolved). MONIKER-3 AC2: an
|
|
692
792
|
* optional pet name; a NEW non-null moniker on re-add updates it, absence leaves it untouched.
|
|
693
793
|
* THROWS on an invalid moniker — callers validate first; this is the can-never-be-stored
|
|
694
|
-
* backstop (same contract as DbIdentityStore.setMoniker).
|
|
695
|
-
|
|
794
|
+
* backstop (same contract as DbIdentityStore.setMoniker).
|
|
795
|
+
*
|
|
796
|
+
* DOD-TIER-1/4: a NEW row is stamped `tier` (never NULL) and an optional `provenance`
|
|
797
|
+
* ('accepted' | 'initiated' | null). The `tier` defaults to the least-privilege UNKNOWN floor —
|
|
798
|
+
* a caller GRANTS trust by passing a higher tier explicitly. Every production creation path is a
|
|
799
|
+
* deliberate operator action and passes KNOWN (initiate, engage/reply, explicit cello_contact_add
|
|
800
|
+
* — DEC-AB-1). INSERT OR IGNORE means an EXISTING contact is untouched — tier and provenance pin
|
|
801
|
+
* at first add, exactly as `added_at`/`moniker` already do; re-adding never downgrades a contact
|
|
802
|
+
* the operator has since promoted. Raising the tier later is `cello_contact_set_tier`'s job. */
|
|
803
|
+
addContact(agentName, pubkey, moniker, provenance, tier = TIER.UNKNOWN) {
|
|
696
804
|
if (!pubkey)
|
|
697
805
|
return;
|
|
698
806
|
// Review F1: a missing DB handle must FAIL the write loudly — returning silently here let
|
|
@@ -702,10 +810,17 @@ export class SessionNodeManager {
|
|
|
702
810
|
if (moniker !== undefined && moniker !== null && validateMoniker(moniker) === null) {
|
|
703
811
|
throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
|
|
704
812
|
}
|
|
813
|
+
// DOD-TIER-4 (review F3): the stored tier must be a known 0..4 constant — a can-never-be-stored
|
|
814
|
+
// backstop mirroring the moniker validation above. All callers pass a TIER constant; this catches
|
|
815
|
+
// a future caller (or a bad refactor) that would otherwise persist a corrupt tier the read side
|
|
816
|
+
// must then defensively normalize.
|
|
817
|
+
if (!isKnownTierValue(tier)) {
|
|
818
|
+
throw new Error(`invalid contact tier for agent '${agentName}': ${tier} (must be 0..4)`);
|
|
819
|
+
}
|
|
705
820
|
const agentId = this.#requireAgentId(agentName);
|
|
706
821
|
this.#db
|
|
707
|
-
.prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at) VALUES (?, ?, ?)")
|
|
708
|
-
.run(agentId, pubkey, Date.now());
|
|
822
|
+
.prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at, tier, provenance) VALUES (?, ?, ?, ?, ?)")
|
|
823
|
+
.run(agentId, pubkey, Date.now(), tier, provenance ?? null);
|
|
709
824
|
if (moniker !== undefined && moniker !== null) {
|
|
710
825
|
this.#db
|
|
711
826
|
.prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
|
|
@@ -726,13 +841,128 @@ export class SessionNodeManager {
|
|
|
726
841
|
const res = this.#db
|
|
727
842
|
.prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
|
|
728
843
|
.run(moniker, this.#requireAgentId(agentName), pubkey);
|
|
844
|
+
// DOD-RENAME-1: setting the local pet name IS the operator acting on a rename — resolve any
|
|
845
|
+
// pending notice for this contact (whether they adopted the offered name or chose their own).
|
|
846
|
+
if (res.changes > 0)
|
|
847
|
+
this.clearRenameNotice(agentName, pubkey);
|
|
848
|
+
return res.changes > 0;
|
|
849
|
+
}
|
|
850
|
+
/** DOD-AWAY-TIER-1: set (or clear, with null) a contact's per-contact away message. Returns false
|
|
851
|
+
* when no such contact — fail-loud at the caller (same contract as setContactMoniker/setContactTier). */
|
|
852
|
+
setContactAwayMessage(agentName, pubkey, message) {
|
|
853
|
+
if (!this.#db)
|
|
854
|
+
throw new Error(`setContactAwayMessage('${agentName}'): database not initialized`);
|
|
855
|
+
const res = this.#db
|
|
856
|
+
.prepare("UPDATE contacts SET away_message = ? WHERE agent_id = ? AND pubkey = ?")
|
|
857
|
+
.run(message, this.#requireAgentId(agentName), pubkey);
|
|
858
|
+
return res.changes > 0;
|
|
859
|
+
}
|
|
860
|
+
/** DOD-AWAY-TIER-1: resolve the most-specific CUSTOM away text for a counterparty, most-specific
|
|
861
|
+
* first: per-contact `away_message` → per-tier away setting → agent default away setting. Returns
|
|
862
|
+
* null when none is configured, so the CALLER applies the system default (code) — making the full
|
|
863
|
+
* four-level resolution TOTAL. A pure read; the resolved text is screened on the outbound path by
|
|
864
|
+
* the caller like any content (SI — it does not bypass the gateway). */
|
|
865
|
+
resolveAwayMessage(agentName, pubkey) {
|
|
866
|
+
if (!this.#db)
|
|
867
|
+
return null;
|
|
868
|
+
const agentId = this.#requireAgentId(agentName);
|
|
869
|
+
const row = this.#db
|
|
870
|
+
.prepare("SELECT away_message FROM contacts WHERE agent_id = ? AND pubkey = ?")
|
|
871
|
+
.get(agentId, pubkey);
|
|
872
|
+
if (row?.away_message != null) {
|
|
873
|
+
this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: "contact" }); // obs AC
|
|
874
|
+
return row.away_message; // 1. per-contact
|
|
875
|
+
}
|
|
876
|
+
const tierName = settableTierName(this.getTier(agentName, pubkey));
|
|
877
|
+
if (tierName !== null) {
|
|
878
|
+
const tierAway = this.getSetting(agentName, awayTierSettingKey(tierName));
|
|
879
|
+
if (tierAway !== null) {
|
|
880
|
+
this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: "tier" });
|
|
881
|
+
return tierAway; // 2. per-tier
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const agentDefault = this.getSetting(agentName, AWAY_DEFAULT_KEY);
|
|
885
|
+
// 3. agent default, else null → caller applies the system default (code). Level logged HERE.
|
|
886
|
+
this.#logger.debug("contact.away.resolved", { agentName, pubkey, level: agentDefault !== null ? "agent_default" : "system" });
|
|
887
|
+
return agentDefault;
|
|
888
|
+
}
|
|
889
|
+
/** DOD-CONTACT-VIEW-1: set an EXISTING contact's reachability tier. Returns false when no such
|
|
890
|
+
* contact — fail-loud at the caller, never a silent no-op success (same contract as
|
|
891
|
+
* setContactMoniker). The caller validates the tier is a known constant BEFORE calling; this
|
|
892
|
+
* stores whatever it is handed (the handler is the validation boundary). */
|
|
893
|
+
setContactTier(agentName, pubkey, tier) {
|
|
894
|
+
if (!this.#db)
|
|
895
|
+
throw new Error(`setContactTier('${agentName}'): database not initialized`);
|
|
896
|
+
const res = this.#db
|
|
897
|
+
.prepare("UPDATE contacts SET tier = ? WHERE agent_id = ? AND pubkey = ?")
|
|
898
|
+
.run(tier, this.#requireAgentId(agentName), pubkey);
|
|
729
899
|
return res.changes > 0;
|
|
730
900
|
}
|
|
901
|
+
/** DOD-RENAME-1 (Option C): record a self-declared name a peer offered, at the moment the offer is
|
|
902
|
+
* SEEN. The stored local pet name (contacts.moniker) is SACROSANCT — this only ever touches
|
|
903
|
+
* last_offered_moniker and the notice queue, never the moniker (AC2). A rename NOTICE is queued
|
|
904
|
+
* only when the peer is a contact the operator has PERSONALLY NAMED (moniker non-null), a name was
|
|
905
|
+
* seen BEFORE (last_offered_moniker non-null), and the new offer DIFFERS (AC3). The first-ever
|
|
906
|
+
* offer just records the baseline (no notice); a repeat of the same name is idempotent (AC4).
|
|
907
|
+
* Called only when a moniker WAS offered (caller-guarded), so silence never clears the baseline
|
|
908
|
+
* (AC5). Limitation: last_offered_moniker updates only on the RECEIVING side of an offer, so rename
|
|
909
|
+
* detection works only for peers who INITIATE to you — a property, not a bug. */
|
|
910
|
+
recordOfferedMoniker(agentName, pubkey, offered) {
|
|
911
|
+
// Fail CLOSED like getTier/setContactTier: a silent skip here would drop a rename baseline update
|
|
912
|
+
// (and any notice) while the daemon reports healthy — the inbound path always has an open DB.
|
|
913
|
+
if (!this.#db)
|
|
914
|
+
throw new Error(`recordOfferedMoniker('${agentName}'): database not initialized`);
|
|
915
|
+
const agentId = this.#requireAgentId(agentName);
|
|
916
|
+
const row = this.#db
|
|
917
|
+
.prepare("SELECT last_offered_moniker, moniker FROM contacts WHERE agent_id = ? AND pubkey = ?")
|
|
918
|
+
.get(agentId, pubkey);
|
|
919
|
+
if (!row)
|
|
920
|
+
return; // not a contact — no row to hold a baseline or a notice
|
|
921
|
+
if (offered === row.last_offered_moniker)
|
|
922
|
+
return; // idempotent — same name already seen (AC4)
|
|
923
|
+
// A genuine change from a previously-seen name, for a contact the operator has named → notice.
|
|
924
|
+
if (row.last_offered_moniker !== null && row.moniker !== null) {
|
|
925
|
+
this.#db
|
|
926
|
+
.prepare("INSERT OR REPLACE INTO contact_rename_notices (agent_id, pubkey, offered_name, noticed_at) VALUES (?, ?, ?, ?)")
|
|
927
|
+
.run(agentId, pubkey, offered, Date.now());
|
|
928
|
+
// Observability: log the FACT, never the attacker-chosen name (same rule as moniker.rejected).
|
|
929
|
+
this.#logger.info("contact.rename.noticed", { agentName, pubkey });
|
|
930
|
+
}
|
|
931
|
+
this.#db
|
|
932
|
+
.prepare("UPDATE contacts SET last_offered_moniker = ? WHERE agent_id = ? AND pubkey = ?")
|
|
933
|
+
.run(offered, agentId, pubkey);
|
|
934
|
+
}
|
|
935
|
+
/** DOD-RENAME-1: pending rename notices for an agent, oldest first (surfaced in
|
|
936
|
+
* cello_check_notifications — an INBOX pull, never a real-time push). */
|
|
937
|
+
getRenameNotices(agentName) {
|
|
938
|
+
if (!this.#db)
|
|
939
|
+
return [];
|
|
940
|
+
// JOIN the local pet name so the notice can NAME the contact (AC3) — a notice only ever fires for
|
|
941
|
+
// a personally-named contact, so moniker is expected non-null (LEFT JOIN is defensive).
|
|
942
|
+
return this.#db
|
|
943
|
+
.prepare(`SELECT n.pubkey, n.offered_name, n.noticed_at, c.moniker
|
|
944
|
+
FROM contact_rename_notices n
|
|
945
|
+
LEFT JOIN contacts c ON c.agent_id = n.agent_id AND c.pubkey = n.pubkey
|
|
946
|
+
WHERE n.agent_id = ? ORDER BY n.noticed_at ASC`)
|
|
947
|
+
.all(this.#requireAgentId(agentName));
|
|
948
|
+
}
|
|
949
|
+
/** DOD-RENAME-1: clear a pending rename notice — the operator acted (adopted a name or removed the
|
|
950
|
+
* contact). Idempotent (no notice → no-op). Fail-closed on a missing DB, like the writes above. */
|
|
951
|
+
clearRenameNotice(agentName, pubkey) {
|
|
952
|
+
if (!this.#db)
|
|
953
|
+
throw new Error(`clearRenameNotice('${agentName}'): database not initialized`);
|
|
954
|
+
this.#db
|
|
955
|
+
.prepare("DELETE FROM contact_rename_notices WHERE agent_id = ? AND pubkey = ?")
|
|
956
|
+
.run(this.#requireAgentId(agentName), pubkey);
|
|
957
|
+
}
|
|
731
958
|
/** M8C-CONTACT-1: known stays known until explicitly removed. */
|
|
732
959
|
removeContact(agentName, pubkey) {
|
|
733
960
|
if (!this.#db)
|
|
734
961
|
return false;
|
|
735
962
|
const res = this.#db.prepare("DELETE FROM contacts WHERE agent_id = ? AND pubkey = ?").run(this.#requireAgentId(agentName), pubkey);
|
|
963
|
+
// DOD-RENAME-1: a removed contact has no pending rename to resolve.
|
|
964
|
+
if (res.changes > 0)
|
|
965
|
+
this.clearRenameNotice(agentName, pubkey);
|
|
736
966
|
return res.changes > 0;
|
|
737
967
|
}
|
|
738
968
|
/** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
|
|
@@ -749,13 +979,23 @@ export class SessionNodeManager {
|
|
|
749
979
|
.get(this.#requireAgentId(agentName), pubkey);
|
|
750
980
|
return row?.moniker ?? null;
|
|
751
981
|
}
|
|
752
|
-
/** M8C-CONTACT-1: list an agent's
|
|
753
|
-
*
|
|
982
|
+
/** M8C-CONTACT-1 + DOD-CONTACT-VIEW-1: list an agent's contacts, oldest-added first, each with its
|
|
983
|
+
* pet name (MONIKER-3), tier + provenance (the address-book metadata), and a READ-side LEFT JOIN
|
|
984
|
+
* against `sessions` for how many SEALED sessions were shared and when they last spoke (MAX
|
|
985
|
+
* updated_at). No new stored data — a pure read. A contact with no sessions shows 0 / null (never),
|
|
986
|
+
* not an error. The JOIN is scoped by agent_id so one agent's sessions never bleed into another's. */
|
|
754
987
|
listContacts(agentName) {
|
|
755
988
|
if (!this.#db)
|
|
756
989
|
return [];
|
|
757
990
|
return this.#db
|
|
758
|
-
.prepare(
|
|
991
|
+
.prepare(`SELECT c.pubkey, c.added_at, c.moniker, c.tier, c.provenance,
|
|
992
|
+
COUNT(CASE WHEN s.status = 'sealed' THEN 1 END) AS sealed_count,
|
|
993
|
+
MAX(s.updated_at) AS last_spoke
|
|
994
|
+
FROM contacts c
|
|
995
|
+
LEFT JOIN sessions s ON s.agent_id = c.agent_id AND s.counterparty_pubkey = c.pubkey
|
|
996
|
+
WHERE c.agent_id = ?
|
|
997
|
+
GROUP BY c.pubkey, c.added_at, c.moniker, c.tier, c.provenance
|
|
998
|
+
ORDER BY c.added_at ASC`)
|
|
759
999
|
.all(this.#requireAgentId(agentName));
|
|
760
1000
|
}
|
|
761
1001
|
/** M8C-ABUSE-1: cumulative RECEIVED byte total for a session (anti-drip-feed accounting). */
|
|
@@ -795,8 +1035,12 @@ export class SessionNodeManager {
|
|
|
795
1035
|
.get(this.#requireAgentId(agentName), counterpartyPubkey);
|
|
796
1036
|
return row.n;
|
|
797
1037
|
}
|
|
798
|
-
/** M8C-ABUSE-1 (anti-swarm): non-terminal sessions this agent holds with
|
|
799
|
-
*
|
|
1038
|
+
/** M8C-ABUSE-1 (anti-swarm) + DOD-TIER-2: non-terminal sessions this agent holds with UNKNOWN-tier
|
|
1039
|
+
* counterparties — the global cap counts across the whole stranger pool. A sender is exempt from
|
|
1040
|
+
* THIS pool iff it is a KNOWN+ contact (tier >= KNOWN); a bare stranger (no row → UNKNOWN) or an
|
|
1041
|
+
* explicitly UNKNOWN-tier contact both count. Keying on `tier >= KNOWN` (bounded to <= VIP so a
|
|
1042
|
+
* corrupt high value cannot grant pool-exemption) replaces the old row-existence proxy, which
|
|
1043
|
+
* would have let a merely-recorded UNKNOWN contact escape the anti-swarm cap. Same
|
|
800
1044
|
* 'interrupted'-status fix as countActiveSessionsForCounterparty above. */
|
|
801
1045
|
countActiveSessionsFromUnknownSenders(agentName) {
|
|
802
1046
|
if (!this.#db)
|
|
@@ -804,23 +1048,36 @@ export class SessionNodeManager {
|
|
|
804
1048
|
const row = this.#db
|
|
805
1049
|
.prepare(`SELECT COUNT(*) AS n FROM sessions s
|
|
806
1050
|
WHERE s.agent_id = ? AND s.status IN ('active', 'interrupted')
|
|
807
|
-
AND NOT EXISTS (
|
|
1051
|
+
AND NOT EXISTS (
|
|
1052
|
+
SELECT 1 FROM contacts c
|
|
1053
|
+
WHERE c.agent_id = s.agent_id AND c.pubkey = s.counterparty_pubkey
|
|
1054
|
+
AND c.tier >= ${TIER.KNOWN} AND c.tier <= ${TIER.VIP}
|
|
1055
|
+
)`)
|
|
808
1056
|
.get(this.#requireAgentId(agentName));
|
|
809
1057
|
return row.n;
|
|
810
1058
|
}
|
|
811
|
-
/** M8C-ABUSE-1: is a NEW inbound session from this counterparty within the
|
|
812
|
-
*
|
|
813
|
-
*
|
|
1059
|
+
/** M8C-ABUSE-1 + DOD-TIER-2/3: is a NEW inbound session from this counterparty within the
|
|
1060
|
+
* acceptance bounds? The per-sender cap is now the sender's TIER cap (DEFAULT_TIER_BOUNDS), not a
|
|
1061
|
+
* flat "3 for strangers, unbounded for contacts". This is where DOD-TIER-3 falls out for free: a
|
|
1062
|
+
* BLOCKED sender's cap is 0, so `perSender (>= 0) >= 0` refuses it through the SAME reason and the
|
|
1063
|
+
* SAME path an over-cap UNKNOWN takes — no separate blocked branch, no distinguishing oracle. The
|
|
1064
|
+
* global anti-swarm cap then applies ONLY to UNKNOWN-tier senders (KNOWN+ are trusted, not part of
|
|
1065
|
+
* the stranger pool; BLOCKED never reaches it). Checked BEFORE accepting a fresh inbound session
|
|
1066
|
+
* (counts reflect sessions already active, not yet counting this one). */
|
|
814
1067
|
checkUnknownSenderAcceptanceBound(agentName, counterpartyPubkey) {
|
|
815
|
-
|
|
816
|
-
|
|
1068
|
+
const tier = this.getTier(agentName, counterpartyPubkey);
|
|
1069
|
+
const perSenderCap = this.resolveTierBound(agentName, tier, "max_sessions");
|
|
817
1070
|
const perSender = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
|
|
818
|
-
if (perSender >=
|
|
1071
|
+
if (perSender >= perSenderCap) {
|
|
819
1072
|
return { ok: false, reason: "abuse_bound_sessions_per_sender" };
|
|
820
1073
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
1074
|
+
// The global stranger cap is only for the UNKNOWN pool. A KNOWN+ sender is past it by trust;
|
|
1075
|
+
// a BLOCKED sender was already refused above (cap 0).
|
|
1076
|
+
if (tier === TIER.UNKNOWN) {
|
|
1077
|
+
const globalUnknown = this.countActiveSessionsFromUnknownSenders(agentName);
|
|
1078
|
+
if (globalUnknown >= ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL) {
|
|
1079
|
+
return { ok: false, reason: "abuse_bound_unknown_sessions_global" };
|
|
1080
|
+
}
|
|
824
1081
|
}
|
|
825
1082
|
return { ok: true };
|
|
826
1083
|
}
|
|
@@ -842,6 +1099,42 @@ export class SessionNodeManager {
|
|
|
842
1099
|
ON CONFLICT(id) DO UPDATE SET bot_token = excluded.bot_token, allowlisted_chat_id = excluded.allowlisted_chat_id, updated_at = excluded.updated_at`)
|
|
843
1100
|
.run(botToken, allowlistedChatId, Date.now());
|
|
844
1101
|
}
|
|
1102
|
+
/** DOD-SETTINGS-1: read a per-agent setting, or null if unset. The get-with-default is the CALLER's
|
|
1103
|
+
* job (an unset key falls back to the hardcoded grid/system default — the daemon runs correctly on
|
|
1104
|
+
* defaults alone, AC3). Returns null on a missing DB (settings are always optional). */
|
|
1105
|
+
getSetting(agentName, key) {
|
|
1106
|
+
if (!this.#db)
|
|
1107
|
+
return null;
|
|
1108
|
+
const row = this.#db
|
|
1109
|
+
.prepare("SELECT value FROM agent_settings WHERE agent_id = ? AND key = ?")
|
|
1110
|
+
.get(this.#requireAgentId(agentName), key);
|
|
1111
|
+
return row?.value ?? null;
|
|
1112
|
+
}
|
|
1113
|
+
/** DOD-SETTINGS-1: write a per-agent setting (upsert). Key VALIDATION is the handler's boundary
|
|
1114
|
+
* (isValidSettingKey); value validation for typed settings (finite bounds, etc.) belongs to the
|
|
1115
|
+
* specific consumer. Throws on a missing DB — a write that silently no-ops would be a lie. */
|
|
1116
|
+
setSetting(agentName, key, value) {
|
|
1117
|
+
if (!this.#db)
|
|
1118
|
+
throw new Error(`setSetting('${agentName}'): database not initialized`);
|
|
1119
|
+
// Store-level backstop (review F2): the handler validates the key, but the dual-layer convention
|
|
1120
|
+
// (cf. MONIKER-1) means an unknown key can NEVER be stored — an internal caller that hand-typed a
|
|
1121
|
+
// key instead of using the builders would otherwise persist a setting that never takes effect.
|
|
1122
|
+
if (!isValidSettingKey(key))
|
|
1123
|
+
throw new Error(`invalid_key: '${key}' is not a known setting`);
|
|
1124
|
+
this.#db
|
|
1125
|
+
.prepare(`INSERT INTO agent_settings (agent_id, key, value, updated_at) VALUES (?, ?, ?, ?)
|
|
1126
|
+
ON CONFLICT(agent_id, key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
|
|
1127
|
+
.run(this.#requireAgentId(agentName), key, value, Date.now());
|
|
1128
|
+
}
|
|
1129
|
+
/** DOD-SETTINGS-1: all explicitly-set settings for an agent (the ones that OVERRIDE a default),
|
|
1130
|
+
* key-sorted. Unset keys are absent — the operator sees only what they changed. */
|
|
1131
|
+
getAllSettings(agentName) {
|
|
1132
|
+
if (!this.#db)
|
|
1133
|
+
return [];
|
|
1134
|
+
return this.#db
|
|
1135
|
+
.prepare("SELECT key, value FROM agent_settings WHERE agent_id = ? ORDER BY key ASC")
|
|
1136
|
+
.all(this.#requireAgentId(agentName));
|
|
1137
|
+
}
|
|
845
1138
|
/** DOD-LOOP-1: whether the given agent has a standing receiver ready (any agent if omitted). */
|
|
846
1139
|
getStandingReceiverReady(agentName) {
|
|
847
1140
|
if (agentName !== undefined)
|
|
@@ -2576,10 +2869,15 @@ export class SessionNodeManager {
|
|
|
2576
2869
|
// seam below (cheap + synchronous — fail fast on volume before spending gateway compute on
|
|
2577
2870
|
// content headed for rejection anyway); both gates are independent and either rejects on its
|
|
2578
2871
|
// own criteria, so ordering between them does not change correctness.
|
|
2579
|
-
|
|
2872
|
+
{
|
|
2873
|
+
// DOD-TIER-2 AC2: the per-session byte cap is the sender's TIER cap (DEFAULT_TIER_BOUNDS),
|
|
2874
|
+
// applied to EVERY sender — no tier is unbounded (INV-TIER-BOUND), so a contact is no longer
|
|
2875
|
+
// "exempt entirely". A stranger (no row → UNKNOWN) keeps the 25 MB cap; KNOWN+ get more.
|
|
2876
|
+
const senderTier = this.getTier(agentName, senderPubkey);
|
|
2877
|
+
const cap = this.resolveTierBound(agentName, senderTier, "max_bytes");
|
|
2580
2878
|
const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
|
|
2581
2879
|
const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
|
|
2582
|
-
if (priorTotal + heldTotal + content.length >
|
|
2880
|
+
if (priorTotal + heldTotal + content.length > cap) {
|
|
2583
2881
|
this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
|
|
2584
2882
|
sessionId,
|
|
2585
2883
|
agentName,
|
|
@@ -2587,7 +2885,8 @@ export class SessionNodeManager {
|
|
|
2587
2885
|
priorTotal,
|
|
2588
2886
|
heldTotal,
|
|
2589
2887
|
incoming: content.length,
|
|
2590
|
-
cap
|
|
2888
|
+
cap,
|
|
2889
|
+
tier: senderTier,
|
|
2591
2890
|
correlationId,
|
|
2592
2891
|
});
|
|
2593
2892
|
return { ok: false, reason: "session_size_limit_exceeded" };
|
|
@@ -2692,10 +2991,16 @@ export class SessionNodeManager {
|
|
|
2692
2991
|
// the append/hold branch is synchronous, so whichever call resumes first appends/holds before
|
|
2693
2992
|
// the second's re-check runs, and the second's freshly-recomputed totals correctly include the
|
|
2694
2993
|
// first's contribution.
|
|
2695
|
-
|
|
2994
|
+
{
|
|
2995
|
+
// DOD-TIER-2 AC2 (re-check): the SAME tier cap as the primary gate above, recomputed in this
|
|
2996
|
+
// synchronous window (the totals can go stale across the screenInbound await). Applied to EVERY
|
|
2997
|
+
// sender — a contact is no longer exempt (INV-TIER-BOUND). Must mirror the primary gate exactly
|
|
2998
|
+
// so a sender can never pass one and fail the other.
|
|
2999
|
+
const senderTier = this.getTier(agentName, senderPubkey);
|
|
3000
|
+
const cap = this.resolveTierBound(agentName, senderTier, "max_bytes");
|
|
2696
3001
|
const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
|
|
2697
3002
|
const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
|
|
2698
|
-
if (priorTotal + heldTotal + content.length >
|
|
3003
|
+
if (priorTotal + heldTotal + content.length > cap) {
|
|
2699
3004
|
this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
|
|
2700
3005
|
sessionId,
|
|
2701
3006
|
agentName,
|
|
@@ -2703,7 +3008,8 @@ export class SessionNodeManager {
|
|
|
2703
3008
|
priorTotal,
|
|
2704
3009
|
heldTotal,
|
|
2705
3010
|
incoming: content.length,
|
|
2706
|
-
cap
|
|
3011
|
+
cap,
|
|
3012
|
+
tier: senderTier,
|
|
2707
3013
|
correlationId,
|
|
2708
3014
|
recheck: true,
|
|
2709
3015
|
});
|