@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
|
@@ -69,6 +69,9 @@
|
|
|
69
69
|
import { openEncryptedDatabase, resolveDbKey, dbKeyPathFor, } from "./sqlcipher-db.js";
|
|
70
70
|
import { migrateToEncryptedIfNeeded } from "./identity-migration.js";
|
|
71
71
|
import { ensureIdentitySchema } from "./db-identity-store.js";
|
|
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";
|
|
72
75
|
import { randomUUID, createHash } from "node:crypto";
|
|
73
76
|
import * as lp from "it-length-prefixed";
|
|
74
77
|
import { decode, Encoder } from "cbor-x";
|
|
@@ -83,15 +86,18 @@ import { RelayReceiptStore } from "./relay-receipt-store.js";
|
|
|
83
86
|
import { SessionSealLeafStore } from "./session-seal-leaf-store.js";
|
|
84
87
|
import { PassthroughGatewayClient, GATEWAY_UNAVAILABLE, GOVERNANCE_TIMEOUT, } from "@cello-protocol/gateway";
|
|
85
88
|
const CBOR_ENC = new Encoder({ tagUint8Array: false });
|
|
86
|
-
// M8C-ABUSE-1: persistence bounds
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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. */
|
|
95
101
|
export const ABUSE_MAX_UNKNOWN_SESSIONS_GLOBAL = 50;
|
|
96
102
|
// ─── SessionNodeManager ───────────────────────────────────────────────────────
|
|
97
103
|
export class SessionNodeManager {
|
|
@@ -332,7 +338,7 @@ export class SessionNodeManager {
|
|
|
332
338
|
this.#db.exec(`
|
|
333
339
|
CREATE TABLE IF NOT EXISTS sessions (
|
|
334
340
|
session_id TEXT NOT NULL,
|
|
335
|
-
|
|
341
|
+
agent_id TEXT NOT NULL,
|
|
336
342
|
counterparty_pubkey TEXT NOT NULL,
|
|
337
343
|
status TEXT NOT NULL,
|
|
338
344
|
created_at INTEGER NOT NULL,
|
|
@@ -340,7 +346,9 @@ export class SessionNodeManager {
|
|
|
340
346
|
-- DOD-LOOP-1: composite key so two of the operator's agents can hold both ends of the
|
|
341
347
|
-- SAME session_id on ONE daemon (the loopback case). A bare session_id PK would reject
|
|
342
348
|
-- the second end's row.
|
|
343
|
-
|
|
349
|
+
-- DOD-AGENT-ID-JOINKEY-1: keyed on the STABLE agent_id, never the mutable, reuse-freed
|
|
350
|
+
-- agent_name. The display name lives on the agents table and is joined in for reads.
|
|
351
|
+
PRIMARY KEY (agent_id, session_id)
|
|
344
352
|
)
|
|
345
353
|
`);
|
|
346
354
|
// M7-SESSION-001: idempotent schema extension — add message_count and interrupted_at
|
|
@@ -386,7 +394,7 @@ export class SessionNodeManager {
|
|
|
386
394
|
// Merkle root so the achieved commitment is never discarded.
|
|
387
395
|
this.#db.exec(`
|
|
388
396
|
CREATE TABLE IF NOT EXISTS seal_interrupted_artifacts (
|
|
389
|
-
|
|
397
|
+
agent_id TEXT NOT NULL,
|
|
390
398
|
session_id TEXT NOT NULL,
|
|
391
399
|
role TEXT NOT NULL,
|
|
392
400
|
own_leaf TEXT NOT NULL,
|
|
@@ -395,7 +403,7 @@ export class SessionNodeManager {
|
|
|
395
403
|
nonce TEXT NOT NULL,
|
|
396
404
|
created_at INTEGER NOT NULL,
|
|
397
405
|
-- DOD-LOOP-1: composite key (per-agent end of a loopback session).
|
|
398
|
-
PRIMARY KEY (
|
|
406
|
+
PRIMARY KEY (agent_id, session_id)
|
|
399
407
|
)
|
|
400
408
|
`);
|
|
401
409
|
// DAEMON-004 (AC-007 / SI-001): the daemon-owned per-session Merkle tree,
|
|
@@ -405,14 +413,14 @@ export class SessionNodeManager {
|
|
|
405
413
|
// by session_id ORDER BY leaf_index is the only read pattern.
|
|
406
414
|
this.#db.exec(`
|
|
407
415
|
CREATE TABLE IF NOT EXISTS session_tree_leaves (
|
|
408
|
-
|
|
416
|
+
agent_id TEXT NOT NULL,
|
|
409
417
|
session_id TEXT NOT NULL,
|
|
410
418
|
leaf_index INTEGER NOT NULL,
|
|
411
419
|
leaf_kind TEXT NOT NULL,
|
|
412
420
|
leaf_hash_hex TEXT NOT NULL,
|
|
413
421
|
created_at INTEGER NOT NULL,
|
|
414
422
|
-- DOD-LOOP-1: composite key so each agent's end has its own append-ordered tree.
|
|
415
|
-
PRIMARY KEY (
|
|
423
|
+
PRIMARY KEY (agent_id, session_id, leaf_index)
|
|
416
424
|
)
|
|
417
425
|
`);
|
|
418
426
|
// DOD-LOG-1 (PERSIST-LOG-001) / PERSIST-002 (AC-010): the durable, ENCRYPTED-at-rest readable
|
|
@@ -422,13 +430,13 @@ export class SessionNodeManager {
|
|
|
422
430
|
// provided by whole-DB SQLCipher, not a per-column cipher (relay/directory never see it — INV-3).
|
|
423
431
|
this.#db.exec(`
|
|
424
432
|
CREATE TABLE IF NOT EXISTS transcript (
|
|
425
|
-
|
|
433
|
+
agent_id TEXT NOT NULL,
|
|
426
434
|
session_id TEXT NOT NULL,
|
|
427
435
|
sequence INTEGER NOT NULL,
|
|
428
436
|
direction TEXT NOT NULL, -- 'sent' | 'received'
|
|
429
437
|
blob BLOB NOT NULL, -- readable plaintext bytes (whole-DB SQLCipher-encrypted at rest)
|
|
430
438
|
created_at INTEGER NOT NULL,
|
|
431
|
-
PRIMARY KEY (
|
|
439
|
+
PRIMARY KEY (agent_id, session_id, sequence, direction)
|
|
432
440
|
)
|
|
433
441
|
`);
|
|
434
442
|
// M8C-INBOX-1 (N2): per-agent, per-session read watermark. `last_delivered_seq` is the highest
|
|
@@ -438,10 +446,10 @@ export class SessionNodeManager {
|
|
|
438
446
|
// across daemon restarts, not just within one process (INV-PUSHPULL). Additive table.
|
|
439
447
|
this.#db.exec(`
|
|
440
448
|
CREATE TABLE IF NOT EXISTS message_watermarks (
|
|
441
|
-
|
|
449
|
+
agent_id TEXT NOT NULL,
|
|
442
450
|
session_id TEXT NOT NULL,
|
|
443
451
|
last_delivered_seq INTEGER NOT NULL,
|
|
444
|
-
PRIMARY KEY (
|
|
452
|
+
PRIMARY KEY (agent_id, session_id)
|
|
445
453
|
)
|
|
446
454
|
`);
|
|
447
455
|
// M8C-CONTACT-1: binary per-agent contact whitelist. This is an ACCESS-CONTROL LIST, not a
|
|
@@ -450,10 +458,10 @@ export class SessionNodeManager {
|
|
|
450
458
|
// re-resolved); known stays known until explicitly removed (no TTL/expiry on membership).
|
|
451
459
|
this.#db.exec(`
|
|
452
460
|
CREATE TABLE IF NOT EXISTS contacts (
|
|
453
|
-
|
|
461
|
+
agent_id TEXT NOT NULL,
|
|
454
462
|
pubkey TEXT NOT NULL,
|
|
455
463
|
added_at INTEGER NOT NULL,
|
|
456
|
-
PRIMARY KEY (
|
|
464
|
+
PRIMARY KEY (agent_id, pubkey)
|
|
457
465
|
)
|
|
458
466
|
`);
|
|
459
467
|
// MONIKER-3 AC1: the receiver's own pet name for a pubkey — the top tier of whoLabel.
|
|
@@ -463,6 +471,20 @@ export class SessionNodeManager {
|
|
|
463
471
|
if (!contactCols.some((c) => c.name === "moniker")) {
|
|
464
472
|
this.#db.exec("ALTER TABLE contacts ADD COLUMN moniker TEXT");
|
|
465
473
|
}
|
|
474
|
+
// DOD-AGENT-ID-JOINKEY-1: finish REMOVE-001. Re-key the seven child tables from the mutable,
|
|
475
|
+
// reuse-freed `agent_name` to the stable `agent_id`, in ONE transaction. Runs AFTER every
|
|
476
|
+
// CREATE/ALTER above, so an existing table has its full historical column set before it is
|
|
477
|
+
// rebuilt, and BEFORE any read below touches it. A no-op once the tables carry `agent_id`.
|
|
478
|
+
//
|
|
479
|
+
// `retry_queue` (the seventh) is created later, by RetryQueue's constructor. On an existing
|
|
480
|
+
// database it already exists here and is re-keyed in the same transaction; on a fresh one it is
|
|
481
|
+
// absent, is skipped, and RetryQueue then creates it directly in the re-keyed shape.
|
|
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);
|
|
466
488
|
// M8C-TGDOOR-1: daemon-wide Telegram settings (bot token + allowlisted operator chat). A
|
|
467
489
|
// NEW dedicated table — NOT folded into the parked M9-CFG-001 config store, because a bot
|
|
468
490
|
// token has no sensible default (a required credential, unlike AWAY/TTL/CONTACT's real
|
|
@@ -475,12 +497,48 @@ export class SessionNodeManager {
|
|
|
475
497
|
allowlisted_chat_id TEXT NOT NULL,
|
|
476
498
|
updated_at INTEGER NOT NULL
|
|
477
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
|
+
)
|
|
478
529
|
`);
|
|
479
530
|
// Step 2: Detect interrupted sessions (SIGKILL detection — AC-010).
|
|
480
531
|
// Any 'active' row in a freshly-started daemon is a remnant of a prior
|
|
481
532
|
// killed process. Batch-update to 'interrupted' before IPC opens.
|
|
533
|
+
// DOD-AGENT-ID-JOINKEY-1: this sweep spans EVERY agent, so it cannot resolve one name up front.
|
|
534
|
+
// It scopes its UPDATE by the row's own agent_id and LEFT JOINs `agents` only to LOG a human
|
|
535
|
+
// name. LEFT, not INNER: an inner join would silently skip a session whose agent row is missing,
|
|
536
|
+
// leaving it 'active' forever — a stuck row hidden by the query that was meant to find it. An
|
|
537
|
+
// orphan is instead marked interrupted like any other AND reported loudly.
|
|
482
538
|
const activeRows = this.#db
|
|
483
|
-
.prepare(
|
|
539
|
+
.prepare(`SELECT s.session_id, s.agent_id, a.agent_name
|
|
540
|
+
FROM sessions s LEFT JOIN agents a ON a.agent_id = s.agent_id
|
|
541
|
+
WHERE s.status = 'active'`)
|
|
484
542
|
.all();
|
|
485
543
|
if (activeRows.length > 0) {
|
|
486
544
|
const now = Date.now();
|
|
@@ -488,8 +546,15 @@ export class SessionNodeManager {
|
|
|
488
546
|
for (const row of activeRows) {
|
|
489
547
|
try {
|
|
490
548
|
this.#db
|
|
491
|
-
.prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE
|
|
492
|
-
.run(now, interruptedAt, row.
|
|
549
|
+
.prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, interrupted_at = COALESCE(interrupted_at, ?) WHERE agent_id = ? AND session_id = ?")
|
|
550
|
+
.run(now, interruptedAt, row.agent_id, row.session_id);
|
|
551
|
+
if (row.agent_name === null) {
|
|
552
|
+
this.#logger.error("session.agent.orphaned", {
|
|
553
|
+
sessionId: row.session_id,
|
|
554
|
+
agentId: row.agent_id,
|
|
555
|
+
impact: "session row references an agent_id with no agents row",
|
|
556
|
+
});
|
|
557
|
+
}
|
|
493
558
|
this.#logger.warn("session.interrupted.detected", {
|
|
494
559
|
sessionId: row.session_id,
|
|
495
560
|
agentName: row.agent_name,
|
|
@@ -557,11 +622,12 @@ export class SessionNodeManager {
|
|
|
557
622
|
if (!this.#db)
|
|
558
623
|
return;
|
|
559
624
|
try {
|
|
625
|
+
const agentId = this.#requireAgentId(agentName);
|
|
560
626
|
const blob = Buffer.from(plaintext);
|
|
561
627
|
this.#db
|
|
562
|
-
.prepare(`INSERT OR IGNORE INTO transcript (
|
|
628
|
+
.prepare(`INSERT OR IGNORE INTO transcript (agent_id, session_id, sequence, direction, blob, created_at)
|
|
563
629
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
564
|
-
.run(
|
|
630
|
+
.run(agentId, sessionId, sequence, direction, blob, Date.now());
|
|
565
631
|
this.#logger.info("transcript.message.recorded", { sessionId, agentName, sequence, direction, correlationId });
|
|
566
632
|
}
|
|
567
633
|
catch (err) {
|
|
@@ -590,8 +656,8 @@ export class SessionNodeManager {
|
|
|
590
656
|
return { messages: [], undecryptable: 0 };
|
|
591
657
|
const rows = this.#db
|
|
592
658
|
.prepare(`SELECT sequence, direction, blob, created_at FROM transcript
|
|
593
|
-
WHERE
|
|
594
|
-
.all(agentName, sessionId);
|
|
659
|
+
WHERE agent_id = ? AND session_id = ? ORDER BY sequence ASC, direction ASC`)
|
|
660
|
+
.all(this.#requireAgentId(agentName), sessionId);
|
|
595
661
|
const messages = [];
|
|
596
662
|
// PERSIST-002 (AC-010): the blob is plaintext (whole-DB SQLCipher at rest), so there is no
|
|
597
663
|
// per-row decrypt step that can fail — `undecryptable` stays 0 and is kept only for callers that
|
|
@@ -614,8 +680,8 @@ export class SessionNodeManager {
|
|
|
614
680
|
if (!this.#db)
|
|
615
681
|
return -1;
|
|
616
682
|
const row = this.#db
|
|
617
|
-
.prepare("SELECT last_delivered_seq FROM message_watermarks WHERE
|
|
618
|
-
.get(agentName, sessionId);
|
|
683
|
+
.prepare("SELECT last_delivered_seq FROM message_watermarks WHERE agent_id = ? AND session_id = ?")
|
|
684
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
619
685
|
return row ? row.last_delivered_seq : -1;
|
|
620
686
|
}
|
|
621
687
|
/** Advance the read watermark (delivery marks read). MONOTONIC — never lowers, so a replayed or
|
|
@@ -624,11 +690,11 @@ export class SessionNodeManager {
|
|
|
624
690
|
if (!this.#db)
|
|
625
691
|
return;
|
|
626
692
|
this.#db
|
|
627
|
-
.prepare(`INSERT INTO message_watermarks (
|
|
693
|
+
.prepare(`INSERT INTO message_watermarks (agent_id, session_id, last_delivered_seq)
|
|
628
694
|
VALUES (?, ?, ?)
|
|
629
|
-
ON CONFLICT(
|
|
695
|
+
ON CONFLICT(agent_id, session_id)
|
|
630
696
|
DO UPDATE SET last_delivered_seq = MAX(last_delivered_seq, excluded.last_delivered_seq)`)
|
|
631
|
-
.run(agentName, sessionId, seq);
|
|
697
|
+
.run(this.#requireAgentId(agentName), sessionId, seq);
|
|
632
698
|
this.#logger.info("message.watermark.advanced", { agentName, sessionId, sequence: seq });
|
|
633
699
|
}
|
|
634
700
|
/** INBOX-1 (N2): per-session unread summary for an agent — sessions that have RECEIVED transcript
|
|
@@ -643,29 +709,98 @@ export class SessionNodeManager {
|
|
|
643
709
|
MAX(t.sequence) AS last_seq
|
|
644
710
|
FROM transcript t
|
|
645
711
|
LEFT JOIN message_watermarks w
|
|
646
|
-
ON w.
|
|
647
|
-
WHERE t.
|
|
712
|
+
ON w.agent_id = t.agent_id AND w.session_id = t.session_id
|
|
713
|
+
WHERE t.agent_id = ?
|
|
648
714
|
AND t.direction = 'received'
|
|
649
715
|
AND t.sequence > COALESCE(w.last_delivered_seq, -1)
|
|
650
716
|
GROUP BY t.session_id
|
|
651
717
|
HAVING unread_count > 0
|
|
652
718
|
ORDER BY t.session_id ASC`)
|
|
653
|
-
.all(agentName);
|
|
719
|
+
.all(this.#requireAgentId(agentName));
|
|
654
720
|
return rows;
|
|
655
721
|
}
|
|
656
722
|
/** M8C-CONTACT-1: is this pubkey a known contact of this agent? */
|
|
657
723
|
isContact(agentName, pubkey) {
|
|
658
724
|
if (!this.#db)
|
|
659
725
|
return false;
|
|
660
|
-
const row = this.#db.prepare("SELECT 1 FROM contacts WHERE
|
|
726
|
+
const row = this.#db.prepare("SELECT 1 FROM contacts WHERE agent_id = ? AND pubkey = ?").get(this.#requireAgentId(agentName), pubkey);
|
|
661
727
|
return row !== undefined;
|
|
662
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
|
+
}
|
|
663
790
|
/** M8C-CONTACT-1: pin a contact at add time — idempotent (re-adding an existing contact is a
|
|
664
791
|
* no-op, never refreshes added_at; identity does not get re-resolved). MONIKER-3 AC2: an
|
|
665
792
|
* optional pet name; a NEW non-null moniker on re-add updates it, absence leaves it untouched.
|
|
666
793
|
* THROWS on an invalid moniker — callers validate first; this is the can-never-be-stored
|
|
667
|
-
* backstop (same contract as DbIdentityStore.setMoniker).
|
|
668
|
-
|
|
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) {
|
|
669
804
|
if (!pubkey)
|
|
670
805
|
return;
|
|
671
806
|
// Review F1: a missing DB handle must FAIL the write loudly — returning silently here let
|
|
@@ -675,13 +810,21 @@ export class SessionNodeManager {
|
|
|
675
810
|
if (moniker !== undefined && moniker !== null && validateMoniker(moniker) === null) {
|
|
676
811
|
throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
|
|
677
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
|
+
}
|
|
820
|
+
const agentId = this.#requireAgentId(agentName);
|
|
678
821
|
this.#db
|
|
679
|
-
.prepare("INSERT OR IGNORE INTO contacts (
|
|
680
|
-
.run(
|
|
822
|
+
.prepare("INSERT OR IGNORE INTO contacts (agent_id, pubkey, added_at, tier, provenance) VALUES (?, ?, ?, ?, ?)")
|
|
823
|
+
.run(agentId, pubkey, Date.now(), tier, provenance ?? null);
|
|
681
824
|
if (moniker !== undefined && moniker !== null) {
|
|
682
825
|
this.#db
|
|
683
|
-
.prepare("UPDATE contacts SET moniker = ? WHERE
|
|
684
|
-
.run(moniker,
|
|
826
|
+
.prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
|
|
827
|
+
.run(moniker, agentId, pubkey);
|
|
685
828
|
}
|
|
686
829
|
}
|
|
687
830
|
/** MONIKER-3 AC3: rename (string) or clear (null) an EXISTING contact's pet name. Returns false
|
|
@@ -696,15 +839,130 @@ export class SessionNodeManager {
|
|
|
696
839
|
throw new Error(`invalid contact moniker for agent '${agentName}': must match ${MONIKER_RE.source}`);
|
|
697
840
|
}
|
|
698
841
|
const res = this.#db
|
|
699
|
-
.prepare("UPDATE contacts SET moniker = ? WHERE
|
|
700
|
-
.run(moniker, agentName, pubkey);
|
|
842
|
+
.prepare("UPDATE contacts SET moniker = ? WHERE agent_id = ? AND pubkey = ?")
|
|
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);
|
|
701
858
|
return res.changes > 0;
|
|
702
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);
|
|
899
|
+
return res.changes > 0;
|
|
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
|
+
}
|
|
703
958
|
/** M8C-CONTACT-1: known stays known until explicitly removed. */
|
|
704
959
|
removeContact(agentName, pubkey) {
|
|
705
960
|
if (!this.#db)
|
|
706
961
|
return false;
|
|
707
|
-
const res = this.#db.prepare("DELETE FROM contacts WHERE
|
|
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);
|
|
708
966
|
return res.changes > 0;
|
|
709
967
|
}
|
|
710
968
|
/** MONIKER-4: the operator's pet name for a pubkey (whoLabel's top tier), or null. Read-only
|
|
@@ -717,26 +975,36 @@ export class SessionNodeManager {
|
|
|
717
975
|
return null;
|
|
718
976
|
}
|
|
719
977
|
const row = this.#db
|
|
720
|
-
.prepare("SELECT moniker FROM contacts WHERE
|
|
721
|
-
.get(agentName, pubkey);
|
|
978
|
+
.prepare("SELECT moniker FROM contacts WHERE agent_id = ? AND pubkey = ?")
|
|
979
|
+
.get(this.#requireAgentId(agentName), pubkey);
|
|
722
980
|
return row?.moniker ?? null;
|
|
723
981
|
}
|
|
724
|
-
/** M8C-CONTACT-1: list an agent's
|
|
725
|
-
*
|
|
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. */
|
|
726
987
|
listContacts(agentName) {
|
|
727
988
|
if (!this.#db)
|
|
728
989
|
return [];
|
|
729
990
|
return this.#db
|
|
730
|
-
.prepare(
|
|
731
|
-
|
|
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`)
|
|
999
|
+
.all(this.#requireAgentId(agentName));
|
|
732
1000
|
}
|
|
733
1001
|
/** M8C-ABUSE-1: cumulative RECEIVED byte total for a session (anti-drip-feed accounting). */
|
|
734
1002
|
#getReceivedBytesTotal(agentName, sessionId) {
|
|
735
1003
|
if (!this.#db)
|
|
736
1004
|
return 0;
|
|
737
1005
|
const row = this.#db
|
|
738
|
-
.prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE
|
|
739
|
-
.get(agentName, sessionId);
|
|
1006
|
+
.prepare("SELECT COALESCE(SUM(LENGTH(blob)), 0) AS total FROM transcript WHERE agent_id = ? AND session_id = ? AND direction = 'received'")
|
|
1007
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
740
1008
|
return row.total;
|
|
741
1009
|
}
|
|
742
1010
|
/** M8C-ABUSE-1 (reviewer HIGH fix, D18): bytes currently sitting in the out-of-order hold
|
|
@@ -763,36 +1031,53 @@ export class SessionNodeManager {
|
|
|
763
1031
|
if (!this.#db)
|
|
764
1032
|
return 0;
|
|
765
1033
|
const row = this.#db
|
|
766
|
-
.prepare("SELECT COUNT(*) AS n FROM sessions WHERE
|
|
767
|
-
.get(agentName, counterpartyPubkey);
|
|
1034
|
+
.prepare("SELECT COUNT(*) AS n FROM sessions WHERE agent_id = ? AND counterparty_pubkey = ? AND status IN ('active', 'interrupted')")
|
|
1035
|
+
.get(this.#requireAgentId(agentName), counterpartyPubkey);
|
|
768
1036
|
return row.n;
|
|
769
1037
|
}
|
|
770
|
-
/** M8C-ABUSE-1 (anti-swarm): non-terminal sessions this agent holds with
|
|
771
|
-
*
|
|
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
|
|
772
1044
|
* 'interrupted'-status fix as countActiveSessionsForCounterparty above. */
|
|
773
1045
|
countActiveSessionsFromUnknownSenders(agentName) {
|
|
774
1046
|
if (!this.#db)
|
|
775
1047
|
return 0;
|
|
776
1048
|
const row = this.#db
|
|
777
1049
|
.prepare(`SELECT COUNT(*) AS n FROM sessions s
|
|
778
|
-
WHERE s.
|
|
779
|
-
AND NOT EXISTS (
|
|
780
|
-
|
|
1050
|
+
WHERE s.agent_id = ? AND s.status IN ('active', 'interrupted')
|
|
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
|
+
)`)
|
|
1056
|
+
.get(this.#requireAgentId(agentName));
|
|
781
1057
|
return row.n;
|
|
782
1058
|
}
|
|
783
|
-
/** M8C-ABUSE-1: is a NEW inbound session from this counterparty within the
|
|
784
|
-
*
|
|
785
|
-
*
|
|
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). */
|
|
786
1067
|
checkUnknownSenderAcceptanceBound(agentName, counterpartyPubkey) {
|
|
787
|
-
|
|
788
|
-
|
|
1068
|
+
const tier = this.getTier(agentName, counterpartyPubkey);
|
|
1069
|
+
const perSenderCap = this.resolveTierBound(agentName, tier, "max_sessions");
|
|
789
1070
|
const perSender = this.countActiveSessionsForCounterparty(agentName, counterpartyPubkey);
|
|
790
|
-
if (perSender >=
|
|
1071
|
+
if (perSender >= perSenderCap) {
|
|
791
1072
|
return { ok: false, reason: "abuse_bound_sessions_per_sender" };
|
|
792
1073
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
+
}
|
|
796
1081
|
}
|
|
797
1082
|
return { ok: true };
|
|
798
1083
|
}
|
|
@@ -814,6 +1099,42 @@ export class SessionNodeManager {
|
|
|
814
1099
|
ON CONFLICT(id) DO UPDATE SET bot_token = excluded.bot_token, allowlisted_chat_id = excluded.allowlisted_chat_id, updated_at = excluded.updated_at`)
|
|
815
1100
|
.run(botToken, allowlistedChatId, Date.now());
|
|
816
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
|
+
}
|
|
817
1138
|
/** DOD-LOOP-1: whether the given agent has a standing receiver ready (any agent if omitted). */
|
|
818
1139
|
getStandingReceiverReady(agentName) {
|
|
819
1140
|
if (agentName !== undefined)
|
|
@@ -906,6 +1227,66 @@ export class SessionNodeManager {
|
|
|
906
1227
|
#k(agentName, sessionId) {
|
|
907
1228
|
return `${agentName}\x1f${sessionId}`;
|
|
908
1229
|
}
|
|
1230
|
+
/**
|
|
1231
|
+
* DOD-AGENT-ID-JOINKEY-1: resolve an agent's NAME to its STABLE agent_id. This is the ONE place a
|
|
1232
|
+
* name becomes a key, and it is the boundary between the two worlds:
|
|
1233
|
+
*
|
|
1234
|
+
* - ABOVE it, addressing by name is correct. The operator says `cello_use_agent { name }`, and
|
|
1235
|
+
* the in-memory maps (#k, standing receivers, keyProviders) key by name safely, because
|
|
1236
|
+
* name-addressing only ever resolves ACTIVE agents and the `agents_active_name` partial unique
|
|
1237
|
+
* index makes active names unique.
|
|
1238
|
+
* - BELOW it, only `agent_id` may touch SQL. `agent_name` is a mutable display attribute that a
|
|
1239
|
+
* retire frees for reuse; a table joined on it hands a new keypair the dead identity's rows.
|
|
1240
|
+
*
|
|
1241
|
+
* It resolves ONLY non-retired agents — a retired identity is gone from the runtime (`list` omits
|
|
1242
|
+
* it, `start` returns agent_not_found), so no live surface may act as one.
|
|
1243
|
+
*
|
|
1244
|
+
* It THROWS on an unresolvable name rather than returning null. A null would flow into a
|
|
1245
|
+
* `WHERE agent_id IS NULL` that quietly matches nothing: reads would return empty and writes would
|
|
1246
|
+
* vanish, and the daemon would look healthy while losing an agent's data. Every caller has already
|
|
1247
|
+
* resolved the agent before it gets here, so an unresolvable name is a bug in the caller, not a
|
|
1248
|
+
* condition to absorb.
|
|
1249
|
+
*/
|
|
1250
|
+
#requireAgentId(agentName) {
|
|
1251
|
+
if (!this.#db)
|
|
1252
|
+
throw new Error("agent_id_unresolved: database is not open");
|
|
1253
|
+
const row = this.#db
|
|
1254
|
+
.prepare("SELECT agent_id FROM agents WHERE agent_name = ? AND state != 'retired'")
|
|
1255
|
+
.get(agentName);
|
|
1256
|
+
if (!row) {
|
|
1257
|
+
this.#logger.error("session.agent_id.unresolved", { agentName });
|
|
1258
|
+
throw new Error(`agent_id_unresolved: no active agent named '${agentName}'. The session tables are keyed by the ` +
|
|
1259
|
+
`stable agent_id (DOD-AGENT-ID-JOINKEY-1); scoping a query by an unresolvable name would ` +
|
|
1260
|
+
`silently match nothing.`);
|
|
1261
|
+
}
|
|
1262
|
+
return row.agent_id;
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* DOD-AGENT-ID-JOINKEY-1: the public form of the name→stable-id resolver, for components that own
|
|
1266
|
+
* agent-scoped tables of their own (RetryQueue) and must be handed the STABLE key, never a name.
|
|
1267
|
+
* The daemon resolves ONCE, at its own boundary, exactly as this class does internally. Throws on
|
|
1268
|
+
* an unresolvable name — see #requireAgentId for why null is not an option.
|
|
1269
|
+
*/
|
|
1270
|
+
resolveAgentId(agentName) {
|
|
1271
|
+
return this.#requireAgentId(agentName);
|
|
1272
|
+
}
|
|
1273
|
+
/**
|
|
1274
|
+
* Reverse lookup: the display name of a stable agent_id, or null if no such agent.
|
|
1275
|
+
*
|
|
1276
|
+
* Deliberately INCLUDES retired agents. Its caller (the startup awaiting-content re-park) holds an
|
|
1277
|
+
* agent_id read off a durable row and needs a name to find that agent's standing receiver. A
|
|
1278
|
+
* retired agent resolves to its name and then has no standing receiver, so the park fails cleanly
|
|
1279
|
+
* and loudly — which is correct. Filtering retired agents out here would instead make the row
|
|
1280
|
+
* unattributable and the failure mute.
|
|
1281
|
+
*/
|
|
1282
|
+
agentNameForId(agentId) {
|
|
1283
|
+
if (!this.#db)
|
|
1284
|
+
return null;
|
|
1285
|
+
const row = this.#db
|
|
1286
|
+
.prepare("SELECT agent_name FROM agents WHERE agent_id = ?")
|
|
1287
|
+
.get(agentId);
|
|
1288
|
+
return row?.agent_name ?? null;
|
|
1289
|
+
}
|
|
909
1290
|
/**
|
|
910
1291
|
* Create a new outbound session node.
|
|
911
1292
|
* Called during cello_initiate_session.
|
|
@@ -1152,8 +1533,8 @@ export class SessionNodeManager {
|
|
|
1152
1533
|
// before the in-memory entry exists) can deposit un-acked content to the same relay.
|
|
1153
1534
|
try {
|
|
1154
1535
|
this.#db
|
|
1155
|
-
?.prepare("UPDATE sessions SET relay_peer_id = ?, relay_addrs = ?, updated_at = ? WHERE
|
|
1156
|
-
.run(relay.relayPeerId, JSON.stringify(relay.relayAddrs), Date.now(), agentName, sessionId);
|
|
1536
|
+
?.prepare("UPDATE sessions SET relay_peer_id = ?, relay_addrs = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
|
|
1537
|
+
.run(relay.relayPeerId, JSON.stringify(relay.relayAddrs), Date.now(), this.#requireAgentId(agentName), sessionId);
|
|
1157
1538
|
}
|
|
1158
1539
|
catch (err) {
|
|
1159
1540
|
this.#logger.warn("session.relay.endpoint.persist.failed", {
|
|
@@ -1595,8 +1976,22 @@ export class SessionNodeManager {
|
|
|
1595
1976
|
getSessionsByStatus(status) {
|
|
1596
1977
|
if (!this.#db)
|
|
1597
1978
|
return [];
|
|
1979
|
+
// Spans EVERY agent (cello_status is daemon-wide), so no single name can be resolved up front —
|
|
1980
|
+
// the display name is joined in from `agents`, its one source of truth. `agent_name` is no
|
|
1981
|
+
// longer a `sessions` column, so without this join buildActiveSessions/buildInterruptedSessions
|
|
1982
|
+
// read `row.agent_name` as undefined.
|
|
1983
|
+
//
|
|
1984
|
+
// DOD-AGENT-ID-JOINKEY-1 (reviewer Finding 1): INNER JOIN with `state != 'retired'`, NOT a bare
|
|
1985
|
+
// LEFT JOIN. This is the LIVE-status + reaper surface, and a retired agent is gone from the
|
|
1986
|
+
// runtime — its leftover session rows (kept for accountability, never re-statused) are not
|
|
1987
|
+
// resumable and must not appear here. If they did, the half-open reaper would resolve their
|
|
1988
|
+
// RETIRED name via #requireAgentId, which throws, taking down cello_status for the whole daemon.
|
|
1989
|
+
// Excluding them also guarantees a non-null agent_name on every returned row. The full historical
|
|
1990
|
+
// archive (getAllSessions) keeps its LEFT JOIN and still shows retired/orphaned rows.
|
|
1598
1991
|
return this.#db
|
|
1599
|
-
.prepare(
|
|
1992
|
+
.prepare(`SELECT s.*, a.agent_name AS agent_name
|
|
1993
|
+
FROM sessions s JOIN agents a ON a.agent_id = s.agent_id
|
|
1994
|
+
WHERE s.status = ? AND a.state != 'retired'`)
|
|
1600
1995
|
.all(status);
|
|
1601
1996
|
}
|
|
1602
1997
|
/**
|
|
@@ -1610,9 +2005,13 @@ export class SessionNodeManager {
|
|
|
1610
2005
|
getSessionsForAgent(agentName) {
|
|
1611
2006
|
if (!this.#db)
|
|
1612
2007
|
return [];
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
2008
|
+
// Scoped by the STABLE id. `agent_name` is not a column of `sessions` any more, so it is stamped
|
|
2009
|
+
// back on for display — and it is exactly the name we just resolved the id FROM, so no join is
|
|
2010
|
+
// needed and no stale copy can exist.
|
|
2011
|
+
const rows = this.#db
|
|
2012
|
+
.prepare("SELECT * FROM sessions WHERE agent_id = ? ORDER BY updated_at DESC")
|
|
2013
|
+
.all(this.#requireAgentId(agentName));
|
|
2014
|
+
return rows.map((r) => ({ ...r, agent_name: agentName }));
|
|
1616
2015
|
}
|
|
1617
2016
|
/**
|
|
1618
2017
|
* Every persisted session across ALL agents, most-recently-updated first. Backs the daemon-wide
|
|
@@ -1622,8 +2021,13 @@ export class SessionNodeManager {
|
|
|
1622
2021
|
getAllSessions() {
|
|
1623
2022
|
if (!this.#db)
|
|
1624
2023
|
return [];
|
|
2024
|
+
// Spans EVERY agent, so no single name can be resolved up front: the display name is joined in
|
|
2025
|
+
// from `agents`, its one source of truth. LEFT JOIN, not INNER — a session whose agent row is
|
|
2026
|
+
// missing must still be listed (an invisible session is worse than an unnamed one).
|
|
1625
2027
|
return this.#db
|
|
1626
|
-
.prepare(
|
|
2028
|
+
.prepare(`SELECT s.*, a.agent_name AS agent_name
|
|
2029
|
+
FROM sessions s LEFT JOIN agents a ON a.agent_id = s.agent_id
|
|
2030
|
+
ORDER BY s.updated_at DESC`)
|
|
1627
2031
|
.all();
|
|
1628
2032
|
}
|
|
1629
2033
|
/**
|
|
@@ -1639,8 +2043,8 @@ export class SessionNodeManager {
|
|
|
1639
2043
|
if (!this.#db)
|
|
1640
2044
|
return;
|
|
1641
2045
|
this.#db
|
|
1642
|
-
.prepare("UPDATE sessions SET seal_legibility = ?, sealed_root_hex = ?, updated_at = ? WHERE
|
|
1643
|
-
.run(legibilityJson, sealedRootHex, Date.now(), agentName, sessionId);
|
|
2046
|
+
.prepare("UPDATE sessions SET seal_legibility = ?, sealed_root_hex = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
|
|
2047
|
+
.run(legibilityJson, sealedRootHex, Date.now(), this.#requireAgentId(agentName), sessionId);
|
|
1644
2048
|
}
|
|
1645
2049
|
/**
|
|
1646
2050
|
* M8B FINDING-6 (cascade-2): persist a seal certificate for a session that may have NO local
|
|
@@ -1658,9 +2062,9 @@ export class SessionNodeManager {
|
|
|
1658
2062
|
const now = Date.now();
|
|
1659
2063
|
this.#db
|
|
1660
2064
|
.prepare(`INSERT OR IGNORE INTO sessions
|
|
1661
|
-
(session_id,
|
|
2065
|
+
(session_id, agent_id, counterparty_pubkey, status, created_at, updated_at)
|
|
1662
2066
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
1663
|
-
.run(sessionId, agentName, counterpartyPubkeyHex, "sealed", now, now);
|
|
2067
|
+
.run(sessionId, this.#requireAgentId(agentName), counterpartyPubkeyHex, "sealed", now, now);
|
|
1664
2068
|
this.recordSealCertificate(agentName, sessionId, sealedRootHex, legibilityJson);
|
|
1665
2069
|
}
|
|
1666
2070
|
/**
|
|
@@ -1673,8 +2077,8 @@ export class SessionNodeManager {
|
|
|
1673
2077
|
if (!this.#db)
|
|
1674
2078
|
return;
|
|
1675
2079
|
this.#db
|
|
1676
|
-
.prepare("UPDATE sessions SET counterparty_primary_pubkey = ?, updated_at = ? WHERE
|
|
1677
|
-
.run(primaryPubkeyHex, Date.now(), agentName, sessionId);
|
|
2080
|
+
.prepare("UPDATE sessions SET counterparty_primary_pubkey = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
|
|
2081
|
+
.run(primaryPubkeyHex, Date.now(), this.#requireAgentId(agentName), sessionId);
|
|
1678
2082
|
}
|
|
1679
2083
|
/**
|
|
1680
2084
|
* M7-SESSION-004 (AC-005/AC-006): read the persisted seal certificate for a session.
|
|
@@ -1688,8 +2092,8 @@ export class SessionNodeManager {
|
|
|
1688
2092
|
if (!this.#db)
|
|
1689
2093
|
return null;
|
|
1690
2094
|
const row = this.#db
|
|
1691
|
-
.prepare("SELECT sealed_root_hex, seal_legibility FROM sessions WHERE
|
|
1692
|
-
.get(agentName, sessionId);
|
|
2095
|
+
.prepare("SELECT sealed_root_hex, seal_legibility FROM sessions WHERE agent_id = ? AND session_id = ?")
|
|
2096
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
1693
2097
|
if (!row || !row.seal_legibility || !row.sealed_root_hex)
|
|
1694
2098
|
return null;
|
|
1695
2099
|
let legibility;
|
|
@@ -1741,8 +2145,8 @@ export class SessionNodeManager {
|
|
|
1741
2145
|
// the pre-check above raced (it cannot — DatabaseSync is synchronous), the
|
|
1742
2146
|
// UPDATE only mutates a row that is still active.
|
|
1743
2147
|
this.#db
|
|
1744
|
-
.prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ? WHERE
|
|
1745
|
-
.run(now, authoritativeCount, interruptedAt, agentName, sessionId);
|
|
2148
|
+
.prepare("UPDATE sessions SET status = 'interrupted', updated_at = ?, message_count = ?, interrupted_at = ? WHERE agent_id = ? AND session_id = ? AND status = 'active'")
|
|
2149
|
+
.run(now, authoritativeCount, interruptedAt, this.#requireAgentId(agentName), sessionId);
|
|
1746
2150
|
}
|
|
1747
2151
|
catch (err) {
|
|
1748
2152
|
this.#logger.error("session.interrupt.db.write.failed", {
|
|
@@ -1826,9 +2230,9 @@ export class SessionNodeManager {
|
|
|
1826
2230
|
try {
|
|
1827
2231
|
this.#db
|
|
1828
2232
|
.prepare(`INSERT OR REPLACE INTO seal_interrupted_artifacts
|
|
1829
|
-
(
|
|
2233
|
+
(agent_id, session_id, role, own_leaf, counterparty_leaf, merkle_root, nonce, created_at)
|
|
1830
2234
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1831
|
-
.run(opts.agentName, opts.sessionId, opts.role, JSON.stringify(opts.ownLeaf), JSON.stringify(opts.counterpartyLeaf), opts.merkleRoot, opts.nonce, now);
|
|
2235
|
+
.run(this.#requireAgentId(opts.agentName), opts.sessionId, opts.role, JSON.stringify(opts.ownLeaf), JSON.stringify(opts.counterpartyLeaf), opts.merkleRoot, opts.nonce, now);
|
|
1832
2236
|
}
|
|
1833
2237
|
catch (err) {
|
|
1834
2238
|
this.#logger.error("session.interrupted.db.write.failed", {
|
|
@@ -1842,8 +2246,8 @@ export class SessionNodeManager {
|
|
|
1842
2246
|
// active-session seal). The guard still refuses to overwrite a terminal
|
|
1843
2247
|
// 'sealed' row or an already-pending one.
|
|
1844
2248
|
const result = this.#db
|
|
1845
|
-
.prepare("UPDATE sessions SET status = 'seal_interrupted_pending', updated_at = ? WHERE
|
|
1846
|
-
.run(now, opts.agentName, opts.sessionId);
|
|
2249
|
+
.prepare("UPDATE sessions SET status = 'seal_interrupted_pending', updated_at = ? WHERE agent_id = ? AND session_id = ? AND status IN ('active', 'interrupted')")
|
|
2250
|
+
.run(now, this.#requireAgentId(opts.agentName), opts.sessionId);
|
|
1847
2251
|
return Number(result.changes) > 0;
|
|
1848
2252
|
}
|
|
1849
2253
|
/**
|
|
@@ -1854,8 +2258,8 @@ export class SessionNodeManager {
|
|
|
1854
2258
|
if (!this.#db)
|
|
1855
2259
|
return null;
|
|
1856
2260
|
const row = this.#db
|
|
1857
|
-
.prepare("SELECT * FROM seal_interrupted_artifacts WHERE
|
|
1858
|
-
.get(agentName, sessionId);
|
|
2261
|
+
.prepare("SELECT * FROM seal_interrupted_artifacts WHERE agent_id = ? AND session_id = ?")
|
|
2262
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
1859
2263
|
if (!row)
|
|
1860
2264
|
return null;
|
|
1861
2265
|
return {
|
|
@@ -1874,9 +2278,11 @@ export class SessionNodeManager {
|
|
|
1874
2278
|
if (!this.#db)
|
|
1875
2279
|
return null;
|
|
1876
2280
|
const row = this.#db
|
|
1877
|
-
.prepare("SELECT * FROM sessions WHERE
|
|
1878
|
-
.get(agentName, sessionId);
|
|
1879
|
-
|
|
2281
|
+
.prepare("SELECT * FROM sessions WHERE agent_id = ? AND session_id = ?")
|
|
2282
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
2283
|
+
// `agent_name` is display-only and no longer stored on the row; stamp back the name whose
|
|
2284
|
+
// agent_id scoped this lookup (~50 daemon call sites read `record.agent_name`).
|
|
2285
|
+
return row ? { ...row, agent_name: agentName } : null;
|
|
1880
2286
|
}
|
|
1881
2287
|
/**
|
|
1882
2288
|
* MSG-2 startup-flush: the persisted relay endpoint for a session, or null if none was
|
|
@@ -1887,8 +2293,8 @@ export class SessionNodeManager {
|
|
|
1887
2293
|
if (!this.#db)
|
|
1888
2294
|
return null;
|
|
1889
2295
|
const row = this.#db
|
|
1890
|
-
.prepare("SELECT relay_peer_id, relay_addrs FROM sessions WHERE
|
|
1891
|
-
.get(agentName, sessionId);
|
|
2296
|
+
.prepare("SELECT relay_peer_id, relay_addrs FROM sessions WHERE agent_id = ? AND session_id = ?")
|
|
2297
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
1892
2298
|
if (!row?.relay_peer_id || !row?.relay_addrs)
|
|
1893
2299
|
return null;
|
|
1894
2300
|
try {
|
|
@@ -1911,8 +2317,8 @@ export class SessionNodeManager {
|
|
|
1911
2317
|
if (!this.#db)
|
|
1912
2318
|
return [];
|
|
1913
2319
|
const rows = this.#db
|
|
1914
|
-
.prepare("SELECT DISTINCT relay_peer_id, relay_addrs FROM sessions WHERE
|
|
1915
|
-
.all(agentName);
|
|
2320
|
+
.prepare("SELECT DISTINCT relay_peer_id, relay_addrs FROM sessions WHERE agent_id = ? AND relay_peer_id IS NOT NULL")
|
|
2321
|
+
.all(this.#requireAgentId(agentName));
|
|
1916
2322
|
const byPeer = new Map();
|
|
1917
2323
|
for (const row of rows) {
|
|
1918
2324
|
if (!row.relay_peer_id || !row.relay_addrs)
|
|
@@ -1962,9 +2368,9 @@ export class SessionNodeManager {
|
|
|
1962
2368
|
try {
|
|
1963
2369
|
this.#db
|
|
1964
2370
|
.prepare(`INSERT INTO session_tree_leaves
|
|
1965
|
-
(
|
|
2371
|
+
(agent_id, session_id, leaf_index, leaf_kind, leaf_hash_hex, created_at)
|
|
1966
2372
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
1967
|
-
.run(agentName, sessionId, leafIndex, kind, leafHashHex, Date.now());
|
|
2373
|
+
.run(this.#requireAgentId(agentName), sessionId, leafIndex, kind, leafHashHex, Date.now());
|
|
1968
2374
|
// DAEMON-004 (finding #2): keep sessions.message_count synced to the tree
|
|
1969
2375
|
// size. message_count is the bilateral leafCount the seal flow signs over
|
|
1970
2376
|
// (handleSealInterruptedFlow / the responder). If it diverged from the
|
|
@@ -1972,8 +2378,8 @@ export class SessionNodeManager {
|
|
|
1972
2378
|
// truncated transcript and the bilateral leafCount check would mismatch.
|
|
1973
2379
|
// The tree (leafIndex + 1 leaves) is authoritative; the column tracks it.
|
|
1974
2380
|
this.#db
|
|
1975
|
-
.prepare("UPDATE sessions SET message_count = ?, updated_at = ? WHERE
|
|
1976
|
-
.run(leafIndex + 1, Date.now(), agentName, sessionId);
|
|
2381
|
+
.prepare("UPDATE sessions SET message_count = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
|
|
2382
|
+
.run(leafIndex + 1, Date.now(), this.#requireAgentId(agentName), sessionId);
|
|
1977
2383
|
}
|
|
1978
2384
|
catch (err) {
|
|
1979
2385
|
// A persist failure must be visible, not swallowed: the in-memory tree
|
|
@@ -2463,10 +2869,15 @@ export class SessionNodeManager {
|
|
|
2463
2869
|
// seam below (cheap + synchronous — fail fast on volume before spending gateway compute on
|
|
2464
2870
|
// content headed for rejection anyway); both gates are independent and either rejects on its
|
|
2465
2871
|
// own criteria, so ordering between them does not change correctness.
|
|
2466
|
-
|
|
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");
|
|
2467
2878
|
const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
|
|
2468
2879
|
const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
|
|
2469
|
-
if (priorTotal + heldTotal + content.length >
|
|
2880
|
+
if (priorTotal + heldTotal + content.length > cap) {
|
|
2470
2881
|
this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
|
|
2471
2882
|
sessionId,
|
|
2472
2883
|
agentName,
|
|
@@ -2474,7 +2885,8 @@ export class SessionNodeManager {
|
|
|
2474
2885
|
priorTotal,
|
|
2475
2886
|
heldTotal,
|
|
2476
2887
|
incoming: content.length,
|
|
2477
|
-
cap
|
|
2888
|
+
cap,
|
|
2889
|
+
tier: senderTier,
|
|
2478
2890
|
correlationId,
|
|
2479
2891
|
});
|
|
2480
2892
|
return { ok: false, reason: "session_size_limit_exceeded" };
|
|
@@ -2579,10 +2991,16 @@ export class SessionNodeManager {
|
|
|
2579
2991
|
// the append/hold branch is synchronous, so whichever call resumes first appends/holds before
|
|
2580
2992
|
// the second's re-check runs, and the second's freshly-recomputed totals correctly include the
|
|
2581
2993
|
// first's contribution.
|
|
2582
|
-
|
|
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");
|
|
2583
3001
|
const priorTotal = this.#getReceivedBytesTotal(agentName, sessionId);
|
|
2584
3002
|
const heldTotal = this.#getHeldBytesTotal(agentName, sessionId);
|
|
2585
|
-
if (priorTotal + heldTotal + content.length >
|
|
3003
|
+
if (priorTotal + heldTotal + content.length > cap) {
|
|
2586
3004
|
this.#logger.warn("session.content.abuse_bound.session_size_exceeded", {
|
|
2587
3005
|
sessionId,
|
|
2588
3006
|
agentName,
|
|
@@ -2590,7 +3008,8 @@ export class SessionNodeManager {
|
|
|
2590
3008
|
priorTotal,
|
|
2591
3009
|
heldTotal,
|
|
2592
3010
|
incoming: content.length,
|
|
2593
|
-
cap
|
|
3011
|
+
cap,
|
|
3012
|
+
tier: senderTier,
|
|
2594
3013
|
correlationId,
|
|
2595
3014
|
recheck: true,
|
|
2596
3015
|
});
|
|
@@ -2803,8 +3222,8 @@ export class SessionNodeManager {
|
|
|
2803
3222
|
if (!this.#db)
|
|
2804
3223
|
return null;
|
|
2805
3224
|
const row = this.#db
|
|
2806
|
-
.prepare("SELECT sealed_root_hex FROM sessions WHERE
|
|
2807
|
-
.get(agentName, sessionId);
|
|
3225
|
+
.prepare("SELECT sealed_root_hex FROM sessions WHERE agent_id = ? AND session_id = ?")
|
|
3226
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
2808
3227
|
return row?.sealed_root_hex ?? null;
|
|
2809
3228
|
}
|
|
2810
3229
|
// ─── CELLO-M7-MSG-001: delivery ACK / TTF tracking (send side) ──────────────
|
|
@@ -2973,8 +3392,8 @@ export class SessionNodeManager {
|
|
|
2973
3392
|
if (!this.#db)
|
|
2974
3393
|
return SessionTree.empty();
|
|
2975
3394
|
const rows = this.#db
|
|
2976
|
-
.prepare("SELECT leaf_kind, leaf_hash_hex FROM session_tree_leaves WHERE
|
|
2977
|
-
.all(agentName, sessionId);
|
|
3395
|
+
.prepare("SELECT leaf_kind, leaf_hash_hex FROM session_tree_leaves WHERE agent_id = ? AND session_id = ? ORDER BY leaf_index ASC")
|
|
3396
|
+
.all(this.#requireAgentId(agentName), sessionId);
|
|
2978
3397
|
return SessionTree.fromLeaves(rows.map((r) => ({ kind: r.leaf_kind === "ctrl" ? "ctrl" : "msg", hashHex: r.leaf_hash_hex })));
|
|
2979
3398
|
}
|
|
2980
3399
|
/**
|
|
@@ -3424,9 +3843,9 @@ export class SessionNodeManager {
|
|
|
3424
3843
|
try {
|
|
3425
3844
|
this.#db
|
|
3426
3845
|
.prepare(`INSERT INTO sessions
|
|
3427
|
-
(session_id,
|
|
3846
|
+
(session_id, agent_id, counterparty_pubkey, status, created_at, updated_at)
|
|
3428
3847
|
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
3429
|
-
.run(sessionId, agentName, counterpartyPubkey, status, now, now);
|
|
3848
|
+
.run(sessionId, this.#requireAgentId(agentName), counterpartyPubkey, status, now, now);
|
|
3430
3849
|
return true;
|
|
3431
3850
|
}
|
|
3432
3851
|
catch (err) {
|
|
@@ -3448,8 +3867,8 @@ export class SessionNodeManager {
|
|
|
3448
3867
|
if (!this.#db)
|
|
3449
3868
|
return 0;
|
|
3450
3869
|
const row = this.#db
|
|
3451
|
-
.prepare("SELECT COUNT(*) AS n FROM transcript WHERE
|
|
3452
|
-
.get(agentName, sessionId);
|
|
3870
|
+
.prepare("SELECT COUNT(*) AS n FROM transcript WHERE agent_id = ? AND session_id = ? AND direction = 'received'")
|
|
3871
|
+
.get(this.#requireAgentId(agentName), sessionId);
|
|
3453
3872
|
return row.n;
|
|
3454
3873
|
}
|
|
3455
3874
|
/** CC-5/F21: unilaterally mark a session locally-terminal ("abandoned") — retire its live node and
|
|
@@ -3471,8 +3890,8 @@ export class SessionNodeManager {
|
|
|
3471
3890
|
const now = Date.now();
|
|
3472
3891
|
try {
|
|
3473
3892
|
this.#db
|
|
3474
|
-
.prepare("UPDATE sessions SET status = ?, updated_at = ? WHERE
|
|
3475
|
-
.run(status, now, agentName, sessionId);
|
|
3893
|
+
.prepare("UPDATE sessions SET status = ?, updated_at = ? WHERE agent_id = ? AND session_id = ?")
|
|
3894
|
+
.run(status, now, this.#requireAgentId(agentName), sessionId);
|
|
3476
3895
|
return true;
|
|
3477
3896
|
}
|
|
3478
3897
|
catch (err) {
|