@securecoms/agent-bridge 0.2.0 → 0.3.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,4879 @@
1
+ // ../connect-core/dist/envelope/types.js
2
+ var MESSAGE_TYPES = [
3
+ "text",
4
+ "decision_request",
5
+ "decision_response",
6
+ "status_alert",
7
+ "artifact_share",
8
+ "action_confirmation",
9
+ "heartbeat",
10
+ "system_event",
11
+ "a2a"
12
+ ];
13
+ var AV_ENVELOPE_VERSION = "1.0";
14
+
15
+ // ../connect-core/dist/envelope/validate.js
16
+ import _Ajv2020 from "ajv/dist/2020.js";
17
+ import _addFormats from "ajv-formats";
18
+
19
+ // ../connect-core/dist/envelope/schemas.generated.js
20
+ var envelopeSchema = {
21
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
22
+ "$id": "https://agentvault.dev/connect/schemas/envelope-1.0.json",
23
+ "title": "AgentVaultEnvelope",
24
+ "description": "Canonical client-signed Connect envelope (spec 02 \xA73.2, post-Blocker-1+5). Single source of truth shared by the TypeScript codec (ajv) and the Python edge (jsonschema).",
25
+ "type": "object",
26
+ "additionalProperties": false,
27
+ "required": [
28
+ "av_version",
29
+ "message_id",
30
+ "channel_id",
31
+ "enclave_id",
32
+ "message_type",
33
+ "sender_id",
34
+ "sender_type",
35
+ "identity_proof",
36
+ "trace_context",
37
+ "policy_binding",
38
+ "mls_group_epoch",
39
+ "replay_nonce",
40
+ "payload_encrypted",
41
+ "references"
42
+ ],
43
+ "properties": {
44
+ "av_version": {
45
+ "type": "string",
46
+ "pattern": "^[0-9]+\\.[0-9]+$",
47
+ "description": "Envelope wire version (major.minor). Compatibility is enforced in code, not by this pattern."
48
+ },
49
+ "message_id": {
50
+ "type": "string",
51
+ "format": "uuid"
52
+ },
53
+ "channel_id": {
54
+ "type": "string",
55
+ "format": "uuid"
56
+ },
57
+ "enclave_id": {
58
+ "type": "string",
59
+ "format": "uuid"
60
+ },
61
+ "message_type": {
62
+ "type": "string",
63
+ "enum": [
64
+ "text",
65
+ "decision_request",
66
+ "decision_response",
67
+ "status_alert",
68
+ "artifact_share",
69
+ "action_confirmation",
70
+ "heartbeat",
71
+ "system_event",
72
+ "a2a"
73
+ ]
74
+ },
75
+ "sender_id": {
76
+ "type": "string",
77
+ "format": "uuid"
78
+ },
79
+ "sender_type": {
80
+ "type": "string",
81
+ "enum": [
82
+ "human",
83
+ "agent",
84
+ "guest"
85
+ ]
86
+ },
87
+ "identity_proof": {
88
+ "type": "object",
89
+ "additionalProperties": false,
90
+ "required": [
91
+ "signature",
92
+ "algo",
93
+ "attested",
94
+ "canonicalization"
95
+ ],
96
+ "properties": {
97
+ "signature": {
98
+ "$ref": "#/$defs/base64_nonempty"
99
+ },
100
+ "algo": {
101
+ "type": "string",
102
+ "enum": [
103
+ "ed25519",
104
+ "ecdsa_p384"
105
+ ]
106
+ },
107
+ "attested": {
108
+ "type": "boolean"
109
+ },
110
+ "canonicalization": {
111
+ "type": "string",
112
+ "const": "RFC8785"
113
+ }
114
+ }
115
+ },
116
+ "trace_context": {
117
+ "type": "object",
118
+ "additionalProperties": false,
119
+ "required": [
120
+ "traceparent"
121
+ ],
122
+ "properties": {
123
+ "traceparent": {
124
+ "type": "string",
125
+ "pattern": "^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$",
126
+ "description": "W3C TraceContext traceparent: version-traceid-spanid-flags."
127
+ },
128
+ "tracestate": {
129
+ "type": "string",
130
+ "maxLength": 512
131
+ }
132
+ }
133
+ },
134
+ "policy_binding": {
135
+ "type": "object",
136
+ "additionalProperties": false,
137
+ "required": [
138
+ "binding_id",
139
+ "version",
140
+ "agent_skill_id",
141
+ "delegation_id",
142
+ "client_stage5_outcome",
143
+ "client_stage5_evaluators"
144
+ ],
145
+ "properties": {
146
+ "binding_id": {
147
+ "type": "string",
148
+ "format": "uuid"
149
+ },
150
+ "version": {
151
+ "type": "string",
152
+ "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
153
+ "description": "Policy binding semver."
154
+ },
155
+ "agent_skill_id": {
156
+ "type": [
157
+ "string",
158
+ "null"
159
+ ]
160
+ },
161
+ "delegation_id": {
162
+ "oneOf": [
163
+ {
164
+ "type": "string",
165
+ "format": "uuid"
166
+ },
167
+ {
168
+ "type": "null"
169
+ }
170
+ ]
171
+ },
172
+ "client_stage5_outcome": {
173
+ "type": "string",
174
+ "enum": [
175
+ "allow",
176
+ "flag"
177
+ ]
178
+ },
179
+ "client_stage5_evaluators": {
180
+ "type": "array",
181
+ "items": {
182
+ "type": "string"
183
+ },
184
+ "maxItems": 64
185
+ }
186
+ }
187
+ },
188
+ "mls_group_epoch": {
189
+ "type": "integer",
190
+ "minimum": 0,
191
+ "description": "Public DS-tracked epoch counter; the server rejects messages where this is below the channel's current epoch (mls.stale_epoch)."
192
+ },
193
+ "replay_nonce": {
194
+ "$ref": "#/$defs/base64_nonempty"
195
+ },
196
+ "payload_encrypted": {
197
+ "$ref": "#/$defs/base64"
198
+ },
199
+ "references": {
200
+ "type": "array",
201
+ "maxItems": 64,
202
+ "items": {
203
+ "type": "object",
204
+ "additionalProperties": false,
205
+ "required": [
206
+ "type",
207
+ "message_id"
208
+ ],
209
+ "properties": {
210
+ "type": {
211
+ "type": "string",
212
+ "enum": [
213
+ "reply_to",
214
+ "recall",
215
+ "quote",
216
+ "reaction"
217
+ ]
218
+ },
219
+ "message_id": {
220
+ "type": "string",
221
+ "format": "uuid"
222
+ }
223
+ }
224
+ }
225
+ },
226
+ "mentions": {
227
+ "type": "array",
228
+ "maxItems": 256,
229
+ "items": {
230
+ "type": "string",
231
+ "format": "uuid"
232
+ },
233
+ "description": "Optional plaintext mention-target identity ids for notification routing (FR-MSG-010). The @name display text lives in the encrypted payload; only the target ids are exposed as metadata so the server can fire mention notifications without seeing plaintext."
234
+ }
235
+ },
236
+ "$defs": {
237
+ "base64": {
238
+ "type": "string",
239
+ "pattern": "^$|^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$",
240
+ "description": "Standard base64 (RFC 4648 \xA74) with mod-4 block structure enforced (rejects lone '=', '==', and malformed padding like 'A='). Empty string permitted because payload_encrypted is opaque at this codec layer. Security-critical fields use base64_nonempty."
241
+ },
242
+ "base64_nonempty": {
243
+ "type": "string",
244
+ "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{4})$",
245
+ "description": "Standard base64 (RFC 4648 \xA74), non-empty + mod-4 block structure. Used for replay_nonce and identity_proof.signature, which must always be present on a received (wire) envelope \u2014 an empty nonce defeats replay protection and an empty signature is an unsigned envelope."
246
+ }
247
+ }
248
+ };
249
+ var payloadsSchema = {
250
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
251
+ "$id": "https://agentvault.dev/connect/schemas/payloads-1.0.json",
252
+ "title": "ConnectPayloads",
253
+ "description": "Plaintext payload schemas per message_type, validated client-side BEFORE encryption and AFTER decryption (the edge never sees plaintext). v1.0 BASELINE: each schema requires its core field(s); additionalProperties is permitted because exact payload shapes firm up in Phase 1D/1E client + SDK work. The envelope schema (envelope.schema.json) is the strict, spec-pinned contract; these payloads are the evolving inner shapes.",
254
+ "$defs": {
255
+ "text": {
256
+ "type": "object",
257
+ "required": [
258
+ "body"
259
+ ],
260
+ "properties": {
261
+ "body": {
262
+ "type": "string",
263
+ "maxLength": 1048576
264
+ },
265
+ "format": {
266
+ "type": "string",
267
+ "enum": [
268
+ "plain",
269
+ "markdown"
270
+ ]
271
+ },
272
+ "mentions": {
273
+ "type": "array",
274
+ "items": {
275
+ "type": "string",
276
+ "format": "uuid"
277
+ }
278
+ }
279
+ }
280
+ },
281
+ "decision_request": {
282
+ "type": "object",
283
+ "required": [
284
+ "decision_id",
285
+ "prompt",
286
+ "options"
287
+ ],
288
+ "properties": {
289
+ "decision_id": {
290
+ "type": "string",
291
+ "format": "uuid"
292
+ },
293
+ "prompt": {
294
+ "type": "string"
295
+ },
296
+ "options": {
297
+ "type": "array",
298
+ "items": {
299
+ "type": "string"
300
+ },
301
+ "minItems": 1
302
+ },
303
+ "deadline": {
304
+ "type": "string",
305
+ "format": "date-time"
306
+ }
307
+ }
308
+ },
309
+ "decision_response": {
310
+ "type": "object",
311
+ "required": [
312
+ "decision_id",
313
+ "choice"
314
+ ],
315
+ "properties": {
316
+ "decision_id": {
317
+ "type": "string",
318
+ "format": "uuid"
319
+ },
320
+ "choice": {
321
+ "type": "string"
322
+ }
323
+ }
324
+ },
325
+ "status_alert": {
326
+ "type": "object",
327
+ "required": [
328
+ "severity",
329
+ "message"
330
+ ],
331
+ "properties": {
332
+ "severity": {
333
+ "type": "string",
334
+ "enum": [
335
+ "info",
336
+ "warning",
337
+ "critical"
338
+ ]
339
+ },
340
+ "message": {
341
+ "type": "string"
342
+ }
343
+ }
344
+ },
345
+ "artifact_share": {
346
+ "type": "object",
347
+ "required": [
348
+ "artifact_type",
349
+ "file_id"
350
+ ],
351
+ "properties": {
352
+ "artifact_type": {
353
+ "type": "string"
354
+ },
355
+ "file_id": {
356
+ "type": "string",
357
+ "format": "uuid"
358
+ },
359
+ "filename": {
360
+ "type": "string"
361
+ }
362
+ }
363
+ },
364
+ "action_confirmation": {
365
+ "type": "object",
366
+ "required": [
367
+ "action"
368
+ ],
369
+ "properties": {
370
+ "action": {
371
+ "type": "string"
372
+ },
373
+ "target_identity": {
374
+ "type": "string",
375
+ "format": "uuid"
376
+ },
377
+ "outcome": {
378
+ "type": "string",
379
+ "enum": [
380
+ "approved",
381
+ "denied",
382
+ "pending"
383
+ ]
384
+ }
385
+ }
386
+ },
387
+ "heartbeat": {
388
+ "type": "object",
389
+ "properties": {
390
+ "client_time": {
391
+ "type": "string",
392
+ "format": "date-time"
393
+ }
394
+ }
395
+ },
396
+ "system_event": {
397
+ "type": "object",
398
+ "required": [
399
+ "event"
400
+ ],
401
+ "properties": {
402
+ "event": {
403
+ "type": "string"
404
+ },
405
+ "detail": {
406
+ "type": "object"
407
+ }
408
+ }
409
+ },
410
+ "a2a": {
411
+ "type": "object",
412
+ "required": [
413
+ "a2a_version",
414
+ "kind",
415
+ "object"
416
+ ],
417
+ "properties": {
418
+ "a2a_version": {
419
+ "type": "string"
420
+ },
421
+ "kind": {
422
+ "type": "string",
423
+ "enum": [
424
+ "message",
425
+ "task",
426
+ "artifact"
427
+ ]
428
+ },
429
+ "object": {
430
+ "type": "object"
431
+ }
432
+ }
433
+ },
434
+ "agent_action": {
435
+ "description": 'The agent_action E2E message body (agent action tree). NOT a message_type \u2014 it rides envelope message_type "system_event"; this "type":"agent_action" field is a body-level discriminator. Two phases: partial (throttled, incremental upsert-by-id while an agent works) and settled (one authoritative full-tree frame, terminal for its runId). Note: this def is compiled standalone (see validate.ts validateAgentActionPayload), so its node sub-schema is nested under a LOCAL $defs (resolved relative to this def, not the sibling top-level $defs) rather than cross-referenced.',
436
+ "$defs": {
437
+ "node": {
438
+ "type": "object",
439
+ "required": [
440
+ "id",
441
+ "type",
442
+ "title",
443
+ "status",
444
+ "ts"
445
+ ],
446
+ "properties": {
447
+ "id": {
448
+ "type": "string"
449
+ },
450
+ "parentId": {
451
+ "type": "string"
452
+ },
453
+ "type": {
454
+ "type": "string",
455
+ "enum": [
456
+ "step",
457
+ "tool_call",
458
+ "sub_task",
459
+ "result"
460
+ ]
461
+ },
462
+ "title": {
463
+ "type": "string"
464
+ },
465
+ "status": {
466
+ "type": "string",
467
+ "enum": [
468
+ "pending",
469
+ "running",
470
+ "done",
471
+ "error"
472
+ ]
473
+ },
474
+ "detail": {
475
+ "type": "object",
476
+ "properties": {
477
+ "args": {},
478
+ "output": {},
479
+ "error": {
480
+ "type": "string"
481
+ }
482
+ }
483
+ },
484
+ "ts": {
485
+ "type": "number"
486
+ }
487
+ }
488
+ }
489
+ },
490
+ "oneOf": [
491
+ {
492
+ "type": "object",
493
+ "required": [
494
+ "v",
495
+ "type",
496
+ "phase",
497
+ "agentActionVersion",
498
+ "runId",
499
+ "nodes"
500
+ ],
501
+ "properties": {
502
+ "v": {
503
+ "const": 1
504
+ },
505
+ "type": {
506
+ "const": "agent_action"
507
+ },
508
+ "phase": {
509
+ "const": "partial"
510
+ },
511
+ "agentActionVersion": {
512
+ "const": "1"
513
+ },
514
+ "runId": {
515
+ "type": "string"
516
+ },
517
+ "nodes": {
518
+ "type": "array",
519
+ "items": {
520
+ "$ref": "#/$defs/node"
521
+ }
522
+ }
523
+ }
524
+ },
525
+ {
526
+ "type": "object",
527
+ "required": [
528
+ "v",
529
+ "type",
530
+ "phase",
531
+ "agentActionVersion",
532
+ "runId",
533
+ "startedAt",
534
+ "endedAt",
535
+ "nodes"
536
+ ],
537
+ "properties": {
538
+ "v": {
539
+ "const": 1
540
+ },
541
+ "type": {
542
+ "const": "agent_action"
543
+ },
544
+ "phase": {
545
+ "const": "settled"
546
+ },
547
+ "agentActionVersion": {
548
+ "const": "1"
549
+ },
550
+ "runId": {
551
+ "type": "string"
552
+ },
553
+ "startedAt": {
554
+ "type": "number"
555
+ },
556
+ "endedAt": {
557
+ "type": "number"
558
+ },
559
+ "resultMessageId": {
560
+ "type": "string"
561
+ },
562
+ "nodes": {
563
+ "type": "array",
564
+ "items": {
565
+ "$ref": "#/$defs/node"
566
+ }
567
+ }
568
+ }
569
+ }
570
+ ]
571
+ }
572
+ }
573
+ };
574
+
575
+ // ../connect-core/dist/envelope/validate.js
576
+ var Ajv2020 = _Ajv2020.default ?? _Ajv2020;
577
+ var addFormats = _addFormats.default ?? _addFormats;
578
+ var ajv = new Ajv2020({ allErrors: true, strict: true });
579
+ addFormats(ajv);
580
+ var envelopeValidator = ajv.compile(envelopeSchema);
581
+ var payloadDefs = payloadsSchema.$defs;
582
+ var payloadValidators = /* @__PURE__ */ new Map();
583
+ for (const t of MESSAGE_TYPES) {
584
+ const def = payloadDefs[t];
585
+ if (!def) {
586
+ throw new Error(`payloads.schema.json is missing a $defs schema for message_type "${t}"`);
587
+ }
588
+ payloadValidators.set(t, ajv.compile(def));
589
+ }
590
+ var agentActionDef = payloadDefs["agent_action"];
591
+ if (!agentActionDef) {
592
+ throw new Error(`payloads.schema.json is missing the "agent_action" $defs schema`);
593
+ }
594
+ var agentActionValidator = ajv.compile(agentActionDef);
595
+
596
+ // ../connect-core/dist/envelope/sign.js
597
+ import { canonicalize } from "@agentvault/crypto/canonical";
598
+ import { ed25519 } from "@noble/curves/ed25519";
599
+ import { p384 } from "@noble/curves/p384";
600
+
601
+ // ../connect-core/dist/base64.js
602
+ var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
603
+ var LOOKUP = (() => {
604
+ const t = new Int16Array(256).fill(-1);
605
+ for (let i = 0; i < ALPHABET.length; i++)
606
+ t[ALPHABET.charCodeAt(i)] = i;
607
+ return t;
608
+ })();
609
+ function bytesToBase64(bytes) {
610
+ let out = "";
611
+ for (let i = 0; i < bytes.length; i += 3) {
612
+ const b0 = bytes[i];
613
+ const b1 = i + 1 < bytes.length ? bytes[i + 1] : 0;
614
+ const b2 = i + 2 < bytes.length ? bytes[i + 2] : 0;
615
+ out += ALPHABET[b0 >> 2];
616
+ out += ALPHABET[(b0 & 3) << 4 | b1 >> 4];
617
+ out += i + 1 < bytes.length ? ALPHABET[(b1 & 15) << 2 | b2 >> 6] : "=";
618
+ out += i + 2 < bytes.length ? ALPHABET[b2 & 63] : "=";
619
+ }
620
+ return out;
621
+ }
622
+ function base64ToBytes(s) {
623
+ const clean = s.replace(/=+$/, "");
624
+ const len = clean.length;
625
+ const outLen = Math.floor(len * 6 / 8);
626
+ const out = new Uint8Array(outLen);
627
+ let bits = 0;
628
+ let acc = 0;
629
+ let o = 0;
630
+ for (let i = 0; i < len; i++) {
631
+ const v = LOOKUP[clean.charCodeAt(i)];
632
+ if (v < 0)
633
+ throw new Error("base64ToBytes: invalid character");
634
+ acc = acc << 6 | v;
635
+ bits += 6;
636
+ if (bits >= 8) {
637
+ bits -= 8;
638
+ out[o++] = acc >> bits & 255;
639
+ }
640
+ }
641
+ return out;
642
+ }
643
+
644
+ // ../connect-core/dist/envelope/sign.js
645
+ async function canonicalEnvelopeBytes(envelope) {
646
+ const { signature: _signature, ...proofWithoutSignature } = envelope.identity_proof;
647
+ const toSign = { ...envelope, identity_proof: proofWithoutSignature };
648
+ return canonicalize(toSign);
649
+ }
650
+
651
+ // ../connect-core/dist/envelope/aead.js
652
+ import { gcm } from "@noble/ciphers/aes";
653
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha";
654
+ import { randomBytes } from "@noble/hashes/utils";
655
+ import { canonicalize as canonicalize2 } from "@agentvault/crypto/canonical";
656
+ var APPLICATION_KEY_BYTES = 32;
657
+ var REPLAY_NONCE_BYTES = 24;
658
+ var NONCE_BYTES = {
659
+ xchacha20poly1305: 24,
660
+ aes256gcm: 12
661
+ };
662
+ function concatBytes(...parts) {
663
+ const total = parts.reduce((n, p) => n + p.length, 0);
664
+ const out = new Uint8Array(total);
665
+ let off = 0;
666
+ for (const p of parts) {
667
+ out.set(p, off);
668
+ off += p.length;
669
+ }
670
+ return out;
671
+ }
672
+ function epochToBytes(epoch) {
673
+ const b = new Uint8Array(8);
674
+ new DataView(b.buffer).setBigUint64(0, BigInt(epoch), false);
675
+ return b;
676
+ }
677
+ function buildExporterContext(enclaveId, channelId, epoch) {
678
+ const enc = new TextEncoder();
679
+ return concatBytes(enc.encode(enclaveId), enc.encode(channelId), epochToBytes(epoch));
680
+ }
681
+ function cipherFor(suite, key, nonce, aad) {
682
+ return suite === "aes256gcm" ? gcm(key, nonce, aad) : xchacha20poly1305(key, nonce, aad);
683
+ }
684
+ function assertKey(key) {
685
+ if (key.length !== APPLICATION_KEY_BYTES) {
686
+ throw new Error(`application key must be ${APPLICATION_KEY_BYTES} bytes (the MLS exporter output); got ${key.length}`);
687
+ }
688
+ }
689
+ function sealPayload(p) {
690
+ assertKey(p.key);
691
+ const nonce = p.nonce ?? randomBytes(NONCE_BYTES[p.suite]);
692
+ const ciphertext = cipherFor(p.suite, p.key, nonce, p.associatedData).encrypt(p.plaintext);
693
+ return concatBytes(nonce, ciphertext);
694
+ }
695
+ function openPayload(p) {
696
+ assertKey(p.key);
697
+ const nlen = NONCE_BYTES[p.suite];
698
+ if (p.ciphertext.length < nlen) {
699
+ throw new Error("openPayload: ciphertext shorter than the AEAD nonce");
700
+ }
701
+ const nonce = p.ciphertext.subarray(0, nlen);
702
+ const body = p.ciphertext.subarray(nlen);
703
+ return cipherFor(p.suite, p.key, nonce, p.associatedData).decrypt(body);
704
+ }
705
+ async function buildAssociatedData(fields) {
706
+ return canonicalize2({
707
+ message_id: fields.message_id,
708
+ channel_id: fields.channel_id,
709
+ sender_id: fields.sender_id,
710
+ mls_group_epoch: fields.mls_group_epoch,
711
+ replay_nonce: fields.replay_nonce,
712
+ policy_binding: fields.policy_binding
713
+ });
714
+ }
715
+ function generateReplayNonce() {
716
+ return bytesToBase64(randomBytes(REPLAY_NONCE_BYTES));
717
+ }
718
+
719
+ // ../connect-core/dist/sdk-dto/validate.js
720
+ import _Ajv20202 from "ajv/dist/2020.js";
721
+ import _addFormats2 from "ajv-formats";
722
+
723
+ // ../connect-core/dist/sdk-dto/schemas.generated.js
724
+ var modelDescriptorSchema = {
725
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
726
+ "$id": "agentvault.connect.sdk.model_descriptor",
727
+ "type": "object",
728
+ "additionalProperties": false,
729
+ "required": [
730
+ "provider",
731
+ "name"
732
+ ],
733
+ "properties": {
734
+ "provider": {
735
+ "type": "string",
736
+ "minLength": 1
737
+ },
738
+ "name": {
739
+ "type": "string",
740
+ "minLength": 1
741
+ },
742
+ "version": {
743
+ "type": "string"
744
+ }
745
+ }
746
+ };
747
+ var registerSchema = {
748
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
749
+ "$id": "agentvault.connect.sdk.register_request",
750
+ "type": "object",
751
+ "additionalProperties": false,
752
+ "required": [
753
+ "skill_id",
754
+ "public_key",
755
+ "algo",
756
+ "model"
757
+ ],
758
+ "properties": {
759
+ "skill_id": {
760
+ "type": "string",
761
+ "minLength": 1
762
+ },
763
+ "public_key": {
764
+ "type": "string",
765
+ "minLength": 1
766
+ },
767
+ "algo": {
768
+ "type": "string",
769
+ "enum": [
770
+ "ed25519",
771
+ "ecdsa_p384"
772
+ ]
773
+ },
774
+ "attested": {
775
+ "type": "boolean"
776
+ },
777
+ "model": {
778
+ "$ref": "agentvault.connect.sdk.model_descriptor"
779
+ }
780
+ }
781
+ };
782
+ var auditQuerySchema = {
783
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
784
+ "$id": "agentvault.connect.sdk.audit_query",
785
+ "type": "object",
786
+ "additionalProperties": false,
787
+ "required": [
788
+ "agent_id"
789
+ ],
790
+ "properties": {
791
+ "agent_id": {
792
+ "type": "string",
793
+ "format": "uuid"
794
+ },
795
+ "from": {
796
+ "type": "string",
797
+ "format": "date-time"
798
+ },
799
+ "to": {
800
+ "type": "string",
801
+ "format": "date-time"
802
+ },
803
+ "channel_id": {
804
+ "type": "string",
805
+ "format": "uuid"
806
+ },
807
+ "message_type": {
808
+ "type": "string"
809
+ }
810
+ }
811
+ };
812
+
813
+ // ../connect-core/dist/sdk-dto/validate.js
814
+ var Ajv20202 = _Ajv20202.default ?? _Ajv20202;
815
+ var addFormats2 = _addFormats2.default ?? _addFormats2;
816
+ var ajv2 = new Ajv20202({ allErrors: true, strict: true });
817
+ addFormats2(ajv2);
818
+ ajv2.addSchema(modelDescriptorSchema, "agentvault.connect.sdk.model_descriptor");
819
+ var modelValidator = ajv2.getSchema("agentvault.connect.sdk.model_descriptor");
820
+ var registerValidator = ajv2.compile(registerSchema);
821
+ var auditQueryValidator = ajv2.compile(auditQuerySchema);
822
+
823
+ // ../connect-clients-core/dist/stage5/evaluators.js
824
+ var ALLOW = { outcome: "allow" };
825
+ var PHI_PATTERNS = [/\b\d{3}-\d{2}-\d{4}\b/, /\bMRN[:#]?\s*\d{6,}\b/i];
826
+ var IP_PATTERNS = [
827
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
828
+ /\bAKIA[0-9A-Z]{16}\b/,
829
+ /\bconfidential\b/i
830
+ ];
831
+ var RELEASABILITY_PATTERNS = [/\bNOFORN\b/, /\bdo not (?:forward|share)\b/i];
832
+ function patternEvaluator(id, patterns, reason) {
833
+ return {
834
+ id,
835
+ async evaluate(plaintext) {
836
+ return patterns.some((p) => p.test(plaintext)) ? { outcome: "flag", reason } : ALLOW;
837
+ }
838
+ };
839
+ }
840
+ var phiEvaluator = patternEvaluator("phi", PHI_PATTERNS, "phi.detected");
841
+ var ipEvaluator = patternEvaluator("ip", IP_PATTERNS, "ip.detected");
842
+ var releasabilityEvaluator = patternEvaluator("releasability", RELEASABILITY_PATTERNS, "releasability.hint");
843
+
844
+ // ../connect-sdk-ts/dist/custody/software.js
845
+ import { ed25519 as ed255192 } from "@noble/curves/ed25519";
846
+ import { p384 as p3842 } from "@noble/curves/p384";
847
+ var SoftwareKeyCustody = class _SoftwareKeyCustody {
848
+ algo;
849
+ priv;
850
+ pub;
851
+ attested = false;
852
+ constructor(algo, priv, pub, quiet = false) {
853
+ this.algo = algo;
854
+ this.priv = priv;
855
+ this.pub = pub;
856
+ if (!quiet) {
857
+ console.warn("[AgentVault] using a software key (dev/CI only) -- envelopes are not attested. Use kms-hosted or byok custody in production.");
858
+ }
859
+ }
860
+ static generate(algo) {
861
+ if (algo === "ed25519") {
862
+ const priv2 = ed255192.utils.randomPrivateKey();
863
+ return new _SoftwareKeyCustody(algo, priv2, ed255192.getPublicKey(priv2));
864
+ }
865
+ const priv = p3842.utils.randomPrivateKey();
866
+ return new _SoftwareKeyCustody(algo, priv, p3842.getPublicKey(priv));
867
+ }
868
+ /** Construct from an existing private key (e.g. a CI fixture).
869
+ * `quiet` suppresses the dev banner for browser flows (start-fresh) where
870
+ * the software key IS the production custody and the warning is noise. */
871
+ static fromPrivateKey(algo, priv, opts) {
872
+ const pub = algo === "ed25519" ? ed255192.getPublicKey(priv) : p3842.getPublicKey(priv);
873
+ return new _SoftwareKeyCustody(algo, priv, pub, opts?.quiet === true);
874
+ }
875
+ async sign(canonicalBytes) {
876
+ if (this.algo === "ed25519")
877
+ return ed255192.sign(canonicalBytes, this.priv);
878
+ return p3842.sign(canonicalBytes, this.priv, { prehash: true }).toCompactRawBytes();
879
+ }
880
+ publicKey() {
881
+ return this.pub;
882
+ }
883
+ };
884
+
885
+ // ../connect-sdk-ts/dist/errors.js
886
+ var ConnectSdkError = class extends Error {
887
+ constructor(message) {
888
+ super(message);
889
+ this.name = new.target.name;
890
+ }
891
+ };
892
+ var RegistrationError = class extends ConnectSdkError {
893
+ };
894
+ var RateLimitError = class extends ConnectSdkError {
895
+ retryAfterSec;
896
+ constructor(message, retryAfterSec) {
897
+ super(message);
898
+ this.retryAfterSec = retryAfterSec;
899
+ }
900
+ };
901
+ var AuditAccessDenied = class extends ConnectSdkError {
902
+ };
903
+ var StaleEpochError = class extends ConnectSdkError {
904
+ clientEpoch;
905
+ serverEpoch;
906
+ constructor(clientEpoch, serverEpoch) {
907
+ super(`mls.stale_epoch: client_epoch=${clientEpoch} server_epoch=${serverEpoch}`);
908
+ this.clientEpoch = clientEpoch;
909
+ this.serverEpoch = serverEpoch;
910
+ }
911
+ };
912
+ async function staleEpochErrorFromResponse(res) {
913
+ if (res.status !== 400)
914
+ return null;
915
+ let body;
916
+ try {
917
+ body = await res.json();
918
+ } catch {
919
+ return null;
920
+ }
921
+ const detail = body?.detail;
922
+ const d = detail;
923
+ if (!d || d.error !== "mls.stale_epoch")
924
+ return null;
925
+ return new StaleEpochError(Number(d.client_epoch ?? 0), Number(d.server_epoch ?? 0));
926
+ }
927
+
928
+ // ../connect-sdk-ts/dist/envelope-builder.js
929
+ async function buildOutboundEnvelope(p) {
930
+ const suite = p.suite ?? p.groupKey.suite;
931
+ const key = await p.groupKey.exporterKey(p.enclaveId, p.channelId, p.epoch);
932
+ const replayNonce = p.replayNonce ?? generateReplayNonce();
933
+ const policyBinding = {
934
+ binding_id: p.bindingId,
935
+ version: p.bindingVersion,
936
+ agent_skill_id: p.agentSkillId,
937
+ delegation_id: null,
938
+ client_stage5_outcome: p.stage5Outcome,
939
+ client_stage5_evaluators: p.stage5Evaluators
940
+ };
941
+ const aad = await buildAssociatedData({
942
+ message_id: p.messageId,
943
+ channel_id: p.channelId,
944
+ sender_id: p.senderId,
945
+ mls_group_epoch: p.epoch,
946
+ replay_nonce: replayNonce,
947
+ policy_binding: policyBinding
948
+ });
949
+ const sealed = sealPayload({
950
+ plaintext: new TextEncoder().encode(p.bodyPlaintext),
951
+ key,
952
+ associatedData: aad,
953
+ suite,
954
+ nonce: p.aeadNonce
955
+ });
956
+ const envelope = {
957
+ av_version: AV_ENVELOPE_VERSION,
958
+ message_id: p.messageId,
959
+ channel_id: p.channelId,
960
+ enclave_id: p.enclaveId,
961
+ message_type: p.type,
962
+ sender_id: p.senderId,
963
+ sender_type: p.senderType,
964
+ identity_proof: {
965
+ signature: "",
966
+ algo: p.custody.algo,
967
+ attested: p.custody.attested,
968
+ canonicalization: "RFC8785"
969
+ },
970
+ trace_context: { traceparent: p.traceparent },
971
+ policy_binding: policyBinding,
972
+ mls_group_epoch: p.epoch,
973
+ replay_nonce: replayNonce,
974
+ payload_encrypted: bytesToBase64(sealed),
975
+ references: p.references
976
+ };
977
+ if (p.mentions && p.mentions.length > 0)
978
+ envelope.mentions = p.mentions;
979
+ const canonical = await canonicalEnvelopeBytes(envelope);
980
+ envelope.identity_proof.signature = bytesToBase64(await p.custody.sign(canonical));
981
+ return envelope;
982
+ }
983
+
984
+ // ../connect-sdk-ts/dist/groupkey/send-self-heal.js
985
+ async function sendWithSelfHeal(session, channelId, build, publish) {
986
+ await session.reconcileForSend(channelId);
987
+ const epoch = await session.currentEpoch(channelId);
988
+ const envelope = await build(epoch);
989
+ try {
990
+ return await publish(envelope);
991
+ } catch (err) {
992
+ if (!(err instanceof StaleEpochError))
993
+ throw err;
994
+ await session.reconcileForSend(channelId);
995
+ const retryEpoch = await session.currentEpoch(channelId);
996
+ const retryEnvelope = await build(retryEpoch);
997
+ return await publish(retryEnvelope);
998
+ }
999
+ }
1000
+
1001
+ // ../connect-sdk-ts/dist/groupkey/stub.js
1002
+ import { sha256 } from "@noble/hashes/sha2";
1003
+
1004
+ // ../connect-sdk-ts/dist/spans/names.js
1005
+ var SPAN = {
1006
+ MESSAGE_SENT: "agentvault.connect.message.sent",
1007
+ MESSAGE_QUEUED_OFFLINE: "agentvault.connect.message.queued_offline",
1008
+ POLICY_RESCAN: "agentvault.connect.policy.rescan",
1009
+ POLICY_EVAL_DELAYED_FLAG: "agentvault.connect.policy.eval.delayed_flag",
1010
+ AUDIT_QUERY: "agentvault.connect.audit.query",
1011
+ AGENT_REGISTERED: "agentvault.connect.agent.registered",
1012
+ AGENT_JOINED: "agentvault.connect.agent.joined",
1013
+ AGENT_CAPABILITIES_DECLARED: "agentvault.connect.agent.capabilities_declared",
1014
+ AGENT_CAPABILITY_INVOKED: "agentvault.connect.agent.capability_invoked",
1015
+ AGENT_TOOL_CALLED: "agentvault.connect.agent.tool_called",
1016
+ ACTION_CONFIRMATION_REQUESTED: "agentvault.connect.action_confirmation.requested",
1017
+ ACTION_CONFIRMATION_RECEIVED: "agentvault.connect.action_confirmation.received",
1018
+ MODEL_DESCRIPTOR_CHANGED: "agentvault.connect.model.descriptor_changed",
1019
+ AGENT_STATUS_REPORTED: "agentvault.connect.agent.status_reported"
1020
+ };
1021
+ var ALWAYS_SAMPLE = /* @__PURE__ */ new Set([
1022
+ SPAN.AGENT_REGISTERED,
1023
+ SPAN.ACTION_CONFIRMATION_REQUESTED,
1024
+ SPAN.ACTION_CONFIRMATION_RECEIVED
1025
+ ]);
1026
+
1027
+ // ../connect-sdk-ts/dist/spans/emitter.js
1028
+ var KNOWN = new Set(Object.values(SPAN));
1029
+
1030
+ // ../connect-sdk-ts/dist/groupkey/mls.js
1031
+ import { MLSGroupManager, bytesToHex, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1032
+
1033
+ // ../connect-sdk-ts/dist/groupkey/operation-queue.js
1034
+ var MlsOperationQueue = class {
1035
+ chains = /* @__PURE__ */ new Map();
1036
+ inFlight = /* @__PURE__ */ new Map();
1037
+ async run(channelId, op) {
1038
+ const prev = this.chains.get(channelId) ?? Promise.resolve();
1039
+ const next = prev.then(op, op);
1040
+ this.chains.set(channelId, next);
1041
+ this.inFlight.set(channelId, (this.inFlight.get(channelId) ?? 0) + 1);
1042
+ try {
1043
+ return await next;
1044
+ } finally {
1045
+ const remaining = (this.inFlight.get(channelId) ?? 1) - 1;
1046
+ if (remaining === 0) {
1047
+ this.inFlight.delete(channelId);
1048
+ if (this.chains.get(channelId) === next) {
1049
+ this.chains.delete(channelId);
1050
+ }
1051
+ } else {
1052
+ this.inFlight.set(channelId, remaining);
1053
+ }
1054
+ }
1055
+ }
1056
+ /** Test / diagnostic only. Number of in-flight + pending ops for a channel. */
1057
+ chainSize(channelId) {
1058
+ return this.inFlight.get(channelId) ?? 0;
1059
+ }
1060
+ };
1061
+
1062
+ // ../connect-sdk-ts/dist/groupkey/mls.js
1063
+ var LABEL = "agentvault-connect-v1";
1064
+ var DEFAULT_STUCK_THRESHOLD = 2;
1065
+ var BootstrapRaceLostError = class extends Error {
1066
+ constructor() {
1067
+ super("bootstrap race lost: another device created the group; join instead");
1068
+ this.name = "BootstrapRaceLostError";
1069
+ }
1070
+ };
1071
+ var LeafRosterMismatchError = class extends Error {
1072
+ constructor() {
1073
+ super("removeDeviceFromLiveGroup: leaf/roster length mismatch (stale roster \u2014 re-fetch and retry)");
1074
+ this.name = "LeafRosterMismatchError";
1075
+ }
1076
+ };
1077
+ var HealAddAfterRemoveError = class extends Error {
1078
+ constructor(cause) {
1079
+ super("healDivergedMember: Add failed after Remove already landed \u2014 member detached, re-run heal to recover");
1080
+ this.name = "HealAddAfterRemoveError";
1081
+ this.cause = cause;
1082
+ }
1083
+ };
1084
+ var GuestHealStaleKpError = class extends Error {
1085
+ constructor(kpId) {
1086
+ super(`healDivergedGuest: fresh key package ${kpId} is gone/consumed \u2014 refusing to seal an un-openable Welcome`);
1087
+ this.name = "GuestHealStaleKpError";
1088
+ }
1089
+ };
1090
+ var ForgedCredentialError = class extends Error {
1091
+ constructor(expected, actual) {
1092
+ super(`heal: KeyPackage credential identity ${actual} != intended ${expected} \u2014 refusing to Add a forged leaf`);
1093
+ this.name = "ForgedCredentialError";
1094
+ }
1095
+ };
1096
+ var MlsGroupKeyProvider = class {
1097
+ o;
1098
+ suite;
1099
+ managers = /* @__PURE__ */ new Map();
1100
+ /**
1101
+ * Per-CHANNEL Promise-chain mutex (gen2's MlsOperationQueue, ported). Every
1102
+ * manager-mutating operation on a channel takes this lock keyed by channelId,
1103
+ * so at most one runs per channel at a time; different channels still run in
1104
+ * parallel. This is the fix for the manager-mutation interleave Sigil's review
1105
+ * of the self-heal-on-send fix surfaced: the device-wide receive pump, the
1106
+ * committer ops, and reconcileForSend could all mutate one channel's cached
1107
+ * MLSGroupManager concurrently.
1108
+ *
1109
+ * Granularity (load-bearing): receiveOnce is DEVICE-wide — it pulls one queue
1110
+ * and routes commit/application rows to MANY channels' managers. It therefore
1111
+ * takes the lock PER ROW, keyed by that row's channel, rather than one
1112
+ * provider-wide lock — so a commit on channel A never serializes against a
1113
+ * decrypt on channel B. The committer ops take the lock for their WHOLE
1114
+ * resolve→commit→distribute→persist window. Because the lock is not reentrant,
1115
+ * the committers' own conflict-recovery drain runs the UNLOCKED core
1116
+ * (receiveOnceUnlocked, scoped to the committer's own channel — it already
1117
+ * holds that channel's lock).
1118
+ */
1119
+ locks = new MlsOperationQueue();
1120
+ /** Per-DEVICE publish mutex (B1). Serializes publishKeyPackage's check-then-act
1121
+ * guard so two callers (ingestOnce divergence branch vs selfHealSweep) cannot
1122
+ * both pass "no pending bundle", both POST, and orphan the first bundle when the
1123
+ * server delete-and-replaces. Keyed by deviceId (a provider has one), SEPARATE
1124
+ * from `locks` (channel-keyed) so it never deadlocks against a held channel lock. */
1125
+ publishLock = new MlsOperationQueue();
1126
+ /**
1127
+ * Per-channel decrypt-error / stuck tracker (ADR-0002). Bounded by the number
1128
+ * of channels this device touches (channels are bounded; an entry is created
1129
+ * only on a real decrypt error), so it cannot grow without limit. Read via
1130
+ * {@link errorStats}; mutated only through {@link recordDecryptError} /
1131
+ * {@link recordDecryptSuccess}.
1132
+ */
1133
+ errors = /* @__PURE__ */ new Map();
1134
+ stuckThreshold;
1135
+ /** Cumulative count of past-epoch application rows permanently skipped (acked
1136
+ * and drained) because forward-secrecy has deleted their epoch's secrets. Read
1137
+ * via {@link permanentlySkippedCount}. */
1138
+ permanentlySkipped = 0;
1139
+ constructor(o) {
1140
+ this.o = o;
1141
+ this.suite = o.suite ?? "xchacha20poly1305";
1142
+ this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
1143
+ }
1144
+ get deviceId() {
1145
+ return this.o.deviceId;
1146
+ }
1147
+ /**
1148
+ * Count a decrypt/parse failure against a channel and fire the optional
1149
+ * onDecryptError hook. Increments BOTH the cumulative total and the
1150
+ * consecutive-since-last-success counter (the latter drives `stuck`). A row
1151
+ * with no channel mapping is still surfaced via the hook but not counted
1152
+ * against any channel (we cannot attribute it). Always called from inside the
1153
+ * row's catch — never changes decrypt/convergence behavior.
1154
+ */
1155
+ recordDecryptError(row, error) {
1156
+ this.o.onDecryptError?.(row, error);
1157
+ const cid = row.connect_channel_id;
1158
+ if (!cid)
1159
+ return;
1160
+ const e = this.errors.get(cid) ?? { decryptErrors: 0, consecutiveErrors: 0, lastErrorAt: null };
1161
+ e.decryptErrors += 1;
1162
+ e.consecutiveErrors += 1;
1163
+ e.lastErrorAt = Date.now();
1164
+ this.errors.set(cid, e);
1165
+ }
1166
+ /**
1167
+ * A commit/application row for the channel decrypted cleanly: the channel is
1168
+ * advancing, so clear the consecutive-error run (un-sticks it). Leaves the
1169
+ * cumulative `decryptErrors` total intact for diagnostics. No-op for a channel
1170
+ * that never errored.
1171
+ */
1172
+ recordDecryptSuccess(channelId) {
1173
+ const e = this.errors.get(channelId);
1174
+ if (e && e.consecutiveErrors !== 0)
1175
+ e.consecutiveErrors = 0;
1176
+ }
1177
+ /**
1178
+ * Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
1179
+ * Surfaces a wedged channel that would otherwise "look idle": the daemon serves
1180
+ * it on GET /v1/metrics and the web shell reads it via a selector. A channel is
1181
+ * `stuck` once it has had `stuckThreshold` consecutive decrypt failures with no
1182
+ * intervening successful decrypt. Returns a plain object keyed by channelId;
1183
+ * absence of a key means zero errors observed for that channel.
1184
+ */
1185
+ errorStats() {
1186
+ const out = {};
1187
+ for (const [cid, e] of this.errors) {
1188
+ out[cid] = {
1189
+ decryptErrors: e.decryptErrors,
1190
+ consecutiveErrors: e.consecutiveErrors,
1191
+ lastErrorAt: e.lastErrorAt,
1192
+ stuck: e.consecutiveErrors >= this.stuckThreshold
1193
+ };
1194
+ }
1195
+ return out;
1196
+ }
1197
+ /** Total past-epoch rows permanently skipped (acked without decrypting) since
1198
+ * this provider was instantiated. A permanently-undecryptable row is one whose
1199
+ * `row.epoch` is strictly less than the channel's live epoch: forward-secrecy
1200
+ * has deleted its epoch secrets so it can never decrypt on any retry. */
1201
+ permanentlySkippedCount() {
1202
+ return this.permanentlySkipped;
1203
+ }
1204
+ /** Evict the live in-memory MLS group manager for a channel. Used when the
1205
+ * device leaves or deletes a channel: dropping the persisted store record is
1206
+ * not enough on its own, because a warm manager keeps decrypting until the
1207
+ * page reloads. The persisted store record is removed separately by the
1208
+ * caller; this clears the matching in-memory cache. `managers` is the only
1209
+ * per-channel in-memory cache the provider holds. */
1210
+ evictChannelState(channelId) {
1211
+ this.managers.delete(channelId);
1212
+ }
1213
+ /**
1214
+ * Warm a channel's group manager so a commit can run even when the room isn't
1215
+ * currently open. Used by the guest reconcile sweep (and slice-3 bell-approve):
1216
+ * the sweep may fire for a channel the host hasn't opened this session, so the
1217
+ * in-memory manager is cold even though the store holds usable state.
1218
+ *
1219
+ * If the store has persisted state, load it into the warm in-memory cache (via
1220
+ * the private manager() loader). Otherwise fall through to the join path
1221
+ * (ensureSession bootstrap:false), which consumes a queued Welcome. Idempotent:
1222
+ * a second call on an already-warm channel is a cheap no-op (manager() returns
1223
+ * the cached manager and ensureSession early-returns on existing store state).
1224
+ */
1225
+ async ensureWarm(channelId, enclaveId) {
1226
+ if (await this.o.store.load(channelId)) {
1227
+ await this.locks.run(channelId, async () => {
1228
+ await this.manager(channelId);
1229
+ });
1230
+ return;
1231
+ }
1232
+ await this.ensureSession(channelId, { enclaveId, bootstrap: false });
1233
+ }
1234
+ /**
1235
+ * Admit a guest's (possibly new) device into a live group, evicting any prior
1236
+ * device leaf for that SAME guest identity so a returning guest holds exactly
1237
+ * ONE active leaf. Used by the guest reconcile sweep when an approved guest
1238
+ * sits in `joining`.
1239
+ *
1240
+ * Add then evict: we commit the Add first so the new leaf exists before the
1241
+ * old one is dropped (the guest is never momentarily leaf-less in the group).
1242
+ * The eviction uses the per-DEVICE removal path (removeDeviceFromLiveGroup),
1243
+ * which resolves the target leaf POSITIONALLY by add-order. At the moment of
1244
+ * eviction the identity owns TWO leaves (prior + new), so the ordered-device
1245
+ * list MUST name both in add-order — the prior device was added first, the new
1246
+ * one just now — or the SDK's leaf/roster guard throws LeafRosterMismatchError.
1247
+ * Callers that hold the real channel roster may override `orderedDeviceIds`;
1248
+ * the default `[priorDeviceId, newDeviceId]` is the natural add-order for the
1249
+ * common single-prior-device return.
1250
+ *
1251
+ * No-op eviction when there is no priorDeviceId (first-time guest) or when it
1252
+ * equals newDeviceId (the same device re-admitted).
1253
+ */
1254
+ async admitGuestDevice(channelId, g) {
1255
+ await this.addMemberToLiveGroup(channelId, g.newDeviceId);
1256
+ if (g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1257
+ const orderedDeviceIds = g.orderedDeviceIds ?? [g.priorDeviceId, g.newDeviceId];
1258
+ await this.removeDeviceFromLiveGroup(channelId, g.priorDeviceId, {
1259
+ identity: g.identityId,
1260
+ orderedDeviceIds
1261
+ });
1262
+ }
1263
+ }
1264
+ /** Load (or reuse) the per-channel manager from the store. Throws if no state. */
1265
+ async manager(channelId) {
1266
+ const cached = this.managers.get(channelId);
1267
+ if (cached)
1268
+ return cached;
1269
+ const saved = await this.o.store.load(channelId);
1270
+ if (!saved)
1271
+ throw new Error(`MlsGroupKeyProvider: no MLS state for channel ${channelId} (call ensureSession first)`);
1272
+ const mgr = new MLSGroupManager(saved.ciphersuite);
1273
+ mgr.importState(saved);
1274
+ this.managers.set(channelId, mgr);
1275
+ return mgr;
1276
+ }
1277
+ async currentEpoch(channelId) {
1278
+ return Number((await this.manager(channelId)).epoch);
1279
+ }
1280
+ /**
1281
+ * Run a committer op and, on ANY throw that escapes it, evict the channel's
1282
+ * cached MLSGroupManager.
1283
+ *
1284
+ * The committer ops mutate the CACHED manager (mgr.addMembers / removeMembers
1285
+ * advance it) and persist the advanced state only AFTER a successful
1286
+ * distribute. The distribute-catch already evicts on a 409. But if the
1287
+ * mutation itself throws — or the B11 epoch-drift guard throws after the
1288
+ * mutation advanced the manager — the cached manager is left partially/fully
1289
+ * mutated and never persisted. The per-channel queue would then run the next
1290
+ * serialized op against that SAME poisoned cache entry (manager() returns the
1291
+ * cache before touching the store). Dropping the cache entry forces the next
1292
+ * manager() to reload a clean instance from the committed store state.
1293
+ *
1294
+ * Always called from INSIDE the channel lock, so the eviction can't race a
1295
+ * concurrent op on the same channel. Idempotent with the distribute-catch's own
1296
+ * delete (a re-delete is a no-op). On success the manager stays cached as
1297
+ * before — no eviction.
1298
+ */
1299
+ async evictOnThrow(channelId, op) {
1300
+ try {
1301
+ return await op();
1302
+ } catch (e) {
1303
+ this.managers.delete(channelId);
1304
+ throw e;
1305
+ }
1306
+ }
1307
+ async exporterKey(enclaveId, channelId, epoch) {
1308
+ const mgr = await this.manager(channelId);
1309
+ return mgr.exportSecret(LABEL, buildExporterContext(enclaveId, channelId, epoch), 32);
1310
+ }
1311
+ /**
1312
+ * Join a channel from the host-committed Welcome, then drain any commits that
1313
+ * landed after the Welcome so we converge on the true current epoch. Without
1314
+ * this, confirm-join 409s `member_epoch_stale` whenever the committer advanced
1315
+ * the epoch between Welcome generation and our join.
1316
+ */
1317
+ async joinAndReconcile(channelId, enclaveId) {
1318
+ await this.ensureSession(channelId, { enclaveId, bootstrap: false });
1319
+ try {
1320
+ await this.receiveOnce(this.o.deviceId);
1321
+ } catch {
1322
+ }
1323
+ return this.currentEpoch(channelId);
1324
+ }
1325
+ /**
1326
+ * Self-heal-on-send (Phase 1.1). Converge the sender to the live group epoch
1327
+ * BEFORE it seals an application message: drain any pending commits (via
1328
+ * {@link receiveOnce}) so {@link currentEpoch} reflects the live group. A
1329
+ * sender one epoch behind would otherwise seal at a stale epoch — the backend
1330
+ * rejects that with an epoch-stale 400 (the "owner-send-stuck / shows queued,
1331
+ * never posts" symptom), and even a blind relay would deliver ciphertext no
1332
+ * live-epoch peer can decrypt (forward secrecy deleted the old secret).
1333
+ *
1334
+ * Best-effort and idempotent: if there is nothing pending it is a cheap no-op
1335
+ * (one pullDelivery), and a drain failure is swallowed so it never breaks the
1336
+ * send path — the caller publishes at whatever epoch we reach and the
1337
+ * defensive reconcile-then-retry-once on a publish 400 covers a commit that
1338
+ * lands between this drain and publish.
1339
+ *
1340
+ * COMMITTER SAFETY (B11): this MUST only be called from a normal application-
1341
+ * send entry point, never inside a committer's resolve→distribute window
1342
+ * (addMemberToLiveGroup / removeMemberFromLiveGroup / removeDeviceFromLiveGroup
1343
+ * stage their own commit and explicitly must NOT receiveOnce between resolving
1344
+ * members and distributing — doing so would race their in-flight commit). A
1345
+ * text/a2a/status send is never mid-distribute, so scoping reconcileForSend to
1346
+ * those entry points keeps the receiveOnce drain out of any committer window.
1347
+ * If the channel has no local MLS state yet (nothing to drain/seal), this is a
1348
+ * no-op rather than an error.
1349
+ */
1350
+ async reconcileForSend(channelId) {
1351
+ if (!await this.o.store.load(channelId))
1352
+ return;
1353
+ try {
1354
+ await this.receiveOnce(this.o.deviceId);
1355
+ } catch {
1356
+ }
1357
+ }
1358
+ /**
1359
+ * Peek the device's delivery queue for a pending Welcome WITHOUT acking it.
1360
+ *
1361
+ * Returns the `connect_channel_id` of the first welcome row, or null when none
1362
+ * is queued yet. Used by the external-guest join to detect host approval over
1363
+ * the guest bearer (the guest can't call the Clerk-gated /me or channel-list).
1364
+ * The row is deliberately NOT acked here — the subsequent ensureSession(join) /
1365
+ * joinAndReconcile consumes the same Welcome and acks it after joining.
1366
+ */
1367
+ async peekWelcomeChannel(deviceId) {
1368
+ const rows = await this.o.backend.pullDelivery(deviceId);
1369
+ const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
1370
+ return w?.connect_channel_id ?? null;
1371
+ }
1372
+ async publishKeyPackage() {
1373
+ return this.publishLock.run(this.o.deviceId, () => this.publishKeyPackageUnlocked());
1374
+ }
1375
+ async publishKeyPackageUnlocked() {
1376
+ if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
1377
+ console.warn("publishKeyPackage: pending bundle exists; skipping republish");
1378
+ return await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
1379
+ }
1380
+ const mgr = new MLSGroupManager();
1381
+ const bundle = await mgr.generateKeyPackage(this.o.identity);
1382
+ const hex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
1383
+ const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
1384
+ await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
1385
+ return id;
1386
+ }
1387
+ /** The server-assigned id of the last KeyPackage this device published,
1388
+ * or null if none has been published yet. Lets a diverged guest's self-heal
1389
+ * signal name its outstanding KP (P2-A). */
1390
+ async getLastPublishedKpId() {
1391
+ return this.o.store.loadLastKpId(this.o.deviceId);
1392
+ }
1393
+ async ensureSession(channelId, opts) {
1394
+ return this.locks.run(channelId, () => this.ensureSessionUnlocked(channelId, opts));
1395
+ }
1396
+ async ensureSessionUnlocked(channelId, opts) {
1397
+ if (!opts.forceReinit && await this.o.store.load(channelId))
1398
+ return;
1399
+ let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1400
+ if (!bundle && (opts.bootstrap || opts.forceReinit)) {
1401
+ await this.publishKeyPackage();
1402
+ bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1403
+ }
1404
+ if (!bundle)
1405
+ throw new Error("ensureSession: call publishKeyPackage() first");
1406
+ if (opts.forceReinit) {
1407
+ const others = opts.otherDeviceIds ?? [];
1408
+ const reinit = await this.o.backend.reinitConnectChannelGroup(channelId, [this.o.deviceId, ...others]);
1409
+ const mgr = new MLSGroupManager();
1410
+ await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
1411
+ const kps = await this.o.backend.fetchKeyPackages(others);
1412
+ const pkgs = others.map((d) => {
1413
+ const hex = kps[d];
1414
+ if (!hex)
1415
+ throw new Error(`ensureSession(forceReinit): no published KeyPackage for device ${d}`);
1416
+ return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
1417
+ });
1418
+ const { commit, welcome } = await mgr.addMembers(pkgs);
1419
+ await this.o.backend.distribute(reinit.group_id, {
1420
+ device_id: this.o.deviceId,
1421
+ commit: bytesToHex(commit),
1422
+ commit_epoch: Number(mgr.epoch),
1423
+ expected_epoch: reinit.current_epoch,
1424
+ member_device_ids: [this.o.deviceId, ...others],
1425
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
1426
+ });
1427
+ await this.o.store.save(channelId, mgr.exportState());
1428
+ this.managers.set(channelId, mgr);
1429
+ await this.o.store.deletePendingBundle(this.o.deviceId);
1430
+ return;
1431
+ }
1432
+ if (opts.bootstrap) {
1433
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
1434
+ if (init.created === false)
1435
+ throw new BootstrapRaceLostError();
1436
+ const mgr = new MLSGroupManager();
1437
+ await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
1438
+ const others = opts.otherDeviceIds ?? [];
1439
+ const kps = await this.o.backend.fetchKeyPackages(others);
1440
+ const pkgs = others.map((d) => {
1441
+ const hex = kps[d];
1442
+ if (!hex)
1443
+ throw new Error(`ensureSession: no published KeyPackage for device ${d}`);
1444
+ return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
1445
+ });
1446
+ const { commit, welcome } = await mgr.addMembers(pkgs);
1447
+ await this.o.backend.distribute(init.group_id, {
1448
+ device_id: this.o.deviceId,
1449
+ commit: bytesToHex(commit),
1450
+ commit_epoch: Number(mgr.epoch),
1451
+ expected_epoch: init.current_epoch,
1452
+ member_device_ids: [this.o.deviceId, ...others],
1453
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
1454
+ });
1455
+ this.managers.set(channelId, mgr);
1456
+ await this.o.store.save(channelId, mgr.exportState());
1457
+ await this.o.store.deletePendingBundle(this.o.deviceId);
1458
+ } else {
1459
+ const rows = await this.o.backend.pullDelivery(this.o.deviceId);
1460
+ const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
1461
+ if (!w)
1462
+ throw new Error("ensureSession(join): no Welcome available yet for this channel");
1463
+ const mgr = new MLSGroupManager();
1464
+ try {
1465
+ await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
1466
+ } catch (err) {
1467
+ this.recordDecryptError(w, err);
1468
+ console.warn(`ensureSession(join): discarding un-openable Welcome for channel ${channelId} (queue_id=${w.queue_id}); private KeyPackage key no longer held`);
1469
+ await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
1470
+ return;
1471
+ }
1472
+ this.managers.set(channelId, mgr);
1473
+ await this.o.store.save(channelId, mgr.exportState());
1474
+ await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
1475
+ const remaining = await this.o.backend.pullDelivery(this.o.deviceId);
1476
+ let burstPending = false;
1477
+ for (const r of remaining) {
1478
+ if (r.message_type !== "welcome")
1479
+ continue;
1480
+ const cid = r.connect_channel_id;
1481
+ if (!cid || cid === channelId || !await this.o.store.load(cid)) {
1482
+ burstPending = true;
1483
+ break;
1484
+ }
1485
+ }
1486
+ if (!burstPending)
1487
+ await this.o.store.deletePendingBundle(this.o.deviceId);
1488
+ }
1489
+ }
1490
+ /**
1491
+ * Committer-side: add a member to an ALREADY-bootstrapped (live) group.
1492
+ *
1493
+ * The bootstrap path (ensureSession bootstrap:true) only adds members at group
1494
+ * creation. The admission flow admits a member AFTER the group is live, so the
1495
+ * committer commits the Add against its saved state and distributes the commit
1496
+ * plus a Welcome targeting the new device.
1497
+ *
1498
+ * group_id resolution reuses the same idempotent init endpoint bootstrap uses:
1499
+ * for an existing channel it returns the live group's id, current epoch, and
1500
+ * full member roster (unioning in any device ids we pass). That avoids a
1501
+ * parallel group_id-by-channel resolver.
1502
+ */
1503
+ async addMemberToLiveGroup(channelId, newDeviceId) {
1504
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0)));
1505
+ }
1506
+ async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
1507
+ const mgr = await this.manager(channelId);
1508
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
1509
+ const kps = await this.o.backend.fetchKeyPackages([newDeviceId]);
1510
+ const hex = kps[newDeviceId];
1511
+ if (!hex)
1512
+ throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
1513
+ const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
1514
+ if (opts?.expectedIdentityId) {
1515
+ const cred = pkg.leafNode.credential;
1516
+ const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
1517
+ if (claimed !== opts.expectedIdentityId) {
1518
+ throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
1519
+ }
1520
+ }
1521
+ const priorEpoch = Number(mgr.epoch);
1522
+ const { commit, welcome } = await mgr.addMembers([pkg]);
1523
+ const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
1524
+ try {
1525
+ await this.o.backend.distribute(init.group_id, {
1526
+ device_id: this.o.deviceId,
1527
+ commit: bytesToHex(commit),
1528
+ commit_epoch: Number(mgr.epoch),
1529
+ expected_epoch: priorEpoch,
1530
+ member_device_ids: members,
1531
+ op: "add",
1532
+ welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex(welcome) }] : []
1533
+ });
1534
+ } catch (e) {
1535
+ this.managers.delete(channelId);
1536
+ if (_attempt >= 2)
1537
+ throw e;
1538
+ try {
1539
+ await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
1540
+ } catch {
1541
+ }
1542
+ try {
1543
+ const work = await this.o.backend.getCommitterWork(channelId);
1544
+ if (!work.adds.includes(newDeviceId))
1545
+ return;
1546
+ } catch {
1547
+ }
1548
+ return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
1549
+ }
1550
+ await this.o.store.save(channelId, mgr.exportState());
1551
+ }
1552
+ /**
1553
+ * Committer-side: REMOVE a member from a live group (Phase C eviction).
1554
+ *
1555
+ * Mirrors {@link addMemberToLiveGroup}, but commits a Remove (no Welcome) and
1556
+ * uses the store's two-slot staging for crash/conflict safety: the advanced
1557
+ * (post-removal) state is written to STAGING before the removal-Commit is
1558
+ * distributed, promoted to MAIN only on a 2xx, and discarded on a 409 so a
1559
+ * reload from MAIN yields the pre-commit state. The removed member's leaf is
1560
+ * identified by its credential identity (a member's devices share one identity,
1561
+ * so this evicts ALL of that member's device leaves). The ts-mls forced
1562
+ * UpdatePath (patches/ts-mls+1.6.2.patch) rotates the new epoch's key schedule
1563
+ * away from every evicted leaf, so the removed member cannot derive the new
1564
+ * exporter secret.
1565
+ *
1566
+ * The server resolves removed_identity_id → the device(s) to drop from the
1567
+ * roster + purge from delivery; member_device_ids here is the SDK's roster view
1568
+ * for relays that don't do that resolution. No-op if the identity isn't a member
1569
+ * of this group.
1570
+ */
1571
+ async removeMemberFromLiveGroup(channelId, identityId) {
1572
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeMemberFromLiveGroupUnlocked(channelId, identityId, 0)));
1573
+ }
1574
+ async removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt) {
1575
+ const mgr = await this.manager(channelId);
1576
+ const indices = mgr.leafIndicesForIdentity(identityId);
1577
+ if (indices.length === 0)
1578
+ return;
1579
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
1580
+ const priorEpoch = Number(mgr.epoch);
1581
+ const { commit } = await mgr.removeMembers(indices);
1582
+ if (Number(mgr.epoch) !== priorEpoch + 1) {
1583
+ throw new Error("removeMemberFromLiveGroup: unexpected epoch drift before distribute");
1584
+ }
1585
+ await this.o.store.saveStaging(channelId, mgr.exportState());
1586
+ const members = init.member_device_ids ?? [this.o.deviceId];
1587
+ try {
1588
+ await this.o.backend.distribute(init.group_id, {
1589
+ device_id: this.o.deviceId,
1590
+ commit: bytesToHex(commit),
1591
+ commit_epoch: Number(mgr.epoch),
1592
+ expected_epoch: priorEpoch,
1593
+ member_device_ids: members,
1594
+ op: "remove",
1595
+ removed_identity_id: identityId,
1596
+ welcomes: []
1597
+ });
1598
+ } catch (e) {
1599
+ this.managers.delete(channelId);
1600
+ await this.o.store.discardStaging(channelId);
1601
+ if (_attempt >= 2)
1602
+ throw e;
1603
+ try {
1604
+ await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
1605
+ } catch {
1606
+ }
1607
+ return this.removeMemberFromLiveGroupUnlocked(channelId, identityId, _attempt + 1);
1608
+ }
1609
+ await this.o.store.promoteStaging(channelId);
1610
+ }
1611
+ /**
1612
+ * Committer-side: REMOVE a SINGLE device (one leaf) from a live group (MD-3
1613
+ * per-device eviction).
1614
+ *
1615
+ * The identity-level {@link removeMemberFromLiveGroup} evicts EVERY leaf of a
1616
+ * credential identity. This evicts ONE — the counterpart to revoking a single
1617
+ * device while the owner keeps their other devices in the channel.
1618
+ *
1619
+ * Leaf resolution (LOCKED): MLS leaves carry only the member's credential
1620
+ * identity, never a device id, so same-identity devices are indistinguishable
1621
+ * at the crypto layer. The ONLY correct mapping is POSITIONAL BY ADD-ORDER:
1622
+ * leaves are appended in the order devices were added, so the i-th add-ordered
1623
+ * device of an identity occupies the i-th ascending leaf index for that
1624
+ * identity. The caller (which holds the channel roster with identities) passes
1625
+ * the target's identity AND the add-ordered device-id list; we resolve the leaf
1626
+ * by `orderedDeviceIds.indexOf(deviceId)`. If the tree's leaf count for that
1627
+ * identity disagrees with the roster's device count, we REFUSE rather than guess
1628
+ * (a wrong leaf would evict the wrong device).
1629
+ *
1630
+ * Everything else mirrors {@link removeMemberFromLiveGroup}: resolve leaf BEFORE
1631
+ * the commit, capture priorEpoch, single-leaf removeMembers, the B11 epoch-drift
1632
+ * guard, and two-slot staging (save→distribute→promote on 2xx / discard + evict
1633
+ * + receiveOnce + retry on 409). The distribute carries op:"remove" +
1634
+ * `removed_device_id` (instead of removed_identity_id); the server resolves the
1635
+ * device's identity, asserts it matches, and purges that ONE device.
1636
+ */
1637
+ async removeDeviceFromLiveGroup(channelId, deviceId, opts) {
1638
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, 0)));
1639
+ }
1640
+ async removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt) {
1641
+ const mgr = await this.manager(channelId);
1642
+ const pos = opts.orderedDeviceIds.indexOf(deviceId);
1643
+ if (pos < 0)
1644
+ return;
1645
+ const leaves = mgr.leafIndicesForIdentity(opts.identity);
1646
+ if (leaves.length !== opts.orderedDeviceIds.length) {
1647
+ throw new LeafRosterMismatchError();
1648
+ }
1649
+ const leafIndex = leaves[pos];
1650
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId]);
1651
+ const priorEpoch = Number(mgr.epoch);
1652
+ const { commit } = await mgr.removeMembers([leafIndex]);
1653
+ if (Number(mgr.epoch) !== priorEpoch + 1) {
1654
+ throw new Error("removeDeviceFromLiveGroup: unexpected epoch drift before distribute");
1655
+ }
1656
+ await this.o.store.saveStaging(channelId, mgr.exportState());
1657
+ const members = init.member_device_ids ?? [this.o.deviceId];
1658
+ const removedIdentity = typeof opts.identity === "string" ? opts.identity : bytesToHex(opts.identity);
1659
+ try {
1660
+ await this.o.backend.distribute(init.group_id, {
1661
+ device_id: this.o.deviceId,
1662
+ commit: bytesToHex(commit),
1663
+ commit_epoch: Number(mgr.epoch),
1664
+ expected_epoch: priorEpoch,
1665
+ member_device_ids: members,
1666
+ op: "remove",
1667
+ removed_identity_id: removedIdentity,
1668
+ removed_device_id: deviceId,
1669
+ welcomes: []
1670
+ });
1671
+ } catch (e) {
1672
+ this.managers.delete(channelId);
1673
+ await this.o.store.discardStaging(channelId);
1674
+ if (_attempt >= 2)
1675
+ throw e;
1676
+ try {
1677
+ await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
1678
+ } catch {
1679
+ }
1680
+ return this.removeDeviceFromLiveGroupUnlocked(channelId, deviceId, opts, _attempt + 1);
1681
+ }
1682
+ await this.o.store.promoteStaging(channelId);
1683
+ }
1684
+ /**
1685
+ * Committer-side: HEAL a permanently-diverged member by Remove(stale leaf) then
1686
+ * Add(device), re-keying it with a fresh Welcome at the live epoch (Phase 1
1687
+ * member self-heal).
1688
+ *
1689
+ * ATOMICITY (S1, honest framing): this is atomic against CONCURRENCY — the
1690
+ * per-channel lock is taken ONCE and held continuously across both mutations, so
1691
+ * no peer commit or receive-pump row can interleave between the Remove and the
1692
+ * Add. It is NOT TRANSACTIONAL across the two server distributes: there is no
1693
+ * rollback of a landed Remove. If the Add throws AFTER the Remove's distribute
1694
+ * already succeeded, the member is left REMOVED-but-not-readded (detached —
1695
+ * strictly worse than the original wedge). That specific failure is surfaced as a
1696
+ * {@link HealAddAfterRemoveError} so a caller can recover by RE-RUNNING the heal:
1697
+ * the retry finds zero leaves for the identity and takes the Add-only branch,
1698
+ * completing the re-key. An Add failure BEFORE any Remove landed (e.g. the
1699
+ * zero-leaf branch, or a fetchKeyPackages failure) is not detaching and rethrows
1700
+ * as-is.
1701
+ *
1702
+ * The wedge this clears: a member fell behind, can't catch up (forward secrecy
1703
+ * deleted the key it needs), still HOLDS a leaf (so the admission watcher won't
1704
+ * re-add it), and its saved MLS state is diverged. There is no MLS-correct
1705
+ * "re-welcome in place" for a member that still occupies a leaf — handing a
1706
+ * Welcome to an identity that already holds a leaf is a two-leaf bug. The only
1707
+ * correct heal is to drop its stale leaf and re-Add the device, which seals a
1708
+ * fresh Welcome to it at the post-Remove live epoch. (reinit is the WRONG tool —
1709
+ * that is the committer-loss hammer, guarded to zero-other-devices, and
1710
+ * Welcome-storms every healthy member.)
1711
+ *
1712
+ * ONE acquisition (load-bearing): we take the per-channel lock ONCE and run BOTH
1713
+ * mutations inside it via the Unlocked variants, so the lock is never released
1714
+ * between Remove and Add — that gap would open a fork window (a peer commit or
1715
+ * the device-wide receive pump could advance the manager between the two). Do
1716
+ * NOT call the public (self-locking) variants in sequence. Remove FIRST, then
1717
+ * Add, so the identity is never momentarily double-leafed.
1718
+ *
1719
+ * Device-level removal (not identity-level): we resolve the member's credential
1720
+ * identity from its freshly-published KeyPackage (which the Add needs anyway)
1721
+ * and evict only the single stale leaf, so the member identity's OTHER healthy
1722
+ * devices are not collaterally evicted. Leaf resolution reads the LIVE
1723
+ * (post-lock) tree:
1724
+ * - exactly ONE leaf → device-level Remove (orderedDeviceIds=[deviceId]) then Add;
1725
+ * - ZERO leaves → no stale leaf (e.g. a heal retry whose Remove already
1726
+ * landed) → Add-only re-key;
1727
+ * - MORE THAN ONE leaf → ambiguous (no add-order roster to disambiguate which
1728
+ * leaf is the stale one) → REFUSE rather than risk evicting a healthy device.
1729
+ *
1730
+ * The Add fetches the device's freshly published KeyPackage from the backend,
1731
+ * exactly like a normal add — the CALLER is responsible for the member having
1732
+ * re-published one first (mirrors addMemberToLiveGroup's "no published
1733
+ * KeyPackage" error). After the call the committer's group has the member
1734
+ * re-added at the live epoch.
1735
+ *
1736
+ * Authorization / single-committer guards are inherited unchanged from the
1737
+ * Unlocked committer ops this composes (removeDeviceFromLiveGroupUnlocked /
1738
+ * addMemberToLiveGroupUnlocked) — no weaker path is introduced.
1739
+ */
1740
+ async healDivergedMember(channelId, memberDeviceId, opts) {
1741
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
1742
+ const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
1743
+ const hex = kps[memberDeviceId];
1744
+ if (!hex)
1745
+ throw new Error(`healDivergedMember: no published KeyPackage for device ${memberDeviceId}`);
1746
+ const cred = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex)).leafNode.credential;
1747
+ if (cred.credentialType !== "basic") {
1748
+ throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
1749
+ }
1750
+ const identity = new TextDecoder().decode(cred.identity);
1751
+ if (opts?.expectedIdentityId && identity !== opts.expectedIdentityId) {
1752
+ throw new ForgedCredentialError(opts.expectedIdentityId, identity);
1753
+ }
1754
+ const mgr = await this.manager(channelId);
1755
+ const leaves = mgr.leafIndicesForIdentity(identity);
1756
+ if (leaves.length > 1) {
1757
+ throw new Error(`healDivergedMember: identity owns ${leaves.length} leaves; ambiguous stale-leaf resolution (refusing to guess)`);
1758
+ }
1759
+ let removeLanded = false;
1760
+ if (leaves.length === 1) {
1761
+ await this.removeDeviceFromLiveGroupUnlocked(channelId, memberDeviceId, { identity, orderedDeviceIds: [memberDeviceId] }, 0);
1762
+ removeLanded = true;
1763
+ } else {
1764
+ const stillZero = (await this.manager(channelId)).leafIndicesForIdentity(identity);
1765
+ if (stillZero.length !== 0) {
1766
+ throw new Error(`healDivergedMember: expected 0 leaves for the Add-only branch but found ${stillZero.length} (a leaf reappeared under the held lock \u2014 refusing to create a duplicate leaf)`);
1767
+ }
1768
+ }
1769
+ try {
1770
+ await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
1771
+ } catch (e) {
1772
+ if (removeLanded)
1773
+ throw new HealAddAfterRemoveError(e);
1774
+ throw e;
1775
+ }
1776
+ }));
1777
+ }
1778
+ /** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
1779
+ * epoch. Identical crypto to healDivergedMember (Remove-first same-device, then
1780
+ * re-Add at the live epoch); named separately for the Phase 2 guest dispatch/
1781
+ * sweep call sites and for the cross-tenant authz that differs only server-side.
1782
+ * Caller contract (like the member path): the guest republishes a fresh
1783
+ * KeyPackage first, then resetChannelForRejoin + joinAndReconcile after.
1784
+ *
1785
+ * Phase 2 fail-closed gate: `expectedKpId` is the id of the guest's fresh,
1786
+ * retained KeyPackage (the one the caller confirmed the guest republished).
1787
+ * Before doing anything else we fetch it BY ID and refuse
1788
+ * ({@link GuestHealStaleKpError}) if it's gone — never Remove+re-Add on the
1789
+ * strength of "some KeyPackage is published for this device", because that
1790
+ * could be stale, consumed, or absent and would seal an un-openable Welcome,
1791
+ * bricking the guest instead of healing it. Once the gate passes we delegate to
1792
+ * the proven {@link healDivergedMember}: given `publish_key_package`'s
1793
+ * delete-and-replace semantics and the guest's pending-bundle republish guard,
1794
+ * the by-device fetch inside healDivergedMember resolves to exactly this same
1795
+ * KeyPackage — the id-fetch here is the fail-closed contract, the member heal
1796
+ * is the mechanism. */
1797
+ async healDivergedGuest(channelId, guestDeviceId, expectedKpId) {
1798
+ const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
1799
+ if (!hex)
1800
+ throw new GuestHealStaleKpError(expectedKpId);
1801
+ return this.healDivergedMember(channelId, guestDeviceId);
1802
+ }
1803
+ /** Re-Welcome a diverged agent/member with BOTH the fail-closed fresh-KP gate
1804
+ * AND the TOCTOU-safe anti-forgery check. Mirrors {@link healDivergedGuest}'s
1805
+ * id-by-value gate (refuse if `expectedKpId` is gone/consumed →
1806
+ * {@link GuestHealStaleKpError}, never sealing an un-openable Welcome), then
1807
+ * delegates to {@link healDivergedMember} with `expectedIdentityId` so the
1808
+ * committed-bytes assertion binds the re-Added leaf to the intended identity
1809
+ * (throws {@link ForgedCredentialError} on a credential-identity mismatch,
1810
+ * including on the 409-retry path). This is the entry point the owner's
1811
+ * agent-diverged heal sweep uses: `expectedKpId`/`expectedIdentityId` come from
1812
+ * the welcome-request row the agent published, so a compromised agent cannot
1813
+ * substitute a KeyPackage that forges a different identity. */
1814
+ async healDivergedMemberFresh(channelId, memberDeviceId, expectedKpId, expectedIdentityId) {
1815
+ const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
1816
+ if (!hex)
1817
+ throw new GuestHealStaleKpError(expectedKpId);
1818
+ return this.healDivergedMember(channelId, memberDeviceId, { expectedIdentityId });
1819
+ }
1820
+ /**
1821
+ * Member-side: drop the diverged local state for a channel so a subsequent
1822
+ * {@link joinAndReconcile} consumes the committer's fresh Welcome (from
1823
+ * {@link healDivergedMember}) instead of early-returning on the stale saved
1824
+ * state (Phase 1 member self-heal). This does NOT rejoin — the caller invokes
1825
+ * joinAndReconcile afterward.
1826
+ *
1827
+ * BOTH the in-memory manager AND the persisted store row must go: evict alone is
1828
+ * insufficient because ensureSession(join) early-returns whenever
1829
+ * `store.load(channelId)` returns existing state, so the member would reload the
1830
+ * diverged row and never rejoin. Also clear this channel's decrypt-error tracker
1831
+ * entry so a healed channel doesn't read as permanently `stuck` in errorStats.
1832
+ *
1833
+ * CHANNEL LOCK (M1): the whole reset runs under the per-channel lock, serialized
1834
+ * against receiveOnce / committer ops / ensureSession on the SAME channel. Without
1835
+ * it a concurrent receiveOnce processing a commit row for this channel could
1836
+ * re-persist the diverged state immediately after our `store.delete`, re-wedging
1837
+ * the rejoin (the join would then early-return on the resurrected row). The
1838
+ * in-memory evict and `errors.delete` are cheap synchronous map mutations but are
1839
+ * kept inside the lock so the reset is one coherent, atomic state clear.
1840
+ *
1841
+ * STAGING (M2): also discard any persisted staging row for the channel. A removal
1842
+ * op interrupted between saveStaging and promote/discard can leave a stale staged
1843
+ * advanced state; if that survives the reset it can re-diverge the rejoin. The
1844
+ * in-memory store's discardStaging is a no-op when no staging row exists.
1845
+ */
1846
+ async resetChannelForRejoin(channelId) {
1847
+ await this.locks.run(channelId, async () => {
1848
+ this.evictChannelState(channelId);
1849
+ await this.o.store.delete(channelId);
1850
+ await this.o.store.discardStaging(channelId);
1851
+ this.errors.delete(channelId);
1852
+ });
1853
+ }
1854
+ /** Owner-attested reset supersede (owner/web side, Task 9). Mirrors the
1855
+ * ensureSession forceReinit branch but posts a client-chosen new_group_id +
1856
+ * signed attestation to the reset-mode endpoint, then builds + distributes
1857
+ * the new group. Runs under the channel lock (serialized against
1858
+ * receiveOnce / committer ops).
1859
+ *
1860
+ * `opts.otherDeviceIds` MUST be the CHANNEL-WIDE union of every active
1861
+ * member device holding an unconsumed KeyPackage (H2) -- the caller (the web
1862
+ * client's members-endpoint enumeration) is responsible for that union;
1863
+ * this method only handles the mechanical THIS-device exclusion: this
1864
+ * device is the committer that creates the group, so it must not be in the
1865
+ * MLS addMembers set (adding your own new leaf via addMembers is
1866
+ * nonsensical / unsupported). The wire member_device_ids below re-includes
1867
+ * it as [deviceId, ...others] (deduped), so the Task-2 completeness guard
1868
+ * (required subset of supplied) is still satisfied even though it never
1869
+ * goes through addMembers. */
1870
+ async resetSessionSupersede(channelId, opts) {
1871
+ return this.locks.run(channelId, async () => {
1872
+ let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1873
+ if (!bundle) {
1874
+ await this.publishKeyPackage();
1875
+ bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1876
+ }
1877
+ if (!bundle)
1878
+ throw new Error("resetSessionSupersede: call publishKeyPackage() first");
1879
+ const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
1880
+ const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
1881
+ const mgr = new MLSGroupManager();
1882
+ await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
1883
+ const kps = await this.o.backend.fetchKeyPackages(others);
1884
+ const pkgs = others.map((d) => {
1885
+ const hex = kps[d];
1886
+ if (!hex)
1887
+ throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
1888
+ return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
1889
+ });
1890
+ const { commit, welcome } = await mgr.addMembers(pkgs);
1891
+ await this.o.backend.distribute(reinit.group_id, {
1892
+ device_id: this.o.deviceId,
1893
+ commit: bytesToHex(commit),
1894
+ commit_epoch: Number(mgr.epoch),
1895
+ expected_epoch: reinit.current_epoch,
1896
+ member_device_ids: [this.o.deviceId, ...others],
1897
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
1898
+ });
1899
+ await this.o.store.save(channelId, mgr.exportState());
1900
+ this.managers.set(channelId, mgr);
1901
+ await this.o.store.deletePendingBundle(this.o.deviceId);
1902
+ });
1903
+ }
1904
+ /** Returns true when an in-memory MLS manager is live for the channel.
1905
+ * Cheap synchronous check; does NOT consult the store. */
1906
+ hasChannelState(channelId) {
1907
+ return this.managers.has(channelId);
1908
+ }
1909
+ /** Returns true when the channel has either a live in-memory manager OR
1910
+ * persisted state in the store. Use this to decide whether to call
1911
+ * ensureSession(bootstrap:false) or ensureSession(bootstrap:true). */
1912
+ async hasPersistedChannelState(channelId) {
1913
+ if (this.managers.has(channelId))
1914
+ return true;
1915
+ return await this.o.store.load(channelId) !== null;
1916
+ }
1917
+ /**
1918
+ * Return the number of leaves the given identity holds in the live tree for
1919
+ * `channelId`. Loads the manager from the store if not already cached.
1920
+ *
1921
+ * Primarily used by the gate (S20) to assert that a peer-committer 409-retry
1922
+ * does NOT create a duplicate leaf for an already-added device. A return value
1923
+ * of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
1924
+ * duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
1925
+ */
1926
+ async leafCountForIdentity(channelId, identityId) {
1927
+ const mgr = await this.manager(channelId);
1928
+ return mgr.leafIndicesForIdentity(identityId).length;
1929
+ }
1930
+ /** Returns true when the live in-memory MLS tree for `channelId` contains at
1931
+ * least one leaf bearing `identityId` as its credential. Used by the agent
1932
+ * admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
1933
+ * needed — if the agent already has a leaf the sweep skips it. Returns false
1934
+ * when there is no in-memory manager for the channel (i.e. the group is not
1935
+ * yet loaded), which causes the sweep to attempt the Add (idempotent on the
1936
+ * server side). */
1937
+ hasLeafForIdentity(channelId, identityId) {
1938
+ const mgr = this.managers.get(channelId);
1939
+ if (!mgr)
1940
+ return false;
1941
+ return mgr.leafIndicesForIdentity(identityId).length > 0;
1942
+ }
1943
+ /** Probe the server for the channel's MLS group without modifying any local
1944
+ * state. Maps the ChannelGroupResp field names to SDK-friendly camelCase. */
1945
+ async probeChannelGroup(channelId) {
1946
+ const r = await this.o.backend.getConnectChannelGroup(channelId);
1947
+ return { exists: r.exists, groupId: r.group_id, epoch: r.current_epoch };
1948
+ }
1949
+ /**
1950
+ * Verified group supersede for the agent's receive pump (owner-attested reset).
1951
+ * Returns true when the channel's state was swapped to the new group and the
1952
+ * Welcome row acked; false when the supersede was REFUSED (row left un-acked).
1953
+ *
1954
+ * FAIL-CLOSED order (spec §3): step-0 pin-presence guard FIRST (no pinned owner
1955
+ * ⇒ refuse, never call verify), then structural signer check, then attestation
1956
+ * verify against the NON-EMPTY pinned key, then post-join group-id assertion.
1957
+ */
1958
+ async trySupersede(channelId, row, heldGroupId) {
1959
+ const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
1960
+ if (!owner || !owner.publicKeyHex) {
1961
+ this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
1962
+ return false;
1963
+ }
1964
+ const retired = await this.o.store.loadRetiredGroupIds(channelId);
1965
+ if (retired.includes(row.group_id)) {
1966
+ this.recordDecryptError(row, new Error(`supersede refused: group ${row.group_id} is retired (rollback/replay)`));
1967
+ return false;
1968
+ }
1969
+ const att = await this.o.backend.fetchResetAttestation(row.group_id);
1970
+ if (!att) {
1971
+ this.recordDecryptError(row, new Error(`supersede refused: no attestation for group ${row.group_id}`));
1972
+ return false;
1973
+ }
1974
+ if (att.signerIdentityId !== owner.identityId) {
1975
+ this.recordDecryptError(row, new Error("supersede refused: signer is not the pinned owner"));
1976
+ return false;
1977
+ }
1978
+ let content;
1979
+ try {
1980
+ content = JSON.parse(att.signedContent);
1981
+ } catch {
1982
+ this.recordDecryptError(row, new Error("supersede refused: unparseable signed_content"));
1983
+ return false;
1984
+ }
1985
+ if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
1986
+ this.recordDecryptError(row, new Error("supersede refused: attestation channel/group mismatch"));
1987
+ return false;
1988
+ }
1989
+ const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
1990
+ if (!ok) {
1991
+ this.recordDecryptError(row, new Error("supersede refused: signature does not verify against pinned owner key"));
1992
+ return false;
1993
+ }
1994
+ const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1995
+ if (!bundle) {
1996
+ this.recordDecryptError(row, new Error("supersede refused: no pending KeyPackage bundle to open the new Welcome"));
1997
+ return false;
1998
+ }
1999
+ return await this.locks.run(channelId, async () => {
2000
+ const mgr = new MLSGroupManager();
2001
+ try {
2002
+ await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
2003
+ } catch (err) {
2004
+ this.recordDecryptError(row, err);
2005
+ return false;
2006
+ }
2007
+ if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
2008
+ this.recordDecryptError(row, new Error("supersede refused: post-join group id != attested new_group_id"));
2009
+ return false;
2010
+ }
2011
+ this.evictChannelState(channelId);
2012
+ await this.o.store.delete(channelId);
2013
+ await this.o.store.discardStaging(channelId);
2014
+ await this.o.store.addRetiredGroupId(channelId, heldGroupId);
2015
+ this.errors.delete(channelId);
2016
+ this.managers.set(channelId, mgr);
2017
+ await this.o.store.save(channelId, mgr.exportState());
2018
+ await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
2019
+ return true;
2020
+ });
2021
+ }
2022
+ async receiveOnce(deviceId) {
2023
+ const rows = await this.o.backend.pullDelivery(deviceId);
2024
+ const appEntries = [];
2025
+ const handshakeAcked = [];
2026
+ const supersededChannelIds = [];
2027
+ const order = { welcome: 0, commit: 1, application: 2 };
2028
+ rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
2029
+ for (const row of rows) {
2030
+ try {
2031
+ if (row.message_type === "welcome") {
2032
+ const wcid = row.connect_channel_id;
2033
+ if (wcid) {
2034
+ const existing = await this.o.store.load(wcid);
2035
+ if (existing) {
2036
+ let heldGroupId = null;
2037
+ try {
2038
+ const mgr = await this.manager(wcid);
2039
+ heldGroupId = new TextDecoder().decode(mgr.groupId);
2040
+ } catch {
2041
+ heldGroupId = null;
2042
+ }
2043
+ if (heldGroupId !== null && heldGroupId === row.group_id) {
2044
+ handshakeAcked.push(row.queue_id);
2045
+ } else if (heldGroupId !== null && heldGroupId !== row.group_id) {
2046
+ const ok = await this.trySupersede(wcid, row, heldGroupId);
2047
+ if (ok)
2048
+ supersededChannelIds.push(wcid);
2049
+ }
2050
+ }
2051
+ }
2052
+ } else if (row.message_type === "commit") {
2053
+ const cid = row.connect_channel_id;
2054
+ if (!cid)
2055
+ continue;
2056
+ await this.locks.run(cid, async () => {
2057
+ const mgr = await this.manager(cid);
2058
+ await mgr.decrypt(hexToBytes(row.payload));
2059
+ await this.o.store.save(cid, mgr.exportState());
2060
+ });
2061
+ this.recordDecryptSuccess(cid);
2062
+ handshakeAcked.push(row.queue_id);
2063
+ } else {
2064
+ const envelope = JSON.parse(new TextDecoder().decode(hexToBytes(row.payload)));
2065
+ const key = await this.locks.run(envelope.channel_id, () => this.exporterKey(envelope.enclave_id, envelope.channel_id, envelope.mls_group_epoch));
2066
+ const aad = await buildAssociatedData({
2067
+ message_id: envelope.message_id,
2068
+ channel_id: envelope.channel_id,
2069
+ sender_id: envelope.sender_id,
2070
+ mls_group_epoch: envelope.mls_group_epoch,
2071
+ replay_nonce: envelope.replay_nonce,
2072
+ policy_binding: envelope.policy_binding
2073
+ });
2074
+ const plaintext = openPayload({ ciphertext: base64ToBytes(envelope.payload_encrypted), key, associatedData: aad, suite: this.suite });
2075
+ const appChannelId = row.connect_channel_id ?? envelope.channel_id;
2076
+ this.recordDecryptSuccess(appChannelId);
2077
+ appEntries.push({
2078
+ queueId: row.queue_id,
2079
+ messageId: envelope.message_id,
2080
+ channelId: appChannelId,
2081
+ plaintext,
2082
+ envelope,
2083
+ receivedAt: Date.now(),
2084
+ retryCount: 0,
2085
+ expires_at: row.expires_at
2086
+ });
2087
+ }
2088
+ } catch (err) {
2089
+ this.recordDecryptError(row, err);
2090
+ const cid = row.connect_channel_id;
2091
+ if (cid && typeof row.epoch === "number") {
2092
+ let liveEpoch;
2093
+ try {
2094
+ liveEpoch = await this.currentEpoch(cid);
2095
+ } catch {
2096
+ liveEpoch = void 0;
2097
+ }
2098
+ const permanentlyDead = liveEpoch !== void 0 && (row.message_type === "commit" ? row.epoch <= liveEpoch : row.message_type === "application" ? row.epoch < liveEpoch : false);
2099
+ if (permanentlyDead) {
2100
+ handshakeAcked.push(row.queue_id);
2101
+ this.permanentlySkipped++;
2102
+ }
2103
+ }
2104
+ }
2105
+ }
2106
+ if (handshakeAcked.length)
2107
+ await this.o.backend.ackDelivery(deviceId, handshakeAcked);
2108
+ if (supersededChannelIds.length) {
2109
+ Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
2110
+ }
2111
+ return appEntries;
2112
+ }
2113
+ /**
2114
+ * Channel-scoped, UNLOCKED commit drain for committer conflict-recovery.
2115
+ *
2116
+ * A committer that loses an epoch race (409) must process the peer's winning
2117
+ * commit to advance to the true current epoch before it retries. It does this
2118
+ * from INSIDE its own channel lock, so it must NOT call the public receiveOnce
2119
+ * (which would re-acquire the same channel lock for that channel's commit rows
2120
+ * and self-deadlock — the queue is not reentrant). This helper does the commit
2121
+ * half of receiveOnce for ONLY the committer's own channel, without taking the
2122
+ * lock (the caller already holds it). Application rows are left untouched — the
2123
+ * committer only needs to converge its handshake epoch — and welcome rows are
2124
+ * left for the join path, exactly as receiveOnce leaves them.
2125
+ */
2126
+ async drainChannelCommitsUnlocked(deviceId, channelId) {
2127
+ const rows = await this.o.backend.pullDelivery(deviceId);
2128
+ const acked = [];
2129
+ for (const row of rows) {
2130
+ if (row.message_type !== "commit")
2131
+ continue;
2132
+ if (row.connect_channel_id !== channelId)
2133
+ continue;
2134
+ try {
2135
+ const mgr = await this.manager(channelId);
2136
+ await mgr.decrypt(hexToBytes(row.payload));
2137
+ await this.o.store.save(channelId, mgr.exportState());
2138
+ this.recordDecryptSuccess(channelId);
2139
+ acked.push(row.queue_id);
2140
+ } catch (err) {
2141
+ this.recordDecryptError(row, err);
2142
+ }
2143
+ }
2144
+ if (acked.length)
2145
+ await this.o.backend.ackDelivery(deviceId, acked);
2146
+ }
2147
+ /** Delegate so the receive pump can ack without reaching the private backend. */
2148
+ async ackDelivery(deviceId, queueIds) {
2149
+ await this.o.backend.ackDelivery(deviceId, queueIds);
2150
+ }
2151
+ };
2152
+
2153
+ // ../connect-sdk-ts/dist/transport/frame-registry.js
2154
+ var str = (v) => String(v ?? "");
2155
+ var FRAME_PARSERS = {
2156
+ mls_delivery: (d) => ({ count: typeof d?.count === "number" ? d.count : void 0 }),
2157
+ delivery_receipt: (d) => ({
2158
+ receipts: [{ messageId: str(d?.message_id), channelId: str(d?.channel_id), status: "delivered" }]
2159
+ }),
2160
+ read_receipt: (d) => ({
2161
+ receipts: [{ messageId: str(d?.message_id), channelId: str(d?.channel_id), status: "read" }]
2162
+ }),
2163
+ agent_status_change: (d) => ({
2164
+ presence: [
2165
+ {
2166
+ identityId: str(d?.identity_id),
2167
+ status: d?.status === "offline" ? "offline" : "online",
2168
+ lastSeen: d?.last_seen
2169
+ }
2170
+ ]
2171
+ }),
2172
+ presence_snapshot: (d) => ({
2173
+ presence: (Array.isArray(d?.online) ? d.online : []).map((id) => ({
2174
+ identityId: str(id),
2175
+ status: "online"
2176
+ }))
2177
+ }),
2178
+ typing: (d) => ({ typing: { channelId: str(d?.channel_id), identityId: str(d?.identity_id) } }),
2179
+ agent_status_keepalive: (d) => ({
2180
+ statusKeepalive: { channelId: str(d?.channel_id), identityId: str(d?.identity_id) }
2181
+ }),
2182
+ // MD-3: a device asked to be Welcomed into a group. A surviving committer
2183
+ // device acts on this for own-identity (PoP-proven) requesters; the durable
2184
+ // state is the welcome_request row, this frame just wakes the handler.
2185
+ mls_welcome_requested: (d) => ({
2186
+ welcomeRequest: {
2187
+ groupId: str(d?.group_id),
2188
+ roomId: d?.room_id ? str(d.room_id) : void 0,
2189
+ requestingDeviceId: str(d?.requesting_device_id),
2190
+ requestingIdentityId: d?.requesting_identity_id ? str(d.requesting_identity_id) : void 0,
2191
+ freshKpId: d?.fresh_kp_id ? String(d.fresh_kp_id) : void 0,
2192
+ requestId: d?.request_id ? str(d.request_id) : void 0
2193
+ }
2194
+ }),
2195
+ // Diverged-guest heal Phase 2 (P2-A): the server wakes surviving devices to
2196
+ // heal a guest whose ratchet tree diverged. Durable state is the
2197
+ // mls_guest_heal_request row; this frame is just the wakeup hint.
2198
+ mls_guest_heal_requested: (d) => ({
2199
+ guestHealRequest: {
2200
+ groupId: str(d?.group_id),
2201
+ channelId: str(d?.channel_id),
2202
+ enclaveId: str(d?.enclave_id),
2203
+ guestIdentityId: str(d?.guest_identity_id),
2204
+ guestDeviceId: str(d?.guest_device_id),
2205
+ freshKpId: str(d?.fresh_kp_id),
2206
+ requestId: str(d?.request_id)
2207
+ }
2208
+ })
2209
+ };
2210
+ var S2C_FRAME_NAMES = new Set(Object.keys(FRAME_PARSERS));
2211
+ function parseServerFrame(frame) {
2212
+ if (!frame || typeof frame !== "object")
2213
+ return void 0;
2214
+ const f = frame;
2215
+ const parser = typeof f.event === "string" ? FRAME_PARSERS[f.event] : void 0;
2216
+ return parser ? parser(f.data) : void 0;
2217
+ }
2218
+
2219
+ // ../connect-sdk-ts/dist/transport/ws-pings.js
2220
+ function openDeliveryPings(o) {
2221
+ const base = o.backoffBaseMs ?? 500;
2222
+ const cap = o.backoffCapMs ?? 8e3;
2223
+ const rng = o.rng ?? Math.random;
2224
+ const maxQueue = o.maxQueue ?? 8;
2225
+ const stableResetMs = o.stableResetMs ?? 1e4;
2226
+ let closed = false;
2227
+ let attempt = 0;
2228
+ let sock;
2229
+ let backoffTimer;
2230
+ let stableTimer;
2231
+ const PRESENCE_PONG_MS = 3e4;
2232
+ let pongTimer;
2233
+ const stopPong = () => {
2234
+ if (pongTimer) {
2235
+ clearInterval(pongTimer);
2236
+ pongTimer = void 0;
2237
+ }
2238
+ };
2239
+ const queue = [];
2240
+ let waiter;
2241
+ let iterStarted = false;
2242
+ const push = (p) => {
2243
+ if (waiter) {
2244
+ const w = waiter;
2245
+ waiter = void 0;
2246
+ w({ value: p, done: false });
2247
+ } else if (queue.length < maxQueue) {
2248
+ queue.push(p);
2249
+ }
2250
+ };
2251
+ const end = () => {
2252
+ closed = true;
2253
+ if (backoffTimer)
2254
+ clearTimeout(backoffTimer);
2255
+ if (stableTimer)
2256
+ clearTimeout(stableTimer);
2257
+ stopPong();
2258
+ try {
2259
+ sock?.close();
2260
+ } catch {
2261
+ }
2262
+ if (waiter) {
2263
+ const w = waiter;
2264
+ waiter = void 0;
2265
+ w({ value: void 0, done: true });
2266
+ }
2267
+ };
2268
+ const scheduleReconnect = () => {
2269
+ if (closed)
2270
+ return;
2271
+ attempt += 1;
2272
+ const ceil = Math.min(cap, base * 2 ** (attempt - 1));
2273
+ backoffTimer = setTimeout(connect, Math.floor(rng() * ceil));
2274
+ };
2275
+ async function connect() {
2276
+ if (closed)
2277
+ return;
2278
+ let token;
2279
+ try {
2280
+ token = await o.tokenProvider();
2281
+ } catch {
2282
+ scheduleReconnect();
2283
+ return;
2284
+ }
2285
+ if (closed)
2286
+ return;
2287
+ const sep = o.url.includes("?") ? "&" : "?";
2288
+ const ws = new o.wsImpl(`${o.url}${sep}token=${encodeURIComponent(token)}`);
2289
+ sock = ws;
2290
+ ws.onopen = () => {
2291
+ push({ reconnected: true });
2292
+ stableTimer = setTimeout(() => {
2293
+ attempt = 0;
2294
+ }, stableResetMs);
2295
+ stopPong();
2296
+ pongTimer = setInterval(() => {
2297
+ try {
2298
+ if (ws.readyState === 1)
2299
+ ws.send(JSON.stringify({ event: "pong" }));
2300
+ } catch {
2301
+ }
2302
+ }, PRESENCE_PONG_MS);
2303
+ };
2304
+ ws.onmessage = (e) => {
2305
+ try {
2306
+ const f = JSON.parse(String(e.data));
2307
+ const ping = parseServerFrame(f);
2308
+ if (ping)
2309
+ push(ping);
2310
+ } catch {
2311
+ }
2312
+ };
2313
+ ws.onerror = () => {
2314
+ };
2315
+ ws.onclose = (e) => {
2316
+ sock = void 0;
2317
+ if (stableTimer)
2318
+ clearTimeout(stableTimer);
2319
+ stopPong();
2320
+ if (closed)
2321
+ return;
2322
+ if (e.code === 4001) {
2323
+ end();
2324
+ return;
2325
+ }
2326
+ scheduleReconnect();
2327
+ };
2328
+ }
2329
+ void connect();
2330
+ const pings = {
2331
+ [Symbol.asyncIterator]() {
2332
+ if (iterStarted)
2333
+ throw new Error("openDeliveryPings: single-use async iterable");
2334
+ iterStarted = true;
2335
+ return {
2336
+ next() {
2337
+ if (closed)
2338
+ return Promise.resolve({ value: void 0, done: true });
2339
+ if (queue.length)
2340
+ return Promise.resolve({ value: queue.shift(), done: false });
2341
+ return new Promise((resolve) => {
2342
+ waiter = resolve;
2343
+ });
2344
+ }
2345
+ };
2346
+ }
2347
+ };
2348
+ const send = (frame) => {
2349
+ try {
2350
+ if (sock && sock.readyState === 1)
2351
+ sock.send(JSON.stringify(frame));
2352
+ } catch {
2353
+ }
2354
+ };
2355
+ return { pings, close: end, send };
2356
+ }
2357
+
2358
+ // ../connect-sdk-ts/dist/transport/rest.js
2359
+ var PATHS = {
2360
+ register: "/api/v1/connect/agents/register",
2361
+ // VERIFY against MOT-136
2362
+ channelMembers: (id) => `/api/v1/connect/channels/${id}/members`,
2363
+ // VERIFY against MOT-139
2364
+ messages: "/api/v1/messages",
2365
+ // live (MOT-194)
2366
+ stream: "/api/v1/messages/stream",
2367
+ // MLS-gated (MOT-144)
2368
+ auditRecords: "/api/v1/audit/records"
2369
+ // MOT-151
2370
+ };
2371
+ var RestTransport = class {
2372
+ opts;
2373
+ f;
2374
+ ws;
2375
+ tokenProvider;
2376
+ /** The most recent device-wide WS send handle, captured on subscribe and
2377
+ * cleared on its close. Control frames (status keepalive) route through it;
2378
+ * a no-op when no subscription is active. */
2379
+ _controlSend = null;
2380
+ constructor(opts) {
2381
+ this.opts = opts;
2382
+ this.f = opts.fetchImpl ?? fetch;
2383
+ this.ws = opts.wsImpl ?? globalThis.WebSocket;
2384
+ this.tokenProvider = opts.tokenProvider ?? (async () => opts.bearerToken);
2385
+ }
2386
+ headers() {
2387
+ return { "content-type": "application/json", authorization: `Bearer ${this.opts.bearerToken}` };
2388
+ }
2389
+ async register(req) {
2390
+ const res = await this.f(this.opts.baseUrl + PATHS.register, {
2391
+ method: "POST",
2392
+ headers: this.headers(),
2393
+ body: JSON.stringify({
2394
+ skill_forge_token: req.skillForgeToken,
2395
+ skill_id: req.skillId,
2396
+ public_key: req.publicKey,
2397
+ algo: req.algo,
2398
+ attested: req.attested,
2399
+ model: req.model
2400
+ })
2401
+ });
2402
+ if (!res.ok)
2403
+ throw new RegistrationError(`register failed: ${res.status}`);
2404
+ const j = await res.json();
2405
+ return {
2406
+ agentId: j.agent_id,
2407
+ tenantId: j.tenant_id,
2408
+ enclaveId: j.enclave_id,
2409
+ bindingId: j.binding_id,
2410
+ bindingVersion: j.binding_version,
2411
+ owningOrg: j.owning_org
2412
+ };
2413
+ }
2414
+ async joinChannel(channelId, types, caps) {
2415
+ const res = await this.f(this.opts.baseUrl + PATHS.channelMembers(channelId), {
2416
+ method: "POST",
2417
+ headers: this.headers(),
2418
+ body: JSON.stringify({ declared_message_types: types, declared_capabilities: caps })
2419
+ });
2420
+ if (!res.ok)
2421
+ throw new Error(`joinChannel failed: ${res.status}`);
2422
+ }
2423
+ async leaveChannel(channelId) {
2424
+ await this.f(this.opts.baseUrl + PATHS.channelMembers(channelId), {
2425
+ method: "DELETE",
2426
+ headers: this.headers()
2427
+ });
2428
+ }
2429
+ async publish(envelope) {
2430
+ const res = await this.f(this.opts.baseUrl + PATHS.messages, {
2431
+ method: "POST",
2432
+ headers: this.headers(),
2433
+ body: JSON.stringify(envelope)
2434
+ });
2435
+ if (res.status === 429) {
2436
+ const retry = Number(res.headers.get("retry-after") ?? "1");
2437
+ throw new RateLimitError("server rate limit", Number.isFinite(retry) ? retry : 1);
2438
+ }
2439
+ if (res.status === 400) {
2440
+ const stale = await staleEpochErrorFromResponse(res);
2441
+ if (stale)
2442
+ throw stale;
2443
+ }
2444
+ if (!res.ok)
2445
+ throw new Error(`publish failed: ${res.status}`);
2446
+ const j = await res.json();
2447
+ return {
2448
+ messageId: String(j.message_id),
2449
+ auditRecordId: String(j.audit_record_id),
2450
+ thisHash: String(j.this_hash),
2451
+ sequenceNumber: Number(j.sequence_number)
2452
+ };
2453
+ }
2454
+ subscribeDeliveryPings(_channelId) {
2455
+ let wsUrl = this.opts.baseUrl.replace(/^http/, "ws").replace(/\/$/, "") + "/api/v1/ws";
2456
+ if (this.opts.connectIdentityId) {
2457
+ wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
2458
+ }
2459
+ const handle = openDeliveryPings({ url: wsUrl, tokenProvider: this.tokenProvider, wsImpl: this.ws });
2460
+ this._controlSend = handle.send;
2461
+ const close = () => {
2462
+ if (this._controlSend === handle.send)
2463
+ this._controlSend = null;
2464
+ handle.close();
2465
+ };
2466
+ return { pings: handle.pings, close, send: handle.send };
2467
+ }
2468
+ sendControlFrame(_channelId, frame) {
2469
+ try {
2470
+ this._controlSend?.(frame);
2471
+ } catch {
2472
+ }
2473
+ }
2474
+ async queryAudit(query) {
2475
+ const qs = new URLSearchParams(query).toString();
2476
+ const res = await this.f(`${this.opts.baseUrl}${PATHS.auditRecords}?${qs}`, {
2477
+ method: "GET",
2478
+ headers: this.headers()
2479
+ });
2480
+ if (res.status === 403)
2481
+ throw new AuditAccessDenied("audit query exceeded caller rights");
2482
+ if (!res.ok)
2483
+ throw new Error(`queryAudit failed: ${res.status}`);
2484
+ const j = await res.json();
2485
+ return (j.records ?? []).map((r) => ({
2486
+ recordId: String(r.record_id),
2487
+ sequenceNumber: Number(r.sequence_number),
2488
+ occurredAt: String(r.occurred_at),
2489
+ messageType: r.message_type ? String(r.message_type) : void 0,
2490
+ channelId: r.channel_id ? String(r.channel_id) : void 0
2491
+ }));
2492
+ }
2493
+ };
2494
+
2495
+ // ../connect-sdk-ts/dist/mls/rest-backend.js
2496
+ var RestMlsBackend = class {
2497
+ opts;
2498
+ f;
2499
+ constructor(opts) {
2500
+ this.opts = opts;
2501
+ this.f = opts.fetchImpl ?? fetch;
2502
+ }
2503
+ headers() {
2504
+ return {
2505
+ "content-type": "application/json",
2506
+ authorization: `Bearer ${this.opts.bearerToken}`
2507
+ };
2508
+ }
2509
+ async post(path, body) {
2510
+ const res = await this.f(this.opts.baseUrl + path, {
2511
+ method: "POST",
2512
+ headers: this.headers(),
2513
+ body: JSON.stringify(body)
2514
+ });
2515
+ if (!res.ok)
2516
+ throw new Error(`${path} failed: ${res.status} ${await res.text()}`);
2517
+ return res.status === 204 ? {} : await res.json();
2518
+ }
2519
+ async get(path) {
2520
+ const res = await this.f(this.opts.baseUrl + path, { headers: this.headers() });
2521
+ if (!res.ok)
2522
+ throw new Error(`${path} failed: ${res.status}`);
2523
+ return await res.json();
2524
+ }
2525
+ async getRegisterChallenge() {
2526
+ const j = await this.post("/api/v1/connect/devices/register-challenge", {});
2527
+ return String(j.challenge);
2528
+ }
2529
+ async registerDevice(req) {
2530
+ const j = await this.post("/api/v1/connect/devices/register", req);
2531
+ return { device_id: String(j.device_id) };
2532
+ }
2533
+ async publishKeyPackage(deviceId, keyPackageHex) {
2534
+ const j = await this.post("/api/v1/mls/key-packages", {
2535
+ key_package: keyPackageHex,
2536
+ device_id: deviceId
2537
+ });
2538
+ return String(j.id);
2539
+ }
2540
+ async getConnectChannelGroup(channelId) {
2541
+ const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/group`);
2542
+ return {
2543
+ exists: Boolean(j.exists),
2544
+ group_id: j.group_id != null ? String(j.group_id) : null,
2545
+ current_epoch: j.current_epoch != null ? Number(j.current_epoch) : null
2546
+ };
2547
+ }
2548
+ async initConnectChannelGroup(channelId, memberDeviceIds) {
2549
+ const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/init`, {
2550
+ member_device_ids: memberDeviceIds
2551
+ });
2552
+ return {
2553
+ group_id: String(j.group_id),
2554
+ current_epoch: Number(j.current_epoch),
2555
+ member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : [],
2556
+ // Back-compat: servers that predate the created field return nothing;
2557
+ // treat absence as true (they always created a fresh group).
2558
+ created: j.created === void 0 ? true : Boolean(j.created)
2559
+ };
2560
+ }
2561
+ async reinitConnectChannelGroup(channelId, memberDeviceIds) {
2562
+ const committer = memberDeviceIds[0];
2563
+ if (!committer)
2564
+ throw new Error("reinitConnectChannelGroup: empty member set");
2565
+ const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
2566
+ member_device_ids: memberDeviceIds,
2567
+ committer_device_id: committer
2568
+ });
2569
+ return {
2570
+ group_id: String(j.group_id),
2571
+ current_epoch: Number(j.current_epoch),
2572
+ member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
2573
+ };
2574
+ }
2575
+ async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
2576
+ const committer = memberDeviceIds[0];
2577
+ if (!committer)
2578
+ throw new Error("resetReinitConnectChannelGroup: empty member set");
2579
+ const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
2580
+ member_device_ids: memberDeviceIds,
2581
+ committer_device_id: committer,
2582
+ mode: "reset",
2583
+ new_group_id: att.newGroupId,
2584
+ reset_attestation_signature: att.signatureHex,
2585
+ reset_attestation_nonce: att.nonceHex,
2586
+ reset_attestation_content: att.signedContentHex
2587
+ });
2588
+ return {
2589
+ group_id: String(j.group_id),
2590
+ current_epoch: Number(j.current_epoch),
2591
+ member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
2592
+ };
2593
+ }
2594
+ async fetchKeyPackages(deviceIds) {
2595
+ const j = await this.get(`/api/v1/mls/key-packages/batch?device_ids=${deviceIds.join(",")}`);
2596
+ return Object.fromEntries(Object.entries(j).map(([k, v]) => [k, String(v)]));
2597
+ }
2598
+ async fetchKeyPackageById(kpId) {
2599
+ try {
2600
+ const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}`);
2601
+ return String(j.key_package);
2602
+ } catch {
2603
+ return null;
2604
+ }
2605
+ }
2606
+ async distribute(groupId, req) {
2607
+ await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
2608
+ ...req,
2609
+ group_id: groupId
2610
+ });
2611
+ }
2612
+ async pullDelivery(deviceId) {
2613
+ const j = await this.get(`/api/v1/mls/delivery?device_id=${deviceId}`);
2614
+ return j.messages ?? [];
2615
+ }
2616
+ async ackDelivery(deviceId, queueIds) {
2617
+ await this.post("/api/v1/mls/delivery/ack", {
2618
+ device_id: deviceId,
2619
+ queue_ids: queueIds
2620
+ });
2621
+ }
2622
+ async requestWelcome(groupId, deviceId, freshKpId) {
2623
+ const body = { device_id: deviceId };
2624
+ if (freshKpId)
2625
+ body.fresh_kp_id = freshKpId;
2626
+ await this.post(`/api/v1/mls/groups/${groupId}/request-welcome`, body);
2627
+ }
2628
+ async getCommitterWork(channelId) {
2629
+ const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
2630
+ return {
2631
+ adds: Array.isArray(j.adds) ? j.adds.map(String) : [],
2632
+ removes: Array.isArray(j.removes) ? j.removes.map(String) : []
2633
+ };
2634
+ }
2635
+ async acquireCommitLease(channelId) {
2636
+ const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
2637
+ if (res.status === 409 || res.status === 503)
2638
+ return null;
2639
+ if (!res.ok)
2640
+ throw new Error(`commit-lease ${res.status}`);
2641
+ const j = await res.json();
2642
+ return String(j.token);
2643
+ }
2644
+ async releaseCommitLease(channelId, token) {
2645
+ await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease/release`, { method: "POST", headers: this.headers(), body: JSON.stringify({ token }) }).catch(() => {
2646
+ });
2647
+ }
2648
+ async fetchResetAttestation(groupId) {
2649
+ try {
2650
+ const j = await this.get(`/api/v1/mls/groups/${groupId}/reset-attestation`);
2651
+ return {
2652
+ signedContent: String(j.signed_content),
2653
+ signatureHex: String(j.signature_hex),
2654
+ signerIdentityId: String(j.signer_identity_id)
2655
+ };
2656
+ } catch {
2657
+ return null;
2658
+ }
2659
+ }
2660
+ };
2661
+
2662
+ // ../connect-sdk-ts/dist/mls/fake-backend.js
2663
+ import { bytesToHex as bytesToHex2, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
2664
+
2665
+ // ../connect-sdk-ts/dist/receive/inbox-cipher.js
2666
+ import { hkdf } from "@noble/hashes/hkdf";
2667
+ import { sha256 as sha2562 } from "@noble/hashes/sha256";
2668
+ var SUITE = "xchacha20poly1305";
2669
+ var INFO = new TextEncoder().encode("agentvault-connect-inbox-v1");
2670
+ var EMPTY_AAD = new Uint8Array(0);
2671
+ var KeyedInboxCipher = class {
2672
+ key;
2673
+ constructor(key) {
2674
+ this.key = key;
2675
+ }
2676
+ async seal(plaintext) {
2677
+ return sealPayload({
2678
+ plaintext,
2679
+ key: this.key,
2680
+ associatedData: EMPTY_AAD,
2681
+ suite: SUITE
2682
+ });
2683
+ }
2684
+ async open(sealed) {
2685
+ return openPayload({
2686
+ ciphertext: sealed,
2687
+ key: this.key,
2688
+ associatedData: EMPTY_AAD,
2689
+ suite: SUITE
2690
+ });
2691
+ }
2692
+ };
2693
+ var MLS_STORE_INFO = new TextEncoder().encode("agentvault-connect-mls-store-v1");
2694
+ function deriveMlsStoreKey(identityKeyMaterial) {
2695
+ return hkdf(sha2562, identityKeyMaterial, void 0, MLS_STORE_INFO, 32);
2696
+ }
2697
+
2698
+ // ../connect-sdk-ts/dist/recovery/backup-crypto.js
2699
+ import { hkdf as hkdf2 } from "@noble/hashes/hkdf";
2700
+ import { sha256 as sha2563 } from "@noble/hashes/sha256";
2701
+ import { randomBytes as randomBytes2 } from "@noble/hashes/utils";
2702
+ var BACKUP_INFO = new TextEncoder().encode("connect-backup-v1");
2703
+ var RECOVERY_INFO = new TextEncoder().encode("connect-recovery-v1");
2704
+
2705
+ // ../connect-sdk-ts/dist/recovery/guest-recovery-crypto.js
2706
+ import { argon2idAsync } from "@noble/hashes/argon2";
2707
+ import { hkdf as hkdf3 } from "@noble/hashes/hkdf";
2708
+ import { sha256 as sha2564 } from "@noble/hashes/sha256";
2709
+ var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
2710
+
2711
+ // ../connect-mls-store/dist/sqlite-store.js
2712
+ import { DatabaseSync } from "node:sqlite";
2713
+
2714
+ // ../connect-mls-store/dist/schema.js
2715
+ var SCHEMA_VERSION = 3;
2716
+ function openSchema(db) {
2717
+ db.exec("PRAGMA journal_mode=WAL");
2718
+ const row = db.prepare("PRAGMA user_version").get();
2719
+ if (row.user_version === 0) {
2720
+ db.exec(`
2721
+ CREATE TABLE IF NOT EXISTS mls_group_state (
2722
+ identity_id TEXT NOT NULL,
2723
+ channel_id TEXT NOT NULL,
2724
+ sealed_main BLOB NOT NULL,
2725
+ sealed_staging BLOB,
2726
+ updated_at INTEGER NOT NULL,
2727
+ PRIMARY KEY (identity_id, channel_id)
2728
+ );
2729
+ `);
2730
+ }
2731
+ if (row.user_version < 2) {
2732
+ db.exec(`
2733
+ CREATE TABLE IF NOT EXISTS mls_identity_pin (
2734
+ owner_identity_id TEXT PRIMARY KEY,
2735
+ public_key_hex TEXT NOT NULL,
2736
+ key_algo TEXT NOT NULL,
2737
+ pinned_at INTEGER NOT NULL
2738
+ );
2739
+ `);
2740
+ }
2741
+ if (row.user_version < 3) {
2742
+ db.exec(`
2743
+ CREATE TABLE IF NOT EXISTS mls_retired_group (
2744
+ channel_id TEXT NOT NULL,
2745
+ group_id TEXT NOT NULL,
2746
+ PRIMARY KEY (channel_id, group_id)
2747
+ );
2748
+ `);
2749
+ }
2750
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
2751
+ }
2752
+
2753
+ // ../connect-mls-store/dist/errors.js
2754
+ var MlsStoreCorruptError = class extends Error {
2755
+ constructor(detail) {
2756
+ super(`MLS store corrupt: ${detail}`);
2757
+ this.name = "MlsStoreCorruptError";
2758
+ }
2759
+ };
2760
+ var MlsStoreSealError = class extends Error {
2761
+ constructor(detail) {
2762
+ super(`MLS store seal/open failed: ${detail}`);
2763
+ this.name = "MlsStoreSealError";
2764
+ }
2765
+ };
2766
+
2767
+ // ../connect-mls-store/dist/sqlite-store.js
2768
+ var SqliteMlsStateStore = class {
2769
+ db;
2770
+ id;
2771
+ cipher;
2772
+ // Bundles stay in-memory (spec §4): private half not durably serializable.
2773
+ // Per-instance map → per-identity store instances are naturally isolated (§6.1).
2774
+ bundles = /* @__PURE__ */ new Map();
2775
+ // Last-published-KP-id: same in-memory-only treatment as bundles above (§4) —
2776
+ // a non-secret pointer, but this store's durability layer only covers group
2777
+ // state today; durable persistence is a future follow-up, not this task's scope.
2778
+ lastKpIds = /* @__PURE__ */ new Map();
2779
+ constructor(opts) {
2780
+ this.id = opts.identityId;
2781
+ this.cipher = opts.cipherFor(opts.identityId);
2782
+ try {
2783
+ this.db = new DatabaseSync(opts.dbPath);
2784
+ } catch (e) {
2785
+ throw new MlsStoreCorruptError(String(e?.message ?? e));
2786
+ }
2787
+ try {
2788
+ openSchema(this.db);
2789
+ } catch (e) {
2790
+ this.db.close();
2791
+ throw new MlsStoreCorruptError(String(e?.message ?? e));
2792
+ }
2793
+ if (opts.dbPath !== ":memory:") {
2794
+ let r;
2795
+ try {
2796
+ r = this.db.prepare("PRAGMA integrity_check").get();
2797
+ } catch (e) {
2798
+ this.db.close();
2799
+ throw new MlsStoreCorruptError(String(e?.message ?? e));
2800
+ }
2801
+ if (r.integrity_check !== "ok") {
2802
+ this.db.close();
2803
+ throw new MlsStoreCorruptError(r.integrity_check);
2804
+ }
2805
+ }
2806
+ }
2807
+ async seal(state) {
2808
+ return this.cipher.seal(new TextEncoder().encode(JSON.stringify(state)));
2809
+ }
2810
+ async unseal(blob) {
2811
+ let bytes;
2812
+ try {
2813
+ bytes = await this.cipher.open(blob);
2814
+ } catch (e) {
2815
+ throw new MlsStoreSealError(String(e?.message ?? e));
2816
+ }
2817
+ return JSON.parse(new TextDecoder().decode(bytes));
2818
+ }
2819
+ async load(channelId) {
2820
+ const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
2821
+ if (!row)
2822
+ return null;
2823
+ return this.unseal(row.sealed_main);
2824
+ }
2825
+ async save(channelId, state) {
2826
+ const sealed = await this.seal(state);
2827
+ this.db.prepare(`INSERT INTO mls_group_state (identity_id, channel_id, sealed_main, updated_at)
2828
+ VALUES (?,?,?,?)
2829
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET sealed_main=excluded.sealed_main, updated_at=excluded.updated_at`).run(this.id, channelId, sealed, Date.now());
2830
+ }
2831
+ async delete(channelId) {
2832
+ this.db.prepare("DELETE FROM mls_group_state WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
2833
+ }
2834
+ async saveStaging(channelId, state) {
2835
+ const sealed = await this.seal(state);
2836
+ this.db.prepare("UPDATE mls_group_state SET sealed_staging=?, updated_at=? WHERE identity_id=? AND channel_id=?").run(sealed, Date.now(), this.id, channelId);
2837
+ }
2838
+ async promoteStaging(channelId) {
2839
+ this.db.prepare(`UPDATE mls_group_state SET sealed_main=sealed_staging, sealed_staging=NULL, updated_at=?
2840
+ WHERE identity_id=? AND channel_id=? AND sealed_staging IS NOT NULL`).run(Date.now(), this.id, channelId);
2841
+ }
2842
+ async discardStaging(channelId) {
2843
+ this.db.prepare("UPDATE mls_group_state SET sealed_staging=NULL WHERE identity_id=? AND channel_id=?").run(this.id, channelId);
2844
+ }
2845
+ async savePendingBundle(ref, bundle) {
2846
+ this.bundles.set(ref, bundle);
2847
+ }
2848
+ async loadPendingBundle(ref) {
2849
+ return this.bundles.get(ref) ?? null;
2850
+ }
2851
+ async deletePendingBundle(ref) {
2852
+ this.bundles.delete(ref);
2853
+ }
2854
+ async saveLastKpId(deviceId, id) {
2855
+ this.lastKpIds.set(deviceId, id);
2856
+ }
2857
+ async loadLastKpId(deviceId) {
2858
+ return this.lastKpIds.get(deviceId) ?? null;
2859
+ }
2860
+ async saveLastKpAndBundle(deviceId, id, bundle) {
2861
+ this.lastKpIds.set(deviceId, id);
2862
+ this.bundles.set(deviceId, bundle);
2863
+ }
2864
+ async savePinnedIdentityKey(identityId, key) {
2865
+ this.db.prepare(`INSERT INTO mls_identity_pin (owner_identity_id, public_key_hex, key_algo, pinned_at)
2866
+ VALUES (?,?,?,?)
2867
+ ON CONFLICT(owner_identity_id) DO UPDATE SET
2868
+ public_key_hex=excluded.public_key_hex, key_algo=excluded.key_algo, pinned_at=excluded.pinned_at`).run(identityId, key.publicKeyHex, key.keyAlgo, Date.now());
2869
+ }
2870
+ async loadPinnedIdentityKey(identityId) {
2871
+ const row = this.db.prepare("SELECT public_key_hex, key_algo FROM mls_identity_pin WHERE owner_identity_id=?").get(identityId);
2872
+ return row ? { publicKeyHex: row.public_key_hex, keyAlgo: row.key_algo } : null;
2873
+ }
2874
+ async addRetiredGroupId(channelId, groupId) {
2875
+ this.db.prepare("INSERT OR IGNORE INTO mls_retired_group (channel_id, group_id) VALUES (?,?)").run(channelId, groupId);
2876
+ }
2877
+ async loadRetiredGroupIds(channelId) {
2878
+ const rows = this.db.prepare("SELECT group_id FROM mls_retired_group WHERE channel_id=?").all(channelId);
2879
+ return rows.map((r) => r.group_id);
2880
+ }
2881
+ /** @internal test-only */
2882
+ _rawMainForTest(identityId, channelId) {
2883
+ const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(identityId, channelId);
2884
+ return row?.sealed_main;
2885
+ }
2886
+ close() {
2887
+ this.db.close();
2888
+ }
2889
+ };
2890
+
2891
+ // src/bridge-agent.ts
2892
+ import { decryptFile, encryptFile, computeFileDigest, base64ToBytes as base64ToBytes2, bytesToBase64 as bytesToBase642 } from "@agentvault/crypto";
2893
+ import { promises as fsp } from "node:fs";
2894
+ import { join, dirname } from "node:path";
2895
+
2896
+ // src/latency.ts
2897
+ var DEFAULT_CAPACITY = 1024;
2898
+ function round(n) {
2899
+ return Math.round(n * 100) / 100;
2900
+ }
2901
+ function percentile(sorted, p) {
2902
+ if (sorted.length === 0) return 0;
2903
+ const rank = Math.ceil(p / 100 * sorted.length);
2904
+ const idx = Math.min(Math.max(rank, 1), sorted.length) - 1;
2905
+ return sorted[idx];
2906
+ }
2907
+ var LatencyRecorder = class {
2908
+ capacity;
2909
+ // Per-stage ring buffer of recent samples (ms).
2910
+ buffers = /* @__PURE__ */ new Map();
2911
+ // Write cursor per stage (for the ring buffer wrap).
2912
+ cursors = /* @__PURE__ */ new Map();
2913
+ constructor(opts = {}) {
2914
+ this.capacity = opts.capacity ?? DEFAULT_CAPACITY;
2915
+ }
2916
+ /** Record a single sample (ms) for a stage. O(1). */
2917
+ record(stage, ms) {
2918
+ let buf = this.buffers.get(stage);
2919
+ if (!buf) {
2920
+ buf = [];
2921
+ this.buffers.set(stage, buf);
2922
+ this.cursors.set(stage, 0);
2923
+ }
2924
+ if (buf.length < this.capacity) {
2925
+ buf.push(ms);
2926
+ } else {
2927
+ const cur = this.cursors.get(stage) ?? 0;
2928
+ buf[cur] = ms;
2929
+ this.cursors.set(stage, (cur + 1) % this.capacity);
2930
+ }
2931
+ }
2932
+ /**
2933
+ * Time an async fn, record its elapsed wall time under `stage`, return its
2934
+ * value. Records even when the fn throws (the failure still consumed time),
2935
+ * then re-throws — so a thrown error never loses the timing sample.
2936
+ */
2937
+ async time(stage, fn) {
2938
+ const start = performance.now();
2939
+ try {
2940
+ return await fn();
2941
+ } finally {
2942
+ this.record(stage, performance.now() - start);
2943
+ }
2944
+ }
2945
+ /** Percentile snapshot per stage. Rounded to 2 decimals (ms). */
2946
+ snapshot() {
2947
+ const out = {};
2948
+ for (const [stage, buf] of this.buffers) {
2949
+ if (buf.length === 0) continue;
2950
+ const sorted = [...buf].sort((a, b) => a - b);
2951
+ const sum = sorted.reduce((acc, n) => acc + n, 0);
2952
+ out[stage] = {
2953
+ count: sorted.length,
2954
+ p50: round(percentile(sorted, 50)),
2955
+ p95: round(percentile(sorted, 95)),
2956
+ p99: round(percentile(sorted, 99)),
2957
+ max: round(sorted[sorted.length - 1]),
2958
+ mean: round(sum / sorted.length)
2959
+ };
2960
+ }
2961
+ return out;
2962
+ }
2963
+ };
2964
+
2965
+ // src/coordination.ts
2966
+ function parseMentions(text) {
2967
+ const mentions = [];
2968
+ const re = /@(\w[\w]*)/gi;
2969
+ let match;
2970
+ while ((match = re.exec(text)) !== null) {
2971
+ mentions.push(match[1].toLowerCase());
2972
+ }
2973
+ return mentions;
2974
+ }
2975
+ function ownRoomNames(agentName, accountId, roomDisplayName) {
2976
+ const names = /* @__PURE__ */ new Set();
2977
+ const add = (n) => {
2978
+ if (!n) return;
2979
+ const lower = n.toLowerCase();
2980
+ names.add(lower);
2981
+ names.add(lower.split(/[\s(]/)[0]);
2982
+ };
2983
+ add(agentName);
2984
+ add(accountId);
2985
+ add(roomDisplayName);
2986
+ return names;
2987
+ }
2988
+ function shouldProcessRoomMessage(plaintext, agentName, accountId, opts) {
2989
+ const mentions = parseMentions(plaintext);
2990
+ if (mentions.length === 0) return true;
2991
+ if (mentions.includes("all") || mentions.includes("everyone")) return true;
2992
+ const ownNames = ownRoomNames(agentName, accountId, opts?.roomDisplayName);
2993
+ if (mentions.some((m) => ownNames.has(m))) return true;
2994
+ return false;
2995
+ }
2996
+
2997
+ // src/rate-limit.ts
2998
+ var TokenBucketFloor = class {
2999
+ capacity;
3000
+ refillPerMs;
3001
+ now;
3002
+ buckets = /* @__PURE__ */ new Map();
3003
+ constructor(opts) {
3004
+ this.capacity = opts.capacity;
3005
+ this.refillPerMs = opts.refillPerMinute / 6e4;
3006
+ this.now = opts.now ?? Date.now;
3007
+ }
3008
+ refill(key) {
3009
+ const t = this.now();
3010
+ let b = this.buckets.get(key);
3011
+ if (!b) {
3012
+ b = { tokens: this.capacity, lastRefill: t };
3013
+ this.buckets.set(key, b);
3014
+ return b;
3015
+ }
3016
+ const elapsed = t - b.lastRefill;
3017
+ if (elapsed > 0) {
3018
+ b.tokens = Math.min(this.capacity, b.tokens + elapsed * this.refillPerMs);
3019
+ b.lastRefill = t;
3020
+ }
3021
+ return b;
3022
+ }
3023
+ /** Consume one token. Returns true if allowed, false if the bucket is empty. */
3024
+ tryConsume(key) {
3025
+ if (this.capacity <= 0) return true;
3026
+ const b = this.refill(key);
3027
+ if (b.tokens >= 1) {
3028
+ b.tokens -= 1;
3029
+ return true;
3030
+ }
3031
+ return false;
3032
+ }
3033
+ /** Milliseconds until one token is available (0 if already available). */
3034
+ msUntilNextToken(key) {
3035
+ if (this.capacity <= 0) return 0;
3036
+ const b = this.refill(key);
3037
+ if (b.tokens >= 1) return 0;
3038
+ if (this.refillPerMs <= 0) return Infinity;
3039
+ return Math.ceil((1 - b.tokens) / this.refillPerMs);
3040
+ }
3041
+ };
3042
+
3043
+ // src/attachments.ts
3044
+ import { basename } from "node:path";
3045
+ function sanitizeFilename(name, fallback) {
3046
+ const base = basename(name ?? "");
3047
+ const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
3048
+ return cleaned.length > 0 ? cleaned : fallback;
3049
+ }
3050
+ var MIME_BY_EXT = {
3051
+ png: "image/png",
3052
+ jpg: "image/jpeg",
3053
+ jpeg: "image/jpeg",
3054
+ gif: "image/gif",
3055
+ webp: "image/webp",
3056
+ svg: "image/svg+xml",
3057
+ pdf: "application/pdf",
3058
+ txt: "text/plain",
3059
+ md: "text/markdown",
3060
+ json: "application/json",
3061
+ csv: "text/csv",
3062
+ html: "text/html",
3063
+ mp4: "video/mp4",
3064
+ mp3: "audio/mpeg",
3065
+ zip: "application/zip",
3066
+ wav: "audio/wav"
3067
+ };
3068
+ function mimeForName(name) {
3069
+ const dot = name.lastIndexOf(".");
3070
+ if (dot < 0 || dot === name.length - 1) return "application/octet-stream";
3071
+ return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
3072
+ }
3073
+ async function prepareAttachment(filePath, caption, channelId, deps) {
3074
+ const bytes = await deps.readFile(filePath);
3075
+ const name = basename(filePath);
3076
+ const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
3077
+ const blobId = await deps.uploadBlob(encryptedData, channelId);
3078
+ const body = {
3079
+ v: 1,
3080
+ type: "artifact_share",
3081
+ name,
3082
+ mime: mimeForName(name),
3083
+ size: bytes.length,
3084
+ blobId,
3085
+ fileKey: deps.bytesToBase64(fileKey),
3086
+ fileNonce: deps.bytesToBase64(fileNonce),
3087
+ digest
3088
+ };
3089
+ if (caption) body.text = caption;
3090
+ return body;
3091
+ }
3092
+ async function materializeAttachment(body, ctx, deps) {
3093
+ const ciphertext = await deps.downloadBlob(body.blobId);
3094
+ const sha2565 = deps.computeFileDigest(ciphertext);
3095
+ if (sha2565 !== body.digest) {
3096
+ throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
3097
+ }
3098
+ const plain = deps.decryptFile(
3099
+ ciphertext,
3100
+ deps.base64ToBytes(body.fileKey),
3101
+ deps.base64ToBytes(body.fileNonce)
3102
+ );
3103
+ const safeName = sanitizeFilename(body.name, ctx.messageId);
3104
+ const dir = `${ctx.attachmentsDir}/${ctx.channelId}`;
3105
+ const path = `${dir}/${ctx.messageId}-${safeName}`;
3106
+ await deps.mkdirp(dir);
3107
+ await deps.writeFile(path, plain);
3108
+ return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2565 };
3109
+ }
3110
+
3111
+ // src/bridge-agent.ts
3112
+ var DEFAULT_MAX_BUFFER = 1e3;
3113
+ var CHANNEL_META_TTL_MS = 5 * 6e4;
3114
+ var CHANNEL_ROSTER_TTL_MS = 5 * 6e4;
3115
+ var ZERO_TRACEPARENT = "00-" + "0".repeat(32) + "-" + "0".repeat(16) + "-01";
3116
+ var ChannelPendingError = class extends Error {
3117
+ constructor(channelId) {
3118
+ super(`channel ${channelId} is not yet admitted (awaiting Welcome)`);
3119
+ this.channelId = channelId;
3120
+ this.name = "ChannelPendingError";
3121
+ }
3122
+ };
3123
+ var RateLimitedError = class extends Error {
3124
+ constructor(channelId, retryAfterMs) {
3125
+ super(`channel ${channelId} send rate limit exceeded (retry in ${retryAfterMs}ms)`);
3126
+ this.channelId = channelId;
3127
+ this.retryAfterMs = retryAfterMs;
3128
+ this.name = "RateLimitedError";
3129
+ }
3130
+ };
3131
+ var InvalidPathError = class extends Error {
3132
+ constructor(filePath, cause) {
3133
+ super(`cannot read attachment file: ${filePath}`);
3134
+ this.filePath = filePath;
3135
+ this.name = "InvalidPathError";
3136
+ if (cause !== void 0) this.cause = cause;
3137
+ }
3138
+ };
3139
+ var UnknownActionRunError = class extends Error {
3140
+ constructor(runId) {
3141
+ super(`unknown or already-settled agent action run: ${runId}`);
3142
+ this.runId = runId;
3143
+ this.name = "UnknownActionRunError";
3144
+ }
3145
+ };
3146
+ var BRIDGE_VERSION = "0.0.0";
3147
+ function hexToBytes3(h) {
3148
+ return new Uint8Array(Buffer.from(h, "hex"));
3149
+ }
3150
+ var BridgeAgent = class _BridgeAgent {
3151
+ constructor(cfg, deps = {}) {
3152
+ this.cfg = cfg;
3153
+ const seed = hexToBytes3(cfg.signingPrivateKey);
3154
+ this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
3155
+ this.transport = deps.transport ?? new RestTransport({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, connectIdentityId: cfg.identityId });
3156
+ this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
3157
+ this.store = new SqliteMlsStateStore({
3158
+ dbPath: cfg.storePath,
3159
+ identityId: cfg.identityId,
3160
+ cipherFor: () => new KeyedInboxCipher(deriveMlsStoreKey(seed))
3161
+ });
3162
+ this.provider = new MlsGroupKeyProvider({
3163
+ store: this.store,
3164
+ backend: this.backend,
3165
+ deviceId: cfg.deviceId,
3166
+ identity: new TextEncoder().encode(cfg.identityId),
3167
+ // ADR-0002: count + log agent-side decrypt failures. Without this hook the
3168
+ // daemon was 100% blind to a wedged channel (it "looked idle"); the
3169
+ // provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
3170
+ onDecryptError: (row, err) => {
3171
+ const cid = row.connect_channel_id ?? "(unmapped)";
3172
+ console.warn(
3173
+ `[bridge] decrypt error on channel ${cid} (queue ${row.queue_id}, epoch ${row.epoch}): ${err instanceof Error ? err.message : String(err)}`
3174
+ );
3175
+ },
3176
+ // Owner-attested Reset Secure Session (Task 8): the SDK's step-0 guard
3177
+ // calls this before honoring an owner-authored supersede. It must return
3178
+ // a value ONLY when the structural owner is unambiguous AND a pin exists
3179
+ // for it — never trust a bare in-memory cache without reconfirming
3180
+ // against the persisted pin (see resolvePinnedOwnerHook below).
3181
+ onResolvePinnedOwner: (channelId) => this.resolvePinnedOwnerHook(channelId)
3182
+ });
3183
+ this.fetchImpl = deps.fetchImpl ?? fetch;
3184
+ this.latency = deps.latency ?? new LatencyRecorder();
3185
+ this.maxBuffer = deps.maxBuffer ?? DEFAULT_MAX_BUFFER;
3186
+ this.rateFloor = new TokenBucketFloor({
3187
+ capacity: cfg.rateLimitCapacity ?? 20,
3188
+ refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
3189
+ });
3190
+ this.actionClock = deps.actionThrottleNow ?? Date.now;
3191
+ this.actionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
3192
+ this.globalActionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
3193
+ const self = this;
3194
+ this.downloadBlobImpl = deps.downloadBlob ?? async function(blobId) {
3195
+ const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
3196
+ const res = await self.fetchImpl(url, { headers: { authorization: `Bearer ${cfg.apiKey}` } });
3197
+ if (!res.ok) throw new Error(`download blob failed: ${res.status}`);
3198
+ return new Uint8Array(await res.arrayBuffer());
3199
+ };
3200
+ this.uploadBlobImpl = deps.uploadBlob ?? async function(ciphertext, channelId) {
3201
+ const url = `${cfg.apiBase}/api/v1/attachments?channel_id=${encodeURIComponent(channelId)}&caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
3202
+ const res = await self.fetchImpl(url, {
3203
+ method: "POST",
3204
+ headers: { "content-type": "application/octet-stream", authorization: `Bearer ${cfg.apiKey}` },
3205
+ body: ciphertext
3206
+ });
3207
+ if (!res.ok) throw new Error(`upload blob failed: ${res.status}`);
3208
+ const j = await res.json();
3209
+ return String(j.attachment_id);
3210
+ };
3211
+ this.attachmentsDirImpl = deps.attachmentsDir ?? join(dirname(cfg.storePath), "attachments");
3212
+ this.action = {
3213
+ begin: (channelId) => this.actionBegin(channelId),
3214
+ start: (opts) => this.actionStart(opts),
3215
+ update: (runId, nodeId, patch) => this.actionUpdate(runId, nodeId, patch),
3216
+ finish: (runId, nodeId, patch) => this.actionFinish(runId, nodeId, patch),
3217
+ settle: (runId, opts) => this.actionSettle(runId, opts),
3218
+ wrap: (runId, fn, opts) => this.actionWrap(runId, fn, opts)
3219
+ };
3220
+ }
3221
+ transport;
3222
+ provider;
3223
+ backend;
3224
+ // stored for peek-then-join in ingestOnce
3225
+ custody;
3226
+ fetchImpl;
3227
+ joinState = /* @__PURE__ */ new Map();
3228
+ /**
3229
+ * Phase 2: lazy channel-metadata cache (channel_id → { is_dm, name, at }) used
3230
+ * by the multi-agent room @mention filter at ingest. Populated/refreshed from a
3231
+ * SINGLE listChannels() per ingest cycle on a cache miss. A channel that cannot
3232
+ * be resolved (network error, or not present in the listing) stays UNKNOWN → the
3233
+ * filter fails OPEN.
3234
+ *
3235
+ * `is_dm` is immutable for a given channel_id (no misclassification risk), so the
3236
+ * TTL exists only to keep `name` fresh after a channel rename — an entry older
3237
+ * than CHANNEL_META_TTL_MS is treated as a miss and re-resolved on the next cycle.
3238
+ */
3239
+ channelMeta = /* @__PURE__ */ new Map();
3240
+ /**
3241
+ * Phase 2 (sender attribution): per-channel roster cache
3242
+ * (channel_id → identity_id → { name, is_agent }) resolved from the GET
3243
+ * members endpoint. Same lazy/TTL/fail-soft discipline as channelMeta: an
3244
+ * unresolved channel (network error, or no entry) leaves sender_name
3245
+ * undefined and never blocks ingest. Resolved AT MOST ONCE per ingest cycle
3246
+ * per channel on a miss.
3247
+ */
3248
+ channelRoster = /* @__PURE__ */ new Map();
3249
+ /**
3250
+ * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
3251
+ * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
3252
+ * the onResolvePinnedOwner hook passed to MlsGroupKeyProvider. "anomalous"
3253
+ * means the DM roster shape was not exactly one non-self, non-agent
3254
+ * counterpart, OR the freshly-fetched key no longer matches an existing pin —
3255
+ * either way the hook must fail closed (return null) for that channel.
3256
+ */
3257
+ channelOwner = /* @__PURE__ */ new Map();
3258
+ buffer = [];
3259
+ maxBuffer;
3260
+ /** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
3261
+ droppedMessages = 0;
3262
+ /**
3263
+ * Cumulative count of ROOM messages dropped by the @mention filter (item 3 /
3264
+ * ADR-0002 ethos). Surfaced on health()/metrics() so an agent correctly filtering
3265
+ * most room traffic is distinguishable from an idle/wedged one.
3266
+ */
3267
+ filteredRoomMessages = 0;
3268
+ /** Cumulative count of sends shed by the per-channel rate-limit floor (surfaced on health()/metrics()). */
3269
+ rateLimitedSends = 0;
3270
+ /** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
3271
+ welcomeRequestsSent = 0;
3272
+ /** 2026-07-03 latency triage: cumulative delivery pings received on the WS
3273
+ * stream. 0 while messages still flow = the stream is dead and inbound is
3274
+ * riding the 25s safety floor (surfaced on health()/metrics). */
3275
+ pingsReceived = 0;
3276
+ /** True once close() begins — gates the ping-stream-death stderr warning so
3277
+ * a normal shutdown doesn't cry wolf. */
3278
+ closingDown = false;
3279
+ /**
3280
+ * Track 5: cumulative count of artifact_share messages where download/decrypt/digest
3281
+ * failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
3282
+ * counter is incremented. Surfaced on health()/metrics().
3283
+ */
3284
+ failedAttachments = 0;
3285
+ /** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
3286
+ lastWelcomeRequest = /* @__PURE__ */ new Map();
3287
+ /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
3288
+ static REQUEST_WELCOME_COOLDOWN_MS = 12e4;
3289
+ /**
3290
+ * Task 12: proactive self-heal sweep kill-switch. Task 11's held-KP signal
3291
+ * only fires reactively, from ingestOnce's Phase-0 pass over PULLED delivery
3292
+ * rows — an idle-but-wiped channel therefore loses its NEXT message (the
3293
+ * very message that would have proven it diverged). selfHealSweep() closes
3294
+ * that gap by proactively walking every non-DM member channel at boot and
3295
+ * on an interval. `SECURECOMS_SELF_HEAL_SWEEP=false` disables it, falling
3296
+ * back to the delivery-row-driven Task 11 behaviour only.
3297
+ */
3298
+ selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
3299
+ /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
3300
+ static SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
3301
+ selfHealTimer;
3302
+ rateFloor;
3303
+ /**
3304
+ * agent.action.* run state, keyed by runId.
3305
+ *
3306
+ * Review M3 (bug fix): the ORIGINAL comment here said "deliberately
3307
+ * unbounded" — but nothing ever bounded it either. Only actionSettle()
3308
+ * deleted an entry; a begun-but-never-settled run (agent crash, bug,
3309
+ * dropped/never-sent reply) leaked for the daemon's entire lifetime. Now
3310
+ * bounded by MAX_UNSETTLED_ACTION_RUNS (LRU eviction by startedAt) plus a
3311
+ * lazy ACTION_RUN_STALE_MS sweep, both applied on every actionBegin() —
3312
+ * mirrors the send-side inbound-buffer bound (capBuffer/maxBuffer).
3313
+ */
3314
+ actionRuns = /* @__PURE__ */ new Map();
3315
+ /** Review M3: hard cap on concurrently-tracked UNSETTLED runs. Exceeding it
3316
+ * evicts the oldest (LRU by startedAt) to make room — never grows past
3317
+ * this regardless of how many runs are begun and abandoned. */
3318
+ static MAX_UNSETTLED_ACTION_RUNS = 50;
3319
+ /** Review M3: an unsettled run older than this (by startedAt, relative to
3320
+ * actionClock()) is swept away on the next actionBegin(), independent of
3321
+ * the count-based cap — bounds memory even when begin() calls are rare. */
3322
+ static ACTION_RUN_STALE_MS = 60 * 6e4;
3323
+ // 60 minutes
3324
+ /** Review I3/M3: injected clock (defaults Date.now) for the action-partial
3325
+ * token buckets AND the stale-run sweep — a single seam so tests can
3326
+ * simulate elapsed time deterministically. */
3327
+ actionClock;
3328
+ /**
3329
+ * Per-run token bucket for `agent_action` PARTIAL frames (Global
3330
+ * Constraint: do NOT model on the unthrottled reportStatus; do NOT reuse
3331
+ * `rateFloor`/`enforceRateFloor`, whose semantics are "throw past
3332
+ * capacity" — partials instead COALESCE: a throttled call just leaves its
3333
+ * change in `dirty` for the next allowed send to pick up).
3334
+ *
3335
+ * Review I3 (bug fix): capacity 1 / refill 60-per-minute was EXACTLY the
3336
+ * backend's MESSAGING_LIMIT (60/60s per sender) — no headroom. Partials +
3337
+ * the settled frame + the agent's real reply + reportStatus all compete
3338
+ * for that one shared per-sender budget, so a long run's settled frame or
3339
+ * reply could 429 right at the end. Lowered to 30/min (~1 per 2s) so
3340
+ * partials alone use at most ~half the identity's send budget, leaving
3341
+ * headroom for the settled frame / reply / status traffic riding the SAME
3342
+ * cap. Independent of `cfg.rateLimitCapacity` (room/a2a/attachment floor).
3343
+ * `settled` frames bypass this bucket entirely — they always send, exactly
3344
+ * once, per run.
3345
+ */
3346
+ actionRateFloor;
3347
+ /**
3348
+ * Review I3 (bug fix): a GLOBAL bucket shared across every run, on top of
3349
+ * the per-run bucket above. Without this, N concurrent runs each get their
3350
+ * OWN ~30/min budget, so the partial send rate multiplies with agent
3351
+ * concurrency and can still blow past the shared 60/min identity edge cap
3352
+ * (each run individually "has headroom" but the SUM doesn't). Same
3353
+ * capacity/refill as the per-run bucket — it caps the aggregate, not any
3354
+ * single run.
3355
+ */
3356
+ globalActionRateFloor;
3357
+ /** Public `agent.action.*` surface — assigned once at the end of the constructor. */
3358
+ action;
3359
+ waiters = [];
3360
+ pingHandle;
3361
+ safetyTimer;
3362
+ listeners = /* @__PURE__ */ new Set();
3363
+ startedAt = 0;
3364
+ store;
3365
+ latency;
3366
+ /** Track 5: injectable blob download fn (default: fetch-based). */
3367
+ downloadBlobImpl;
3368
+ /** Track 5: injectable blob upload fn (default: fetch-based). */
3369
+ uploadBlobImpl;
3370
+ /** Track 5: directory where received attachments are saved. */
3371
+ attachmentsDirImpl;
3372
+ /**
3373
+ * Enforce the inbound-buffer cap with a DROP-OLDEST policy.
3374
+ *
3375
+ * The buffer is uniformly ordered oldest→newest: ingestOnce() appends freshly
3376
+ * decrypted inbound at the BACK (newest), and requeueFront() unshifts un-acked
3377
+ * messages at the FRONT — those re-buffered messages were consumed earlier, so
3378
+ * they are by definition OLDER than whatever already sits in the buffer. In
3379
+ * every push path the newest messages therefore live at the back. To honour
3380
+ * "the most-recent survive" we always shed from the FRONT, then record the
3381
+ * shed so a silently-dropping daemon stays observable (item 3 / ADR-0002).
3382
+ */
3383
+ capBuffer() {
3384
+ if (this.maxBuffer <= 0) return;
3385
+ const overflow = this.buffer.length - this.maxBuffer;
3386
+ if (overflow > 0) {
3387
+ this.buffer.splice(0, overflow);
3388
+ this.droppedMessages += overflow;
3389
+ }
3390
+ }
3391
+ async start() {
3392
+ this.startedAt = Date.now();
3393
+ await this.provider.publishKeyPackage();
3394
+ this.startReceivePump();
3395
+ if (this.selfHealSweepEnabled) void this.selfHealSweep();
3396
+ }
3397
+ async reconcileJoin(channelId) {
3398
+ if (this.joinState.get(channelId) === "JOINED") return true;
3399
+ try {
3400
+ await this.provider.joinAndReconcile(channelId, this.cfg.enclaveId);
3401
+ this.joinState.set(channelId, "JOINED");
3402
+ return true;
3403
+ } catch {
3404
+ this.joinState.set(channelId, "PENDING");
3405
+ return false;
3406
+ }
3407
+ }
3408
+ /**
3409
+ * The held-KP re-Welcome signal (Task 11), shared by both the reactive
3410
+ * divergence branch in ingestOnce() and the proactive selfHealSweep()
3411
+ * (Task 12). Guarantees a HELD bundle backs the signalled KP:
3412
+ * getLastPublishedKpId returns an id even after the pending bundle was
3413
+ * consumed/lost, which would make the owner seal an un-openable Welcome.
3414
+ * publishKeyPackage is a no-op (returns the same id) while a bundle IS
3415
+ * held, and mints a fresh held KP (delete-and-replace) when none is held.
3416
+ * Callers are responsible for their own cooldown/rate-limit gate and
3417
+ * try/catch — this method does neither.
3418
+ */
3419
+ async signalHeldKpWelcome(groupId) {
3420
+ await this.provider.publishKeyPackage();
3421
+ const freshKpId = await this.provider.getLastPublishedKpId() ?? void 0;
3422
+ await this.backend.requestWelcome(groupId, this.cfg.deviceId, freshKpId);
3423
+ this.welcomeRequestsSent++;
3424
+ }
3425
+ /**
3426
+ * Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
3427
+ * branch only fires reactively — off a delivery ROW that already arrived
3428
+ * for a diverged channel. A wiped-but-IDLE channel (no traffic since the
3429
+ * wipe) never produces that row, so its next real message would be the
3430
+ * first thing lost. This sweep walks every non-DM member channel (fired
3431
+ * once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS) and, for any with no
3432
+ * persisted state even after a reconcile attempt, resolves group_id via
3433
+ * the READ-ONLY getConnectChannelGroup probe (never initConnectChannelGroup,
3434
+ * which would side-effect a member_device_ids union + commit) and applies
3435
+ * the same held-KP signal — respecting the existing per-channel cooldown.
3436
+ *
3437
+ * Each channel is wrapped in its own try/catch: `listChannels` filters at
3438
+ * the identity level while `requestWelcome` (via request_welcome) gates at
3439
+ * the device level, so a channel where this identity is a member but this
3440
+ * DEVICE never held a leaf can 403 on the signal call. That's harmless and
3441
+ * must not abort the sweep for every other channel.
3442
+ */
3443
+ async selfHealSweep() {
3444
+ let chans;
3445
+ try {
3446
+ chans = await this.listChannels();
3447
+ } catch {
3448
+ return;
3449
+ }
3450
+ for (const c of chans) {
3451
+ if (c.is_dm) continue;
3452
+ try {
3453
+ if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
3454
+ await this.reconcileJoin(c.channel_id);
3455
+ if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
3456
+ const g = await this.backend.getConnectChannelGroup(c.channel_id);
3457
+ if (!g?.group_id) continue;
3458
+ const now = performance.now();
3459
+ const last = this.lastWelcomeRequest.get(c.channel_id) ?? -Infinity;
3460
+ if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
3461
+ this.lastWelcomeRequest.set(c.channel_id, now);
3462
+ await this.signalHeldKpWelcome(g.group_id);
3463
+ } catch {
3464
+ }
3465
+ }
3466
+ }
3467
+ /**
3468
+ * Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
3469
+ * trip (incrementing the observable counter). `kind` namespaces the bucket so
3470
+ * room and a2a traffic on the same channel are tracked separately.
3471
+ */
3472
+ enforceRateFloor(kind, channelId) {
3473
+ const key = `${kind}:${channelId}`;
3474
+ if (!this.rateFloor.tryConsume(key)) {
3475
+ this.rateLimitedSends++;
3476
+ throw new RateLimitedError(channelId, this.rateFloor.msUntilNextToken(key));
3477
+ }
3478
+ }
3479
+ async send(channelId, text, messageId) {
3480
+ const meta = this.freshMeta(channelId);
3481
+ if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
3482
+ const joined = await this.reconcileJoin(channelId);
3483
+ if (!joined) throw new ChannelPendingError(channelId);
3484
+ const id = messageId ?? crypto.randomUUID();
3485
+ const sendStart = performance.now();
3486
+ await sendWithSelfHeal(
3487
+ this.provider,
3488
+ channelId,
3489
+ (epoch) => this.latency.time(
3490
+ "send.encrypt",
3491
+ () => buildOutboundEnvelope({
3492
+ custody: this.custody,
3493
+ groupKey: this.provider,
3494
+ messageId: id,
3495
+ channelId,
3496
+ enclaveId: this.cfg.enclaveId,
3497
+ senderId: this.cfg.identityId,
3498
+ senderType: "agent",
3499
+ type: "text",
3500
+ bodyPlaintext: JSON.stringify({ text }),
3501
+ bindingId: "",
3502
+ bindingVersion: "0.0.0",
3503
+ agentSkillId: null,
3504
+ epoch,
3505
+ traceparent: ZERO_TRACEPARENT,
3506
+ references: [],
3507
+ stage5Outcome: "allow",
3508
+ stage5Evaluators: []
3509
+ })
3510
+ ),
3511
+ (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
3512
+ );
3513
+ this.latency.record("send.total", performance.now() - sendStart);
3514
+ return { message_id: id, status: "sent" };
3515
+ }
3516
+ async sendA2A(channelId, a2a, messageId) {
3517
+ this.enforceRateFloor("a2a", channelId);
3518
+ const joined = await this.reconcileJoin(channelId);
3519
+ if (!joined) throw new ChannelPendingError(channelId);
3520
+ const id = messageId ?? crypto.randomUUID();
3521
+ const sendStart = performance.now();
3522
+ await sendWithSelfHeal(
3523
+ this.provider,
3524
+ channelId,
3525
+ (epoch) => this.latency.time(
3526
+ "send.encrypt",
3527
+ () => buildOutboundEnvelope({
3528
+ custody: this.custody,
3529
+ groupKey: this.provider,
3530
+ messageId: id,
3531
+ channelId,
3532
+ enclaveId: this.cfg.enclaveId,
3533
+ senderId: this.cfg.identityId,
3534
+ senderType: "agent",
3535
+ type: "a2a",
3536
+ bodyPlaintext: JSON.stringify({ type: "a2a", a2a_version: a2a.a2a_version, kind: a2a.kind, object: a2a.object }),
3537
+ bindingId: "",
3538
+ bindingVersion: "0.0.0",
3539
+ agentSkillId: null,
3540
+ epoch,
3541
+ traceparent: ZERO_TRACEPARENT,
3542
+ references: [],
3543
+ stage5Outcome: "allow",
3544
+ stage5Evaluators: []
3545
+ })
3546
+ ),
3547
+ (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
3548
+ );
3549
+ this.latency.record("send.total", performance.now() - sendStart);
3550
+ return { message_id: id, status: "sent" };
3551
+ }
3552
+ sendDeps() {
3553
+ return {
3554
+ uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
3555
+ encryptFile,
3556
+ bytesToBase64: bytesToBase642,
3557
+ readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
3558
+ };
3559
+ }
3560
+ async sendAttachment(channelId, filePath, caption) {
3561
+ this.enforceRateFloor("attachment", channelId);
3562
+ const joined = await this.reconcileJoin(channelId);
3563
+ if (!joined) throw new ChannelPendingError(channelId);
3564
+ let body;
3565
+ try {
3566
+ body = await prepareAttachment(filePath, caption, channelId, this.sendDeps());
3567
+ } catch (e) {
3568
+ if (e.code === "ENOENT" || e.code === "EACCES" || e.code === "ENOTDIR" || e.code === "EISDIR") {
3569
+ throw new InvalidPathError(filePath, e);
3570
+ }
3571
+ throw e;
3572
+ }
3573
+ const id = crypto.randomUUID();
3574
+ const sendStart = performance.now();
3575
+ await sendWithSelfHeal(
3576
+ this.provider,
3577
+ channelId,
3578
+ (epoch) => this.latency.time(
3579
+ "send.encrypt",
3580
+ () => buildOutboundEnvelope({
3581
+ custody: this.custody,
3582
+ groupKey: this.provider,
3583
+ messageId: id,
3584
+ channelId,
3585
+ enclaveId: this.cfg.enclaveId,
3586
+ senderId: this.cfg.identityId,
3587
+ senderType: "agent",
3588
+ type: "artifact_share",
3589
+ bodyPlaintext: JSON.stringify(body),
3590
+ bindingId: "",
3591
+ bindingVersion: "0.0.0",
3592
+ agentSkillId: null,
3593
+ epoch,
3594
+ traceparent: ZERO_TRACEPARENT,
3595
+ references: [],
3596
+ stage5Outcome: "allow",
3597
+ stage5Evaluators: []
3598
+ })
3599
+ ),
3600
+ (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
3601
+ );
3602
+ this.latency.record("send.total", performance.now() - sendStart);
3603
+ return { message_id: id, status: "sent" };
3604
+ }
3605
+ async listChannels() {
3606
+ const url = `${this.cfg.apiBase}/api/v1/enclaves/${this.cfg.enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
3607
+ const res = await this.fetchImpl(url, {
3608
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` }
3609
+ });
3610
+ if (!res.ok) throw new Error(`list_channels failed: ${res.status}`);
3611
+ const rows = await res.json();
3612
+ return rows.map((r) => ({
3613
+ channel_id: r.channel_id,
3614
+ name: r.name ?? null,
3615
+ is_dm: r.is_dm,
3616
+ joined: true
3617
+ }));
3618
+ }
3619
+ /** A live channelMeta entry, or undefined if missing OR expired past the TTL. */
3620
+ freshMeta(channelId) {
3621
+ const hit = this.channelMeta.get(channelId);
3622
+ if (!hit) return void 0;
3623
+ if (Date.now() - hit.at >= CHANNEL_META_TTL_MS) return void 0;
3624
+ return { is_dm: hit.is_dm, name: hit.name };
3625
+ }
3626
+ /**
3627
+ * One-shot, whole-listing refresh of channelMeta. Called AT MOST ONCE per
3628
+ * ingest cycle (the caller guards with a cycle-local "refreshed" flag), so a
3629
+ * burst of K unknown channels fires a single listChannels() instead of K. Any
3630
+ * listChannels failure is swallowed (UNKNOWN → fail open) so ingest never
3631
+ * blocks on the network.
3632
+ */
3633
+ async refreshChannelMeta() {
3634
+ try {
3635
+ const now = Date.now();
3636
+ const channels = await this.listChannels();
3637
+ for (const c of channels) {
3638
+ this.channelMeta.set(c.channel_id, { is_dm: c.is_dm, name: c.name, at: now });
3639
+ }
3640
+ } catch {
3641
+ }
3642
+ }
3643
+ /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
3644
+ freshRoster(channelId) {
3645
+ const hit = this.channelRoster.get(channelId);
3646
+ if (!hit) return void 0;
3647
+ if (Date.now() - hit.at >= CHANNEL_ROSTER_TTL_MS) return void 0;
3648
+ return hit.members;
3649
+ }
3650
+ /**
3651
+ * Resolve one channel's roster (members + display names + is-agent) from the
3652
+ * GET members endpoint into the roster cache. Called AT MOST ONCE per ingest
3653
+ * cycle per channel on a cache miss. Fail-soft: any error leaves the cache
3654
+ * as-is so the affected senders stay unresolved (sender_name undefined) —
3655
+ * attribution must NEVER block or drop a message.
3656
+ */
3657
+ async refreshChannelRoster(channelId) {
3658
+ try {
3659
+ const url = `${this.cfg.apiBase}/api/v1/channels/${encodeURIComponent(
3660
+ channelId
3661
+ )}/members?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
3662
+ const res = await this.fetchImpl(url, {
3663
+ headers: { authorization: `Bearer ${this.cfg.apiKey}` }
3664
+ });
3665
+ if (!res.ok) return;
3666
+ const rows = await res.json();
3667
+ const members = /* @__PURE__ */ new Map();
3668
+ for (const r of rows) {
3669
+ members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
3670
+ }
3671
+ this.channelRoster.set(channelId, { members, at: Date.now() });
3672
+ await this.resolveAndPinOwner(channelId, rows);
3673
+ } catch {
3674
+ }
3675
+ }
3676
+ /**
3677
+ * Task 8 (owner-attested Reset Secure Session): STRUCTURAL owner selection —
3678
+ * NEVER read the server's `role` field to pick the owner. In a DM the owner
3679
+ * is the SINGLE non-self, non-agent counterpart; any other roster shape
3680
+ * (zero or 2+ non-agent counterparts) is anomalous and REFUSES (no pin, no
3681
+ * supersede eligibility). On the first clean sighting of an unambiguous
3682
+ * owner with no existing pin, TOFU-pin its public key. On a later sighting
3683
+ * where a pin already exists and the freshly-fetched key differs, this is an
3684
+ * anomaly: surface it (log) and refuse — NEVER silently re-pin. Fail-soft:
3685
+ * this must never throw into the roster refresh path.
3686
+ */
3687
+ async resolveAndPinOwner(channelId, rows) {
3688
+ try {
3689
+ const counterparts = rows.filter((r) => r.identity_id !== this.cfg.identityId && !r.is_agent);
3690
+ if (counterparts.length !== 1) {
3691
+ this.channelOwner.set(channelId, "anomalous");
3692
+ return;
3693
+ }
3694
+ const owner = counterparts[0];
3695
+ const publicKeyHex = owner.public_key ?? "";
3696
+ if (!publicKeyHex) {
3697
+ this.channelOwner.set(channelId, "anomalous");
3698
+ return;
3699
+ }
3700
+ this.channelOwner.set(channelId, { identityId: owner.identity_id, publicKeyHex });
3701
+ const existing = await this.store.loadPinnedIdentityKey(owner.identity_id);
3702
+ if (!existing) {
3703
+ await this.store.savePinnedIdentityKey(owner.identity_id, {
3704
+ publicKeyHex,
3705
+ keyAlgo: owner.key_algo ?? "ed25519"
3706
+ });
3707
+ } else if (existing.publicKeyHex !== publicKeyHex) {
3708
+ console.warn(
3709
+ `[bridge] pin mismatch for owner ${owner.identity_id} on channel ${channelId}: pinned != roster`
3710
+ );
3711
+ this.channelOwner.set(channelId, "anomalous");
3712
+ }
3713
+ } catch {
3714
+ }
3715
+ }
3716
+ /**
3717
+ * Task 8: the `onResolvePinnedOwner` hook handed to MlsGroupKeyProvider. The
3718
+ * SDK's step-0 guard calls this before honoring an owner-authored supersede;
3719
+ * returning null makes it fail closed. Returns a value ONLY when (a) the
3720
+ * structural owner cache has an unambiguous entry for this channel AND (b) a
3721
+ * persisted pin still exists and matches it — a bare in-memory cache hit is
3722
+ * never trusted on its own, it is always reconfirmed against the store.
3723
+ */
3724
+ async resolvePinnedOwnerHook(channelId) {
3725
+ const cached = this.channelOwner.get(channelId);
3726
+ if (!cached || cached === "anomalous") return null;
3727
+ const pin = await this.store.loadPinnedIdentityKey(cached.identityId);
3728
+ if (!pin || pin.publicKeyHex !== cached.publicKeyHex) return null;
3729
+ return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
3730
+ }
3731
+ async reportStatus(channelId, state, detail) {
3732
+ const joined = await this.reconcileJoin(channelId);
3733
+ if (!joined) throw new ChannelPendingError(channelId);
3734
+ await sendWithSelfHeal(
3735
+ this.provider,
3736
+ channelId,
3737
+ (epoch) => buildOutboundEnvelope({
3738
+ custody: this.custody,
3739
+ groupKey: this.provider,
3740
+ messageId: crypto.randomUUID(),
3741
+ channelId,
3742
+ enclaveId: this.cfg.enclaveId,
3743
+ senderId: this.cfg.identityId,
3744
+ senderType: "agent",
3745
+ type: "system_event",
3746
+ bodyPlaintext: JSON.stringify({
3747
+ v: 1,
3748
+ type: "agent_status",
3749
+ state,
3750
+ at: (/* @__PURE__ */ new Date()).toISOString(),
3751
+ ...detail ? { detail } : {}
3752
+ }),
3753
+ bindingId: "",
3754
+ bindingVersion: "0.0.0",
3755
+ agentSkillId: null,
3756
+ epoch,
3757
+ traceparent: ZERO_TRACEPARENT,
3758
+ references: [],
3759
+ stage5Outcome: "allow",
3760
+ stage5Evaluators: []
3761
+ }),
3762
+ (envelope) => this.transport.publish(envelope)
3763
+ );
3764
+ }
3765
+ /** `runId` must exist and not have settled yet — settle() is terminal. */
3766
+ requireActionRun(runId) {
3767
+ const run = this.actionRuns.get(runId);
3768
+ if (!run || run.settled) throw new UnknownActionRunError(runId);
3769
+ return run;
3770
+ }
3771
+ /**
3772
+ * Build + send one `agent_action` E2E body over `system_event` (mirrors
3773
+ * sendA2A/reportStatus's envelope construction — same self-heal seam). Used
3774
+ * for BOTH partial and settled frames; the caller decides whether a send is
3775
+ * throttle-gated (partials, via flushActionPartial) or unconditional
3776
+ * (settled, always exactly once).
3777
+ */
3778
+ async sendActionFrame(channelId, body) {
3779
+ const joined = await this.reconcileJoin(channelId);
3780
+ if (!joined) throw new ChannelPendingError(channelId);
3781
+ await sendWithSelfHeal(
3782
+ this.provider,
3783
+ channelId,
3784
+ (epoch) => buildOutboundEnvelope({
3785
+ custody: this.custody,
3786
+ groupKey: this.provider,
3787
+ messageId: crypto.randomUUID(),
3788
+ channelId,
3789
+ enclaveId: this.cfg.enclaveId,
3790
+ senderId: this.cfg.identityId,
3791
+ senderType: "agent",
3792
+ type: "system_event",
3793
+ bodyPlaintext: JSON.stringify(body),
3794
+ bindingId: "",
3795
+ bindingVersion: "0.0.0",
3796
+ agentSkillId: null,
3797
+ epoch,
3798
+ traceparent: ZERO_TRACEPARENT,
3799
+ references: [],
3800
+ stage5Outcome: "allow",
3801
+ stage5Evaluators: []
3802
+ }),
3803
+ (envelope) => this.transport.publish(envelope)
3804
+ );
3805
+ }
3806
+ /**
3807
+ * The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
3808
+ * If the run has no dirty nodes, no-op. If the per-run bucket has no token,
3809
+ * no-op too — the change stays in `dirty` and rides along with whatever the
3810
+ * NEXT allowed send captures (a later start/update/finish call, or settle(),
3811
+ * which always flushes regardless of the bucket). This is the coalescing:
3812
+ * a burst of rapid calls collapses into however many sends the ~1/sec floor
3813
+ * actually allows, each carrying every node changed since the last send.
3814
+ */
3815
+ async flushActionPartial(runId, run) {
3816
+ if (run.dirty.size === 0) return;
3817
+ if (!this.actionRateFloor.tryConsume(`action:${runId}`)) return;
3818
+ if (!this.globalActionRateFloor.tryConsume("action:__global__")) return;
3819
+ const nodes = [...run.dirty].map((id) => run.nodes.get(id)).filter((n) => n !== void 0);
3820
+ run.dirty.clear();
3821
+ await this.sendActionFrame(run.channelId, {
3822
+ v: 1,
3823
+ type: "agent_action",
3824
+ agentActionVersion: "1",
3825
+ phase: "partial",
3826
+ runId,
3827
+ nodes
3828
+ });
3829
+ }
3830
+ /** Review M3: drop any unsettled run older than ACTION_RUN_STALE_MS
3831
+ * (relative to `now`). Lazy — invoked on every actionBegin() rather than
3832
+ * a background timer, mirroring the client store's orphan-TTL pattern. */
3833
+ sweepStaleActionRuns(now) {
3834
+ for (const [runId, run] of this.actionRuns) {
3835
+ if (now - run.startedAt >= _BridgeAgent.ACTION_RUN_STALE_MS) this.actionRuns.delete(runId);
3836
+ }
3837
+ }
3838
+ actionBegin(channelId) {
3839
+ const now = this.actionClock();
3840
+ this.sweepStaleActionRuns(now);
3841
+ if (this.actionRuns.size >= _BridgeAgent.MAX_UNSETTLED_ACTION_RUNS) {
3842
+ let oldestId;
3843
+ let oldestAt = Infinity;
3844
+ for (const [id, r] of this.actionRuns) {
3845
+ if (r.startedAt < oldestAt) {
3846
+ oldestAt = r.startedAt;
3847
+ oldestId = id;
3848
+ }
3849
+ }
3850
+ if (oldestId !== void 0) this.actionRuns.delete(oldestId);
3851
+ }
3852
+ const runId = crypto.randomUUID();
3853
+ this.actionRuns.set(runId, {
3854
+ channelId,
3855
+ nodes: /* @__PURE__ */ new Map(),
3856
+ dirty: /* @__PURE__ */ new Set(),
3857
+ startedAt: now,
3858
+ settled: false
3859
+ });
3860
+ return runId;
3861
+ }
3862
+ async actionStart(opts) {
3863
+ const run = this.requireActionRun(opts.runId);
3864
+ const nodeId = crypto.randomUUID();
3865
+ const node = {
3866
+ id: nodeId,
3867
+ type: opts.type,
3868
+ title: opts.title,
3869
+ status: "running",
3870
+ ts: Date.now(),
3871
+ ...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
3872
+ };
3873
+ run.nodes.set(nodeId, node);
3874
+ run.dirty.add(nodeId);
3875
+ await this.flushActionPartial(opts.runId, run);
3876
+ return nodeId;
3877
+ }
3878
+ async actionUpdate(runId, nodeId, patch) {
3879
+ const run = this.requireActionRun(runId);
3880
+ const existing = run.nodes.get(nodeId);
3881
+ if (!existing) throw new UnknownActionRunError(`${runId}/${nodeId}`);
3882
+ const updated = {
3883
+ ...existing,
3884
+ ...patch.status !== void 0 ? { status: patch.status } : {},
3885
+ ...patch.detail !== void 0 ? { detail: patch.detail } : {},
3886
+ ts: Date.now()
3887
+ };
3888
+ run.nodes.set(nodeId, updated);
3889
+ run.dirty.add(nodeId);
3890
+ await this.flushActionPartial(runId, run);
3891
+ }
3892
+ async actionFinish(runId, nodeId, patch) {
3893
+ await this.actionUpdate(runId, nodeId, patch);
3894
+ }
3895
+ async actionSettle(runId, opts = {}) {
3896
+ const run = this.requireActionRun(runId);
3897
+ run.settled = true;
3898
+ run.dirty.clear();
3899
+ const nodes = [...run.nodes.values()];
3900
+ await this.sendActionFrame(run.channelId, {
3901
+ v: 1,
3902
+ type: "agent_action",
3903
+ agentActionVersion: "1",
3904
+ phase: "settled",
3905
+ runId,
3906
+ startedAt: run.startedAt,
3907
+ endedAt: Date.now(),
3908
+ ...opts.resultMessageId !== void 0 ? { resultMessageId: opts.resultMessageId } : {},
3909
+ nodes
3910
+ });
3911
+ this.actionRuns.delete(runId);
3912
+ }
3913
+ /**
3914
+ * Auto-capture seam (Task 4 "auto-capture"): wraps a tool-call / sub-agent
3915
+ * invocation so a bare agent gets a start→finish node pair without hand-
3916
+ * rolling the begin/start/update/finish calls itself. Success → status
3917
+ * "done"; a thrown error → status "error" with the message captured in
3918
+ * `detail.error`, then the original error is rethrown (wrap never swallows).
3919
+ */
3920
+ async actionWrap(runId, fn, opts) {
3921
+ const nodeId = await this.actionStart({
3922
+ runId,
3923
+ type: opts.type,
3924
+ title: opts.title,
3925
+ ...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
3926
+ });
3927
+ try {
3928
+ const result = await fn();
3929
+ await this.actionFinish(runId, nodeId, { status: "done" });
3930
+ return result;
3931
+ } catch (err) {
3932
+ await this.actionFinish(runId, nodeId, {
3933
+ status: "error",
3934
+ detail: { error: err instanceof Error ? err.message : String(err) }
3935
+ });
3936
+ throw err;
3937
+ }
3938
+ }
3939
+ startReceivePump() {
3940
+ const sub = this.transport.subscribeDeliveryPings("");
3941
+ this.pingHandle = { close: sub.close };
3942
+ void (async () => {
3943
+ try {
3944
+ for await (const ping of sub.pings) {
3945
+ this.pingsReceived++;
3946
+ if (ping?.reconnected) {
3947
+ process.stderr.write("[bridge] delivery-ping WS (re)connected \u2014 push inbound active\n");
3948
+ }
3949
+ await this.ingestOnce();
3950
+ }
3951
+ } catch {
3952
+ }
3953
+ if (!this.closingDown) {
3954
+ process.stderr.write(
3955
+ "[bridge] delivery-ping stream ENDED (terminal WS close, e.g. 4001) \u2014 inbound reduced to the 25s safety-floor drain until the daemon restarts\n"
3956
+ );
3957
+ }
3958
+ })();
3959
+ this.safetyTimer = setInterval(() => {
3960
+ void this.ingestOnce();
3961
+ }, 25e3);
3962
+ if (this.selfHealSweepEnabled) {
3963
+ this.selfHealTimer = setInterval(() => {
3964
+ void this.selfHealSweep();
3965
+ }, _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS);
3966
+ }
3967
+ }
3968
+ async ingestOnce() {
3969
+ const cycleStart = performance.now();
3970
+ try {
3971
+ const rows = await this.latency.time("recv.pull", () => this.backend.pullDelivery(this.cfg.deviceId));
3972
+ const channelIds = /* @__PURE__ */ new Set();
3973
+ for (const row of rows) {
3974
+ if (row.connect_channel_id) channelIds.add(row.connect_channel_id);
3975
+ }
3976
+ for (const cid of channelIds) {
3977
+ if (this.joinState.get(cid) !== "JOINED") {
3978
+ await this.reconcileJoin(cid);
3979
+ }
3980
+ }
3981
+ for (const cid of channelIds) {
3982
+ if (this.freshRoster(cid) === void 0) {
3983
+ await this.refreshChannelRoster(cid);
3984
+ }
3985
+ }
3986
+ for (const row of rows) {
3987
+ const cid = row.connect_channel_id;
3988
+ const gid = row.group_id;
3989
+ if (!cid || !gid) continue;
3990
+ if (await this.provider.hasPersistedChannelState(cid)) continue;
3991
+ const now = performance.now();
3992
+ const last = this.lastWelcomeRequest.get(cid) ?? -Infinity;
3993
+ if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
3994
+ this.lastWelcomeRequest.set(cid, now);
3995
+ try {
3996
+ await this.signalHeldKpWelcome(gid);
3997
+ } catch {
3998
+ }
3999
+ }
4000
+ } catch {
4001
+ }
4002
+ let entries;
4003
+ try {
4004
+ entries = await this.latency.time("recv.decrypt", () => this.provider.receiveOnce(this.cfg.deviceId));
4005
+ } catch {
4006
+ return;
4007
+ }
4008
+ for (const cid of entries.supersededChannelIds ?? []) {
4009
+ this.joinState.set(cid, "JOINED");
4010
+ }
4011
+ if (!entries.length) return;
4012
+ let metaRefreshed = false;
4013
+ if (entries.some((m) => this.freshMeta(m.channelId) === void 0)) {
4014
+ await this.refreshChannelMeta();
4015
+ metaRefreshed = true;
4016
+ }
4017
+ const rosterRefreshed = /* @__PURE__ */ new Set();
4018
+ for (const m of entries) {
4019
+ if (rosterRefreshed.has(m.channelId)) continue;
4020
+ rosterRefreshed.add(m.channelId);
4021
+ if (this.freshRoster(m.channelId) === void 0) {
4022
+ await this.refreshChannelRoster(m.channelId);
4023
+ }
4024
+ }
4025
+ const acked = [];
4026
+ for (const m of entries) {
4027
+ try {
4028
+ const parsed = JSON.parse(
4029
+ new TextDecoder().decode(m.plaintext)
4030
+ );
4031
+ if (parsed.type && parsed.type !== "text") {
4032
+ if (parsed.type === "artifact_share") {
4033
+ try {
4034
+ const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
4035
+ const senderType = m.envelope.sender_type;
4036
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
4037
+ const meta = await materializeAttachment(
4038
+ parsed,
4039
+ { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
4040
+ {
4041
+ downloadBlob: this.downloadBlobImpl,
4042
+ decryptFile,
4043
+ computeFileDigest,
4044
+ base64ToBytes: base64ToBytes2,
4045
+ mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
4046
+ writeFile: (p, data) => fsp.writeFile(p, data)
4047
+ }
4048
+ );
4049
+ this.buffer.push({
4050
+ channel_id: m.channelId,
4051
+ message_id: m.messageId,
4052
+ sender_id: m.envelope.sender_id,
4053
+ text: typeof parsed.text === "string" ? parsed.text : "",
4054
+ ts: m.receivedAt,
4055
+ sender_name: senderName,
4056
+ sender_is_agent: senderIsAgent,
4057
+ attachments: [meta]
4058
+ });
4059
+ this.capBuffer();
4060
+ } catch (e) {
4061
+ this.failedAttachments++;
4062
+ process.stderr.write(
4063
+ `[bridge] attachment failed on ${m.messageId}: ${e.message}
4064
+ `
4065
+ );
4066
+ }
4067
+ }
4068
+ acked.push(m.queueId);
4069
+ continue;
4070
+ }
4071
+ if (typeof parsed.text === "string") {
4072
+ const meta = this.freshMeta(m.channelId);
4073
+ const senderType = m.envelope.sender_type;
4074
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
4075
+ let shouldBuffer = true;
4076
+ if (meta && meta.is_dm === false) {
4077
+ shouldBuffer = shouldProcessRoomMessage(
4078
+ parsed.text,
4079
+ this.cfg.agentName ?? this.cfg.identityId,
4080
+ this.cfg.identityId,
4081
+ { senderIsAgent, roomDisplayName: meta.name ?? void 0 }
4082
+ );
4083
+ if (!shouldBuffer) this.filteredRoomMessages++;
4084
+ }
4085
+ if (shouldBuffer) {
4086
+ const senderName = this.freshRoster(m.channelId)?.get(
4087
+ m.envelope.sender_id
4088
+ )?.name;
4089
+ this.buffer.push({
4090
+ channel_id: m.channelId,
4091
+ message_id: m.messageId,
4092
+ sender_id: m.envelope.sender_id,
4093
+ text: parsed.text,
4094
+ ts: m.receivedAt,
4095
+ sender_name: senderName,
4096
+ sender_is_agent: senderIsAgent
4097
+ });
4098
+ this.capBuffer();
4099
+ }
4100
+ }
4101
+ acked.push(m.queueId);
4102
+ } catch {
4103
+ acked.push(m.queueId);
4104
+ }
4105
+ }
4106
+ if (acked.length) {
4107
+ try {
4108
+ await this.latency.time("recv.ack", () => this.provider.ackDelivery(this.cfg.deviceId, acked));
4109
+ } catch {
4110
+ }
4111
+ }
4112
+ this.latency.record("recv.cycle", performance.now() - cycleStart);
4113
+ if (this.buffer.length) {
4114
+ for (const l of this.listeners) l();
4115
+ const w = this.waiters.splice(0);
4116
+ for (const r of w) r();
4117
+ }
4118
+ }
4119
+ async drain(channelId, waitSeconds) {
4120
+ let got = this.takeBuffered(channelId);
4121
+ if (got.length || waitSeconds <= 0) return got;
4122
+ const cap = Math.min(waitSeconds, 50) * 1e3;
4123
+ await new Promise((resolve) => {
4124
+ let fired = false;
4125
+ let timer;
4126
+ const wake = () => {
4127
+ if (fired) return;
4128
+ fired = true;
4129
+ clearTimeout(timer);
4130
+ const i = this.waiters.indexOf(wake);
4131
+ if (i >= 0) this.waiters.splice(i, 1);
4132
+ resolve();
4133
+ };
4134
+ timer = setTimeout(wake, cap);
4135
+ this.waiters.push(wake);
4136
+ });
4137
+ return this.takeBuffered(channelId);
4138
+ }
4139
+ /** Notify-only SSE signal: fired after a buffer push. Listener pulls via takeBuffered. */
4140
+ subscribe(listener) {
4141
+ this.listeners.add(listener);
4142
+ return () => this.listeners.delete(listener);
4143
+ }
4144
+ /** Synchronous splice of buffered messages (all, or one channel). Shared by drain + SSE. */
4145
+ takeBuffered(channelId) {
4146
+ if (channelId === void 0) return this.buffer.splice(0);
4147
+ const keep = [];
4148
+ const out = [];
4149
+ for (const m of this.buffer) (m.channel_id === channelId ? out : keep).push(m);
4150
+ this.buffer.length = 0;
4151
+ this.buffer.push(...keep);
4152
+ return out;
4153
+ }
4154
+ /**
4155
+ * Put messages back at the front (SSE/disconnect write-failure recovery).
4156
+ *
4157
+ * The re-buffered messages are older un-acked deliveries, so they go at the
4158
+ * FRONT (oldest). A large un-acked backlog re-buffered on reconnect is exactly
4159
+ * the OOM case Phase 1 item 4 targets, so this path is capped too. capBuffer()
4160
+ * sheds from the front, preserving the newest maxBuffer messages across the
4161
+ * combined [requeued-older..., existing-newer...] buffer.
4162
+ */
4163
+ requeueFront(msgs) {
4164
+ if (msgs.length) {
4165
+ this.buffer.unshift(...msgs);
4166
+ this.capBuffer();
4167
+ }
4168
+ }
4169
+ /**
4170
+ * Poke subscribers + waiters to drain the buffer now. Used after a
4171
+ * disconnect re-buffers un-acked messages so a still-connected consumer
4172
+ * (e.g. a replacement WS) redelivers them without waiting for new inbound.
4173
+ * Mirrors the notify tail of ingestOnce.
4174
+ */
4175
+ wakeListeners() {
4176
+ for (const l of this.listeners) l();
4177
+ const w = this.waiters.splice(0);
4178
+ for (const r of w) r();
4179
+ }
4180
+ health() {
4181
+ let joined = 0;
4182
+ for (const s of this.joinState.values()) if (s === "JOINED") joined++;
4183
+ return {
4184
+ status: "ok",
4185
+ identity_id: this.cfg.identityId,
4186
+ joined_channels: joined,
4187
+ buffered: this.buffer.length,
4188
+ dropped: this.droppedMessages,
4189
+ filtered_room: this.filteredRoomMessages,
4190
+ rate_limited: this.rateLimitedSends,
4191
+ permanently_skipped: this.provider.permanentlySkippedCount(),
4192
+ failed_attachments: this.failedAttachments,
4193
+ delivery_pings: this.pingsReceived,
4194
+ uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
4195
+ version: BRIDGE_VERSION
4196
+ };
4197
+ }
4198
+ /**
4199
+ * Loopback observability snapshot served at GET /v1/metrics.
4200
+ *
4201
+ * `stages`: per-stage SecureComs-envelope latency percentiles (ms), measured
4202
+ * locally and monotonically — send.encrypt / send.publish / send.total (the
4203
+ * sender half) and recv.pull / recv.decrypt / recv.ack / recv.cycle (the
4204
+ * recipient half). Excludes the agent's LLM time entirely.
4205
+ *
4206
+ * `errors` (ADR-0002): per-channel decrypt-error / stuck snapshot from the MLS
4207
+ * provider, so a wedged channel is OBSERVABLE here instead of looking idle. A
4208
+ * channel appears only once it has had a decrypt error; `stuck:true` means
4209
+ * repeated failures with no intervening successful decrypt.
4210
+ *
4211
+ * `dropped` (Phase 1 item 4): cumulative count of inbound messages shed by the
4212
+ * bounded-buffer cap. A daemon silently dropping messages under backpressure is
4213
+ * exactly the blindness ADR-0002 eliminates, so the shed is surfaced here (and
4214
+ * on health()) rather than swallowed.
4215
+ *
4216
+ * `filteredRoomMessages` (item 3 / ADR-0002 ethos): cumulative ROOM messages
4217
+ * dropped by the @mention filter. A silently-filtered room message produces no
4218
+ * other signal, so an agent correctly filtering 95% of room traffic would look
4219
+ * identical to an idle/wedged one without this counter.
4220
+ *
4221
+ * `permanentlySkipped` (ADR-0002 ethos): cumulative count of inbound rows
4222
+ * acked-and-dropped because they were provably past-epoch undecryptable
4223
+ * (forward-secrecy secret gone), so a self-healed poison row is observable
4224
+ * here instead of silently dropped.
4225
+ */
4226
+ metrics() {
4227
+ return {
4228
+ stages: this.latency.snapshot(),
4229
+ errors: this.provider.errorStats(),
4230
+ dropped: this.droppedMessages,
4231
+ filteredRoomMessages: this.filteredRoomMessages,
4232
+ rateLimitedSends: this.rateLimitedSends,
4233
+ permanentlySkipped: this.provider.permanentlySkippedCount(),
4234
+ welcomeRequestsSent: this.welcomeRequestsSent,
4235
+ failedAttachments: this.failedAttachments
4236
+ };
4237
+ }
4238
+ async close() {
4239
+ this.closingDown = true;
4240
+ this.pingHandle?.close();
4241
+ if (this.safetyTimer) clearInterval(this.safetyTimer);
4242
+ if (this.selfHealTimer) clearInterval(this.selfHealTimer);
4243
+ this.store.close();
4244
+ }
4245
+ };
4246
+
4247
+ // src/config.ts
4248
+ import { readFileSync, statSync } from "node:fs";
4249
+ import { dirname as dirname2, join as join2 } from "node:path";
4250
+ var REQUIRED = ["api_key", "identity_id", "device_id", "signing_private_key", "enclave_id", "api_base"];
4251
+ function loadConfig(opts) {
4252
+ const raw = readFileSync(opts.credentialPath, "utf8");
4253
+ try {
4254
+ const mode = statSync(opts.credentialPath).mode & 511;
4255
+ if (mode & 63) process.stderr.write(`[bridge] WARNING: credential file ${opts.credentialPath} permission ${mode.toString(8)} is not 0600 \u2014 tighten it (chmod 600).
4256
+ `);
4257
+ } catch {
4258
+ }
4259
+ let json2;
4260
+ try {
4261
+ json2 = JSON.parse(raw);
4262
+ } catch {
4263
+ throw new Error("credential file is not valid JSON");
4264
+ }
4265
+ for (const k of REQUIRED) if (typeof json2[k] !== "string" || !json2[k]) throw new Error(`credential file missing required field: ${k}`);
4266
+ const storePath = opts.storePath ?? join2(dirname2(opts.credentialPath), "bridge-state.db");
4267
+ const agentName = typeof json2.agent_name === "string" && json2.agent_name ? json2.agent_name : typeof json2.display_name === "string" && json2.display_name ? json2.display_name : void 0;
4268
+ const rateLimitCapacity = typeof json2.rate_limit_capacity === "number" ? json2.rate_limit_capacity : 20;
4269
+ const rateLimitRefillPerMinute = typeof json2.rate_limit_refill_per_minute === "number" ? json2.rate_limit_refill_per_minute : 20;
4270
+ return {
4271
+ apiKey: json2.api_key,
4272
+ identityId: json2.identity_id,
4273
+ deviceId: json2.device_id,
4274
+ signingPrivateKey: json2.signing_private_key,
4275
+ enclaveId: json2.enclave_id,
4276
+ apiBase: json2.api_base,
4277
+ storePath,
4278
+ agentName,
4279
+ rateLimitCapacity,
4280
+ rateLimitRefillPerMinute
4281
+ };
4282
+ }
4283
+ function arg(argv, flag) {
4284
+ const i = argv.indexOf(flag);
4285
+ return i >= 0 ? argv[i + 1] : void 0;
4286
+ }
4287
+ function args(argv, flag) {
4288
+ const out = [];
4289
+ for (let i = 0; i < argv.length; i++) if (argv[i] === flag && argv[i + 1]) out.push(argv[i + 1]);
4290
+ return out;
4291
+ }
4292
+ function parseDaemonArgs(argv, env) {
4293
+ const tlsCert = arg(argv, "--tls-cert") ?? env.SECURECOMS_TLS_CERT;
4294
+ const tlsKey = arg(argv, "--tls-key") ?? env.SECURECOMS_TLS_KEY;
4295
+ return {
4296
+ port: Number(arg(argv, "--port") ?? env.SECURECOMS_PORT ?? 7077),
4297
+ bind: arg(argv, "--bind") ?? env.SECURECOMS_BIND ?? "127.0.0.1",
4298
+ tokenFile: arg(argv, "--token-file") ?? env.SECURECOMS_TOKEN_FILE,
4299
+ token: arg(argv, "--token") ?? env.SECURECOMS_LOCAL_TOKEN,
4300
+ tls: Boolean(tlsCert && tlsKey),
4301
+ tlsCert,
4302
+ tlsKey,
4303
+ allowOrigins: args(argv, "--allow-origin")
4304
+ };
4305
+ }
4306
+ function isLoopback(host) {
4307
+ return host === "localhost" || host === "::1" || host === "[::1]" || /^127\./.test(host);
4308
+ }
4309
+ function validateBind(bind, tls) {
4310
+ if (!isLoopback(bind) && !tls) {
4311
+ throw new Error(
4312
+ `refusing non-loopback bind '${bind}' without TLS \u2014 supply --tls-cert and --tls-key for cross-host, or bind a loopback address`
4313
+ );
4314
+ }
4315
+ }
4316
+
4317
+ // src/http-server.ts
4318
+ import { createServer as createHttp } from "node:http";
4319
+ import { createServer as createHttps } from "node:https";
4320
+ import { z } from "zod";
4321
+
4322
+ // src/auth.ts
4323
+ import { randomBytes as randomBytes3, timingSafeEqual } from "node:crypto";
4324
+ import { existsSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
4325
+ function resolveToken(opts) {
4326
+ if (opts.token) return opts.token;
4327
+ const f = opts.tokenFile;
4328
+ if (f && existsSync(f)) return readFileSync2(f, "utf8").trim();
4329
+ const tok = randomBytes3(32).toString("base64url");
4330
+ if (f) writeFileSync(f, tok, { mode: 384 });
4331
+ return tok;
4332
+ }
4333
+ function tokenMatches(provided, expected) {
4334
+ if (!provided) return false;
4335
+ const a = Buffer.from(provided);
4336
+ const b = Buffer.from(expected);
4337
+ if (a.length !== b.length) return false;
4338
+ return timingSafeEqual(a, b);
4339
+ }
4340
+ function bearerFrom(header) {
4341
+ if (!header) return void 0;
4342
+ const m = /^Bearer (.+)$/i.exec(header.trim());
4343
+ return m ? m[1] : void 0;
4344
+ }
4345
+ function hostnameOf(hostHeader) {
4346
+ if (!hostHeader) return void 0;
4347
+ if (hostHeader.startsWith("[")) return hostHeader.slice(0, hostHeader.indexOf("]") + 1);
4348
+ const i = hostHeader.lastIndexOf(":");
4349
+ return i > 0 ? hostHeader.slice(0, i) : hostHeader;
4350
+ }
4351
+ function originHostAllowed(headers, allowOrigins) {
4352
+ const host = hostnameOf(headers.host);
4353
+ if (host && !isLoopback(host) && !isLoopback(`[${host}]`)) return false;
4354
+ const origin = headers.origin;
4355
+ if (!origin) return true;
4356
+ if (allowOrigins.includes(origin)) return true;
4357
+ try {
4358
+ return isLoopback(new URL(origin).hostname);
4359
+ } catch {
4360
+ return false;
4361
+ }
4362
+ }
4363
+
4364
+ // src/errors.ts
4365
+ var BRIDGE_ERRORS = {
4366
+ unauthorized: { http: 401, message: "missing or invalid bearer token" },
4367
+ forbidden_origin: { http: 403, message: "origin or host not allowed" },
4368
+ not_found: { http: 404, message: "no such route" },
4369
+ invalid_request: { http: 400, message: "request failed validation" },
4370
+ invalid_json: { http: 400, message: "request body is not valid JSON" },
4371
+ body_too_large: { http: 400, message: "request body exceeds the size limit" },
4372
+ channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
4373
+ rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
4374
+ unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
4375
+ internal: { http: 500, message: "internal error" }
4376
+ };
4377
+ function errorBody(code, detail) {
4378
+ const body = { error: code, message: BRIDGE_ERRORS[code].message };
4379
+ if (detail !== void 0) body.detail = detail;
4380
+ return body;
4381
+ }
4382
+ function httpStatus(code) {
4383
+ return BRIDGE_ERRORS[code].http;
4384
+ }
4385
+ function wsErrorFrame(code, detail) {
4386
+ return { type: "error", ...errorBody(code, detail) };
4387
+ }
4388
+
4389
+ // src/http-server.ts
4390
+ function writeSse(res, msgs) {
4391
+ for (let i = 0; i < msgs.length; i++) {
4392
+ if (res.destroyed || res.writableEnded) return msgs.slice(i);
4393
+ try {
4394
+ res.write(`event: message
4395
+ data: ${JSON.stringify(msgs[i])}
4396
+
4397
+ `);
4398
+ } catch {
4399
+ return msgs.slice(i);
4400
+ }
4401
+ }
4402
+ return [];
4403
+ }
4404
+ function capabilities(opts) {
4405
+ return {
4406
+ api_version: "1",
4407
+ receive_modes: ["longpoll", "sse", ...opts.websocket ? ["websocket"] : []],
4408
+ send_types: ["text", "agent_status", "a2a"],
4409
+ webhook_push: false
4410
+ // B3: A2A-style push-notification config (ciphertext/wake-only)
4411
+ };
4412
+ }
4413
+ var SEND_CAP = 64 * 1024;
4414
+ var OTHER_CAP = 8 * 1024;
4415
+ var a2aSchema = z.object({
4416
+ a2a_version: z.string().min(1),
4417
+ kind: z.enum(["message", "task", "artifact"]),
4418
+ object: z.record(z.unknown())
4419
+ });
4420
+ var sendAttachmentSchema = z.object({
4421
+ channel_id: z.string().min(1),
4422
+ path: z.string().min(1),
4423
+ caption: z.string().optional()
4424
+ });
4425
+ var sendSchema = z.object({
4426
+ channel_id: z.string().min(1),
4427
+ text: z.string().optional(),
4428
+ idempotency_key: z.string().optional(),
4429
+ a2a: a2aSchema.optional()
4430
+ }).refine((d) => typeof d.text === "string" || d.a2a !== void 0, {
4431
+ message: "send requires either text or a2a"
4432
+ });
4433
+ var statusSchema = z.object({
4434
+ channel_id: z.string().min(1),
4435
+ state: z.enum(["idle", "working", "stalled", "error"]),
4436
+ detail: z.string().optional()
4437
+ });
4438
+ function json(res, status, body) {
4439
+ const s = JSON.stringify(body);
4440
+ res.writeHead(status, { "content-type": "application/json" });
4441
+ res.end(s);
4442
+ }
4443
+ function jsonError(res, code, detail) {
4444
+ json(res, httpStatus(code), errorBody(code, detail));
4445
+ }
4446
+ function readBody(req, cap) {
4447
+ return new Promise((resolve, reject) => {
4448
+ let size = 0;
4449
+ let done = false;
4450
+ const chunks = [];
4451
+ const onData = (c) => {
4452
+ if (done) return;
4453
+ size += c.length;
4454
+ if (size > cap) {
4455
+ done = true;
4456
+ req.removeListener("data", onData);
4457
+ req.pause();
4458
+ reject(new Error("body_too_large"));
4459
+ return;
4460
+ }
4461
+ chunks.push(c);
4462
+ };
4463
+ req.on("data", onData);
4464
+ req.on("end", () => {
4465
+ if (!done) {
4466
+ done = true;
4467
+ resolve(Buffer.concat(chunks).toString("utf8"));
4468
+ }
4469
+ });
4470
+ req.on("error", (e) => {
4471
+ if (!done) {
4472
+ done = true;
4473
+ reject(e);
4474
+ }
4475
+ });
4476
+ });
4477
+ }
4478
+ function createHttpServer(agent, opts) {
4479
+ let closeSse = () => {
4480
+ };
4481
+ let sse = null;
4482
+ const tearDownSse = () => {
4483
+ if (!sse) return;
4484
+ sse.unsubscribe();
4485
+ clearInterval(sse.keepalive);
4486
+ try {
4487
+ sse.res.end();
4488
+ } catch {
4489
+ }
4490
+ sse = null;
4491
+ };
4492
+ closeSse = tearDownSse;
4493
+ const handler = async (req, res) => {
4494
+ try {
4495
+ const url = new URL(req.url ?? "/", "http://localhost");
4496
+ const path = url.pathname;
4497
+ const method = req.method ?? "GET";
4498
+ if (method === "GET" && path === "/healthz") {
4499
+ return json(res, 200, { ...agent.health(), capabilities: capabilities(opts) });
4500
+ }
4501
+ if (!tokenMatches(bearerFrom(req.headers.authorization), opts.token)) {
4502
+ return jsonError(res, "unauthorized");
4503
+ }
4504
+ if (!originHostAllowed({ origin: req.headers.origin, host: req.headers.host }, opts.allowOrigins)) {
4505
+ return jsonError(res, "forbidden_origin");
4506
+ }
4507
+ if (method === "GET" && path === "/v1/channels") {
4508
+ return json(res, 200, await agent.listChannels());
4509
+ }
4510
+ if (method === "GET" && path === "/v1/metrics") {
4511
+ return json(res, 200, agent.metrics());
4512
+ }
4513
+ if (method === "POST" && path === "/v1/send") {
4514
+ const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
4515
+ if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
4516
+ const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a, parsed.data.idempotency_key) : await agent.send(parsed.data.channel_id, parsed.data.text, parsed.data.idempotency_key);
4517
+ return json(res, 200, r);
4518
+ }
4519
+ if (method === "POST" && path === "/v1/send-attachment") {
4520
+ const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
4521
+ if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
4522
+ const r = await agent.sendAttachment(parsed.data.channel_id, parsed.data.path, parsed.data.caption);
4523
+ return json(res, 200, r);
4524
+ }
4525
+ if (method === "POST" && path === "/v1/status") {
4526
+ const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
4527
+ if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
4528
+ await agent.reportStatus(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
4529
+ return json(res, 200, { ok: true });
4530
+ }
4531
+ if (method === "GET" && path === "/v1/messages") {
4532
+ const channel = url.searchParams.get("channel_id") ?? void 0;
4533
+ const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
4534
+ return json(res, 200, await agent.drain(channel, wait));
4535
+ }
4536
+ if (method === "GET" && path === "/v1/events") {
4537
+ const channel = url.searchParams.get("channel_id") ?? void 0;
4538
+ tearDownSse();
4539
+ res.writeHead(200, {
4540
+ "content-type": "text/event-stream",
4541
+ "cache-control": "no-cache",
4542
+ connection: "keep-alive"
4543
+ });
4544
+ res.write(":connected\n\n");
4545
+ const drainToClient = () => {
4546
+ const left = writeSse(res, agent.takeBuffered(channel));
4547
+ if (left.length) agent.requeueFront(left);
4548
+ };
4549
+ const unsubscribe = agent.subscribe(drainToClient);
4550
+ const keepalive = setInterval(() => {
4551
+ try {
4552
+ res.write(":keepalive\n\n");
4553
+ } catch {
4554
+ }
4555
+ }, 15e3);
4556
+ sse = { res, unsubscribe, keepalive };
4557
+ drainToClient();
4558
+ req.on("close", () => {
4559
+ if (sse?.res === res) tearDownSse();
4560
+ });
4561
+ return;
4562
+ }
4563
+ return jsonError(res, "not_found");
4564
+ } catch (e) {
4565
+ if (e.message === "body_too_large") {
4566
+ if (!res.headersSent) {
4567
+ res.writeHead(httpStatus("body_too_large"), { "content-type": "application/json", connection: "close" });
4568
+ }
4569
+ res.end(JSON.stringify(errorBody("body_too_large")));
4570
+ return;
4571
+ }
4572
+ if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
4573
+ if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
4574
+ if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
4575
+ if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
4576
+ process.stderr.write(`[bridge] request error: ${e?.message ?? e}
4577
+ `);
4578
+ return jsonError(res, "internal");
4579
+ }
4580
+ };
4581
+ const server = opts.tls ? createHttps({ cert: opts.tls.cert, key: opts.tls.key }, (req, res) => void handler(req, res)) : createHttp((req, res) => void handler(req, res));
4582
+ return { server, closeAllSseStreams: () => closeSse() };
4583
+ }
4584
+
4585
+ // src/ws-server.ts
4586
+ import { WebSocketServer, WebSocket } from "ws";
4587
+ var PING_INTERVAL_MS = 3e4;
4588
+ function tokenFromUpgrade(req) {
4589
+ try {
4590
+ const url = new URL(req.url ?? "/", "http://localhost");
4591
+ const q = url.searchParams.get("token");
4592
+ if (q) return q;
4593
+ } catch {
4594
+ }
4595
+ return bearerFrom(req.headers.authorization);
4596
+ }
4597
+ function authedUpgrade(req, opts) {
4598
+ if (!tokenMatches(tokenFromUpgrade(req), opts.token)) return false;
4599
+ if (!originHostAllowed({ origin: req.headers.origin, host: req.headers.host }, opts.allowOrigins)) {
4600
+ return false;
4601
+ }
4602
+ return true;
4603
+ }
4604
+ function attachAgentWebSocket(server, agent, opts) {
4605
+ const wss = new WebSocketServer({ server, path: "/v1/ws" });
4606
+ const keepalive = setInterval(() => {
4607
+ for (const client of wss.clients) {
4608
+ const sock = client;
4609
+ if (sock.isAlive === false) {
4610
+ sock.terminate();
4611
+ continue;
4612
+ }
4613
+ sock.isAlive = false;
4614
+ try {
4615
+ sock.ping();
4616
+ } catch {
4617
+ }
4618
+ }
4619
+ }, PING_INTERVAL_MS);
4620
+ if (typeof keepalive.unref === "function") keepalive.unref();
4621
+ let current = null;
4622
+ wss.on("connection", (sock, req) => {
4623
+ if (!authedUpgrade(req, opts)) {
4624
+ sock.close(1008, "unauthorized");
4625
+ return;
4626
+ }
4627
+ if (current && current.sock !== sock) {
4628
+ const prior = current;
4629
+ if (prior.sock.readyState === WebSocket.OPEN) {
4630
+ prior.sock.close(1e3, "replaced by newer connection");
4631
+ }
4632
+ prior.cleanup();
4633
+ }
4634
+ sock.isAlive = true;
4635
+ sock.on("pong", () => {
4636
+ sock.isAlive = true;
4637
+ });
4638
+ const inflight = /* @__PURE__ */ new Map();
4639
+ const flush = () => {
4640
+ if (sock.readyState !== WebSocket.OPEN) return;
4641
+ const batch = agent.takeBuffered();
4642
+ for (let i = 0; i < batch.length; i++) {
4643
+ if (sock.readyState !== WebSocket.OPEN) {
4644
+ agent.requeueFront(batch.slice(i));
4645
+ return;
4646
+ }
4647
+ const frame = { type: "message", ...batch[i] };
4648
+ try {
4649
+ sock.send(JSON.stringify(frame));
4650
+ inflight.set(batch[i].message_id, batch[i]);
4651
+ } catch {
4652
+ agent.requeueFront(batch.slice(i));
4653
+ return;
4654
+ }
4655
+ }
4656
+ };
4657
+ const unsubscribe = agent.subscribe(flush);
4658
+ let cleaned = false;
4659
+ const cleanup = () => {
4660
+ if (cleaned) return;
4661
+ cleaned = true;
4662
+ unsubscribe();
4663
+ if (inflight.size) {
4664
+ agent.requeueFront([...inflight.values()]);
4665
+ inflight.clear();
4666
+ agent.wakeListeners();
4667
+ }
4668
+ if (current?.sock === sock) current = null;
4669
+ };
4670
+ current = { sock, cleanup };
4671
+ sock.on("close", cleanup);
4672
+ sock.on("error", cleanup);
4673
+ sock.on("message", (data) => {
4674
+ void handleInbound(sock, agent, data, inflight);
4675
+ });
4676
+ flush();
4677
+ });
4678
+ return {
4679
+ close() {
4680
+ clearInterval(keepalive);
4681
+ for (const client of wss.clients) {
4682
+ try {
4683
+ client.terminate();
4684
+ } catch {
4685
+ }
4686
+ }
4687
+ wss.close();
4688
+ }
4689
+ };
4690
+ }
4691
+ function sendJson(sock, body) {
4692
+ if (sock.readyState !== WebSocket.OPEN) return;
4693
+ try {
4694
+ sock.send(JSON.stringify(body));
4695
+ } catch {
4696
+ }
4697
+ }
4698
+ async function handleInbound(sock, agent, data, inflight) {
4699
+ let msg;
4700
+ try {
4701
+ msg = JSON.parse(data.toString("utf8"));
4702
+ } catch {
4703
+ sendJson(sock, wsErrorFrame("invalid_request"));
4704
+ return;
4705
+ }
4706
+ if (typeof msg !== "object" || msg === null) {
4707
+ sendJson(sock, wsErrorFrame("invalid_request"));
4708
+ return;
4709
+ }
4710
+ const m = msg;
4711
+ if (m.type === "ack") {
4712
+ if (typeof m.message_id !== "string" || !m.message_id) {
4713
+ sendJson(sock, wsErrorFrame("invalid_request"));
4714
+ return;
4715
+ }
4716
+ inflight.delete(m.message_id);
4717
+ return;
4718
+ }
4719
+ if (m.type === "send") {
4720
+ if (typeof m.channel_id !== "string" || !m.channel_id) {
4721
+ sendJson(sock, wsErrorFrame("invalid_request"));
4722
+ return;
4723
+ }
4724
+ if (m.a2a && typeof m.a2a === "object") {
4725
+ const a = m.a2a;
4726
+ if (typeof a.a2a_version !== "string" || !a.a2a_version || a.kind !== "message" && a.kind !== "task" && a.kind !== "artifact" || typeof a.object !== "object" || a.object === null) {
4727
+ sendJson(sock, wsErrorFrame("invalid_request"));
4728
+ return;
4729
+ }
4730
+ try {
4731
+ const r = await agent.sendA2A(m.channel_id, { a2a_version: a.a2a_version, kind: a.kind, object: a.object });
4732
+ sendJson(sock, { type: "sent", message_id: r.message_id });
4733
+ } catch (e) {
4734
+ if (e instanceof ChannelPendingError) {
4735
+ sendJson(sock, wsErrorFrame("channel_pending"));
4736
+ } else if (e instanceof RateLimitedError) {
4737
+ sendJson(sock, wsErrorFrame("rate_limited"));
4738
+ } else {
4739
+ sendJson(sock, wsErrorFrame("invalid_request"));
4740
+ }
4741
+ }
4742
+ return;
4743
+ }
4744
+ if (typeof m.text !== "string" || !m.text) {
4745
+ sendJson(sock, wsErrorFrame("invalid_request"));
4746
+ return;
4747
+ }
4748
+ const idempotencyKey = typeof m.idempotency_key === "string" ? m.idempotency_key : void 0;
4749
+ try {
4750
+ const r = await agent.send(m.channel_id, m.text, idempotencyKey);
4751
+ sendJson(sock, { type: "sent", message_id: r.message_id });
4752
+ } catch (e) {
4753
+ if (e instanceof ChannelPendingError) {
4754
+ sendJson(sock, wsErrorFrame("channel_pending"));
4755
+ } else if (e instanceof RateLimitedError) {
4756
+ sendJson(sock, wsErrorFrame("rate_limited"));
4757
+ } else {
4758
+ sendJson(sock, wsErrorFrame("invalid_request"));
4759
+ }
4760
+ }
4761
+ return;
4762
+ }
4763
+ if (m.type === "status") {
4764
+ const states = ["idle", "working", "stalled", "error"];
4765
+ if (typeof m.channel_id !== "string" || !m.channel_id || typeof m.state !== "string" || !states.includes(m.state)) {
4766
+ sendJson(sock, wsErrorFrame("invalid_request"));
4767
+ return;
4768
+ }
4769
+ const detail = typeof m.detail === "string" ? m.detail : void 0;
4770
+ try {
4771
+ await agent.reportStatus(m.channel_id, m.state, detail);
4772
+ sendJson(sock, { type: "ok" });
4773
+ } catch (e) {
4774
+ if (e instanceof ChannelPendingError) {
4775
+ sendJson(sock, wsErrorFrame("channel_pending"));
4776
+ } else if (e instanceof RateLimitedError) {
4777
+ sendJson(sock, wsErrorFrame("rate_limited"));
4778
+ } else {
4779
+ sendJson(sock, wsErrorFrame("invalid_request"));
4780
+ }
4781
+ }
4782
+ return;
4783
+ }
4784
+ const NODE_TYPES = ["step", "tool_call", "sub_task", "result"];
4785
+ const NODE_STATUSES = ["pending", "running", "done", "error"];
4786
+ const actionError = (e) => {
4787
+ sendJson(sock, wsErrorFrame(e instanceof UnknownActionRunError ? "unknown_action_run" : "invalid_request"));
4788
+ };
4789
+ if (m.type === "action_begin") {
4790
+ if (typeof m.channel_id !== "string" || !m.channel_id) {
4791
+ sendJson(sock, wsErrorFrame("invalid_request", "channel_id required"));
4792
+ return;
4793
+ }
4794
+ try {
4795
+ sendJson(sock, { type: "action_begun", run_id: agent.action.begin(m.channel_id) });
4796
+ } catch (e) {
4797
+ actionError(e);
4798
+ }
4799
+ return;
4800
+ }
4801
+ if (m.type === "action_start") {
4802
+ if (typeof m.run_id !== "string" || !m.run_id || typeof m.title !== "string" || !m.title || !NODE_TYPES.includes(m.node_type)) {
4803
+ sendJson(sock, wsErrorFrame("invalid_request", "run_id, node_type, title required"));
4804
+ return;
4805
+ }
4806
+ try {
4807
+ const nodeId = await agent.action.start({
4808
+ runId: m.run_id,
4809
+ type: m.node_type,
4810
+ title: m.title,
4811
+ parentId: typeof m.parent_id === "string" && m.parent_id ? m.parent_id : void 0
4812
+ });
4813
+ sendJson(sock, { type: "action_started", node_id: nodeId });
4814
+ } catch (e) {
4815
+ actionError(e);
4816
+ }
4817
+ return;
4818
+ }
4819
+ if (m.type === "action_update" || m.type === "action_finish") {
4820
+ if (typeof m.run_id !== "string" || !m.run_id || typeof m.node_id !== "string" || !m.node_id) {
4821
+ sendJson(sock, wsErrorFrame("invalid_request", "run_id and node_id required"));
4822
+ return;
4823
+ }
4824
+ try {
4825
+ if (m.type === "action_finish") {
4826
+ if (m.status !== "done" && m.status !== "error") {
4827
+ sendJson(sock, wsErrorFrame("invalid_request", "finish status must be done|error"));
4828
+ return;
4829
+ }
4830
+ await agent.action.finish(m.run_id, m.node_id, { status: m.status, detail: m.detail });
4831
+ } else {
4832
+ if (m.status !== void 0 && !NODE_STATUSES.includes(m.status)) {
4833
+ sendJson(sock, wsErrorFrame("invalid_request", "bad status"));
4834
+ return;
4835
+ }
4836
+ await agent.action.update(m.run_id, m.node_id, { status: m.status, detail: m.detail });
4837
+ }
4838
+ sendJson(sock, { type: "ok" });
4839
+ } catch (e) {
4840
+ actionError(e);
4841
+ }
4842
+ return;
4843
+ }
4844
+ if (m.type === "action_settle") {
4845
+ if (typeof m.run_id !== "string" || !m.run_id) {
4846
+ sendJson(sock, wsErrorFrame("invalid_request", "run_id required"));
4847
+ return;
4848
+ }
4849
+ try {
4850
+ await agent.action.settle(m.run_id, { resultMessageId: typeof m.result_message_id === "string" && m.result_message_id ? m.result_message_id : void 0 });
4851
+ sendJson(sock, { type: "action_settled", run_id: m.run_id });
4852
+ } catch (e) {
4853
+ actionError(e);
4854
+ }
4855
+ return;
4856
+ }
4857
+ sendJson(sock, wsErrorFrame("invalid_request"));
4858
+ }
4859
+ export {
4860
+ BRIDGE_ERRORS,
4861
+ BRIDGE_VERSION,
4862
+ BridgeAgent,
4863
+ ChannelPendingError,
4864
+ RateLimitedError,
4865
+ attachAgentWebSocket,
4866
+ capabilities,
4867
+ createHttpServer,
4868
+ errorBody,
4869
+ httpStatus,
4870
+ isLoopback,
4871
+ loadConfig,
4872
+ originHostAllowed,
4873
+ parseDaemonArgs,
4874
+ resolveToken,
4875
+ tokenMatches,
4876
+ validateBind,
4877
+ writeSse,
4878
+ wsErrorFrame
4879
+ };