@securecoms/agent-bridge 0.11.0 → 0.11.1

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.
@@ -0,0 +1,831 @@
1
+ import { RestTransport, RestMlsBackend, SoftwareKeyCustody, MlsGroupKeyProvider, buildOutboundEnvelope, sendWithSelfHeal, KeyedInboxCipher, deriveMlsStoreKey, } from "@agentvault/connect-sdk-ts";
2
+ import { SqliteMlsStateStore } from "@agentvault/connect-mls-store";
3
+ import { decryptFile, encryptFile, computeFileDigest, base64ToBytes, bytesToBase64 } from "@agentvault/crypto";
4
+ import { promises as fsp } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { LatencyRecorder } from "./latency.js";
7
+ import { shouldProcessRoomMessage } from "./coordination.js";
8
+ import { TokenBucketFloor } from "./rate-limit.js";
9
+ import { materializeAttachment, prepareAttachment } from "./attachments.js";
10
+ /** Default inbound-buffer cap (messages) when deps.maxBuffer is unset. */
11
+ export const DEFAULT_MAX_BUFFER = 1000;
12
+ /**
13
+ * TTL for the Phase 2 channelMeta cache. `is_dm` is immutable for a channel_id,
14
+ * so this exists only so a cached `name` (the room display name used by the
15
+ * @mention filter) re-resolves after a channel rename. Lazy expiry on read.
16
+ */
17
+ export const CHANNEL_META_TTL_MS = 5 * 60_000;
18
+ /**
19
+ * TTL for the Phase 2 channel ROSTER cache (channel_id → identity_id → name /
20
+ * is_agent), used to attribute decrypted messages by sender. Parallels
21
+ * CHANNEL_META_TTL_MS: a member's display_name can change (rename) and the
22
+ * member set can change, so a stale roster re-resolves on a later cycle. Lazy
23
+ * expiry on read. Resolution is best-effort and fail-soft — a miss leaves
24
+ * sender_name undefined and NEVER blocks or drops a message.
25
+ */
26
+ export const CHANNEL_ROSTER_TTL_MS = 5 * 60_000;
27
+ const ZERO_TRACEPARENT = "00-" + "0".repeat(32) + "-" + "0".repeat(16) + "-01";
28
+ export class ChannelPendingError extends Error {
29
+ channelId;
30
+ constructor(channelId) {
31
+ super(`channel ${channelId} is not yet admitted (awaiting Welcome)`);
32
+ this.channelId = channelId;
33
+ this.name = "ChannelPendingError";
34
+ }
35
+ }
36
+ export class RateLimitedError extends Error {
37
+ channelId;
38
+ retryAfterMs;
39
+ constructor(channelId, retryAfterMs) {
40
+ super(`channel ${channelId} send rate limit exceeded (retry in ${retryAfterMs}ms)`);
41
+ this.channelId = channelId;
42
+ this.retryAfterMs = retryAfterMs;
43
+ this.name = "RateLimitedError";
44
+ }
45
+ }
46
+ export class InvalidPathError extends Error {
47
+ filePath;
48
+ constructor(filePath, cause) {
49
+ super(`cannot read attachment file: ${filePath}`);
50
+ this.filePath = filePath;
51
+ this.name = "InvalidPathError";
52
+ if (cause !== undefined)
53
+ this.cause = cause;
54
+ }
55
+ }
56
+ export const BRIDGE_VERSION = "0.0.0"; // surfaced by /healthz; bump with package.json
57
+ function hexToBytes(h) {
58
+ return new Uint8Array(Buffer.from(h, "hex"));
59
+ }
60
+ export class BridgeAgent {
61
+ cfg;
62
+ transport;
63
+ provider;
64
+ backend; // stored for peek-then-join in ingestOnce
65
+ custody;
66
+ fetchImpl;
67
+ joinState = new Map();
68
+ /**
69
+ * Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
70
+ * by the multi-agent room @mention filter at ingest. Populated/refreshed from a
71
+ * SINGLE listChannels() per ingest cycle on a cache miss. A channel that cannot
72
+ * be resolved (network error, or not present in the listing) stays UNKNOWN → the
73
+ * filter fails OPEN.
74
+ *
75
+ * `is_dm` is immutable for a given channel_id (no misclassification risk), so the
76
+ * TTL exists only to keep `name` fresh after a channel rename — an entry older
77
+ * than CHANNEL_META_TTL_MS is treated as a miss and re-resolved on the next cycle.
78
+ */
79
+ channelMeta = new Map();
80
+ /**
81
+ * Phase 2 (sender attribution): per-channel roster cache
82
+ * (channel_id → identity_id → { name, is_agent }) resolved from the GET
83
+ * members endpoint. Same lazy/TTL/fail-soft discipline as channelMeta: an
84
+ * unresolved channel (network error, or no entry) leaves sender_name
85
+ * undefined and never blocks ingest. Resolved AT MOST ONCE per ingest cycle
86
+ * per channel on a miss.
87
+ */
88
+ channelRoster = new Map();
89
+ buffer = [];
90
+ maxBuffer;
91
+ /** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
92
+ droppedMessages = 0;
93
+ /**
94
+ * Cumulative count of ROOM messages dropped by the @mention filter (item 3 /
95
+ * ADR-0002 ethos). Surfaced on health()/metrics() so an agent correctly filtering
96
+ * most room traffic is distinguishable from an idle/wedged one.
97
+ */
98
+ filteredRoomMessages = 0;
99
+ /** Cumulative count of sends shed by the per-channel rate-limit floor (surfaced on health()/metrics()). */
100
+ rateLimitedSends = 0;
101
+ /** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
102
+ welcomeRequestsSent = 0;
103
+ /**
104
+ * Track 5: cumulative count of artifact_share messages where download/decrypt/digest
105
+ * failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
106
+ * counter is incremented. Surfaced on health()/metrics().
107
+ */
108
+ failedAttachments = 0;
109
+ /** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
110
+ lastWelcomeRequest = new Map();
111
+ /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
112
+ static REQUEST_WELCOME_COOLDOWN_MS = 120_000;
113
+ rateFloor;
114
+ waiters = [];
115
+ pingHandle;
116
+ safetyTimer;
117
+ listeners = new Set();
118
+ startedAt = 0;
119
+ store;
120
+ latency;
121
+ /** Track 5: injectable blob download fn (default: fetch-based). */
122
+ downloadBlobImpl;
123
+ /** Track 5: injectable blob upload fn (default: fetch-based). */
124
+ uploadBlobImpl;
125
+ /** Track 5: directory where received attachments are saved. */
126
+ attachmentsDirImpl;
127
+ constructor(cfg, deps = {}) {
128
+ this.cfg = cfg;
129
+ const seed = hexToBytes(cfg.signingPrivateKey);
130
+ this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
131
+ this.transport =
132
+ deps.transport ??
133
+ new RestTransport({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, connectIdentityId: cfg.identityId });
134
+ this.backend =
135
+ deps.backend ??
136
+ new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
137
+ this.store = new SqliteMlsStateStore({
138
+ dbPath: cfg.storePath,
139
+ identityId: cfg.identityId,
140
+ cipherFor: () => new KeyedInboxCipher(deriveMlsStoreKey(seed)),
141
+ });
142
+ this.provider = new MlsGroupKeyProvider({
143
+ store: this.store,
144
+ backend: this.backend,
145
+ deviceId: cfg.deviceId,
146
+ identity: new TextEncoder().encode(cfg.identityId),
147
+ // ADR-0002: count + log agent-side decrypt failures. Without this hook the
148
+ // daemon was 100% blind to a wedged channel (it "looked idle"); the
149
+ // provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
150
+ onDecryptError: (row, err) => {
151
+ const cid = row.connect_channel_id ?? "(unmapped)";
152
+ console.warn(`[bridge] decrypt error on channel ${cid} (queue ${row.queue_id}, epoch ${row.epoch}): ${err instanceof Error ? err.message : String(err)}`);
153
+ },
154
+ });
155
+ this.fetchImpl = deps.fetchImpl ?? fetch;
156
+ this.latency = deps.latency ?? new LatencyRecorder();
157
+ this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
158
+ this.rateFloor = new TokenBucketFloor({
159
+ capacity: cfg.rateLimitCapacity ?? 20,
160
+ refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20,
161
+ });
162
+ // Track 5: attachment receive deps
163
+ const self = this;
164
+ this.downloadBlobImpl = deps.downloadBlob ?? async function (blobId) {
165
+ const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
166
+ const res = await self.fetchImpl(url, { headers: { authorization: `Bearer ${cfg.apiKey}` } });
167
+ if (!res.ok)
168
+ throw new Error(`download blob failed: ${res.status}`);
169
+ return new Uint8Array(await res.arrayBuffer());
170
+ };
171
+ this.uploadBlobImpl = deps.uploadBlob ?? async function (ciphertext, channelId) {
172
+ const url = `${cfg.apiBase}/api/v1/attachments?channel_id=${encodeURIComponent(channelId)}&caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
173
+ const res = await self.fetchImpl(url, {
174
+ method: "POST",
175
+ headers: { "content-type": "application/octet-stream", authorization: `Bearer ${cfg.apiKey}` },
176
+ body: ciphertext,
177
+ });
178
+ if (!res.ok)
179
+ throw new Error(`upload blob failed: ${res.status}`);
180
+ const j = (await res.json());
181
+ return String(j.attachment_id);
182
+ };
183
+ this.attachmentsDirImpl = deps.attachmentsDir ?? join(dirname(cfg.storePath), "attachments");
184
+ }
185
+ /**
186
+ * Enforce the inbound-buffer cap with a DROP-OLDEST policy.
187
+ *
188
+ * The buffer is uniformly ordered oldest→newest: ingestOnce() appends freshly
189
+ * decrypted inbound at the BACK (newest), and requeueFront() unshifts un-acked
190
+ * messages at the FRONT — those re-buffered messages were consumed earlier, so
191
+ * they are by definition OLDER than whatever already sits in the buffer. In
192
+ * every push path the newest messages therefore live at the back. To honour
193
+ * "the most-recent survive" we always shed from the FRONT, then record the
194
+ * shed so a silently-dropping daemon stays observable (item 3 / ADR-0002).
195
+ */
196
+ capBuffer() {
197
+ if (this.maxBuffer <= 0)
198
+ return;
199
+ const overflow = this.buffer.length - this.maxBuffer;
200
+ if (overflow > 0) {
201
+ this.buffer.splice(0, overflow); // drop the oldest, keep the newest maxBuffer
202
+ this.droppedMessages += overflow;
203
+ }
204
+ }
205
+ async start() {
206
+ this.startedAt = Date.now();
207
+ // B1 carry-forward: a restart loses the in-memory pending bundle, so always
208
+ // re-publish so the owner's cycle-A admission watcher (re)adds this leaf.
209
+ await this.provider.publishKeyPackage();
210
+ this.startReceivePump();
211
+ }
212
+ async reconcileJoin(channelId) {
213
+ if (this.joinState.get(channelId) === "JOINED")
214
+ return true;
215
+ try {
216
+ // Reconcile, not a plain join. joinAndReconcile = ensureSession(join)
217
+ // (consume the Welcome, or early-return if we already hold state) THEN
218
+ // drain pending commits so we converge on the group's CURRENT epoch.
219
+ // A plain ensureSession(join) lands at the Welcome's epoch and never
220
+ // advances: if the owner adds another member after we joined (a commit
221
+ // bumps the epoch), every later message arrives at an epoch we don't
222
+ // hold, fails to decrypt, and piles up unconsumed — the channel goes
223
+ // silent for the agent even though it is a member. Reconciling on
224
+ // (re)join applies that commit and unsticks it.
225
+ await this.provider.joinAndReconcile(channelId, this.cfg.enclaveId);
226
+ this.joinState.set(channelId, "JOINED");
227
+ return true;
228
+ }
229
+ catch {
230
+ // "no Welcome available yet" — the owner watcher hasn't added us. Stay
231
+ // PENDING; the pump MUST NOT die. Retried on the next cycle/use.
232
+ this.joinState.set(channelId, "PENDING");
233
+ return false;
234
+ }
235
+ }
236
+ /**
237
+ * Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
238
+ * trip (incrementing the observable counter). `kind` namespaces the bucket so
239
+ * room and a2a traffic on the same channel are tracked separately.
240
+ */
241
+ enforceRateFloor(kind, channelId) {
242
+ const key = `${kind}:${channelId}`;
243
+ if (!this.rateFloor.tryConsume(key)) {
244
+ this.rateLimitedSends++;
245
+ throw new RateLimitedError(channelId, this.rateFloor.msUntilNextToken(key));
246
+ }
247
+ }
248
+ async send(channelId, text, messageId) {
249
+ const meta = this.freshMeta(channelId);
250
+ if (meta && !meta.is_dm)
251
+ this.enforceRateFloor("room", channelId);
252
+ const joined = await this.reconcileJoin(channelId);
253
+ if (!joined)
254
+ throw new ChannelPendingError(channelId);
255
+ const id = messageId ?? crypto.randomUUID();
256
+ const sendStart = performance.now();
257
+ // Self-heal-on-send (Phase 1.1) via the SHARED SDK seam. reconcileJoin only
258
+ // converges on the FIRST join (it early-returns once JOINED), so a bridge
259
+ // that fell behind because a peer committed would seal at a stale epoch and
260
+ // be rejected with an epoch-stale 400. sendWithSelfHeal reconciles per send
261
+ // (drain pending commits) and retries once on a stale 400.
262
+ // send.encrypt = MLS application-message seal (XChaCha20-Poly1305 over the
263
+ // group's current epoch secret). send.publish = relay to the backend over
264
+ // the persistent transport. send.total = the full agent-visible send cost.
265
+ await sendWithSelfHeal(this.provider, channelId, (epoch) => this.latency.time("send.encrypt", () => buildOutboundEnvelope({
266
+ custody: this.custody,
267
+ groupKey: this.provider,
268
+ messageId: id,
269
+ channelId,
270
+ enclaveId: this.cfg.enclaveId,
271
+ senderId: this.cfg.identityId,
272
+ senderType: "agent",
273
+ type: "text",
274
+ bodyPlaintext: JSON.stringify({ text }),
275
+ bindingId: "",
276
+ bindingVersion: "0.0.0",
277
+ agentSkillId: null,
278
+ epoch,
279
+ traceparent: ZERO_TRACEPARENT,
280
+ references: [],
281
+ stage5Outcome: "allow",
282
+ stage5Evaluators: [],
283
+ })), (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope)));
284
+ this.latency.record("send.total", performance.now() - sendStart);
285
+ return { message_id: id, status: "sent" };
286
+ }
287
+ async sendA2A(channelId, a2a, messageId) {
288
+ this.enforceRateFloor("a2a", channelId);
289
+ const joined = await this.reconcileJoin(channelId);
290
+ if (!joined)
291
+ throw new ChannelPendingError(channelId);
292
+ const id = messageId ?? crypto.randomUUID();
293
+ const sendStart = performance.now();
294
+ // Self-heal-on-send (Phase 1.1) via the SHARED SDK seam (mirrors send()).
295
+ await sendWithSelfHeal(this.provider, channelId, (epoch) => this.latency.time("send.encrypt", () => buildOutboundEnvelope({
296
+ custody: this.custody,
297
+ groupKey: this.provider,
298
+ messageId: id,
299
+ channelId,
300
+ enclaveId: this.cfg.enclaveId,
301
+ senderId: this.cfg.identityId,
302
+ senderType: "agent",
303
+ type: "a2a",
304
+ bodyPlaintext: JSON.stringify({ type: "a2a", a2a_version: a2a.a2a_version, kind: a2a.kind, object: a2a.object }),
305
+ bindingId: "",
306
+ bindingVersion: "0.0.0",
307
+ agentSkillId: null,
308
+ epoch,
309
+ traceparent: ZERO_TRACEPARENT,
310
+ references: [],
311
+ stage5Outcome: "allow",
312
+ stage5Evaluators: [],
313
+ })), (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope)));
314
+ this.latency.record("send.total", performance.now() - sendStart);
315
+ return { message_id: id, status: "sent" };
316
+ }
317
+ sendDeps() {
318
+ return {
319
+ uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
320
+ encryptFile,
321
+ bytesToBase64,
322
+ readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b)),
323
+ };
324
+ }
325
+ async sendAttachment(channelId, filePath, caption) {
326
+ this.enforceRateFloor("attachment", channelId);
327
+ const joined = await this.reconcileJoin(channelId);
328
+ if (!joined)
329
+ throw new ChannelPendingError(channelId);
330
+ // prepareAttachment reads, encrypts, and uploads the file — wrap readFile failures
331
+ // as InvalidPathError so the HTTP layer can return a clear 400 instead of 500.
332
+ let body;
333
+ try {
334
+ body = await prepareAttachment(filePath, caption, channelId, this.sendDeps());
335
+ }
336
+ catch (e) {
337
+ if (e.code === "ENOENT" || e.code === "EACCES" || e.code === "ENOTDIR" || e.code === "EISDIR") {
338
+ throw new InvalidPathError(filePath, e);
339
+ }
340
+ throw e;
341
+ }
342
+ const id = crypto.randomUUID();
343
+ const sendStart = performance.now();
344
+ await sendWithSelfHeal(this.provider, channelId, (epoch) => this.latency.time("send.encrypt", () => buildOutboundEnvelope({
345
+ custody: this.custody,
346
+ groupKey: this.provider,
347
+ messageId: id,
348
+ channelId,
349
+ enclaveId: this.cfg.enclaveId,
350
+ senderId: this.cfg.identityId,
351
+ senderType: "agent",
352
+ type: "artifact_share",
353
+ bodyPlaintext: JSON.stringify(body),
354
+ bindingId: "",
355
+ bindingVersion: "0.0.0",
356
+ agentSkillId: null,
357
+ epoch,
358
+ traceparent: ZERO_TRACEPARENT,
359
+ references: [],
360
+ stage5Outcome: "allow",
361
+ stage5Evaluators: [],
362
+ })), (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope)));
363
+ this.latency.record("send.total", performance.now() - sendStart);
364
+ return { message_id: id, status: "sent" };
365
+ }
366
+ async listChannels() {
367
+ const url = `${this.cfg.apiBase}/api/v1/enclaves/${this.cfg.enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
368
+ const res = await this.fetchImpl(url, {
369
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` },
370
+ });
371
+ if (!res.ok)
372
+ throw new Error(`list_channels failed: ${res.status}`);
373
+ const rows = (await res.json());
374
+ return rows.map((r) => ({
375
+ channel_id: r.channel_id,
376
+ name: r.name ?? null,
377
+ is_dm: r.is_dm,
378
+ joined: true,
379
+ }));
380
+ }
381
+ /** A live channelMeta entry, or undefined if missing OR expired past the TTL. */
382
+ freshMeta(channelId) {
383
+ const hit = this.channelMeta.get(channelId);
384
+ if (!hit)
385
+ return undefined;
386
+ // Lazy TTL expiry: a stale entry is treated as a miss so `name` re-resolves
387
+ // on the next cycle's single refresh. (is_dm is immutable; this is for name.)
388
+ if (Date.now() - hit.at >= CHANNEL_META_TTL_MS)
389
+ return undefined;
390
+ return { is_dm: hit.is_dm, name: hit.name };
391
+ }
392
+ /**
393
+ * One-shot, whole-listing refresh of channelMeta. Called AT MOST ONCE per
394
+ * ingest cycle (the caller guards with a cycle-local "refreshed" flag), so a
395
+ * burst of K unknown channels fires a single listChannels() instead of K. Any
396
+ * listChannels failure is swallowed (UNKNOWN → fail open) so ingest never
397
+ * blocks on the network.
398
+ */
399
+ async refreshChannelMeta() {
400
+ try {
401
+ const now = Date.now();
402
+ const channels = await this.listChannels();
403
+ for (const c of channels) {
404
+ this.channelMeta.set(c.channel_id, { is_dm: c.is_dm, name: c.name, at: now });
405
+ }
406
+ }
407
+ catch {
408
+ /* network error → leave cache as-is → unresolved channels fail open */
409
+ }
410
+ }
411
+ /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
412
+ freshRoster(channelId) {
413
+ const hit = this.channelRoster.get(channelId);
414
+ if (!hit)
415
+ return undefined;
416
+ if (Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS)
417
+ return undefined;
418
+ return hit.members;
419
+ }
420
+ /**
421
+ * Resolve one channel's roster (members + display names + is-agent) from the
422
+ * GET members endpoint into the roster cache. Called AT MOST ONCE per ingest
423
+ * cycle per channel on a cache miss. Fail-soft: any error leaves the cache
424
+ * as-is so the affected senders stay unresolved (sender_name undefined) —
425
+ * attribution must NEVER block or drop a message.
426
+ */
427
+ async refreshChannelRoster(channelId) {
428
+ try {
429
+ const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(channelId)}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
430
+ const res = await this.fetchImpl(url, {
431
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` },
432
+ });
433
+ if (!res.ok)
434
+ return; // fail-soft: leave unresolved
435
+ const rows = (await res.json());
436
+ const members = new Map();
437
+ for (const r of rows) {
438
+ members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
439
+ }
440
+ this.channelRoster.set(channelId, { members, at: Date.now() });
441
+ }
442
+ catch {
443
+ /* network/parse error → leave cache as-is → senders fail soft (unresolved) */
444
+ }
445
+ }
446
+ async reportStatus(channelId, state, detail) {
447
+ const joined = await this.reconcileJoin(channelId);
448
+ if (!joined)
449
+ throw new ChannelPendingError(channelId);
450
+ // Self-heal-on-send (Phase 1.1) via the SHARED SDK seam (mirrors send()).
451
+ await sendWithSelfHeal(this.provider, channelId, (epoch) => buildOutboundEnvelope({
452
+ custody: this.custody,
453
+ groupKey: this.provider,
454
+ messageId: crypto.randomUUID(),
455
+ channelId,
456
+ enclaveId: this.cfg.enclaveId,
457
+ senderId: this.cfg.identityId,
458
+ senderType: "agent",
459
+ type: "system_event",
460
+ bodyPlaintext: JSON.stringify({
461
+ v: 1,
462
+ type: "agent_status",
463
+ state,
464
+ at: new Date().toISOString(),
465
+ ...(detail ? { detail } : {}),
466
+ }),
467
+ bindingId: "",
468
+ bindingVersion: "0.0.0",
469
+ agentSkillId: null,
470
+ epoch,
471
+ traceparent: ZERO_TRACEPARENT,
472
+ references: [],
473
+ stage5Outcome: "allow",
474
+ stage5Evaluators: [],
475
+ }), (envelope) => this.transport.publish(envelope));
476
+ }
477
+ startReceivePump() {
478
+ // Device-scoped WS ping stream (channel arg ignored by RestTransport).
479
+ const sub = this.transport.subscribeDeliveryPings("");
480
+ this.pingHandle = { close: sub.close };
481
+ void (async () => {
482
+ try {
483
+ for await (const _ping of sub.pings) {
484
+ await this.ingestOnce();
485
+ }
486
+ }
487
+ catch {
488
+ /* stream closed — pump exits cleanly */
489
+ }
490
+ })();
491
+ // Safety floor: drain every 25s even if a ping is missed.
492
+ this.safetyTimer = setInterval(() => {
493
+ void this.ingestOnce();
494
+ }, 25_000);
495
+ }
496
+ async ingestOnce() {
497
+ // Phase 0: peek at pending delivery rows to find channel IDs. Reconcile
498
+ // join for each unjoined channel so that receiveOnce can load the MLS state
499
+ // and decrypt application messages. Without this, application decryption
500
+ // silently fails because the MLS group manager hasn't been initialised for
501
+ // the channel yet (the Welcome must be consumed via ensureSession first).
502
+ const cycleStart = performance.now();
503
+ try {
504
+ const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
505
+ const channelIds = new Set();
506
+ for (const row of rows) {
507
+ if (row.connect_channel_id)
508
+ channelIds.add(row.connect_channel_id);
509
+ }
510
+ for (const cid of channelIds) {
511
+ if (this.joinState.get(cid) !== "JOINED") {
512
+ await this.reconcileJoin(cid);
513
+ }
514
+ }
515
+ // Self-heal: a channel we received delivery for but still have NO local MLS
516
+ // state for (after attempting a join) is diverged — ask the committer to
517
+ // re-Welcome us. Rate-limited per channel. (No string-matching; the
518
+ // persisted-state check is the signal.) Use hasPersistedChannelState (store
519
+ // + in-memory), NOT the in-memory-only hasChannelState: a healthy agent
520
+ // whose manager is merely cold this cycle (e.g. a stray-welcome-only cycle)
521
+ // has persisted state and must NOT trigger a spurious heal/epoch-churn.
522
+ for (const row of rows) {
523
+ const cid = row.connect_channel_id;
524
+ const gid = row.group_id;
525
+ if (!cid || !gid)
526
+ continue;
527
+ if (await this.provider.hasPersistedChannelState(cid))
528
+ continue;
529
+ const now = performance.now();
530
+ const last = this.lastWelcomeRequest.get(cid) ?? -Infinity;
531
+ if (now - last < BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS)
532
+ continue;
533
+ this.lastWelcomeRequest.set(cid, now);
534
+ try {
535
+ await this.backend.requestWelcome(gid, this.cfg.deviceId);
536
+ this.welcomeRequestsSent++;
537
+ }
538
+ catch { /* best-effort; retried next eligible cycle */ }
539
+ }
540
+ }
541
+ catch {
542
+ /* best-effort; receiveOnce proceeds regardless */
543
+ }
544
+ let entries;
545
+ try {
546
+ // recv.decrypt = pull + MLS open of any queued application messages
547
+ // (symmetric AEAD over the sender's epoch secret) for this device.
548
+ entries = await this.latency.time("recv.decrypt", () => this.provider.receiveOnce(this.cfg.deviceId));
549
+ }
550
+ catch {
551
+ return; // never let a receive error kill the pump
552
+ }
553
+ // Only record a cycle when there was actually something to process — empty
554
+ // poll ticks (the 25s safety drain) would otherwise dominate the metric.
555
+ if (!entries.length)
556
+ return;
557
+ // S-1: resolve channel metadata AT MOST ONCE per cycle. If any entry's
558
+ // channel is missing/expired in the cache, do a SINGLE listChannels() for the
559
+ // whole cycle and set metaRefreshed so no second call fires this tick. Any
560
+ // channel still unknown after that one refresh fails OPEN (buffers) with no
561
+ // further calls. ≤1 listChannels() per ingest cycle.
562
+ let metaRefreshed = false;
563
+ if (entries.some((m) => this.freshMeta(m.channelId) === undefined)) {
564
+ await this.refreshChannelMeta();
565
+ metaRefreshed = true;
566
+ }
567
+ // S-1 (roster): resolve each distinct channel's roster AT MOST ONCE per
568
+ // cycle on a miss, so a burst across K channels fires at most K member
569
+ // fetches (one per channel) and a single channel with N messages fires
570
+ // exactly one. Fail-soft inside refreshChannelRoster (a miss leaves
571
+ // sender_name undefined; never blocks ingest).
572
+ const rosterRefreshed = new Set();
573
+ for (const m of entries) {
574
+ if (rosterRefreshed.has(m.channelId))
575
+ continue;
576
+ rosterRefreshed.add(m.channelId);
577
+ if (this.freshRoster(m.channelId) === undefined) {
578
+ await this.refreshChannelRoster(m.channelId);
579
+ }
580
+ }
581
+ const acked = [];
582
+ for (const m of entries) {
583
+ try {
584
+ const parsed = JSON.parse(new TextDecoder().decode(m.plaintext));
585
+ // Handle non-text messages: artifact_share → materialize; others → skip+ack
586
+ if (parsed.type && parsed.type !== "text") {
587
+ if (parsed.type === "artifact_share") {
588
+ // Track 5: fail-soft — a download/decrypt/digest failure acks the message
589
+ // (no redelivery), increments the counter, and logs but never throws.
590
+ try {
591
+ const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
592
+ const senderType = m.envelope.sender_type;
593
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : undefined;
594
+ const meta = await materializeAttachment(parsed, { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId }, {
595
+ downloadBlob: this.downloadBlobImpl,
596
+ decryptFile,
597
+ computeFileDigest,
598
+ base64ToBytes,
599
+ mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => undefined),
600
+ writeFile: (p, data) => fsp.writeFile(p, data),
601
+ });
602
+ this.buffer.push({
603
+ channel_id: m.channelId,
604
+ message_id: m.messageId,
605
+ sender_id: m.envelope.sender_id,
606
+ text: typeof parsed.text === "string"
607
+ ? parsed.text
608
+ : "",
609
+ ts: m.receivedAt,
610
+ sender_name: senderName,
611
+ sender_is_agent: senderIsAgent,
612
+ attachments: [meta],
613
+ });
614
+ this.capBuffer();
615
+ }
616
+ catch (e) {
617
+ this.failedAttachments++;
618
+ process.stderr.write(`[bridge] attachment failed on ${m.messageId}: ${e.message}\n`);
619
+ }
620
+ }
621
+ // other non-text control/status messages: skip (still ack below)
622
+ acked.push(m.queueId);
623
+ continue;
624
+ }
625
+ if (typeof parsed.text === "string") {
626
+ // Phase 2 — multi-agent room @mention filter. Classify the channel:
627
+ // DM → always buffer (1:1, no filtering)
628
+ // Room → buffer only if the @mention filter says this agent should
629
+ // see it; a message @mentioning a *different* agent is
630
+ // skipped (but still acked so it isn't redelivered forever)
631
+ // UNKNOWN→ fail OPEN: buffer (never silently drop the unclassifiable)
632
+ // Metadata was resolved once at the top of the cycle (metaRefreshed).
633
+ // freshMeta is a pure cache read here — no per-message network call.
634
+ void metaRefreshed;
635
+ const meta = this.freshMeta(m.channelId);
636
+ // sender_type is the on-wire envelope field (see envelope-builder /
637
+ // fake-backend). The @mention filter passes it through; Phase 2 also
638
+ // surfaces it on BridgeMessage as sender_is_agent (same derivation,
639
+ // not recomputed differently).
640
+ const senderType = m.envelope.sender_type;
641
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : undefined;
642
+ let shouldBuffer = true;
643
+ if (meta && meta.is_dm === false) {
644
+ shouldBuffer = shouldProcessRoomMessage(parsed.text, this.cfg.agentName ?? this.cfg.identityId, this.cfg.identityId, { senderIsAgent, roomDisplayName: meta.name ?? undefined });
645
+ if (!shouldBuffer)
646
+ this.filteredRoomMessages++; // item 3: observable filtering
647
+ }
648
+ if (shouldBuffer) {
649
+ // Phase 2: attribute the sender. Roster lookup is a pure cache
650
+ // read here (resolved once per cycle above) — never a per-message
651
+ // network call. Unresolved → sender_name undefined (fail-soft;
652
+ // consumer falls back to sender_id). NEVER drop/delay on a miss.
653
+ const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
654
+ this.buffer.push({
655
+ channel_id: m.channelId,
656
+ message_id: m.messageId,
657
+ sender_id: m.envelope.sender_id,
658
+ text: parsed.text,
659
+ ts: m.receivedAt,
660
+ sender_name: senderName,
661
+ sender_is_agent: senderIsAgent,
662
+ });
663
+ // Bound the buffer: a never-draining consumer must not OOM the daemon.
664
+ // Newest was just appended at the back → shed the oldest from the front.
665
+ this.capBuffer();
666
+ }
667
+ // Filtered-out room messages are still acked below (no redelivery loop).
668
+ }
669
+ acked.push(m.queueId);
670
+ }
671
+ catch {
672
+ // non-JSON / unparseable: ack to avoid redelivery loop
673
+ acked.push(m.queueId);
674
+ }
675
+ }
676
+ if (acked.length) {
677
+ try {
678
+ await this.latency.time("recv.ack", () => this.provider.ackDelivery(this.cfg.deviceId, acked));
679
+ }
680
+ catch {
681
+ /* retried next cycle */
682
+ }
683
+ }
684
+ // recv.cycle = the full receive-and-decrypt cost for a non-empty tick
685
+ // (pull + decrypt + parse + ack), i.e. the recipient half of the envelope.
686
+ this.latency.record("recv.cycle", performance.now() - cycleStart);
687
+ if (this.buffer.length) {
688
+ for (const l of this.listeners)
689
+ l();
690
+ const w = this.waiters.splice(0);
691
+ for (const r of w)
692
+ r();
693
+ }
694
+ }
695
+ async drain(channelId, waitSeconds) {
696
+ let got = this.takeBuffered(channelId);
697
+ if (got.length || waitSeconds <= 0)
698
+ return got;
699
+ const cap = Math.min(waitSeconds, 50) * 1000;
700
+ await new Promise((resolve) => {
701
+ // review FATAL-3: one-shot guard — timer and ingestOnce both call wake();
702
+ // the first wins, the second is a no-op, preventing a late timer from
703
+ // splicing an unrelated waiter under a concurrent drain.
704
+ let fired = false;
705
+ let timer;
706
+ const wake = () => {
707
+ if (fired)
708
+ return;
709
+ fired = true;
710
+ clearTimeout(timer);
711
+ const i = this.waiters.indexOf(wake);
712
+ if (i >= 0)
713
+ this.waiters.splice(i, 1);
714
+ resolve();
715
+ };
716
+ timer = setTimeout(wake, cap);
717
+ this.waiters.push(wake);
718
+ });
719
+ return this.takeBuffered(channelId);
720
+ }
721
+ /** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
722
+ subscribe(listener) {
723
+ this.listeners.add(listener);
724
+ return () => this.listeners.delete(listener);
725
+ }
726
+ /** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
727
+ takeBuffered(channelId) {
728
+ if (channelId === undefined)
729
+ return this.buffer.splice(0);
730
+ const keep = [];
731
+ const out = [];
732
+ for (const m of this.buffer)
733
+ (m.channel_id === channelId ? out : keep).push(m);
734
+ this.buffer.length = 0;
735
+ this.buffer.push(...keep);
736
+ return out;
737
+ }
738
+ /**
739
+ * Put messages back at the front (SSE/disconnect write-failure recovery).
740
+ *
741
+ * The re-buffered messages are older un-acked deliveries, so they go at the
742
+ * FRONT (oldest). A large un-acked backlog re-buffered on reconnect is exactly
743
+ * the OOM case Phase 1 item 4 targets, so this path is capped too. capBuffer()
744
+ * sheds from the front, preserving the newest maxBuffer messages across the
745
+ * combined [requeued-older..., existing-newer...] buffer.
746
+ */
747
+ requeueFront(msgs) {
748
+ if (msgs.length) {
749
+ this.buffer.unshift(...msgs);
750
+ this.capBuffer();
751
+ }
752
+ }
753
+ /**
754
+ * Poke subscribers + waiters to drain the buffer now. Used after a
755
+ * disconnect re-buffers un-acked messages so a still-connected consumer
756
+ * (e.g. a replacement WS) redelivers them without waiting for new inbound.
757
+ * Mirrors the notify tail of ingestOnce.
758
+ */
759
+ wakeListeners() {
760
+ for (const l of this.listeners)
761
+ l();
762
+ const w = this.waiters.splice(0);
763
+ for (const r of w)
764
+ r();
765
+ }
766
+ health() {
767
+ let joined = 0;
768
+ for (const s of this.joinState.values())
769
+ if (s === "JOINED")
770
+ joined++;
771
+ return {
772
+ status: "ok",
773
+ identity_id: this.cfg.identityId,
774
+ joined_channels: joined,
775
+ buffered: this.buffer.length,
776
+ dropped: this.droppedMessages,
777
+ filtered_room: this.filteredRoomMessages,
778
+ rate_limited: this.rateLimitedSends,
779
+ permanently_skipped: this.provider.permanentlySkippedCount(),
780
+ failed_attachments: this.failedAttachments,
781
+ uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1000) : 0,
782
+ version: BRIDGE_VERSION,
783
+ };
784
+ }
785
+ /**
786
+ * Loopback observability snapshot served at GET /v1/metrics.
787
+ *
788
+ * `stages`: per-stage SecureComs-envelope latency percentiles (ms), measured
789
+ * locally and monotonically — send.encrypt / send.publish / send.total (the
790
+ * sender half) and recv.pull / recv.decrypt / recv.ack / recv.cycle (the
791
+ * recipient half). Excludes the agent's LLM time entirely.
792
+ *
793
+ * `errors` (ADR-0002): per-channel decrypt-error / stuck snapshot from the MLS
794
+ * provider, so a wedged channel is OBSERVABLE here instead of looking idle. A
795
+ * channel appears only once it has had a decrypt error; `stuck:true` means
796
+ * repeated failures with no intervening successful decrypt.
797
+ *
798
+ * `dropped` (Phase 1 item 4): cumulative count of inbound messages shed by the
799
+ * bounded-buffer cap. A daemon silently dropping messages under backpressure is
800
+ * exactly the blindness ADR-0002 eliminates, so the shed is surfaced here (and
801
+ * on health()) rather than swallowed.
802
+ *
803
+ * `filteredRoomMessages` (item 3 / ADR-0002 ethos): cumulative ROOM messages
804
+ * dropped by the @mention filter. A silently-filtered room message produces no
805
+ * other signal, so an agent correctly filtering 95% of room traffic would look
806
+ * identical to an idle/wedged one without this counter.
807
+ *
808
+ * `permanentlySkipped` (ADR-0002 ethos): cumulative count of inbound rows
809
+ * acked-and-dropped because they were provably past-epoch undecryptable
810
+ * (forward-secrecy secret gone), so a self-healed poison row is observable
811
+ * here instead of silently dropped.
812
+ */
813
+ metrics() {
814
+ return {
815
+ stages: this.latency.snapshot(),
816
+ errors: this.provider.errorStats(),
817
+ dropped: this.droppedMessages,
818
+ filteredRoomMessages: this.filteredRoomMessages,
819
+ rateLimitedSends: this.rateLimitedSends,
820
+ permanentlySkipped: this.provider.permanentlySkippedCount(),
821
+ welcomeRequestsSent: this.welcomeRequestsSent,
822
+ failedAttachments: this.failedAttachments,
823
+ };
824
+ }
825
+ async close() {
826
+ this.pingHandle?.close();
827
+ if (this.safetyTimer)
828
+ clearInterval(this.safetyTimer);
829
+ this.store.close(); // review SERIOUS-2: release the DatabaseSync handle/WAL
830
+ }
831
+ }