@agentvault/grok-bridge 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1074,6 +1074,28 @@ var __toESM = (mod4, isNodeMode, target) => (target = mod4 != null ? __create(__
1074
1074
  isNodeMode || !mod4 || !mod4.__esModule ? __defProp2(target, "default", { value: mod4, enumerable: true }) : target,
1075
1075
  mod4
1076
1076
  ));
1077
+ function stripWrappers(s2) {
1078
+ let start = 0;
1079
+ let end = s2.length;
1080
+ while (start < end && WRAPPERS.includes(s2[start])) start++;
1081
+ while (end > start && WRAPPERS.includes(s2[end - 1])) end--;
1082
+ return s2.slice(start, end);
1083
+ }
1084
+ function isAbstention(text) {
1085
+ if (typeof text !== "string") return false;
1086
+ return stripWrappers(text).toLowerCase().startsWith(SENTINEL);
1087
+ }
1088
+ var SENTINEL;
1089
+ var WRAPPERS;
1090
+ var INSTRUCTION;
1091
+ var init_abstention = __esm2({
1092
+ "src/abstention.ts"() {
1093
+ "use strict";
1094
+ SENTINEL = "[[no-reply]]";
1095
+ WRAPPERS = " \r\n`\"'*_";
1096
+ INSTRUCTION = "not every message needs an answer from you: if this one is not addressed to you and asks nothing of you, reply with exactly " + SENTINEL + " and nothing else \u2014 that is discarded, not posted, and is how you stay quiet; never post a message SAYING that you are staying quiet";
1097
+ }
1098
+ });
1077
1099
  function toEpoch(v2) {
1078
1100
  if (typeof v2 === "bigint") return v2;
1079
1101
  if (typeof v2 === "number") {
@@ -45899,6 +45921,11 @@ var init_ratchet = __esm2({
45899
45921
  return { header, headerSignature, ciphertext, nonce };
45900
45922
  }
45901
45923
  decrypt(message) {
45924
+ if (this.state.peerIdentityPublicKey) {
45925
+ if (!verifyHeaderSignature(message.header, message.headerSignature, this.state.peerIdentityPublicKey)) {
45926
+ throw new Error("Header signature verification failed");
45927
+ }
45928
+ }
45902
45929
  const isV2 = message.envelopeVersion === "2.0.0" && message.encryptedHeader != null && message.headerNonce != null;
45903
45930
  const skippedResult = this.trySkippedKeys(message, isV2);
45904
45931
  if (skippedResult !== null) {
@@ -45912,14 +45939,14 @@ var init_ratchet = __esm2({
45912
45939
  if (message.header.messageNumber === 0) {
45913
45940
  try {
45914
45941
  const { messageKey: testKey, headerKey: testHeaderKey, nextChainKey: nextChainKey2 } = kdfChainKey(this.state.rootKey);
45915
- let ad2;
45942
+ let ad;
45916
45943
  if (isV2) {
45917
45944
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, testHeaderKey);
45918
- ad2 = message.encryptedHeader;
45945
+ ad = message.encryptedHeader;
45919
45946
  } else {
45920
- ad2 = serializeHeader(message.header);
45947
+ ad = serializeHeader(message.header);
45921
45948
  }
45922
- const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad2, message.nonce, testKey);
45949
+ const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, testKey);
45923
45950
  this.state.dhReceivingPublicKey = message.header.dhPublicKey;
45924
45951
  this.state.receivingChain = {
45925
45952
  chainKey: nextChainKey2,
@@ -45939,13 +45966,7 @@ var init_ratchet = __esm2({
45939
45966
  this.skipMessages(this.state.receivingChain, message.header.messageNumber, message.header.dhPublicKey, isV2);
45940
45967
  const chain = this.state.receivingChain;
45941
45968
  const { nextChainKey, messageKey, headerKey } = kdfChainKey(chain.chainKey);
45942
- chain.chainKey = nextChainKey;
45943
- chain.messageNumber++;
45944
- if (this.state.peerIdentityPublicKey) {
45945
- if (!verifyHeaderSignature(message.header, message.headerSignature, this.state.peerIdentityPublicKey)) {
45946
- throw new Error("Header signature verification failed");
45947
- }
45948
- }
45969
+ let plaintextBytes;
45949
45970
  if (isV2) {
45950
45971
  try {
45951
45972
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, headerKey);
@@ -45953,19 +45974,21 @@ var init_ratchet = __esm2({
45953
45974
  throw new Error("V2 header decryption failed");
45954
45975
  }
45955
45976
  try {
45956
- const plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, message.encryptedHeader, message.nonce, messageKey);
45957
- return libsodium_wrappers_default.to_string(plaintextBytes);
45977
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, message.encryptedHeader, message.nonce, messageKey);
45958
45978
  } catch {
45959
45979
  throw new Error("V2 decryption failed: ciphertext tampered or wrong key");
45960
45980
  }
45981
+ } else {
45982
+ const ad = serializeHeader(message.header);
45983
+ try {
45984
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, messageKey);
45985
+ } catch {
45986
+ throw new Error("Decryption failed: ciphertext tampered or wrong key");
45987
+ }
45961
45988
  }
45962
- const ad = serializeHeader(message.header);
45963
- try {
45964
- const plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, messageKey);
45965
- return libsodium_wrappers_default.to_string(plaintextBytes);
45966
- } catch {
45967
- throw new Error("Decryption failed: ciphertext tampered or wrong key");
45968
- }
45989
+ chain.chainKey = nextChainKey;
45990
+ chain.messageNumber++;
45991
+ return libsodium_wrappers_default.to_string(plaintextBytes);
45969
45992
  }
45970
45993
  dhRatchetReceive(theirDhPublic) {
45971
45994
  this.state.previousSendingChainLength = this.state.sendingChain?.messageNumber ?? 0;
@@ -63751,6 +63774,39 @@ var init_mls_group = __esm2({
63751
63774
  const s2 = this._requireState();
63752
63775
  return s2.groupContext.groupId;
63753
63776
  }
63777
+ /**
63778
+ * Proof that this client actually holds the group's keys at its current
63779
+ * epoch. Throws if not initialized.
63780
+ *
63781
+ * RFC 9420 §8.2's `epoch_authenticator`, derived from the epoch secret. Two
63782
+ * properties make it the thing this codebase has been missing:
63783
+ *
63784
+ * * every member at the same epoch computes the SAME value, so it is
63785
+ * comparable across clients and can be checked by a server;
63786
+ * * it cannot be computed without the key schedule, so possessing it is
63787
+ * evidence of membership rather than a claim about it. RFC 9420 names
63788
+ * this as its purpose: "to authenticate the shared group state".
63789
+ *
63790
+ * It is safe to publish. It is a KDF output over the epoch secret, not the
63791
+ * secret, and the RFC intends it to be shown to out-of-band verifiers.
63792
+ *
63793
+ * WHY THIS EXISTS. `mls_groups.member_device_ids` records membership; the
63794
+ * ability to decrypt lives only in one browser's localStorage. Nothing
63795
+ * compared the two, and nothing could — so a device in the roster holding no
63796
+ * keys was indistinguishable from a healthy one, on every signal the server
63797
+ * has. Measured on production 2026-08-26: one owner device carried the keys
63798
+ * for 19 of the 21 owner leaves on the platform, and one 1:1 group had been
63799
+ * unreadable by its owner's browser for two months while every status column
63800
+ * read healthy.
63801
+ *
63802
+ * `epoch` alone cannot do this job: it is a number the client reports about
63803
+ * itself, it collides across groups, and a client that has lost its state
63804
+ * still remembers one.
63805
+ */
63806
+ get epochAuthenticator() {
63807
+ const s2 = this._requireState();
63808
+ return s2.keySchedule.epochAuthenticator;
63809
+ }
63754
63810
  /** The cipher suite name in use. */
63755
63811
  get ciphersuiteName() {
63756
63812
  return this._ciphersuiteName;
@@ -63901,11 +63957,84 @@ var init_mls_group = __esm2({
63901
63957
  return { commit: commitBytes, welcome: welcomeBytes };
63902
63958
  }
63903
63959
  /**
63904
- * Remove a member from the group by leaf index.
63960
+ * Admit N members with TWO commits total, replacing any stale leaf they hold.
63961
+ *
63962
+ * `addMembers` cannot do this alone: MLS rejects an Add for an identity
63963
+ * already in the tree, so a member that lost its local state must be removed
63964
+ * first. Doing that pair PER MEMBER is what the fulfiller did, and it is not
63965
+ * merely slow — it is self-defeating.
63966
+ *
63967
+ * Live 2026-08-28 14:02:21-28Z: the owner sweep re-admitted 7 room members
63968
+ * sequentially, each as its own removeMember + addMembers. Room 007 ran
63969
+ * **epoch 71 -> 85 in seven seconds**, and the Welcome minted for member n
63970
+ * named an epoch that member n+1's commit had already replaced — so six of
63971
+ * seven were stale before they finished joining, and one (`smartcode`) could
63972
+ * not replay forward afterwards at all.
63973
+ *
63974
+ * 🎯 A REPAIR THAT MOVES THE EPOCH ONCE PER MEMBER IS RACING ITSELF.
63905
63975
  *
63906
- * @param memberIndex - The leaf index of the member to remove.
63907
- * @returns The commit message to broadcast.
63976
+ * ⚠️ **Why two commits and not one.** RFC 9420 §12.3 allows Remove and Add in
63977
+ * a single commit, and ts-mls builds it happily — but with MORE THAN ONE of
63978
+ * each, a REMAINING member fails to process it:
63979
+ *
63980
+ * OpenError at applyUpdatePathSecret (ts-mls/src/createCommit.ts:471)
63981
+ * at updatePrivateKeyPath (processMessages.ts:397)
63982
+ *
63983
+ * One remove + one add in a single commit is fine; 2+2 is not. That is
63984
+ * pinned by a test in `mls-group.test.ts` so this cannot be "optimised" back
63985
+ * into one commit without the failure showing up first. Two commits still
63986
+ * turns 7 members from 14 epochs into 2, and every joiner lands current.
63987
+ *
63988
+ * @param keyPackages - Fresh KeyPackages, one per member to admit. Members
63989
+ * already holding a leaf are removed by the first commit; members with no
63990
+ * leaf are plain adds. Duplicate identities keep the first KeyPackage.
63991
+ * @returns Every commit to broadcast IN ORDER, and the Welcome to deliver to
63992
+ * EVERY member in the batch (each finds its own secrets inside it).
63908
63993
  */
63994
+ async reAdmitMembers(keyPackages) {
63995
+ this._requireState();
63996
+ await this._ensureCiphersuite();
63997
+ const seenIdentities = [];
63998
+ const toAdd = [];
63999
+ const leavesToRemove = [];
64000
+ for (const kp of keyPackages) {
64001
+ const identity = keyPackageIdentity(kp);
64002
+ if (identity !== null) {
64003
+ if (seenIdentities.some((id) => bytesEqual(id, identity)))
64004
+ continue;
64005
+ seenIdentities.push(identity);
64006
+ const leaf = this.memberLeafIndex(identity);
64007
+ if (leaf !== null)
64008
+ leavesToRemove.push(leaf);
64009
+ }
64010
+ toAdd.push(kp);
64011
+ }
64012
+ if (toAdd.length === 0) {
64013
+ throw new Error("reAdmitMembers: no members to admit");
64014
+ }
64015
+ const commits = [];
64016
+ if (leavesToRemove.length > 0) {
64017
+ const { commit } = await this.removeMembers(leavesToRemove);
64018
+ commits.push({ commit, epoch: this.epoch });
64019
+ }
64020
+ const added = await this.addMembers(toAdd);
64021
+ commits.push({ commit: added.commit, epoch: this.epoch });
64022
+ return { commits, welcome: added.welcome };
64023
+ }
64024
+ async removeMembers(memberIndices) {
64025
+ const s2 = this._requireState();
64026
+ const cs = await this._ensureCiphersuite();
64027
+ if (memberIndices.length === 0) {
64028
+ throw new Error("removeMembers: no members to remove");
64029
+ }
64030
+ const extraProposals = memberIndices.map((removed) => ({
64031
+ proposalType: "remove",
64032
+ remove: { removed }
64033
+ }));
64034
+ const result = await createCommit({ state: s2, cipherSuite: cs, pskIndex: this._pskIndex }, { extraProposals });
64035
+ this.state = result.newState;
64036
+ return { commit: encodeMlsMessage(result.commit) };
64037
+ }
63909
64038
  async removeMember(memberIndex) {
63910
64039
  const s2 = this._requireState();
63911
64040
  const cs = await this._ensureCiphersuite();
@@ -64139,6 +64268,23 @@ var init_mls_delivery_order = __esm2({
64139
64268
  "use strict";
64140
64269
  }
64141
64270
  });
64271
+ function noteObservedEpoch(current, seen) {
64272
+ const n2 = Number(seen);
64273
+ if (!Number.isInteger(n2) || n2 < 0)
64274
+ return current;
64275
+ return n2 > current ? n2 : current;
64276
+ }
64277
+ function needsPreSendResync(localEpoch, observedEpoch) {
64278
+ if (!Number.isInteger(localEpoch) || !Number.isInteger(observedEpoch)) {
64279
+ return false;
64280
+ }
64281
+ return observedEpoch > localEpoch;
64282
+ }
64283
+ var init_mls_presend_epoch = __esm2({
64284
+ "../crypto/dist/mls-presend-epoch.js"() {
64285
+ "use strict";
64286
+ }
64287
+ });
64142
64288
  var dist_exports = {};
64143
64289
  __export2(dist_exports, {
64144
64290
  AV_CREDENTIAL_CONTEXT: () => AV_CREDENTIAL_CONTEXT,
@@ -64208,7 +64354,9 @@ __export2(dist_exports, {
64208
64354
  hexTransportToEncryptedMessage: () => hexTransportToEncryptedMessage,
64209
64355
  issueCredential: () => issueCredential,
64210
64356
  multibaseToPublicKey: () => multibaseToPublicKey,
64357
+ needsPreSendResync: () => needsPreSendResync,
64211
64358
  normalizeBackupCode: () => normalizeBackupCode,
64359
+ noteObservedEpoch: () => noteObservedEpoch,
64212
64360
  orderDeliveryBatch: () => orderDeliveryBatch,
64213
64361
  parseTraceparent: () => parseTraceparent,
64214
64362
  performX3DH: () => performX3DH,
@@ -64248,6 +64396,7 @@ var init_dist = __esm2({
64248
64396
  init_mls_group();
64249
64397
  await init_owner_sync();
64250
64398
  init_mls_delivery_order();
64399
+ init_mls_presend_epoch();
64251
64400
  }
64252
64401
  });
64253
64402
  async function ensureSecureDir(dir) {
@@ -64440,7 +64589,7 @@ var init_mls_kp_pool = __esm2({
64440
64589
  }
64441
64590
  });
64442
64591
  function ownIdentity() {
64443
- const v2 = true ? "0.23.23" : FALLBACK;
64592
+ const v2 = true ? "0.23.29" : FALLBACK;
64444
64593
  return `${PACKAGE}@${v2}`;
64445
64594
  }
64446
64595
  function buildClientVersion(override) {
@@ -65352,10 +65501,12 @@ var SecureChannel;
65352
65501
  var init_channel = __esm2({
65353
65502
  async "src/channel.ts"() {
65354
65503
  "use strict";
65504
+ init_abstention();
65355
65505
  init_mls_commit_classify();
65356
65506
  await init_libsodium_wrappers();
65357
65507
  await init_dist();
65358
65508
  await init_dist();
65509
+ init_mls_presend_epoch();
65359
65510
  init_mls_state();
65360
65511
  await init_mls_kp_pool();
65361
65512
  init_client_version();
@@ -65469,6 +65620,17 @@ var init_channel = __esm2({
65469
65620
  * self-heal rather than be logged and dropped. */
65470
65621
  _mlsCommitFailCounts = /* @__PURE__ */ new Map();
65471
65622
  static MAX_MLS_DECRYPT_FAILS = 3;
65623
+ /**
65624
+ * #1084: consecutive resyncs that found NOTHING to apply, per group.
65625
+ *
65626
+ * "No new commits" means the server holds no commit past our epoch — we are
65627
+ * NOT BEHIND. Treating that as corruption and re-keying is what drove the
65628
+ * epoch churn (see `_resyncOnDivergence`). But a state that is genuinely
65629
+ * broken while its epoch counter happens to match must still recover, so the
65630
+ * suppression is bounded rather than absolute.
65631
+ */
65632
+ _resyncNoOpStrikes = /* @__PURE__ */ new Map();
65633
+ static MAX_RESYNC_NOOPS = 3;
65472
65634
  /** Cached MLS KeyPackage bundle for this device (regenerated on each connect). */
65473
65635
  _mlsKeyPackage = null;
65474
65636
  /** Pending KeyPackage bundle from request-Welcome flow (used by _handleMlsWelcome). */
@@ -65506,6 +65668,13 @@ var init_channel = __esm2({
65506
65668
  _kpPoolFilling = false;
65507
65669
  /** Buffer for MLS commits received before Welcome (keyed by groupId, sorted by epoch). */
65508
65670
  _pendingMlsCommits = /* @__PURE__ */ new Map();
65671
+ /**
65672
+ * #1013: the highest group epoch any inbound frame has reported, per MLS
65673
+ * group id. Every inbound frame carries the epoch the group was at when it
65674
+ * was produced, so each is a LOWER BOUND on the group's current epoch — the
65675
+ * hint is monotonic and only ever rises.
65676
+ */
65677
+ _observedGroupEpochs = /* @__PURE__ */ new Map();
65509
65678
  /** In-memory credential store for renter-provided credentials (never persisted). */
65510
65679
  _credentialStore = new CredentialStore();
65511
65680
  /** Rooms whose roster has been refreshed from the backend at least once in
@@ -65762,6 +65931,18 @@ var init_channel = __esm2({
65762
65931
  get ownHubAddress() {
65763
65932
  return this._persisted?.hubAddress ?? "";
65764
65933
  }
65934
+ /** This agent's AgentVault hub_id. "" until enrolment persists it.
65935
+ *
65936
+ * Lives in the credential store, NOT in the channel config — which is where
65937
+ * the memory-recall helper was reading it from. `channel.config.hubId` is
65938
+ * unset for every agent enrolled through the normal flow, so
65939
+ * `channel.config.hubId || ""` produced `/api/v1/agents//memory/search`, a
65940
+ * 404 on every inbound message, swallowed by `if (!res.ok) return ""`.
65941
+ * Memory recall was silently dead while the hub_id sat right here.
65942
+ */
65943
+ get ownHubId() {
65944
+ return this._persisted?.hubId ?? "";
65945
+ }
65765
65946
  /** Public best-effort trigger to (re)resolve this agent's display_name.
65766
65947
  * Called lazily from the 1:1 DM path when the name is still unknown, so a
65767
65948
  * resolve that failed at connect (or a fresh agent that hadn't registered
@@ -66150,6 +66331,7 @@ var init_channel = __esm2({
66150
66331
  * Each session gets the same plaintext encrypted independently.
66151
66332
  */
66152
66333
  async send(plaintext, options) {
66334
+ if (isAbstention(plaintext)) return;
66153
66335
  if (this._state === "error" || this._state === "idle") {
66154
66336
  throw new Error("Channel is not ready");
66155
66337
  }
@@ -66190,16 +66372,44 @@ var init_channel = __esm2({
66190
66372
  const pendingWsSends = [];
66191
66373
  const sentSharedGroupIds = /* @__PURE__ */ new Set();
66192
66374
  const addressedMlsGroupIds = [];
66375
+ if (!(this._persisted?.mlsGroups && this._state === "ready" && this._ws)) {
66376
+ console.warn(
66377
+ `[SecureChannel] send(): shared-MLS block SKIPPED \u2014 mlsGroups=${this._persisted?.mlsGroups ? Object.keys(this._persisted.mlsGroups).length : "none"} state=${this._state} ws=${this._ws ? "open" : "null"}`
66378
+ );
66379
+ }
66193
66380
  if (this._persisted?.mlsGroups && this._state === "ready" && this._ws) {
66194
66381
  const targetSharedGid = targetConvId ? this._sessionGroupIds?.get(targetConvId) : void 0;
66382
+ console.log(
66383
+ `[SecureChannel] send(): considering ${Object.keys(this._persisted.mlsGroups).length} shared group(s); targetConvId=${targetConvId ? targetConvId.slice(0, 8) : "(none \u2014 broadcast)"} targetSharedGid=${targetSharedGid ? targetSharedGid.slice(0, 8) : "(none)"}`
66384
+ );
66385
+ const skipped = [];
66195
66386
  for (const [gid, entry] of Object.entries(this._persisted.mlsGroups)) {
66196
- if (targetConvId && gid !== targetSharedGid) continue;
66197
- if (!entry.mlsGroupId) continue;
66387
+ if (targetConvId && gid !== targetSharedGid) {
66388
+ skipped.push(`${gid.slice(0, 8)}:not-target`);
66389
+ continue;
66390
+ }
66391
+ if (!entry.mlsGroupId) {
66392
+ skipped.push(`${gid.slice(0, 8)}:no-mlsGroupId`);
66393
+ continue;
66394
+ }
66198
66395
  const mlsGroup = this._mlsGroups.get(`1to1-group:${gid}`);
66199
- if (!mlsGroup?.isInitialized || Number(mlsGroup.epoch) <= 0) continue;
66396
+ if (!mlsGroup?.isInitialized) {
66397
+ skipped.push(`${gid.slice(0, 8)}:not-in-memory`);
66398
+ continue;
66399
+ }
66400
+ if (Number(mlsGroup.epoch) <= 0) {
66401
+ skipped.push(`${gid.slice(0, 8)}:epoch<=0`);
66402
+ continue;
66403
+ }
66200
66404
  try {
66201
66405
  const plaintextBytes = new TextEncoder().encode(plaintext);
66202
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66406
+ const cipherBytes = await this._encryptWithCatchUp(
66407
+ mlsGroup,
66408
+ `1to1-group:${gid}`,
66409
+ entry.mlsGroupId,
66410
+ `1:1 group ${gid.slice(0, 8)}`,
66411
+ plaintextBytes
66412
+ );
66203
66413
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66204
66414
  const payload = {
66205
66415
  group_id: entry.mlsGroupId,
@@ -66221,8 +66431,12 @@ var init_channel = __esm2({
66221
66431
  console.log(`[SecureChannel] Shared MLS group send for group ${gid.slice(0, 8)} (${entry.mlsGroupId.slice(0, 8)})`);
66222
66432
  } catch (err) {
66223
66433
  console.error(`[SecureChannel] Shared MLS group send failed for ${gid.slice(0, 8)}:`, err);
66434
+ skipped.push(`${gid.slice(0, 8)}:encrypt-threw`);
66224
66435
  }
66225
66436
  }
66437
+ if (skipped.length > 0) {
66438
+ console.log(`[SecureChannel] send(): skipped ${skipped.length} group(s) \u2014 ${skipped.join(", ")}`);
66439
+ }
66226
66440
  }
66227
66441
  for (const [convId, session] of this._sessions) {
66228
66442
  if (!session.activated) continue;
@@ -66242,7 +66456,14 @@ var init_channel = __esm2({
66242
66456
  }
66243
66457
  if (mlsGroup?.isInitialized && mlsGroupId && Number(mlsGroup.epoch) > 0 && this._state === "ready" && this._ws) {
66244
66458
  const plaintextBytes = new TextEncoder().encode(plaintext);
66245
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66459
+ const sessionGroupKey = convGroupId && this._mlsGroups.has(`1to1-group:${convGroupId}`) ? `1to1-group:${convGroupId}` : `conv:${convId}`;
66460
+ const cipherBytes = await this._encryptWithCatchUp(
66461
+ mlsGroup,
66462
+ sessionGroupKey,
66463
+ mlsGroupId,
66464
+ `conversation ${convId.slice(0, 8)}`,
66465
+ plaintextBytes
66466
+ );
66246
66467
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66247
66468
  const payload = {
66248
66469
  conversation_id: convId,
@@ -66337,7 +66558,14 @@ var init_channel = __esm2({
66337
66558
  }
66338
66559
  try {
66339
66560
  const plaintextBytes = new TextEncoder().encode(plaintext);
66340
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66561
+ const mlsOnlyGroupKey = mlsOnlyConvGroupId && this._mlsGroups.has(`1to1-group:${mlsOnlyConvGroupId}`) ? `1to1-group:${mlsOnlyConvGroupId}` : `conv:${mlsConvId}`;
66562
+ const cipherBytes = await this._encryptWithCatchUp(
66563
+ mlsGroup,
66564
+ mlsOnlyGroupKey,
66565
+ resolvedMlsGroupId,
66566
+ `conversation ${mlsConvId.slice(0, 8)}`,
66567
+ plaintextBytes
66568
+ );
66341
66569
  await saveMlsState(this.config.dataDir, resolvedMlsGroupId, JSON.stringify(mlsGroup.exportState()));
66342
66570
  const payload = {
66343
66571
  conversation_id: mlsConvId,
@@ -66363,13 +66591,26 @@ var init_channel = __esm2({
66363
66591
  }
66364
66592
  }
66365
66593
  }
66366
- if (sentCount === 0 && this._sessions.size > 0) {
66367
- console.warn("[SecureChannel] send() delivered to 0 sessions (all skipped or failed)");
66594
+ if (sentCount === 0) {
66595
+ console.warn(
66596
+ `[SecureChannel] send() delivered to 0 destinations \u2014 nothing was encrypted or queued (sessions=${this._sessions.size}, sharedGroups=${this._persisted?.mlsGroups ? Object.keys(this._persisted.mlsGroups).length : 0})`
66597
+ );
66368
66598
  }
66369
66599
  await this._persistState();
66600
+ const wsReady = this._ws?.readyState === 1;
66601
+ if (pendingWsSends.length > 0 && !wsReady) {
66602
+ console.warn(
66603
+ `[SecureChannel] send(): ${pendingWsSends.length} frame(s) NOT written \u2014 socket readyState=${this._ws?.readyState ?? "null"} (1=OPEN)`
66604
+ );
66605
+ }
66370
66606
  for (const frame of pendingWsSends) {
66371
66607
  this._ws.send(frame);
66372
66608
  }
66609
+ if (pendingWsSends.length > 0) {
66610
+ console.log(
66611
+ `[SecureChannel] send(): wrote ${pendingWsSends.length} frame(s) to the socket (sentCount=${sentCount}, readyState=${this._ws?.readyState ?? "null"})`
66612
+ );
66613
+ }
66373
66614
  if (!options?.isResend) {
66374
66615
  for (const mlsGroupId of addressedMlsGroupIds) {
66375
66616
  this._rememberRetryCandidate(mlsGroupId, plaintext, options);
@@ -66419,7 +66660,7 @@ var init_channel = __esm2({
66419
66660
  */
66420
66661
  sendActivitySpan(spanData) {
66421
66662
  if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
66422
- const pluginVersion = true ? "0.23.23" : "0.0.0-dev";
66663
+ const pluginVersion = true ? "0.23.29" : "0.0.0-dev";
66423
66664
  const agentName = this.config.agentName ?? "Agent";
66424
66665
  const resource = {
66425
66666
  "service.name": "agentvault-agent",
@@ -66613,6 +66854,7 @@ var init_channel = __esm2({
66613
66854
  * Uses MLS (preferred) or pairwise fan-out as fallback.
66614
66855
  */
66615
66856
  async sendToRoom(roomId, plaintext, opts) {
66857
+ if (isAbstention(plaintext)) return;
66616
66858
  if (!this._persisted?.rooms?.[roomId]) {
66617
66859
  throw new Error(`Room ${roomId} not found`);
66618
66860
  }
@@ -66629,7 +66871,13 @@ var init_channel = __esm2({
66629
66871
  if (mlsGroup?.isInitialized) {
66630
66872
  try {
66631
66873
  const plaintextBytes = new TextEncoder().encode(plaintext);
66632
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
66874
+ const ciphertext = await this._encryptWithCatchUp(
66875
+ mlsGroup,
66876
+ roomId,
66877
+ room.mlsGroupId,
66878
+ `room ${roomId.slice(0, 8)}`,
66879
+ plaintextBytes
66880
+ );
66633
66881
  await saveMlsState(this.config.dataDir, room.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66634
66882
  if (this._state === "ready" && this._ws) {
66635
66883
  this._ws.send(JSON.stringify({
@@ -66880,11 +67128,17 @@ var init_channel = __esm2({
66880
67128
  const before = this._persisted.groupId;
66881
67129
  this._sessionGroupIds = new Map(mine.map((r2) => [r2.id, r2.group_id]));
66882
67130
  this._persisted.conversationGroupIds = Object.fromEntries(this._sessionGroupIds);
66883
- const primary = mine.find((r2) => r2.id === this._persisted.primaryConversationId) ?? mine[0];
67131
+ const joined = mine.find((r2) => this._persisted.mlsGroups?.[r2.group_id]);
67132
+ const primary = mine.find((r2) => r2.id === this._persisted.primaryConversationId) ?? joined ?? mine[0];
67133
+ if (!mine.some((r2) => r2.id === this._persisted.primaryConversationId)) {
67134
+ console.log(
67135
+ `[SecureChannel] Adopting new primary conversation ${primary.id.slice(0, 8)} (group ${primary.group_id.slice(0, 8)}) \u2014 ${joined ? "we hold MLS state for it" : "no joined conversation found, first active row"}`
67136
+ );
67137
+ }
66884
67138
  this._persisted.groupId = primary.group_id;
66885
67139
  this._persisted.primaryConversationId = primary.id;
66886
67140
  const liveGroupIds = new Set(mine.map((r2) => r2.group_id));
66887
- if (!liveGroupIds.has(staleGid)) {
67141
+ if (staleGid && staleMlsGroupId && !liveGroupIds.has(staleGid)) {
66888
67142
  try {
66889
67143
  await deleteMlsState(this.config.dataDir, staleMlsGroupId);
66890
67144
  } catch {
@@ -66898,9 +67152,9 @@ var init_channel = __esm2({
66898
67152
  }
66899
67153
  await this._persistState();
66900
67154
  console.log(
66901
- `[SecureChannel] Re-resolved conversation groups after 'unknown group' ${staleMlsGroupId.slice(0, 8)}: primary group ${String(before).slice(0, 8)} \u2192 ${this._persisted.groupId.slice(0, 8)} (${mine.length} active conversation(s))`
67155
+ `[SecureChannel] Re-resolved conversation groups ${staleMlsGroupId ? `after 'unknown group' ${staleMlsGroupId.slice(0, 8)}` : "on connect (#1034)"}: primary group ${String(before).slice(0, 8)} \u2192 ${this._persisted.groupId.slice(0, 8)} (${mine.length} active conversation(s))`
66902
67156
  );
66903
- return this._persisted.groupId !== before && before === staleGid;
67157
+ return staleGid !== void 0 && this._persisted.groupId !== before && before === staleGid;
66904
67158
  }
66905
67159
  /**
66906
67160
  * Resend the message that a now-reconciled `unknown group` refusal killed (#732).
@@ -67808,7 +68062,13 @@ var init_channel = __esm2({
67808
68062
  const mlsGroup = this._mlsGroups.get(`a2a:${channelEntry.channelId}`);
67809
68063
  if (mlsGroup?.isInitialized) {
67810
68064
  const plaintextBytes = new TextEncoder().encode(text);
67811
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
68065
+ const ciphertext = await this._encryptWithCatchUp(
68066
+ mlsGroup,
68067
+ `a2a:${channelEntry.channelId}`,
68068
+ channelEntry.mlsGroupId,
68069
+ `A2A ${channelEntry.channelId.slice(0, 8)}`,
68070
+ plaintextBytes
68071
+ );
67812
68072
  await saveMlsState(
67813
68073
  this.config.dataDir,
67814
68074
  channelEntry.mlsGroupId,
@@ -67856,7 +68116,13 @@ var init_channel = __esm2({
67856
68116
  const mlsGroup = this._mlsGroups.get(`a2a:${channelId}`);
67857
68117
  if (mlsGroup?.isInitialized) {
67858
68118
  const plaintextBytes = new TextEncoder().encode(text);
67859
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
68119
+ const ciphertext = await this._encryptWithCatchUp(
68120
+ mlsGroup,
68121
+ `a2a:${channelId}`,
68122
+ entry.mlsGroupId,
68123
+ `A2A ${channelId.slice(0, 8)}`,
68124
+ plaintextBytes
68125
+ );
67860
68126
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
67861
68127
  this._ws.send(JSON.stringify({
67862
68128
  event: "a2a_message_mls",
@@ -68302,6 +68568,9 @@ var init_channel = __esm2({
68302
68568
  await this._pullDrDeliveryQueue();
68303
68569
  await this._flushOutboundQueue();
68304
68570
  this._setState("ready");
68571
+ void this._reconcileConversationGroups().catch((err) => {
68572
+ console.warn("[SecureChannel] Conversation-group reconcile failed:", err);
68573
+ });
68305
68574
  void this._reconcileRoomsWithServer().catch(
68306
68575
  (err) => console.warn(`[SecureChannel] room reconcile failed (ignored): ${err instanceof Error ? err.message : String(err)}`)
68307
68576
  ).then(() => this._quarantineOrphanedMlsGroups()).catch(
@@ -68329,7 +68598,7 @@ var init_channel = __esm2({
68329
68598
  agentVersion: this.config.agentVersion ?? "0.0.0",
68330
68599
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68331
68600
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68332
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68601
+ pluginVersion: true ? "0.23.29" : "0.0.0-dev"
68333
68602
  });
68334
68603
  this._telemetryReporter.startAutoFlush(3e4);
68335
68604
  }
@@ -68653,7 +68922,7 @@ var init_channel = __esm2({
68653
68922
  agentVersion: this.config.agentVersion ?? "0.0.0",
68654
68923
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68655
68924
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68656
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68925
+ pluginVersion: true ? "0.23.29" : "0.0.0-dev"
68657
68926
  });
68658
68927
  this._telemetryReporter.startAutoFlush(3e4);
68659
68928
  }
@@ -69019,6 +69288,7 @@ var init_channel = __esm2({
69019
69288
  const convGroupId = data.conversation_group_id;
69020
69289
  const groupId = data.group_id;
69021
69290
  const senderDeviceId = data.sender_device_id;
69291
+ this._noteGroupEpoch(groupId, data.epoch);
69022
69292
  if (senderDeviceId === this._deviceId) return;
69023
69293
  let mgr;
69024
69294
  let mgrKey;
@@ -69096,9 +69366,15 @@ var init_channel = __esm2({
69096
69366
  })),
69097
69367
  no_history: filtered.length === 0
69098
69368
  });
69099
- if (mgr && mlsGroupId) {
69369
+ if (mgr && mgrKey && mlsGroupId) {
69100
69370
  const responseBytes = new TextEncoder().encode(responsePayload);
69101
- const cipher = await mgr.encrypt(responseBytes);
69371
+ const cipher = await this._encryptWithCatchUp(
69372
+ mgr,
69373
+ mgrKey,
69374
+ mlsGroupId,
69375
+ `history catch-up ${mlsGroupId.slice(0, 8)}`,
69376
+ responseBytes
69377
+ );
69102
69378
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mgr.exportState()));
69103
69379
  if (this._ws) {
69104
69380
  this._ws.send(JSON.stringify({
@@ -69841,9 +70117,36 @@ ${messageText}`;
69841
70117
  // ---------------------------------------------------------------------------
69842
70118
  // MLS room message handlers
69843
70119
  // ---------------------------------------------------------------------------
70120
+ /**
70121
+ * #1014: hand a room-framed frame to the handler that owns its group family.
70122
+ *
70123
+ * Returns true when the frame was routed. Routing means "give it to the right
70124
+ * handler" — NOT "decrypt it as a room message". Decrypting under the wrong
70125
+ * group is how MLS state gets corrupted, so an unplaceable group is left for
70126
+ * the caller to self-heal rather than guessed at.
70127
+ */
70128
+ async _routeNonRoomGroupMessage(data, groupId) {
70129
+ const target = this._resolveGroupFamily(groupId);
70130
+ if (!target || target.family === "room") return false;
70131
+ console.log(
70132
+ `[SecureChannel] Room-framed message belongs to ${target.label} \u2014 routing to the handler that owns it (#1014)`
70133
+ );
70134
+ switch (target.family) {
70135
+ case "shared1to1":
70136
+ await this._handleMessageMLS({ ...data, conversation_group_id: target.id });
70137
+ return true;
70138
+ case "conversation":
70139
+ await this._handleMessageMLS({ ...data, conversation_id: target.id });
70140
+ return true;
70141
+ case "a2a":
70142
+ await this._handleA2AMessageMLS({ ...data, a2a_channel_id: target.id });
70143
+ return true;
70144
+ }
70145
+ }
69844
70146
  async _handleRoomMessageMLS(data) {
69845
70147
  const groupId = data.group_id;
69846
70148
  const senderDeviceId = data.sender_device_id;
70149
+ this._noteGroupEpoch(groupId, data.epoch);
69847
70150
  if (senderDeviceId === this._deviceId) return;
69848
70151
  let roomId;
69849
70152
  for (const [rid, room] of Object.entries(this._persisted?.rooms ?? {})) {
@@ -69857,7 +70160,9 @@ ${messageText}`;
69857
70160
  roomId = data.room_id;
69858
70161
  console.log(`[SecureChannel] Room ${roomId.slice(0, 8)} matched by room_id (group_id ${groupId?.slice(0, 8)} mismatch)`);
69859
70162
  } else {
70163
+ if (await this._routeNonRoomGroupMessage(data, groupId)) return;
69860
70164
  console.warn(`[SecureChannel] No room found for MLS group ${groupId?.slice(0, 8)}`);
70165
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
69861
70166
  return;
69862
70167
  }
69863
70168
  }
@@ -70045,63 +70350,63 @@ ${messageText}`;
70045
70350
  console.warn(`[SecureChannel] Failed to send history catchup response:`, sendErr);
70046
70351
  }
70047
70352
  }
70353
+ /**
70354
+ * Apply an inbound MLS commit to whichever group it addresses.
70355
+ *
70356
+ * Returns TRUE when the commit was applied, buffered, or handed to the
70357
+ * failure ladder — i.e. when this agent has taken responsibility for it and
70358
+ * the delivery-queue row may safely be acked. Returns FALSE when the commit
70359
+ * names a group we hold no state for at all, so the caller must NACK and let
70360
+ * the row survive.
70361
+ *
70362
+ * #1012: this used to loop `rooms` then `a2aChannels` and then END. A shared
70363
+ * 1:1 commit (`mlsGroups`) and a legacy per-conversation commit
70364
+ * (`mlsConversations`) matched neither. The call fell off the bottom with no
70365
+ * log, no buffer and no error — and because it did not THROW,
70366
+ * `_pullDeliveryQueue` acked the row and the server deleted it. An MLS commit
70367
+ * cannot be regenerated, so the agent stayed at epoch N while the group moved
70368
+ * to N+1 and every later send() encrypted at an epoch nobody could decrypt.
70369
+ *
70370
+ * The families are enumerated in ONE list resolved by ONE loop, because two
70371
+ * hand-copied loops are exactly how the 1:1 path fell two families behind.
70372
+ */
70048
70373
  async _handleMlsCommit(data) {
70049
70374
  const groupId = data.group_id;
70050
70375
  const epoch = typeof data.epoch === "number" ? data.epoch : 0;
70051
- for (const [roomId, room] of Object.entries(this._persisted?.rooms ?? {})) {
70052
- if (room.mlsGroupId === groupId) {
70053
- const mlsGroup = this._mlsGroups.get(roomId);
70054
- if (mlsGroup?.isInitialized) {
70055
- try {
70056
- const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
70057
- await mlsGroup.processCommit(commitBytes);
70058
- await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
70059
- console.log(`[SecureChannel] MLS commit processed for room ${roomId.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70060
- this._mlsCommitFailCounts.delete(roomId);
70061
- } catch (err) {
70062
- await this._onCommitFailure(
70063
- roomId,
70064
- groupId,
70065
- err,
70066
- `room ${roomId.slice(0, 8)}`,
70067
- data.epoch,
70068
- mlsGroup.epoch
70069
- );
70070
- }
70071
- } else {
70072
- this._bufferMlsCommit(groupId, epoch, data);
70073
- console.log(`[SecureChannel] Buffered MLS commit for room ${roomId.slice(0, 8)} (epoch=${epoch}, group not initialized)`);
70074
- }
70075
- return;
70376
+ this._noteGroupEpoch(groupId, data.epoch);
70377
+ const target = this._resolveGroupFamily(groupId);
70378
+ if (target) {
70379
+ const { managerKey: groupKey, label } = target;
70380
+ const mlsGroup = this._mlsGroups.get(groupKey);
70381
+ if (!mlsGroup?.isInitialized) {
70382
+ this._bufferMlsCommit(groupId, epoch, data);
70383
+ console.log(`[SecureChannel] Buffered MLS commit for ${label} (epoch=${epoch}, group not initialized)`);
70384
+ return true;
70076
70385
  }
70077
- }
70078
- for (const [chId, chState] of Object.entries(this._persisted?.a2aChannels ?? {})) {
70079
- if (chState.mlsGroupId === groupId) {
70080
- const mlsGroup = this._mlsGroups.get(`a2a:${chId}`);
70081
- if (mlsGroup?.isInitialized) {
70082
- try {
70083
- const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
70084
- await mlsGroup.processCommit(commitBytes);
70085
- await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
70086
- console.log(`[SecureChannel] MLS commit processed for A2A ${chId.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70087
- this._mlsCommitFailCounts.delete(`a2a:${chId}`);
70088
- } catch (err) {
70089
- await this._onCommitFailure(
70090
- `a2a:${chId}`,
70091
- groupId,
70092
- err,
70093
- `A2A ${chId.slice(0, 8)}`,
70094
- data.epoch,
70095
- mlsGroup.epoch
70096
- );
70097
- }
70098
- } else {
70099
- this._bufferMlsCommit(groupId, epoch, data);
70100
- console.log(`[SecureChannel] Buffered MLS commit for A2A ${chId.slice(0, 8)} (epoch=${epoch}, group not initialized)`);
70101
- }
70102
- return;
70386
+ try {
70387
+ const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
70388
+ await mlsGroup.processCommit(commitBytes);
70389
+ await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
70390
+ console.log(`[SecureChannel] MLS commit processed for ${label} (epoch=${mlsGroup.epoch})`);
70391
+ this._mlsCommitFailCounts.delete(groupKey);
70392
+ } catch (err) {
70393
+ await this._onCommitFailure(
70394
+ groupKey,
70395
+ groupId,
70396
+ err,
70397
+ label,
70398
+ data.epoch,
70399
+ mlsGroup.epoch
70400
+ );
70103
70401
  }
70402
+ return true;
70104
70403
  }
70404
+ this._bufferMlsCommit(groupId, epoch, data);
70405
+ console.warn(
70406
+ `[SecureChannel] MLS commit for UNKNOWN group ${String(groupId).slice(0, 8)} (epoch=${epoch}) \u2014 buffered, requesting a Welcome, NOT acked`
70407
+ );
70408
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
70409
+ return false;
70105
70410
  }
70106
70411
  /** Buffer an MLS commit for replay after Welcome join. Max 50 per group. */
70107
70412
  _bufferMlsCommit(groupId, epoch, data) {
@@ -70291,6 +70596,229 @@ ${messageText}`;
70291
70596
  * `1to1-group:<gid>`, `a2a:<channelId>`, …) — also the decrypt-fail-count key.
70292
70597
  * @param groupId the server-side MLS group id used in the /sync URL.
70293
70598
  */
70599
+ /**
70600
+ * #1015 — the ONLY place this package fulfils a Welcome request.
70601
+ *
70602
+ * Add the members, transmit, and record `fulfilled` **only for the targets
70603
+ * whose frames actually went out**. Every one of the five sites that used to
70604
+ * do this by hand followed the same sequence and had the same three holes:
70605
+ * `this._ws` null (both sends skipped by an `if`), a CLOSING socket (`send`
70606
+ * returns without throwing), and a fulfil POST wrapped in `.catch(() => {})`.
70607
+ *
70608
+ * A fulfilled request is never re-requested. So a Welcome that evaporated
70609
+ * left the requester locked out, the fulfiller at epoch N+1, everyone else at
70610
+ * N, and the bridging commit untransmitted and impossible to regenerate — a
70611
+ * permanently wedged group in which every signal read success.
70612
+ *
70613
+ * Returns the number of requests actually fulfilled.
70614
+ */
70615
+ async _fulfilWelcomeRequests(opts) {
70616
+ const { mlsGroup, mlsGroupId, label, welcomeFields, targets } = opts;
70617
+ if (targets.length === 0) return 0;
70618
+ if (!this._wsIsOpen()) {
70619
+ console.warn(
70620
+ `[SecureChannel] ${label}: socket is not OPEN \u2014 NOT advancing the group; ${targets.length} request(s) stay pending for the next pull`
70621
+ );
70622
+ return 0;
70623
+ }
70624
+ const snapshot = mlsGroup.exportState();
70625
+ const { commit, welcome } = await mlsGroup.addMembers(targets.map((t2) => t2.keyPackage));
70626
+ const commitSent = await this._sendFrameConfirmed(
70627
+ {
70628
+ event: "mls_commit",
70629
+ data: {
70630
+ group_id: mlsGroupId,
70631
+ epoch: Number(mlsGroup.epoch),
70632
+ payload: Buffer.from(commit).toString("hex")
70633
+ }
70634
+ },
70635
+ `${label} commit`
70636
+ );
70637
+ if (!commitSent) {
70638
+ mlsGroup.importState(snapshot);
70639
+ console.warn(
70640
+ `[SecureChannel] ${label}: commit was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing fulfilled, requests stay pending`
70641
+ );
70642
+ return 0;
70643
+ }
70644
+ await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70645
+ if (!welcome) {
70646
+ console.warn(
70647
+ `[SecureChannel] ${label}: addMembers produced NO Welcome \u2014 ${targets.length} request(s) left pending rather than marked fulfilled`
70648
+ );
70649
+ return 0;
70650
+ }
70651
+ let fulfilled = 0;
70652
+ for (const t2 of targets) {
70653
+ const sent = await this._sendFrameConfirmed(
70654
+ {
70655
+ event: "mls_welcome",
70656
+ data: {
70657
+ target_device_id: t2.deviceId,
70658
+ group_id: mlsGroupId,
70659
+ ...welcomeFields,
70660
+ payload: Buffer.from(welcome).toString("hex")
70661
+ }
70662
+ },
70663
+ `${label} welcome\u2192${t2.deviceId.slice(0, 8)}`
70664
+ );
70665
+ if (!sent) {
70666
+ console.warn(
70667
+ `[SecureChannel] ${label}: Welcome for ${t2.deviceId.slice(0, 8)} was NOT written \u2014 leaving the request PENDING so the next pull retries it`
70668
+ );
70669
+ continue;
70670
+ }
70671
+ if (await this._postFulfilWelcome(mlsGroupId, t2.requestId, `${label} ${t2.deviceId.slice(0, 8)}`)) {
70672
+ fulfilled++;
70673
+ }
70674
+ }
70675
+ return fulfilled;
70676
+ }
70677
+ /**
70678
+ * Resolve an MLS group id to the family that owns it.
70679
+ *
70680
+ * There are four: rooms, A2A channels, shared 1:1 groups, and the legacy
70681
+ * per-conversation groups. They all store the MLS group id under
70682
+ * `mlsGroupId`; they differ only in the key their live `MLSGroupManager` sits
70683
+ * under in `_mlsGroups`.
70684
+ *
70685
+ * ONE resolver, because hand-copying this enumeration is what produced
70686
+ * #1012 (`_handleMlsCommit` handled two families of four), #1014 (a
70687
+ * room-framed 1:1 replay matched nothing) and #1016 (a rejoin dropped only
70688
+ * the room manager). Three bugs, one missing abstraction.
70689
+ */
70690
+ _resolveGroupFamily(mlsGroupId) {
70691
+ if (!mlsGroupId) return null;
70692
+ const families = [
70693
+ { family: "room", entries: this._persisted?.rooms, managerKey: (id) => id, label: "room" },
70694
+ { family: "a2a", entries: this._persisted?.a2aChannels, managerKey: (id) => `a2a:${id}`, label: "A2A" },
70695
+ { family: "shared1to1", entries: this._persisted?.mlsGroups, managerKey: (id) => `1to1-group:${id}`, label: "1:1 group" },
70696
+ { family: "conversation", entries: this._persisted?.mlsConversations, managerKey: (id) => `conv:${id}`, label: "conversation" }
70697
+ ];
70698
+ for (const f2 of families) {
70699
+ for (const [id, entry] of Object.entries(f2.entries ?? {})) {
70700
+ if (entry?.mlsGroupId !== mlsGroupId) continue;
70701
+ return {
70702
+ family: f2.family,
70703
+ id,
70704
+ managerKey: f2.managerKey(id),
70705
+ label: `${f2.label} ${id.slice(0, 8)}`
70706
+ };
70707
+ }
70708
+ }
70709
+ return null;
70710
+ }
70711
+ /** #1015: is the socket in a state that can actually take a write? */
70712
+ _wsIsOpen() {
70713
+ return this._ws?.readyState === 1;
70714
+ }
70715
+ /**
70716
+ * #1015: write one frame and report whether the socket took it.
70717
+ *
70718
+ * `ws.send()` on a CLOSING or CLOSED socket returns WITHOUT throwing — the
70719
+ * error surfaces only through the callback, and every fulfilment site passed
70720
+ * none. So a Welcome could be "sent" into a dead socket and the requester
70721
+ * still marked `fulfilled`, which it never re-requests.
70722
+ */
70723
+ _sendFrameConfirmed(frame, label) {
70724
+ const ws = this._ws;
70725
+ if (!ws || ws.readyState !== 1) {
70726
+ console.warn(
70727
+ `[SecureChannel] ${label}: socket is not OPEN (readyState=${ws?.readyState ?? "null"}) \u2014 frame NOT written`
70728
+ );
70729
+ return Promise.resolve(false);
70730
+ }
70731
+ return new Promise((resolve3) => {
70732
+ try {
70733
+ ws.send(JSON.stringify(frame), (err) => {
70734
+ if (err) {
70735
+ console.error(`[SecureChannel] ${label}: socket refused the frame:`, err);
70736
+ resolve3(false);
70737
+ } else {
70738
+ resolve3(true);
70739
+ }
70740
+ });
70741
+ } catch (err) {
70742
+ console.error(`[SecureChannel] ${label}: send threw:`, err);
70743
+ resolve3(false);
70744
+ }
70745
+ });
70746
+ }
70747
+ /**
70748
+ * #1015: record a Welcome as fulfilled — and say so when it fails.
70749
+ *
70750
+ * The batched shared-1:1 site posted this with `.catch(() => {})`, so an HTTP
70751
+ * failure here was invisible on top of the sends being invisible.
70752
+ */
70753
+ async _postFulfilWelcome(mlsGroupId, requestId, label) {
70754
+ try {
70755
+ const res = await fetch(
70756
+ `${this.config.apiUrl}/api/v1/mls/groups/${mlsGroupId}/fulfill-welcome`,
70757
+ {
70758
+ method: "POST",
70759
+ headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70760
+ body: JSON.stringify({ request_id: requestId })
70761
+ }
70762
+ );
70763
+ if (!res.ok) {
70764
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome returned ${res.status}`);
70765
+ return false;
70766
+ }
70767
+ return true;
70768
+ } catch (err) {
70769
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome failed:`, err);
70770
+ return false;
70771
+ }
70772
+ }
70773
+ /**
70774
+ * #1013: fold an epoch seen on an inbound frame into the running hint.
70775
+ *
70776
+ * Anything unusable (missing, non-numeric, negative, fractional) is ignored
70777
+ * rather than defaulted. A defaulted epoch here would be a guess, and a guess
70778
+ * that reads LOW silently disables the check it exists to drive.
70779
+ */
70780
+ _noteGroupEpoch(mlsGroupId, epoch) {
70781
+ if (typeof mlsGroupId !== "string" || !mlsGroupId) return;
70782
+ const current = this._observedGroupEpochs.get(mlsGroupId) ?? 0;
70783
+ const next = noteObservedEpoch(current, epoch);
70784
+ if (next > current) this._observedGroupEpochs.set(mlsGroupId, next);
70785
+ }
70786
+ /**
70787
+ * #1013 — the ONLY place this package encrypts an MLS application message.
70788
+ *
70789
+ * Catch up first when the group is known to have moved past us, then encrypt.
70790
+ * Hermes has done this since #440 and the browser since #996; the plugin —
70791
+ * the implementation that actually ships to customers — did not, so an agent
70792
+ * that missed one commit encrypted every later message at an epoch nobody
70793
+ * could read, while the server stored and relayed it and every signal read
70794
+ * 200.
70795
+ *
70796
+ * Funnelling every sender through one method is the point. A guard applied at
70797
+ * seven call sites decays to a guard applied at six: that is exactly how
70798
+ * `_handleMlsCommit` ended up handling two group families out of four (#1012).
70799
+ *
70800
+ * Best-effort by design. A catch-up that cannot replay — or that throws — must
70801
+ * NOT block the send; we fall through and encrypt at the local epoch, which is
70802
+ * precisely today's behaviour, so the worst case is no regression.
70803
+ */
70804
+ async _encryptWithCatchUp(mlsGroup, groupKey, mlsGroupId, label, plaintextBytes) {
70805
+ const observed = this._observedGroupEpochs.get(mlsGroupId);
70806
+ if (observed !== void 0 && needsPreSendResync(Number(mlsGroup.epoch), observed)) {
70807
+ console.warn(
70808
+ `[SecureChannel] ${label}: local epoch ${Number(mlsGroup.epoch)} is behind the observed group epoch ${observed} \u2014 catching up before encrypt (#1013)`
70809
+ );
70810
+ try {
70811
+ if (!await this._resyncOnDivergence(groupKey, mlsGroupId)) {
70812
+ console.warn(
70813
+ `[SecureChannel] ${label}: catch-up could not replay \u2014 sending at epoch ${Number(mlsGroup.epoch)}. Peers past this epoch will not read it.`
70814
+ );
70815
+ }
70816
+ } catch (err) {
70817
+ console.warn(`[SecureChannel] ${label}: catch-up failed:`, err);
70818
+ }
70819
+ }
70820
+ return await mlsGroup.encrypt(plaintextBytes);
70821
+ }
70294
70822
  async _resyncOnDivergence(groupKey, groupId) {
70295
70823
  const mgr = this._mlsGroups.get(groupKey);
70296
70824
  if (!mgr?.isInitialized) return false;
@@ -70317,11 +70845,24 @@ ${messageText}`;
70317
70845
  applied++;
70318
70846
  }
70319
70847
  if (applied === 0) {
70320
- console.log(`[SecureChannel] Resync for ${groupId.slice(0, 8)}: no new commits to apply (epoch=${mgr.epoch})`);
70848
+ const strikes = (this._resyncNoOpStrikes.get(groupKey) ?? 0) + 1;
70849
+ this._resyncNoOpStrikes.set(groupKey, strikes);
70850
+ if (strikes < _SecureChannel.MAX_RESYNC_NOOPS) {
70851
+ this._mlsDecryptFailCounts.delete(groupKey);
70852
+ console.log(
70853
+ `[SecureChannel] Resync for ${groupId.slice(0, 8)}: already current at epoch=${mgr.epoch} (strike ${strikes}/${_SecureChannel.MAX_RESYNC_NOOPS}) \u2014 NOT re-keying (#1084)`
70854
+ );
70855
+ return true;
70856
+ }
70857
+ this._resyncNoOpStrikes.delete(groupKey);
70858
+ console.warn(
70859
+ `[SecureChannel] Resync for ${groupId.slice(0, 8)}: still failing at epoch=${mgr.epoch} after ${_SecureChannel.MAX_RESYNC_NOOPS} no-op resyncs \u2014 escalating to re-key`
70860
+ );
70321
70861
  return false;
70322
70862
  }
70323
70863
  await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mgr.exportState()));
70324
70864
  this._mlsDecryptFailCounts.delete(groupKey);
70865
+ this._resyncNoOpStrikes.delete(groupKey);
70325
70866
  console.log(
70326
70867
  `[SecureChannel] Resync-on-divergence: applied ${applied} missed commit(s) for ${groupId.slice(0, 8)}, now epoch=${mgr.epoch}`
70327
70868
  );
@@ -70708,6 +71249,7 @@ ${messageText}`;
70708
71249
  const ackedIds = [];
70709
71250
  const nackedIds = [];
70710
71251
  for (const msg of messages) {
71252
+ let handled = true;
70711
71253
  try {
70712
71254
  if (msg.sender_device_id === this._deviceId) {
70713
71255
  ackedIds.push(msg.queue_id);
@@ -70724,7 +71266,7 @@ ${messageText}`;
70724
71266
  a2a_channel_id: msg.a2a_channel_id
70725
71267
  });
70726
71268
  } else if (msg.message_type === "commit") {
70727
- await this._handleMlsCommit({
71269
+ handled = await this._handleMlsCommit({
70728
71270
  group_id: msg.group_id,
70729
71271
  sender_device_id: msg.sender_device_id,
70730
71272
  epoch: msg.epoch,
@@ -70762,9 +71304,18 @@ ${messageText}`;
70762
71304
  conversation_group_id: msg.conversation_group_id,
70763
71305
  created_at: msg.created_at
70764
71306
  });
71307
+ } else {
71308
+ handled = false;
71309
+ console.warn(
71310
+ `[SecureChannel] Delivery application row ${String(msg.message_id).slice(0, 8)} names no room, A2A channel or conversation \u2014 NOT acked`
71311
+ );
70765
71312
  }
70766
71313
  }
70767
- ackedIds.push(msg.queue_id);
71314
+ if (handled) {
71315
+ ackedIds.push(msg.queue_id);
71316
+ } else {
71317
+ nackedIds.push(msg.queue_id);
71318
+ }
70768
71319
  } catch (err) {
70769
71320
  console.warn(`[SecureChannel] Delivery ${msg.message_type} processing failed:`, err);
70770
71321
  nackedIds.push(msg.queue_id);
@@ -70833,38 +71384,20 @@ ${messageText}`;
70833
71384
  if (!kpHex) continue;
70834
71385
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
70835
71386
  const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
70836
- const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
70837
- if (this._ws) {
70838
- this._ws.send(JSON.stringify({
70839
- event: "mls_commit",
70840
- data: {
70841
- group_id: roomState.mlsGroupId,
70842
- epoch: Number(mlsGroup.epoch),
70843
- payload: Buffer.from(commit).toString("hex")
70844
- }
70845
- }));
70846
- }
70847
- if (welcome && this._ws) {
70848
- this._ws.send(JSON.stringify({
70849
- event: "mls_welcome",
70850
- data: {
70851
- target_device_id: req.requesting_device_id,
70852
- group_id: roomState.mlsGroupId,
70853
- room_id: roomId,
70854
- payload: Buffer.from(welcome).toString("hex")
70855
- }
70856
- }));
71387
+ const done = await this._fulfilWelcomeRequests({
71388
+ mlsGroup,
71389
+ mlsGroupId: roomState.mlsGroupId,
71390
+ label: `room ${roomId.slice(0, 8)}`,
71391
+ welcomeFields: { room_id: roomId },
71392
+ targets: [{
71393
+ requestId: req.id,
71394
+ deviceId: req.requesting_device_id,
71395
+ keyPackage: memberKp
71396
+ }]
71397
+ });
71398
+ if (done > 0) {
71399
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in room ${roomId.slice(0, 8)}`);
70857
71400
  }
70858
- await fetch(
70859
- `${this.config.apiUrl}/api/v1/mls/groups/${roomState.mlsGroupId}/fulfill-welcome`,
70860
- {
70861
- method: "POST",
70862
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70863
- body: JSON.stringify({ request_id: req.id })
70864
- }
70865
- );
70866
- await saveMlsState(this.config.dataDir, roomState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70867
- console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in room ${roomId.slice(0, 8)}`);
70868
71401
  } catch (fulfillErr) {
70869
71402
  console.warn(`[SecureChannel] Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
70870
71403
  }
@@ -70903,65 +71436,38 @@ ${messageText}`;
70903
71436
  validReqs.push({ req, kp: MLSGroupManager.deserializeKeyPackage(kpBytes) });
70904
71437
  }
70905
71438
  if (validReqs.length === 0) continue;
71439
+ const sharedTargets = validReqs.map((v2) => ({
71440
+ requestId: v2.req.id,
71441
+ deviceId: v2.req.requesting_device_id,
71442
+ keyPackage: v2.kp
71443
+ }));
70906
71444
  try {
70907
- const { commit, welcome } = await mlsGroup.addMembers(validReqs.map((v2) => v2.kp));
70908
- if (this._ws) {
70909
- this._ws.send(JSON.stringify({
70910
- event: "mls_commit",
70911
- data: {
70912
- group_id: entry.mlsGroupId,
70913
- epoch: Number(mlsGroup.epoch),
70914
- payload: Buffer.from(commit).toString("hex")
70915
- }
70916
- }));
70917
- }
70918
- if (welcome && this._ws) {
70919
- for (const { req } of validReqs) {
70920
- this._ws.send(JSON.stringify({
70921
- event: "mls_welcome",
70922
- data: {
70923
- target_device_id: req.requesting_device_id,
70924
- group_id: entry.mlsGroupId,
70925
- conversation_group_id: gid,
70926
- payload: Buffer.from(welcome).toString("hex")
70927
- }
70928
- }));
70929
- }
70930
- }
70931
- for (const { req } of validReqs) {
70932
- await fetch(
70933
- `${this.config.apiUrl}/api/v1/mls/groups/${entry.mlsGroupId}/fulfill-welcome`,
70934
- {
70935
- method: "POST",
70936
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70937
- body: JSON.stringify({ request_id: req.id })
70938
- }
70939
- ).catch(() => {
70940
- });
71445
+ const done = await this._fulfilWelcomeRequests({
71446
+ mlsGroup,
71447
+ mlsGroupId: entry.mlsGroupId,
71448
+ label: `shared group ${gid.slice(0, 8)}`,
71449
+ welcomeFields: { conversation_group_id: gid },
71450
+ targets: sharedTargets
71451
+ });
71452
+ if (done > 0) {
71453
+ console.log(`[SecureChannel] Batched Welcome for ${done}/${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70941
71454
  }
70942
- await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70943
- console.log(`[SecureChannel] Batched Welcome for ${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70944
71455
  } catch (batchErr) {
70945
71456
  console.warn(`[SecureChannel] Batched Welcome failed for shared group ${gid.slice(0, 8)}, falling back to individual:`, batchErr);
70946
- for (const { req, kp } of validReqs) {
71457
+ for (const target of sharedTargets) {
70947
71458
  try {
70948
- const { commit, welcome } = await mlsGroup.addMembers([kp]);
70949
- if (this._ws) {
70950
- this._ws.send(JSON.stringify({ event: "mls_commit", data: { group_id: entry.mlsGroupId, epoch: Number(mlsGroup.epoch), payload: Buffer.from(commit).toString("hex") } }));
70951
- }
70952
- if (welcome && this._ws) {
70953
- this._ws.send(JSON.stringify({ event: "mls_welcome", data: { target_device_id: req.requesting_device_id, group_id: entry.mlsGroupId, conversation_group_id: gid, payload: Buffer.from(welcome).toString("hex") } }));
70954
- }
70955
- await fetch(`${this.config.apiUrl}/api/v1/mls/groups/${entry.mlsGroupId}/fulfill-welcome`, {
70956
- method: "POST",
70957
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70958
- body: JSON.stringify({ request_id: req.id })
70959
- }).catch(() => {
71459
+ const done = await this._fulfilWelcomeRequests({
71460
+ mlsGroup,
71461
+ mlsGroupId: entry.mlsGroupId,
71462
+ label: `shared group ${gid.slice(0, 8)}`,
71463
+ welcomeFields: { conversation_group_id: gid },
71464
+ targets: [target]
70960
71465
  });
70961
- await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70962
- console.log(`[SecureChannel] Individual Welcome for ${req.requesting_device_id.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
71466
+ if (done > 0) {
71467
+ console.log(`[SecureChannel] Individual Welcome for ${target.deviceId.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
71468
+ }
70963
71469
  } catch (indivErr) {
70964
- console.warn(`[SecureChannel] Individual Welcome failed for ${req.requesting_device_id.slice(0, 8)}:`, indivErr);
71470
+ console.warn(`[SecureChannel] Individual Welcome failed for ${target.deviceId.slice(0, 8)}:`, indivErr);
70965
71471
  }
70966
71472
  }
70967
71473
  }
@@ -70993,38 +71499,20 @@ ${messageText}`;
70993
71499
  if (!kpHex) continue;
70994
71500
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
70995
71501
  const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
70996
- const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
70997
- if (this._ws) {
70998
- this._ws.send(JSON.stringify({
70999
- event: "mls_commit",
71000
- data: {
71001
- group_id: convEntry.mlsGroupId,
71002
- epoch: Number(mlsGroup.epoch),
71003
- payload: Buffer.from(commit).toString("hex")
71004
- }
71005
- }));
71006
- }
71007
- if (welcome && this._ws) {
71008
- this._ws.send(JSON.stringify({
71009
- event: "mls_welcome",
71010
- data: {
71011
- target_device_id: req.requesting_device_id,
71012
- group_id: convEntry.mlsGroupId,
71013
- conversation_id: convId,
71014
- payload: Buffer.from(welcome).toString("hex")
71015
- }
71016
- }));
71502
+ const done = await this._fulfilWelcomeRequests({
71503
+ mlsGroup,
71504
+ mlsGroupId: convEntry.mlsGroupId,
71505
+ label: `conv ${convId.slice(0, 8)}`,
71506
+ welcomeFields: { conversation_id: convId },
71507
+ targets: [{
71508
+ requestId: req.id,
71509
+ deviceId: req.requesting_device_id,
71510
+ keyPackage: memberKp
71511
+ }]
71512
+ });
71513
+ if (done > 0) {
71514
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
71017
71515
  }
71018
- await fetch(
71019
- `${this.config.apiUrl}/api/v1/mls/groups/${convEntry.mlsGroupId}/fulfill-welcome`,
71020
- {
71021
- method: "POST",
71022
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
71023
- body: JSON.stringify({ request_id: req.id })
71024
- }
71025
- );
71026
- await saveMlsState(this.config.dataDir, convEntry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
71027
- console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
71028
71516
  } catch (fulfillErr) {
71029
71517
  console.warn(`[SecureChannel] Conv Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
71030
71518
  }
@@ -71205,12 +71693,13 @@ ${messageText}`;
71205
71693
  } catch (distErr) {
71206
71694
  console.warn(`[SecureChannel] A2A distribute network error:`, distErr);
71207
71695
  }
71208
- if (!distributed && this._ws) {
71209
- this._ws.send(JSON.stringify({
71696
+ if (!distributed && this._wsIsOpen()) {
71697
+ const a2aLabel = `A2A ${chId.slice(0, 8)}`;
71698
+ const commitSent = await this._sendFrameConfirmed({
71210
71699
  event: "mls_commit",
71211
71700
  data: { group_id: chState.mlsGroupId, epoch: Number(mlsGroup.epoch), payload: commitHex }
71212
- }));
71213
- this._ws.send(JSON.stringify({
71701
+ }, `${a2aLabel} commit`);
71702
+ const welcomeSent = commitSent && await this._sendFrameConfirmed({
71214
71703
  event: "mls_welcome",
71215
71704
  data: {
71216
71705
  target_device_id: req.requesting_device_id,
@@ -71218,7 +71707,11 @@ ${messageText}`;
71218
71707
  a2a_channel_id: chId,
71219
71708
  payload: welcomeHex
71220
71709
  }
71221
- }));
71710
+ }, `${a2aLabel} welcome`);
71711
+ if (!welcomeSent) {
71712
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome frames were NOT written \u2014 leaving the request pending`);
71713
+ continue;
71714
+ }
71222
71715
  try {
71223
71716
  await fetch(
71224
71717
  `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
@@ -71241,16 +71734,15 @@ ${messageText}`;
71241
71734
  console.warn(`[SecureChannel] Cannot deliver Welcome \u2014 HTTP distribute and WS both unavailable`);
71242
71735
  continue;
71243
71736
  }
71244
- await fetch(
71245
- `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/fulfill-welcome`,
71246
- {
71247
- method: "POST",
71248
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
71249
- body: JSON.stringify({ request_id: req.id })
71250
- }
71737
+ const a2aFulfilled = await this._postFulfilWelcome(
71738
+ chState.mlsGroupId,
71739
+ req.id,
71740
+ `A2A ${chId.slice(0, 8)}`
71251
71741
  );
71252
71742
  await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
71253
- console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
71743
+ if (a2aFulfilled) {
71744
+ console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
71745
+ }
71254
71746
  } catch (fulfillErr) {
71255
71747
  console.log(`[SecureChannel] A2A Welcome fulfill failed: ${fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr)}`);
71256
71748
  }
@@ -71424,12 +71916,12 @@ ${messageText}`;
71424
71916
  console.log(`[SecureChannel] MLS sync: re-join needed for group ${groupId?.slice(0, 8)}`);
71425
71917
  if (groupId) {
71426
71918
  await deleteMlsState(this.config.dataDir, groupId);
71427
- for (const [roomId, room] of Object.entries(this._persisted?.rooms ?? {})) {
71428
- if (room.mlsGroupId === groupId) {
71429
- this._mlsGroups.delete(roomId);
71430
- break;
71431
- }
71919
+ const target = this._resolveGroupFamily(groupId);
71920
+ if (target) {
71921
+ this._mlsGroups.delete(target.managerKey);
71922
+ console.log(`[SecureChannel] MLS sync rejoin: dropped local state for ${target.label}`);
71432
71923
  }
71924
+ void this._requestWelcomeSelfHeal(groupId);
71433
71925
  }
71434
71926
  } else if (action === "replay_complete") {
71435
71927
  console.log(`[SecureChannel] MLS sync complete for group ${groupId?.slice(0, 8)} (${data.count} messages replayed)`);
@@ -71441,6 +71933,7 @@ ${messageText}`;
71441
71933
  */
71442
71934
  async _handleA2AMessageMLS(data) {
71443
71935
  const groupId = data.group_id;
71936
+ this._noteGroupEpoch(groupId, data.epoch);
71444
71937
  const channelId = data.channel_id ?? data.a2a_channel_id;
71445
71938
  let a2aChannelId = channelId;
71446
71939
  if (!a2aChannelId) {
@@ -72573,6 +73066,45 @@ var init_gateway_send = __esm2({
72573
73066
  init_openclaw_compat();
72574
73067
  }
72575
73068
  });
73069
+ function parseMentions(text) {
73070
+ const mentions = [];
73071
+ const re2 = /@(\w[\w]*)/gi;
73072
+ let match;
73073
+ while ((match = re2.exec(text)) !== null) {
73074
+ mentions.push(match[1].toLowerCase());
73075
+ }
73076
+ return mentions;
73077
+ }
73078
+ function owesRoomReply(plaintext, agentName, accountId, senderIsAgent, roomDisplayName, ownDisplayName, selfResolved = true) {
73079
+ const mentions = parseMentions(plaintext);
73080
+ if (mentions.length === 0) return false;
73081
+ if (mentions.includes("all") || mentions.includes("everyone")) {
73082
+ return senderIsAgent !== true;
73083
+ }
73084
+ const ownNames = ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName);
73085
+ if (mentions.some((m2) => ownNames.has(m2))) return true;
73086
+ if (!selfResolved) return true;
73087
+ return false;
73088
+ }
73089
+ function ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName) {
73090
+ const names = /* @__PURE__ */ new Set();
73091
+ const add4 = (n2) => {
73092
+ if (!n2) return;
73093
+ const lower = n2.toLowerCase();
73094
+ names.add(lower);
73095
+ names.add(lower.split(/[\s(]/)[0]);
73096
+ };
73097
+ add4(agentName);
73098
+ add4(accountId);
73099
+ add4(roomDisplayName);
73100
+ add4(ownDisplayName);
73101
+ return names;
73102
+ }
73103
+ var init_room_protocol = __esm2({
73104
+ "src/room-protocol.ts"() {
73105
+ "use strict";
73106
+ }
73107
+ });
72576
73108
  var init_llm_response_parser = __esm2({
72577
73109
  "src/llm-response-parser.ts"() {
72578
73110
  "use strict";
@@ -72606,6 +73138,7 @@ var isUsingManagedRoutes;
72606
73138
  var init_openclaw_entry = __esm2({
72607
73139
  "src/openclaw-entry.ts"() {
72608
73140
  "use strict";
73141
+ init_room_protocol();
72609
73142
  init_account_config();
72610
73143
  init_fetch_interceptor();
72611
73144
  init_http_handlers();
@@ -98664,7 +99197,8 @@ var init_index = __esm2({
98664
99197
  init_skill_invoker();
98665
99198
  await init_skill_telemetry();
98666
99199
  await init_policy_enforcer();
98667
- VERSION = true ? "0.23.23" : "0.0.0-dev";
99200
+ init_room_protocol();
99201
+ VERSION = true ? "0.23.29" : "0.0.0-dev";
98668
99202
  }
98669
99203
  });
98670
99204
  await init_index();
@@ -99417,9 +99951,32 @@ function roomTurnPrefix(roomId, senderName, roomName) {
99417
99951
  const room = roomName ? `"${roomName}"` : roomId.slice(0, 8);
99418
99952
  return `[room ${room} | ${senderName}]`;
99419
99953
  }
99954
+ function _makeOwesReply(agentName, channel, log) {
99955
+ let warnedNoIdentity = false;
99956
+ return (e) => {
99957
+ const own = agentName ?? "";
99958
+ const hub = channel.ownDisplayName ?? "";
99959
+ if (!own && !hub && !warnedNoIdentity) {
99960
+ warnedNoIdentity = true;
99961
+ log(
99962
+ "WARNING: this bridge has no agentName and no hub display_name, so it cannot recognise its own @mention (#1107). It will answer @all and any @-addressed message (fail-open), but targeted replies will be unreliable. Set the agent name in the bridge config."
99963
+ );
99964
+ }
99965
+ return owesRoomReply(
99966
+ e.plaintext,
99967
+ own,
99968
+ own,
99969
+ e.senderIsAgent,
99970
+ void 0,
99971
+ hub,
99972
+ Boolean(own || hub)
99973
+ );
99974
+ };
99975
+ }
99420
99976
  function wireBridge(channel, session, target, opts = {}) {
99421
99977
  const log = opts.log ?? (() => {
99422
99978
  });
99979
+ const _owesReply = _makeOwesReply(opts.agentName, channel, log);
99423
99980
  channel.on("error", (err) => {
99424
99981
  const msg = err instanceof Error ? err.message : String(err);
99425
99982
  log(`channel error (non-fatal, not a reconnect trigger): ${msg}`);
@@ -99458,7 +100015,7 @@ function wireBridge(channel, session, target, opts = {}) {
99458
100015
  target.setRoom(e.roomId);
99459
100016
  session.push(`${roomTurnPrefix(e.roomId, e.senderName, roomNames.get(e.roomId))}: ${e.plaintext}`, target.snapshotReply(channel, log), {
99460
100017
  autoReplyOnText: false,
99461
- replyExpected: e.senderIsAgent === false
100018
+ replyExpected: _owesReply(e)
99462
100019
  });
99463
100020
  });
99464
100021
  channel.on("message", (text, metadata) => {
@@ -99567,7 +100124,7 @@ async function main() {
99567
100124
  const info = parseInfoFlags(process.argv.slice(2));
99568
100125
  if (info.showVersion || info.showHelp) {
99569
100126
  const version2 = buildClientVersion2(
99570
- true ? "0.4.0" : void 0
100127
+ true ? "0.4.2" : void 0
99571
100128
  );
99572
100129
  if (info.showVersion) printLine(version2);
99573
100130
  if (info.showHelp) {
@@ -99620,7 +100177,7 @@ async function main() {
99620
100177
  agentName: cfg.agentName,
99621
100178
  platform: "node",
99622
100179
  clientVersion: buildClientVersion2(
99623
- true ? "0.4.0" : void 0
100180
+ true ? "0.4.2" : void 0
99624
100181
  )
99625
100182
  });
99626
100183
  channelRef = channel;
@@ -99700,6 +100257,9 @@ async function main() {
99700
100257
  const target = new ActiveTarget();
99701
100258
  wireBridge(channel, session, target, {
99702
100259
  roomFilter: cfg.roomFilter,
100260
+ // #1107: needed to recognise this agent's own @mention. Without it the
100261
+ // agent can only answer @all.
100262
+ agentName: cfg.agentName,
99703
100263
  log: (m2) => logLine("[grok-bridge] " + m2)
99704
100264
  });
99705
100265
  attachLifecycle2(channel, {