@agentvault/claude-bridge 0.8.0 → 0.8.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
@@ -1081,6 +1081,28 @@ var __toESM = (mod4, isNodeMode, target) => (target = mod4 != null ? __create(__
1081
1081
  isNodeMode || !mod4 || !mod4.__esModule ? __defProp2(target, "default", { value: mod4, enumerable: true }) : target,
1082
1082
  mod4
1083
1083
  ));
1084
+ function stripWrappers(s22) {
1085
+ let start = 0;
1086
+ let end = s22.length;
1087
+ while (start < end && WRAPPERS.includes(s22[start])) start++;
1088
+ while (end > start && WRAPPERS.includes(s22[end - 1])) end--;
1089
+ return s22.slice(start, end);
1090
+ }
1091
+ function isAbstention(text) {
1092
+ if (typeof text !== "string") return false;
1093
+ return stripWrappers(text).toLowerCase().startsWith(SENTINEL);
1094
+ }
1095
+ var SENTINEL;
1096
+ var WRAPPERS;
1097
+ var INSTRUCTION;
1098
+ var init_abstention = __esm2({
1099
+ "src/abstention.ts"() {
1100
+ "use strict";
1101
+ SENTINEL = "[[no-reply]]";
1102
+ WRAPPERS = " \r\n`\"'*_";
1103
+ 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";
1104
+ }
1105
+ });
1084
1106
  function toEpoch(v22) {
1085
1107
  if (typeof v22 === "bigint") return v22;
1086
1108
  if (typeof v22 === "number") {
@@ -45906,6 +45928,11 @@ var init_ratchet = __esm2({
45906
45928
  return { header, headerSignature, ciphertext, nonce };
45907
45929
  }
45908
45930
  decrypt(message) {
45931
+ if (this.state.peerIdentityPublicKey) {
45932
+ if (!verifyHeaderSignature(message.header, message.headerSignature, this.state.peerIdentityPublicKey)) {
45933
+ throw new Error("Header signature verification failed");
45934
+ }
45935
+ }
45909
45936
  const isV2 = message.envelopeVersion === "2.0.0" && message.encryptedHeader != null && message.headerNonce != null;
45910
45937
  const skippedResult = this.trySkippedKeys(message, isV2);
45911
45938
  if (skippedResult !== null) {
@@ -45919,14 +45946,14 @@ var init_ratchet = __esm2({
45919
45946
  if (message.header.messageNumber === 0) {
45920
45947
  try {
45921
45948
  const { messageKey: testKey, headerKey: testHeaderKey, nextChainKey: nextChainKey2 } = kdfChainKey(this.state.rootKey);
45922
- let ad2;
45949
+ let ad;
45923
45950
  if (isV2) {
45924
45951
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, testHeaderKey);
45925
- ad2 = message.encryptedHeader;
45952
+ ad = message.encryptedHeader;
45926
45953
  } else {
45927
- ad2 = serializeHeader(message.header);
45954
+ ad = serializeHeader(message.header);
45928
45955
  }
45929
- const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad2, message.nonce, testKey);
45956
+ const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, testKey);
45930
45957
  this.state.dhReceivingPublicKey = message.header.dhPublicKey;
45931
45958
  this.state.receivingChain = {
45932
45959
  chainKey: nextChainKey2,
@@ -45946,13 +45973,7 @@ var init_ratchet = __esm2({
45946
45973
  this.skipMessages(this.state.receivingChain, message.header.messageNumber, message.header.dhPublicKey, isV2);
45947
45974
  const chain = this.state.receivingChain;
45948
45975
  const { nextChainKey, messageKey, headerKey } = kdfChainKey(chain.chainKey);
45949
- chain.chainKey = nextChainKey;
45950
- chain.messageNumber++;
45951
- if (this.state.peerIdentityPublicKey) {
45952
- if (!verifyHeaderSignature(message.header, message.headerSignature, this.state.peerIdentityPublicKey)) {
45953
- throw new Error("Header signature verification failed");
45954
- }
45955
- }
45976
+ let plaintextBytes;
45956
45977
  if (isV2) {
45957
45978
  try {
45958
45979
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, headerKey);
@@ -45960,19 +45981,21 @@ var init_ratchet = __esm2({
45960
45981
  throw new Error("V2 header decryption failed");
45961
45982
  }
45962
45983
  try {
45963
- const plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, message.encryptedHeader, message.nonce, messageKey);
45964
- return libsodium_wrappers_default.to_string(plaintextBytes);
45984
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, message.encryptedHeader, message.nonce, messageKey);
45965
45985
  } catch {
45966
45986
  throw new Error("V2 decryption failed: ciphertext tampered or wrong key");
45967
45987
  }
45988
+ } else {
45989
+ const ad = serializeHeader(message.header);
45990
+ try {
45991
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, messageKey);
45992
+ } catch {
45993
+ throw new Error("Decryption failed: ciphertext tampered or wrong key");
45994
+ }
45968
45995
  }
45969
- const ad = serializeHeader(message.header);
45970
- try {
45971
- const plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, messageKey);
45972
- return libsodium_wrappers_default.to_string(plaintextBytes);
45973
- } catch {
45974
- throw new Error("Decryption failed: ciphertext tampered or wrong key");
45975
- }
45996
+ chain.chainKey = nextChainKey;
45997
+ chain.messageNumber++;
45998
+ return libsodium_wrappers_default.to_string(plaintextBytes);
45976
45999
  }
45977
46000
  dhRatchetReceive(theirDhPublic) {
45978
46001
  this.state.previousSendingChainLength = this.state.sendingChain?.messageNumber ?? 0;
@@ -63758,6 +63781,39 @@ var init_mls_group = __esm2({
63758
63781
  const s22 = this._requireState();
63759
63782
  return s22.groupContext.groupId;
63760
63783
  }
63784
+ /**
63785
+ * Proof that this client actually holds the group's keys at its current
63786
+ * epoch. Throws if not initialized.
63787
+ *
63788
+ * RFC 9420 §8.2's `epoch_authenticator`, derived from the epoch secret. Two
63789
+ * properties make it the thing this codebase has been missing:
63790
+ *
63791
+ * * every member at the same epoch computes the SAME value, so it is
63792
+ * comparable across clients and can be checked by a server;
63793
+ * * it cannot be computed without the key schedule, so possessing it is
63794
+ * evidence of membership rather than a claim about it. RFC 9420 names
63795
+ * this as its purpose: "to authenticate the shared group state".
63796
+ *
63797
+ * It is safe to publish. It is a KDF output over the epoch secret, not the
63798
+ * secret, and the RFC intends it to be shown to out-of-band verifiers.
63799
+ *
63800
+ * WHY THIS EXISTS. `mls_groups.member_device_ids` records membership; the
63801
+ * ability to decrypt lives only in one browser's localStorage. Nothing
63802
+ * compared the two, and nothing could — so a device in the roster holding no
63803
+ * keys was indistinguishable from a healthy one, on every signal the server
63804
+ * has. Measured on production 2026-08-26: one owner device carried the keys
63805
+ * for 19 of the 21 owner leaves on the platform, and one 1:1 group had been
63806
+ * unreadable by its owner's browser for two months while every status column
63807
+ * read healthy.
63808
+ *
63809
+ * `epoch` alone cannot do this job: it is a number the client reports about
63810
+ * itself, it collides across groups, and a client that has lost its state
63811
+ * still remembers one.
63812
+ */
63813
+ get epochAuthenticator() {
63814
+ const s22 = this._requireState();
63815
+ return s22.keySchedule.epochAuthenticator;
63816
+ }
63761
63817
  /** The cipher suite name in use. */
63762
63818
  get ciphersuiteName() {
63763
63819
  return this._ciphersuiteName;
@@ -63908,11 +63964,84 @@ var init_mls_group = __esm2({
63908
63964
  return { commit: commitBytes, welcome: welcomeBytes };
63909
63965
  }
63910
63966
  /**
63911
- * Remove a member from the group by leaf index.
63967
+ * Admit N members with TWO commits total, replacing any stale leaf they hold.
63968
+ *
63969
+ * `addMembers` cannot do this alone: MLS rejects an Add for an identity
63970
+ * already in the tree, so a member that lost its local state must be removed
63971
+ * first. Doing that pair PER MEMBER is what the fulfiller did, and it is not
63972
+ * merely slow — it is self-defeating.
63973
+ *
63974
+ * Live 2026-08-28 14:02:21-28Z: the owner sweep re-admitted 7 room members
63975
+ * sequentially, each as its own removeMember + addMembers. Room 007 ran
63976
+ * **epoch 71 -> 85 in seven seconds**, and the Welcome minted for member n
63977
+ * named an epoch that member n+1's commit had already replaced — so six of
63978
+ * seven were stale before they finished joining, and one (`smartcode`) could
63979
+ * not replay forward afterwards at all.
63980
+ *
63981
+ * 🎯 A REPAIR THAT MOVES THE EPOCH ONCE PER MEMBER IS RACING ITSELF.
63982
+ *
63983
+ * ⚠️ **Why two commits and not one.** RFC 9420 §12.3 allows Remove and Add in
63984
+ * a single commit, and ts-mls builds it happily — but with MORE THAN ONE of
63985
+ * each, a REMAINING member fails to process it:
63912
63986
  *
63913
- * @param memberIndex - The leaf index of the member to remove.
63914
- * @returns The commit message to broadcast.
63987
+ * OpenError at applyUpdatePathSecret (ts-mls/src/createCommit.ts:471)
63988
+ * at updatePrivateKeyPath (processMessages.ts:397)
63989
+ *
63990
+ * One remove + one add in a single commit is fine; 2+2 is not. That is
63991
+ * pinned by a test in `mls-group.test.ts` so this cannot be "optimised" back
63992
+ * into one commit without the failure showing up first. Two commits still
63993
+ * turns 7 members from 14 epochs into 2, and every joiner lands current.
63994
+ *
63995
+ * @param keyPackages - Fresh KeyPackages, one per member to admit. Members
63996
+ * already holding a leaf are removed by the first commit; members with no
63997
+ * leaf are plain adds. Duplicate identities keep the first KeyPackage.
63998
+ * @returns Every commit to broadcast IN ORDER, and the Welcome to deliver to
63999
+ * EVERY member in the batch (each finds its own secrets inside it).
63915
64000
  */
64001
+ async reAdmitMembers(keyPackages) {
64002
+ this._requireState();
64003
+ await this._ensureCiphersuite();
64004
+ const seenIdentities = [];
64005
+ const toAdd = [];
64006
+ const leavesToRemove = [];
64007
+ for (const kp of keyPackages) {
64008
+ const identity = keyPackageIdentity(kp);
64009
+ if (identity !== null) {
64010
+ if (seenIdentities.some((id) => bytesEqual(id, identity)))
64011
+ continue;
64012
+ seenIdentities.push(identity);
64013
+ const leaf = this.memberLeafIndex(identity);
64014
+ if (leaf !== null)
64015
+ leavesToRemove.push(leaf);
64016
+ }
64017
+ toAdd.push(kp);
64018
+ }
64019
+ if (toAdd.length === 0) {
64020
+ throw new Error("reAdmitMembers: no members to admit");
64021
+ }
64022
+ const commits = [];
64023
+ if (leavesToRemove.length > 0) {
64024
+ const { commit } = await this.removeMembers(leavesToRemove);
64025
+ commits.push({ commit, epoch: this.epoch });
64026
+ }
64027
+ const added = await this.addMembers(toAdd);
64028
+ commits.push({ commit: added.commit, epoch: this.epoch });
64029
+ return { commits, welcome: added.welcome };
64030
+ }
64031
+ async removeMembers(memberIndices) {
64032
+ const s22 = this._requireState();
64033
+ const cs = await this._ensureCiphersuite();
64034
+ if (memberIndices.length === 0) {
64035
+ throw new Error("removeMembers: no members to remove");
64036
+ }
64037
+ const extraProposals = memberIndices.map((removed) => ({
64038
+ proposalType: "remove",
64039
+ remove: { removed }
64040
+ }));
64041
+ const result = await createCommit({ state: s22, cipherSuite: cs, pskIndex: this._pskIndex }, { extraProposals });
64042
+ this.state = result.newState;
64043
+ return { commit: encodeMlsMessage(result.commit) };
64044
+ }
63916
64045
  async removeMember(memberIndex) {
63917
64046
  const s22 = this._requireState();
63918
64047
  const cs = await this._ensureCiphersuite();
@@ -64146,6 +64275,23 @@ var init_mls_delivery_order = __esm2({
64146
64275
  "use strict";
64147
64276
  }
64148
64277
  });
64278
+ function noteObservedEpoch(current, seen) {
64279
+ const n22 = Number(seen);
64280
+ if (!Number.isInteger(n22) || n22 < 0)
64281
+ return current;
64282
+ return n22 > current ? n22 : current;
64283
+ }
64284
+ function needsPreSendResync(localEpoch, observedEpoch) {
64285
+ if (!Number.isInteger(localEpoch) || !Number.isInteger(observedEpoch)) {
64286
+ return false;
64287
+ }
64288
+ return observedEpoch > localEpoch;
64289
+ }
64290
+ var init_mls_presend_epoch = __esm2({
64291
+ "../crypto/dist/mls-presend-epoch.js"() {
64292
+ "use strict";
64293
+ }
64294
+ });
64149
64295
  var dist_exports = {};
64150
64296
  __export2(dist_exports, {
64151
64297
  AV_CREDENTIAL_CONTEXT: () => AV_CREDENTIAL_CONTEXT,
@@ -64215,7 +64361,9 @@ __export2(dist_exports, {
64215
64361
  hexTransportToEncryptedMessage: () => hexTransportToEncryptedMessage,
64216
64362
  issueCredential: () => issueCredential,
64217
64363
  multibaseToPublicKey: () => multibaseToPublicKey,
64364
+ needsPreSendResync: () => needsPreSendResync,
64218
64365
  normalizeBackupCode: () => normalizeBackupCode,
64366
+ noteObservedEpoch: () => noteObservedEpoch,
64219
64367
  orderDeliveryBatch: () => orderDeliveryBatch,
64220
64368
  parseTraceparent: () => parseTraceparent,
64221
64369
  performX3DH: () => performX3DH,
@@ -64255,6 +64403,7 @@ var init_dist = __esm2({
64255
64403
  init_mls_group();
64256
64404
  await init_owner_sync();
64257
64405
  init_mls_delivery_order();
64406
+ init_mls_presend_epoch();
64258
64407
  }
64259
64408
  });
64260
64409
  async function ensureSecureDir(dir) {
@@ -64447,7 +64596,7 @@ var init_mls_kp_pool = __esm2({
64447
64596
  }
64448
64597
  });
64449
64598
  function ownIdentity() {
64450
- const v22 = true ? "0.23.23" : FALLBACK;
64599
+ const v22 = true ? "0.23.29" : FALLBACK;
64451
64600
  return `${PACKAGE}@${v22}`;
64452
64601
  }
64453
64602
  function buildClientVersion(override) {
@@ -65359,10 +65508,12 @@ var SecureChannel;
65359
65508
  var init_channel = __esm2({
65360
65509
  async "src/channel.ts"() {
65361
65510
  "use strict";
65511
+ init_abstention();
65362
65512
  init_mls_commit_classify();
65363
65513
  await init_libsodium_wrappers();
65364
65514
  await init_dist();
65365
65515
  await init_dist();
65516
+ init_mls_presend_epoch();
65366
65517
  init_mls_state();
65367
65518
  await init_mls_kp_pool();
65368
65519
  init_client_version();
@@ -65476,6 +65627,17 @@ var init_channel = __esm2({
65476
65627
  * self-heal rather than be logged and dropped. */
65477
65628
  _mlsCommitFailCounts = /* @__PURE__ */ new Map();
65478
65629
  static MAX_MLS_DECRYPT_FAILS = 3;
65630
+ /**
65631
+ * #1084: consecutive resyncs that found NOTHING to apply, per group.
65632
+ *
65633
+ * "No new commits" means the server holds no commit past our epoch — we are
65634
+ * NOT BEHIND. Treating that as corruption and re-keying is what drove the
65635
+ * epoch churn (see `_resyncOnDivergence`). But a state that is genuinely
65636
+ * broken while its epoch counter happens to match must still recover, so the
65637
+ * suppression is bounded rather than absolute.
65638
+ */
65639
+ _resyncNoOpStrikes = /* @__PURE__ */ new Map();
65640
+ static MAX_RESYNC_NOOPS = 3;
65479
65641
  /** Cached MLS KeyPackage bundle for this device (regenerated on each connect). */
65480
65642
  _mlsKeyPackage = null;
65481
65643
  /** Pending KeyPackage bundle from request-Welcome flow (used by _handleMlsWelcome). */
@@ -65513,6 +65675,13 @@ var init_channel = __esm2({
65513
65675
  _kpPoolFilling = false;
65514
65676
  /** Buffer for MLS commits received before Welcome (keyed by groupId, sorted by epoch). */
65515
65677
  _pendingMlsCommits = /* @__PURE__ */ new Map();
65678
+ /**
65679
+ * #1013: the highest group epoch any inbound frame has reported, per MLS
65680
+ * group id. Every inbound frame carries the epoch the group was at when it
65681
+ * was produced, so each is a LOWER BOUND on the group's current epoch — the
65682
+ * hint is monotonic and only ever rises.
65683
+ */
65684
+ _observedGroupEpochs = /* @__PURE__ */ new Map();
65516
65685
  /** In-memory credential store for renter-provided credentials (never persisted). */
65517
65686
  _credentialStore = new CredentialStore();
65518
65687
  /** Rooms whose roster has been refreshed from the backend at least once in
@@ -65769,6 +65938,18 @@ var init_channel = __esm2({
65769
65938
  get ownHubAddress() {
65770
65939
  return this._persisted?.hubAddress ?? "";
65771
65940
  }
65941
+ /** This agent's AgentVault hub_id. "" until enrolment persists it.
65942
+ *
65943
+ * Lives in the credential store, NOT in the channel config — which is where
65944
+ * the memory-recall helper was reading it from. `channel.config.hubId` is
65945
+ * unset for every agent enrolled through the normal flow, so
65946
+ * `channel.config.hubId || ""` produced `/api/v1/agents//memory/search`, a
65947
+ * 404 on every inbound message, swallowed by `if (!res.ok) return ""`.
65948
+ * Memory recall was silently dead while the hub_id sat right here.
65949
+ */
65950
+ get ownHubId() {
65951
+ return this._persisted?.hubId ?? "";
65952
+ }
65772
65953
  /** Public best-effort trigger to (re)resolve this agent's display_name.
65773
65954
  * Called lazily from the 1:1 DM path when the name is still unknown, so a
65774
65955
  * resolve that failed at connect (or a fresh agent that hadn't registered
@@ -66157,6 +66338,7 @@ var init_channel = __esm2({
66157
66338
  * Each session gets the same plaintext encrypted independently.
66158
66339
  */
66159
66340
  async send(plaintext, options) {
66341
+ if (isAbstention(plaintext)) return;
66160
66342
  if (this._state === "error" || this._state === "idle") {
66161
66343
  throw new Error("Channel is not ready");
66162
66344
  }
@@ -66197,16 +66379,44 @@ var init_channel = __esm2({
66197
66379
  const pendingWsSends = [];
66198
66380
  const sentSharedGroupIds = /* @__PURE__ */ new Set();
66199
66381
  const addressedMlsGroupIds = [];
66382
+ if (!(this._persisted?.mlsGroups && this._state === "ready" && this._ws)) {
66383
+ console.warn(
66384
+ `[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"}`
66385
+ );
66386
+ }
66200
66387
  if (this._persisted?.mlsGroups && this._state === "ready" && this._ws) {
66201
66388
  const targetSharedGid = targetConvId ? this._sessionGroupIds?.get(targetConvId) : void 0;
66389
+ console.log(
66390
+ `[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)"}`
66391
+ );
66392
+ const skipped = [];
66202
66393
  for (const [gid, entry] of Object.entries(this._persisted.mlsGroups)) {
66203
- if (targetConvId && gid !== targetSharedGid) continue;
66204
- if (!entry.mlsGroupId) continue;
66394
+ if (targetConvId && gid !== targetSharedGid) {
66395
+ skipped.push(`${gid.slice(0, 8)}:not-target`);
66396
+ continue;
66397
+ }
66398
+ if (!entry.mlsGroupId) {
66399
+ skipped.push(`${gid.slice(0, 8)}:no-mlsGroupId`);
66400
+ continue;
66401
+ }
66205
66402
  const mlsGroup = this._mlsGroups.get(`1to1-group:${gid}`);
66206
- if (!mlsGroup?.isInitialized || Number(mlsGroup.epoch) <= 0) continue;
66403
+ if (!mlsGroup?.isInitialized) {
66404
+ skipped.push(`${gid.slice(0, 8)}:not-in-memory`);
66405
+ continue;
66406
+ }
66407
+ if (Number(mlsGroup.epoch) <= 0) {
66408
+ skipped.push(`${gid.slice(0, 8)}:epoch<=0`);
66409
+ continue;
66410
+ }
66207
66411
  try {
66208
66412
  const plaintextBytes = new TextEncoder().encode(plaintext);
66209
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66413
+ const cipherBytes = await this._encryptWithCatchUp(
66414
+ mlsGroup,
66415
+ `1to1-group:${gid}`,
66416
+ entry.mlsGroupId,
66417
+ `1:1 group ${gid.slice(0, 8)}`,
66418
+ plaintextBytes
66419
+ );
66210
66420
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66211
66421
  const payload = {
66212
66422
  group_id: entry.mlsGroupId,
@@ -66228,8 +66438,12 @@ var init_channel = __esm2({
66228
66438
  console.log(`[SecureChannel] Shared MLS group send for group ${gid.slice(0, 8)} (${entry.mlsGroupId.slice(0, 8)})`);
66229
66439
  } catch (err) {
66230
66440
  console.error(`[SecureChannel] Shared MLS group send failed for ${gid.slice(0, 8)}:`, err);
66441
+ skipped.push(`${gid.slice(0, 8)}:encrypt-threw`);
66231
66442
  }
66232
66443
  }
66444
+ if (skipped.length > 0) {
66445
+ console.log(`[SecureChannel] send(): skipped ${skipped.length} group(s) \u2014 ${skipped.join(", ")}`);
66446
+ }
66233
66447
  }
66234
66448
  for (const [convId, session] of this._sessions) {
66235
66449
  if (!session.activated) continue;
@@ -66249,7 +66463,14 @@ var init_channel = __esm2({
66249
66463
  }
66250
66464
  if (mlsGroup?.isInitialized && mlsGroupId && Number(mlsGroup.epoch) > 0 && this._state === "ready" && this._ws) {
66251
66465
  const plaintextBytes = new TextEncoder().encode(plaintext);
66252
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66466
+ const sessionGroupKey = convGroupId && this._mlsGroups.has(`1to1-group:${convGroupId}`) ? `1to1-group:${convGroupId}` : `conv:${convId}`;
66467
+ const cipherBytes = await this._encryptWithCatchUp(
66468
+ mlsGroup,
66469
+ sessionGroupKey,
66470
+ mlsGroupId,
66471
+ `conversation ${convId.slice(0, 8)}`,
66472
+ plaintextBytes
66473
+ );
66253
66474
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66254
66475
  const payload = {
66255
66476
  conversation_id: convId,
@@ -66344,7 +66565,14 @@ var init_channel = __esm2({
66344
66565
  }
66345
66566
  try {
66346
66567
  const plaintextBytes = new TextEncoder().encode(plaintext);
66347
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66568
+ const mlsOnlyGroupKey = mlsOnlyConvGroupId && this._mlsGroups.has(`1to1-group:${mlsOnlyConvGroupId}`) ? `1to1-group:${mlsOnlyConvGroupId}` : `conv:${mlsConvId}`;
66569
+ const cipherBytes = await this._encryptWithCatchUp(
66570
+ mlsGroup,
66571
+ mlsOnlyGroupKey,
66572
+ resolvedMlsGroupId,
66573
+ `conversation ${mlsConvId.slice(0, 8)}`,
66574
+ plaintextBytes
66575
+ );
66348
66576
  await saveMlsState(this.config.dataDir, resolvedMlsGroupId, JSON.stringify(mlsGroup.exportState()));
66349
66577
  const payload = {
66350
66578
  conversation_id: mlsConvId,
@@ -66370,13 +66598,26 @@ var init_channel = __esm2({
66370
66598
  }
66371
66599
  }
66372
66600
  }
66373
- if (sentCount === 0 && this._sessions.size > 0) {
66374
- console.warn("[SecureChannel] send() delivered to 0 sessions (all skipped or failed)");
66601
+ if (sentCount === 0) {
66602
+ console.warn(
66603
+ `[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})`
66604
+ );
66375
66605
  }
66376
66606
  await this._persistState();
66607
+ const wsReady = this._ws?.readyState === 1;
66608
+ if (pendingWsSends.length > 0 && !wsReady) {
66609
+ console.warn(
66610
+ `[SecureChannel] send(): ${pendingWsSends.length} frame(s) NOT written \u2014 socket readyState=${this._ws?.readyState ?? "null"} (1=OPEN)`
66611
+ );
66612
+ }
66377
66613
  for (const frame of pendingWsSends) {
66378
66614
  this._ws.send(frame);
66379
66615
  }
66616
+ if (pendingWsSends.length > 0) {
66617
+ console.log(
66618
+ `[SecureChannel] send(): wrote ${pendingWsSends.length} frame(s) to the socket (sentCount=${sentCount}, readyState=${this._ws?.readyState ?? "null"})`
66619
+ );
66620
+ }
66380
66621
  if (!options?.isResend) {
66381
66622
  for (const mlsGroupId of addressedMlsGroupIds) {
66382
66623
  this._rememberRetryCandidate(mlsGroupId, plaintext, options);
@@ -66426,7 +66667,7 @@ var init_channel = __esm2({
66426
66667
  */
66427
66668
  sendActivitySpan(spanData) {
66428
66669
  if (!this._ws || this._ws.readyState !== WebSocket2.OPEN) return;
66429
- const pluginVersion = true ? "0.23.23" : "0.0.0-dev";
66670
+ const pluginVersion = true ? "0.23.29" : "0.0.0-dev";
66430
66671
  const agentName = this.config.agentName ?? "Agent";
66431
66672
  const resource = {
66432
66673
  "service.name": "agentvault-agent",
@@ -66620,6 +66861,7 @@ var init_channel = __esm2({
66620
66861
  * Uses MLS (preferred) or pairwise fan-out as fallback.
66621
66862
  */
66622
66863
  async sendToRoom(roomId, plaintext, opts) {
66864
+ if (isAbstention(plaintext)) return;
66623
66865
  if (!this._persisted?.rooms?.[roomId]) {
66624
66866
  throw new Error(`Room ${roomId} not found`);
66625
66867
  }
@@ -66636,7 +66878,13 @@ var init_channel = __esm2({
66636
66878
  if (mlsGroup?.isInitialized) {
66637
66879
  try {
66638
66880
  const plaintextBytes = new TextEncoder().encode(plaintext);
66639
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
66881
+ const ciphertext = await this._encryptWithCatchUp(
66882
+ mlsGroup,
66883
+ roomId,
66884
+ room.mlsGroupId,
66885
+ `room ${roomId.slice(0, 8)}`,
66886
+ plaintextBytes
66887
+ );
66640
66888
  await saveMlsState(this.config.dataDir, room.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66641
66889
  if (this._state === "ready" && this._ws) {
66642
66890
  this._ws.send(JSON.stringify({
@@ -66887,11 +67135,17 @@ var init_channel = __esm2({
66887
67135
  const before = this._persisted.groupId;
66888
67136
  this._sessionGroupIds = new Map(mine.map((r22) => [r22.id, r22.group_id]));
66889
67137
  this._persisted.conversationGroupIds = Object.fromEntries(this._sessionGroupIds);
66890
- const primary = mine.find((r22) => r22.id === this._persisted.primaryConversationId) ?? mine[0];
67138
+ const joined = mine.find((r22) => this._persisted.mlsGroups?.[r22.group_id]);
67139
+ const primary = mine.find((r22) => r22.id === this._persisted.primaryConversationId) ?? joined ?? mine[0];
67140
+ if (!mine.some((r22) => r22.id === this._persisted.primaryConversationId)) {
67141
+ console.log(
67142
+ `[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"}`
67143
+ );
67144
+ }
66891
67145
  this._persisted.groupId = primary.group_id;
66892
67146
  this._persisted.primaryConversationId = primary.id;
66893
67147
  const liveGroupIds = new Set(mine.map((r22) => r22.group_id));
66894
- if (!liveGroupIds.has(staleGid)) {
67148
+ if (staleGid && staleMlsGroupId && !liveGroupIds.has(staleGid)) {
66895
67149
  try {
66896
67150
  await deleteMlsState(this.config.dataDir, staleMlsGroupId);
66897
67151
  } catch {
@@ -66905,9 +67159,9 @@ var init_channel = __esm2({
66905
67159
  }
66906
67160
  await this._persistState();
66907
67161
  console.log(
66908
- `[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))`
67162
+ `[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))`
66909
67163
  );
66910
- return this._persisted.groupId !== before && before === staleGid;
67164
+ return staleGid !== void 0 && this._persisted.groupId !== before && before === staleGid;
66911
67165
  }
66912
67166
  /**
66913
67167
  * Resend the message that a now-reconciled `unknown group` refusal killed (#732).
@@ -67815,7 +68069,13 @@ var init_channel = __esm2({
67815
68069
  const mlsGroup = this._mlsGroups.get(`a2a:${channelEntry.channelId}`);
67816
68070
  if (mlsGroup?.isInitialized) {
67817
68071
  const plaintextBytes = new TextEncoder().encode(text);
67818
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
68072
+ const ciphertext = await this._encryptWithCatchUp(
68073
+ mlsGroup,
68074
+ `a2a:${channelEntry.channelId}`,
68075
+ channelEntry.mlsGroupId,
68076
+ `A2A ${channelEntry.channelId.slice(0, 8)}`,
68077
+ plaintextBytes
68078
+ );
67819
68079
  await saveMlsState(
67820
68080
  this.config.dataDir,
67821
68081
  channelEntry.mlsGroupId,
@@ -67863,7 +68123,13 @@ var init_channel = __esm2({
67863
68123
  const mlsGroup = this._mlsGroups.get(`a2a:${channelId}`);
67864
68124
  if (mlsGroup?.isInitialized) {
67865
68125
  const plaintextBytes = new TextEncoder().encode(text);
67866
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
68126
+ const ciphertext = await this._encryptWithCatchUp(
68127
+ mlsGroup,
68128
+ `a2a:${channelId}`,
68129
+ entry.mlsGroupId,
68130
+ `A2A ${channelId.slice(0, 8)}`,
68131
+ plaintextBytes
68132
+ );
67867
68133
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
67868
68134
  this._ws.send(JSON.stringify({
67869
68135
  event: "a2a_message_mls",
@@ -68309,6 +68575,9 @@ var init_channel = __esm2({
68309
68575
  await this._pullDrDeliveryQueue();
68310
68576
  await this._flushOutboundQueue();
68311
68577
  this._setState("ready");
68578
+ void this._reconcileConversationGroups().catch((err) => {
68579
+ console.warn("[SecureChannel] Conversation-group reconcile failed:", err);
68580
+ });
68312
68581
  void this._reconcileRoomsWithServer().catch(
68313
68582
  (err) => console.warn(`[SecureChannel] room reconcile failed (ignored): ${err instanceof Error ? err.message : String(err)}`)
68314
68583
  ).then(() => this._quarantineOrphanedMlsGroups()).catch(
@@ -68336,7 +68605,7 @@ var init_channel = __esm2({
68336
68605
  agentVersion: this.config.agentVersion ?? "0.0.0",
68337
68606
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68338
68607
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68339
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68608
+ pluginVersion: true ? "0.23.29" : "0.0.0-dev"
68340
68609
  });
68341
68610
  this._telemetryReporter.startAutoFlush(3e4);
68342
68611
  }
@@ -68660,7 +68929,7 @@ var init_channel = __esm2({
68660
68929
  agentVersion: this.config.agentVersion ?? "0.0.0",
68661
68930
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68662
68931
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68663
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68932
+ pluginVersion: true ? "0.23.29" : "0.0.0-dev"
68664
68933
  });
68665
68934
  this._telemetryReporter.startAutoFlush(3e4);
68666
68935
  }
@@ -69026,6 +69295,7 @@ var init_channel = __esm2({
69026
69295
  const convGroupId = data.conversation_group_id;
69027
69296
  const groupId = data.group_id;
69028
69297
  const senderDeviceId = data.sender_device_id;
69298
+ this._noteGroupEpoch(groupId, data.epoch);
69029
69299
  if (senderDeviceId === this._deviceId) return;
69030
69300
  let mgr;
69031
69301
  let mgrKey;
@@ -69103,9 +69373,15 @@ var init_channel = __esm2({
69103
69373
  })),
69104
69374
  no_history: filtered.length === 0
69105
69375
  });
69106
- if (mgr && mlsGroupId) {
69376
+ if (mgr && mgrKey && mlsGroupId) {
69107
69377
  const responseBytes = new TextEncoder().encode(responsePayload);
69108
- const cipher = await mgr.encrypt(responseBytes);
69378
+ const cipher = await this._encryptWithCatchUp(
69379
+ mgr,
69380
+ mgrKey,
69381
+ mlsGroupId,
69382
+ `history catch-up ${mlsGroupId.slice(0, 8)}`,
69383
+ responseBytes
69384
+ );
69109
69385
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mgr.exportState()));
69110
69386
  if (this._ws) {
69111
69387
  this._ws.send(JSON.stringify({
@@ -69848,9 +70124,36 @@ ${messageText}`;
69848
70124
  // ---------------------------------------------------------------------------
69849
70125
  // MLS room message handlers
69850
70126
  // ---------------------------------------------------------------------------
70127
+ /**
70128
+ * #1014: hand a room-framed frame to the handler that owns its group family.
70129
+ *
70130
+ * Returns true when the frame was routed. Routing means "give it to the right
70131
+ * handler" — NOT "decrypt it as a room message". Decrypting under the wrong
70132
+ * group is how MLS state gets corrupted, so an unplaceable group is left for
70133
+ * the caller to self-heal rather than guessed at.
70134
+ */
70135
+ async _routeNonRoomGroupMessage(data, groupId) {
70136
+ const target = this._resolveGroupFamily(groupId);
70137
+ if (!target || target.family === "room") return false;
70138
+ console.log(
70139
+ `[SecureChannel] Room-framed message belongs to ${target.label} \u2014 routing to the handler that owns it (#1014)`
70140
+ );
70141
+ switch (target.family) {
70142
+ case "shared1to1":
70143
+ await this._handleMessageMLS({ ...data, conversation_group_id: target.id });
70144
+ return true;
70145
+ case "conversation":
70146
+ await this._handleMessageMLS({ ...data, conversation_id: target.id });
70147
+ return true;
70148
+ case "a2a":
70149
+ await this._handleA2AMessageMLS({ ...data, a2a_channel_id: target.id });
70150
+ return true;
70151
+ }
70152
+ }
69851
70153
  async _handleRoomMessageMLS(data) {
69852
70154
  const groupId = data.group_id;
69853
70155
  const senderDeviceId = data.sender_device_id;
70156
+ this._noteGroupEpoch(groupId, data.epoch);
69854
70157
  if (senderDeviceId === this._deviceId) return;
69855
70158
  let roomId;
69856
70159
  for (const [rid, room] of Object.entries(this._persisted?.rooms ?? {})) {
@@ -69864,7 +70167,9 @@ ${messageText}`;
69864
70167
  roomId = data.room_id;
69865
70168
  console.log(`[SecureChannel] Room ${roomId.slice(0, 8)} matched by room_id (group_id ${groupId?.slice(0, 8)} mismatch)`);
69866
70169
  } else {
70170
+ if (await this._routeNonRoomGroupMessage(data, groupId)) return;
69867
70171
  console.warn(`[SecureChannel] No room found for MLS group ${groupId?.slice(0, 8)}`);
70172
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
69868
70173
  return;
69869
70174
  }
69870
70175
  }
@@ -70052,63 +70357,63 @@ ${messageText}`;
70052
70357
  console.warn(`[SecureChannel] Failed to send history catchup response:`, sendErr);
70053
70358
  }
70054
70359
  }
70360
+ /**
70361
+ * Apply an inbound MLS commit to whichever group it addresses.
70362
+ *
70363
+ * Returns TRUE when the commit was applied, buffered, or handed to the
70364
+ * failure ladder — i.e. when this agent has taken responsibility for it and
70365
+ * the delivery-queue row may safely be acked. Returns FALSE when the commit
70366
+ * names a group we hold no state for at all, so the caller must NACK and let
70367
+ * the row survive.
70368
+ *
70369
+ * #1012: this used to loop `rooms` then `a2aChannels` and then END. A shared
70370
+ * 1:1 commit (`mlsGroups`) and a legacy per-conversation commit
70371
+ * (`mlsConversations`) matched neither. The call fell off the bottom with no
70372
+ * log, no buffer and no error — and because it did not THROW,
70373
+ * `_pullDeliveryQueue` acked the row and the server deleted it. An MLS commit
70374
+ * cannot be regenerated, so the agent stayed at epoch N while the group moved
70375
+ * to N+1 and every later send() encrypted at an epoch nobody could decrypt.
70376
+ *
70377
+ * The families are enumerated in ONE list resolved by ONE loop, because two
70378
+ * hand-copied loops are exactly how the 1:1 path fell two families behind.
70379
+ */
70055
70380
  async _handleMlsCommit(data) {
70056
70381
  const groupId = data.group_id;
70057
70382
  const epoch = typeof data.epoch === "number" ? data.epoch : 0;
70058
- for (const [roomId, room] of Object.entries(this._persisted?.rooms ?? {})) {
70059
- if (room.mlsGroupId === groupId) {
70060
- const mlsGroup = this._mlsGroups.get(roomId);
70061
- if (mlsGroup?.isInitialized) {
70062
- try {
70063
- const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
70064
- await mlsGroup.processCommit(commitBytes);
70065
- await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
70066
- console.log(`[SecureChannel] MLS commit processed for room ${roomId.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70067
- this._mlsCommitFailCounts.delete(roomId);
70068
- } catch (err) {
70069
- await this._onCommitFailure(
70070
- roomId,
70071
- groupId,
70072
- err,
70073
- `room ${roomId.slice(0, 8)}`,
70074
- data.epoch,
70075
- mlsGroup.epoch
70076
- );
70077
- }
70078
- } else {
70079
- this._bufferMlsCommit(groupId, epoch, data);
70080
- console.log(`[SecureChannel] Buffered MLS commit for room ${roomId.slice(0, 8)} (epoch=${epoch}, group not initialized)`);
70081
- }
70082
- return;
70383
+ this._noteGroupEpoch(groupId, data.epoch);
70384
+ const target = this._resolveGroupFamily(groupId);
70385
+ if (target) {
70386
+ const { managerKey: groupKey, label } = target;
70387
+ const mlsGroup = this._mlsGroups.get(groupKey);
70388
+ if (!mlsGroup?.isInitialized) {
70389
+ this._bufferMlsCommit(groupId, epoch, data);
70390
+ console.log(`[SecureChannel] Buffered MLS commit for ${label} (epoch=${epoch}, group not initialized)`);
70391
+ return true;
70083
70392
  }
70084
- }
70085
- for (const [chId, chState] of Object.entries(this._persisted?.a2aChannels ?? {})) {
70086
- if (chState.mlsGroupId === groupId) {
70087
- const mlsGroup = this._mlsGroups.get(`a2a:${chId}`);
70088
- if (mlsGroup?.isInitialized) {
70089
- try {
70090
- const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
70091
- await mlsGroup.processCommit(commitBytes);
70092
- await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
70093
- console.log(`[SecureChannel] MLS commit processed for A2A ${chId.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70094
- this._mlsCommitFailCounts.delete(`a2a:${chId}`);
70095
- } catch (err) {
70096
- await this._onCommitFailure(
70097
- `a2a:${chId}`,
70098
- groupId,
70099
- err,
70100
- `A2A ${chId.slice(0, 8)}`,
70101
- data.epoch,
70102
- mlsGroup.epoch
70103
- );
70104
- }
70105
- } else {
70106
- this._bufferMlsCommit(groupId, epoch, data);
70107
- console.log(`[SecureChannel] Buffered MLS commit for A2A ${chId.slice(0, 8)} (epoch=${epoch}, group not initialized)`);
70108
- }
70109
- return;
70393
+ try {
70394
+ const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
70395
+ await mlsGroup.processCommit(commitBytes);
70396
+ await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
70397
+ console.log(`[SecureChannel] MLS commit processed for ${label} (epoch=${mlsGroup.epoch})`);
70398
+ this._mlsCommitFailCounts.delete(groupKey);
70399
+ } catch (err) {
70400
+ await this._onCommitFailure(
70401
+ groupKey,
70402
+ groupId,
70403
+ err,
70404
+ label,
70405
+ data.epoch,
70406
+ mlsGroup.epoch
70407
+ );
70110
70408
  }
70409
+ return true;
70111
70410
  }
70411
+ this._bufferMlsCommit(groupId, epoch, data);
70412
+ console.warn(
70413
+ `[SecureChannel] MLS commit for UNKNOWN group ${String(groupId).slice(0, 8)} (epoch=${epoch}) \u2014 buffered, requesting a Welcome, NOT acked`
70414
+ );
70415
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
70416
+ return false;
70112
70417
  }
70113
70418
  /** Buffer an MLS commit for replay after Welcome join. Max 50 per group. */
70114
70419
  _bufferMlsCommit(groupId, epoch, data) {
@@ -70298,6 +70603,229 @@ ${messageText}`;
70298
70603
  * `1to1-group:<gid>`, `a2a:<channelId>`, …) — also the decrypt-fail-count key.
70299
70604
  * @param groupId the server-side MLS group id used in the /sync URL.
70300
70605
  */
70606
+ /**
70607
+ * #1015 — the ONLY place this package fulfils a Welcome request.
70608
+ *
70609
+ * Add the members, transmit, and record `fulfilled` **only for the targets
70610
+ * whose frames actually went out**. Every one of the five sites that used to
70611
+ * do this by hand followed the same sequence and had the same three holes:
70612
+ * `this._ws` null (both sends skipped by an `if`), a CLOSING socket (`send`
70613
+ * returns without throwing), and a fulfil POST wrapped in `.catch(() => {})`.
70614
+ *
70615
+ * A fulfilled request is never re-requested. So a Welcome that evaporated
70616
+ * left the requester locked out, the fulfiller at epoch N+1, everyone else at
70617
+ * N, and the bridging commit untransmitted and impossible to regenerate — a
70618
+ * permanently wedged group in which every signal read success.
70619
+ *
70620
+ * Returns the number of requests actually fulfilled.
70621
+ */
70622
+ async _fulfilWelcomeRequests(opts) {
70623
+ const { mlsGroup, mlsGroupId, label, welcomeFields, targets } = opts;
70624
+ if (targets.length === 0) return 0;
70625
+ if (!this._wsIsOpen()) {
70626
+ console.warn(
70627
+ `[SecureChannel] ${label}: socket is not OPEN \u2014 NOT advancing the group; ${targets.length} request(s) stay pending for the next pull`
70628
+ );
70629
+ return 0;
70630
+ }
70631
+ const snapshot = mlsGroup.exportState();
70632
+ const { commit, welcome } = await mlsGroup.addMembers(targets.map((t22) => t22.keyPackage));
70633
+ const commitSent = await this._sendFrameConfirmed(
70634
+ {
70635
+ event: "mls_commit",
70636
+ data: {
70637
+ group_id: mlsGroupId,
70638
+ epoch: Number(mlsGroup.epoch),
70639
+ payload: Buffer.from(commit).toString("hex")
70640
+ }
70641
+ },
70642
+ `${label} commit`
70643
+ );
70644
+ if (!commitSent) {
70645
+ mlsGroup.importState(snapshot);
70646
+ console.warn(
70647
+ `[SecureChannel] ${label}: commit was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing fulfilled, requests stay pending`
70648
+ );
70649
+ return 0;
70650
+ }
70651
+ await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70652
+ if (!welcome) {
70653
+ console.warn(
70654
+ `[SecureChannel] ${label}: addMembers produced NO Welcome \u2014 ${targets.length} request(s) left pending rather than marked fulfilled`
70655
+ );
70656
+ return 0;
70657
+ }
70658
+ let fulfilled = 0;
70659
+ for (const t22 of targets) {
70660
+ const sent = await this._sendFrameConfirmed(
70661
+ {
70662
+ event: "mls_welcome",
70663
+ data: {
70664
+ target_device_id: t22.deviceId,
70665
+ group_id: mlsGroupId,
70666
+ ...welcomeFields,
70667
+ payload: Buffer.from(welcome).toString("hex")
70668
+ }
70669
+ },
70670
+ `${label} welcome\u2192${t22.deviceId.slice(0, 8)}`
70671
+ );
70672
+ if (!sent) {
70673
+ console.warn(
70674
+ `[SecureChannel] ${label}: Welcome for ${t22.deviceId.slice(0, 8)} was NOT written \u2014 leaving the request PENDING so the next pull retries it`
70675
+ );
70676
+ continue;
70677
+ }
70678
+ if (await this._postFulfilWelcome(mlsGroupId, t22.requestId, `${label} ${t22.deviceId.slice(0, 8)}`)) {
70679
+ fulfilled++;
70680
+ }
70681
+ }
70682
+ return fulfilled;
70683
+ }
70684
+ /**
70685
+ * Resolve an MLS group id to the family that owns it.
70686
+ *
70687
+ * There are four: rooms, A2A channels, shared 1:1 groups, and the legacy
70688
+ * per-conversation groups. They all store the MLS group id under
70689
+ * `mlsGroupId`; they differ only in the key their live `MLSGroupManager` sits
70690
+ * under in `_mlsGroups`.
70691
+ *
70692
+ * ONE resolver, because hand-copying this enumeration is what produced
70693
+ * #1012 (`_handleMlsCommit` handled two families of four), #1014 (a
70694
+ * room-framed 1:1 replay matched nothing) and #1016 (a rejoin dropped only
70695
+ * the room manager). Three bugs, one missing abstraction.
70696
+ */
70697
+ _resolveGroupFamily(mlsGroupId) {
70698
+ if (!mlsGroupId) return null;
70699
+ const families = [
70700
+ { family: "room", entries: this._persisted?.rooms, managerKey: (id) => id, label: "room" },
70701
+ { family: "a2a", entries: this._persisted?.a2aChannels, managerKey: (id) => `a2a:${id}`, label: "A2A" },
70702
+ { family: "shared1to1", entries: this._persisted?.mlsGroups, managerKey: (id) => `1to1-group:${id}`, label: "1:1 group" },
70703
+ { family: "conversation", entries: this._persisted?.mlsConversations, managerKey: (id) => `conv:${id}`, label: "conversation" }
70704
+ ];
70705
+ for (const f22 of families) {
70706
+ for (const [id, entry] of Object.entries(f22.entries ?? {})) {
70707
+ if (entry?.mlsGroupId !== mlsGroupId) continue;
70708
+ return {
70709
+ family: f22.family,
70710
+ id,
70711
+ managerKey: f22.managerKey(id),
70712
+ label: `${f22.label} ${id.slice(0, 8)}`
70713
+ };
70714
+ }
70715
+ }
70716
+ return null;
70717
+ }
70718
+ /** #1015: is the socket in a state that can actually take a write? */
70719
+ _wsIsOpen() {
70720
+ return this._ws?.readyState === 1;
70721
+ }
70722
+ /**
70723
+ * #1015: write one frame and report whether the socket took it.
70724
+ *
70725
+ * `ws.send()` on a CLOSING or CLOSED socket returns WITHOUT throwing — the
70726
+ * error surfaces only through the callback, and every fulfilment site passed
70727
+ * none. So a Welcome could be "sent" into a dead socket and the requester
70728
+ * still marked `fulfilled`, which it never re-requests.
70729
+ */
70730
+ _sendFrameConfirmed(frame, label) {
70731
+ const ws = this._ws;
70732
+ if (!ws || ws.readyState !== 1) {
70733
+ console.warn(
70734
+ `[SecureChannel] ${label}: socket is not OPEN (readyState=${ws?.readyState ?? "null"}) \u2014 frame NOT written`
70735
+ );
70736
+ return Promise.resolve(false);
70737
+ }
70738
+ return new Promise((resolve32) => {
70739
+ try {
70740
+ ws.send(JSON.stringify(frame), (err) => {
70741
+ if (err) {
70742
+ console.error(`[SecureChannel] ${label}: socket refused the frame:`, err);
70743
+ resolve32(false);
70744
+ } else {
70745
+ resolve32(true);
70746
+ }
70747
+ });
70748
+ } catch (err) {
70749
+ console.error(`[SecureChannel] ${label}: send threw:`, err);
70750
+ resolve32(false);
70751
+ }
70752
+ });
70753
+ }
70754
+ /**
70755
+ * #1015: record a Welcome as fulfilled — and say so when it fails.
70756
+ *
70757
+ * The batched shared-1:1 site posted this with `.catch(() => {})`, so an HTTP
70758
+ * failure here was invisible on top of the sends being invisible.
70759
+ */
70760
+ async _postFulfilWelcome(mlsGroupId, requestId, label) {
70761
+ try {
70762
+ const res = await fetch(
70763
+ `${this.config.apiUrl}/api/v1/mls/groups/${mlsGroupId}/fulfill-welcome`,
70764
+ {
70765
+ method: "POST",
70766
+ headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70767
+ body: JSON.stringify({ request_id: requestId })
70768
+ }
70769
+ );
70770
+ if (!res.ok) {
70771
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome returned ${res.status}`);
70772
+ return false;
70773
+ }
70774
+ return true;
70775
+ } catch (err) {
70776
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome failed:`, err);
70777
+ return false;
70778
+ }
70779
+ }
70780
+ /**
70781
+ * #1013: fold an epoch seen on an inbound frame into the running hint.
70782
+ *
70783
+ * Anything unusable (missing, non-numeric, negative, fractional) is ignored
70784
+ * rather than defaulted. A defaulted epoch here would be a guess, and a guess
70785
+ * that reads LOW silently disables the check it exists to drive.
70786
+ */
70787
+ _noteGroupEpoch(mlsGroupId, epoch) {
70788
+ if (typeof mlsGroupId !== "string" || !mlsGroupId) return;
70789
+ const current = this._observedGroupEpochs.get(mlsGroupId) ?? 0;
70790
+ const next = noteObservedEpoch(current, epoch);
70791
+ if (next > current) this._observedGroupEpochs.set(mlsGroupId, next);
70792
+ }
70793
+ /**
70794
+ * #1013 — the ONLY place this package encrypts an MLS application message.
70795
+ *
70796
+ * Catch up first when the group is known to have moved past us, then encrypt.
70797
+ * Hermes has done this since #440 and the browser since #996; the plugin —
70798
+ * the implementation that actually ships to customers — did not, so an agent
70799
+ * that missed one commit encrypted every later message at an epoch nobody
70800
+ * could read, while the server stored and relayed it and every signal read
70801
+ * 200.
70802
+ *
70803
+ * Funnelling every sender through one method is the point. A guard applied at
70804
+ * seven call sites decays to a guard applied at six: that is exactly how
70805
+ * `_handleMlsCommit` ended up handling two group families out of four (#1012).
70806
+ *
70807
+ * Best-effort by design. A catch-up that cannot replay — or that throws — must
70808
+ * NOT block the send; we fall through and encrypt at the local epoch, which is
70809
+ * precisely today's behaviour, so the worst case is no regression.
70810
+ */
70811
+ async _encryptWithCatchUp(mlsGroup, groupKey, mlsGroupId, label, plaintextBytes) {
70812
+ const observed = this._observedGroupEpochs.get(mlsGroupId);
70813
+ if (observed !== void 0 && needsPreSendResync(Number(mlsGroup.epoch), observed)) {
70814
+ console.warn(
70815
+ `[SecureChannel] ${label}: local epoch ${Number(mlsGroup.epoch)} is behind the observed group epoch ${observed} \u2014 catching up before encrypt (#1013)`
70816
+ );
70817
+ try {
70818
+ if (!await this._resyncOnDivergence(groupKey, mlsGroupId)) {
70819
+ console.warn(
70820
+ `[SecureChannel] ${label}: catch-up could not replay \u2014 sending at epoch ${Number(mlsGroup.epoch)}. Peers past this epoch will not read it.`
70821
+ );
70822
+ }
70823
+ } catch (err) {
70824
+ console.warn(`[SecureChannel] ${label}: catch-up failed:`, err);
70825
+ }
70826
+ }
70827
+ return await mlsGroup.encrypt(plaintextBytes);
70828
+ }
70301
70829
  async _resyncOnDivergence(groupKey, groupId) {
70302
70830
  const mgr = this._mlsGroups.get(groupKey);
70303
70831
  if (!mgr?.isInitialized) return false;
@@ -70324,11 +70852,24 @@ ${messageText}`;
70324
70852
  applied++;
70325
70853
  }
70326
70854
  if (applied === 0) {
70327
- console.log(`[SecureChannel] Resync for ${groupId.slice(0, 8)}: no new commits to apply (epoch=${mgr.epoch})`);
70855
+ const strikes = (this._resyncNoOpStrikes.get(groupKey) ?? 0) + 1;
70856
+ this._resyncNoOpStrikes.set(groupKey, strikes);
70857
+ if (strikes < _SecureChannel.MAX_RESYNC_NOOPS) {
70858
+ this._mlsDecryptFailCounts.delete(groupKey);
70859
+ console.log(
70860
+ `[SecureChannel] Resync for ${groupId.slice(0, 8)}: already current at epoch=${mgr.epoch} (strike ${strikes}/${_SecureChannel.MAX_RESYNC_NOOPS}) \u2014 NOT re-keying (#1084)`
70861
+ );
70862
+ return true;
70863
+ }
70864
+ this._resyncNoOpStrikes.delete(groupKey);
70865
+ console.warn(
70866
+ `[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`
70867
+ );
70328
70868
  return false;
70329
70869
  }
70330
70870
  await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mgr.exportState()));
70331
70871
  this._mlsDecryptFailCounts.delete(groupKey);
70872
+ this._resyncNoOpStrikes.delete(groupKey);
70332
70873
  console.log(
70333
70874
  `[SecureChannel] Resync-on-divergence: applied ${applied} missed commit(s) for ${groupId.slice(0, 8)}, now epoch=${mgr.epoch}`
70334
70875
  );
@@ -70715,6 +71256,7 @@ ${messageText}`;
70715
71256
  const ackedIds = [];
70716
71257
  const nackedIds = [];
70717
71258
  for (const msg of messages) {
71259
+ let handled = true;
70718
71260
  try {
70719
71261
  if (msg.sender_device_id === this._deviceId) {
70720
71262
  ackedIds.push(msg.queue_id);
@@ -70731,7 +71273,7 @@ ${messageText}`;
70731
71273
  a2a_channel_id: msg.a2a_channel_id
70732
71274
  });
70733
71275
  } else if (msg.message_type === "commit") {
70734
- await this._handleMlsCommit({
71276
+ handled = await this._handleMlsCommit({
70735
71277
  group_id: msg.group_id,
70736
71278
  sender_device_id: msg.sender_device_id,
70737
71279
  epoch: msg.epoch,
@@ -70769,9 +71311,18 @@ ${messageText}`;
70769
71311
  conversation_group_id: msg.conversation_group_id,
70770
71312
  created_at: msg.created_at
70771
71313
  });
71314
+ } else {
71315
+ handled = false;
71316
+ console.warn(
71317
+ `[SecureChannel] Delivery application row ${String(msg.message_id).slice(0, 8)} names no room, A2A channel or conversation \u2014 NOT acked`
71318
+ );
70772
71319
  }
70773
71320
  }
70774
- ackedIds.push(msg.queue_id);
71321
+ if (handled) {
71322
+ ackedIds.push(msg.queue_id);
71323
+ } else {
71324
+ nackedIds.push(msg.queue_id);
71325
+ }
70775
71326
  } catch (err) {
70776
71327
  console.warn(`[SecureChannel] Delivery ${msg.message_type} processing failed:`, err);
70777
71328
  nackedIds.push(msg.queue_id);
@@ -70840,38 +71391,20 @@ ${messageText}`;
70840
71391
  if (!kpHex) continue;
70841
71392
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
70842
71393
  const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
70843
- const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
70844
- if (this._ws) {
70845
- this._ws.send(JSON.stringify({
70846
- event: "mls_commit",
70847
- data: {
70848
- group_id: roomState.mlsGroupId,
70849
- epoch: Number(mlsGroup.epoch),
70850
- payload: Buffer.from(commit).toString("hex")
70851
- }
70852
- }));
70853
- }
70854
- if (welcome && this._ws) {
70855
- this._ws.send(JSON.stringify({
70856
- event: "mls_welcome",
70857
- data: {
70858
- target_device_id: req.requesting_device_id,
70859
- group_id: roomState.mlsGroupId,
70860
- room_id: roomId,
70861
- payload: Buffer.from(welcome).toString("hex")
70862
- }
70863
- }));
71394
+ const done = await this._fulfilWelcomeRequests({
71395
+ mlsGroup,
71396
+ mlsGroupId: roomState.mlsGroupId,
71397
+ label: `room ${roomId.slice(0, 8)}`,
71398
+ welcomeFields: { room_id: roomId },
71399
+ targets: [{
71400
+ requestId: req.id,
71401
+ deviceId: req.requesting_device_id,
71402
+ keyPackage: memberKp
71403
+ }]
71404
+ });
71405
+ if (done > 0) {
71406
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in room ${roomId.slice(0, 8)}`);
70864
71407
  }
70865
- await fetch(
70866
- `${this.config.apiUrl}/api/v1/mls/groups/${roomState.mlsGroupId}/fulfill-welcome`,
70867
- {
70868
- method: "POST",
70869
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70870
- body: JSON.stringify({ request_id: req.id })
70871
- }
70872
- );
70873
- await saveMlsState(this.config.dataDir, roomState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70874
- console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in room ${roomId.slice(0, 8)}`);
70875
71408
  } catch (fulfillErr) {
70876
71409
  console.warn(`[SecureChannel] Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
70877
71410
  }
@@ -70910,65 +71443,38 @@ ${messageText}`;
70910
71443
  validReqs.push({ req, kp: MLSGroupManager.deserializeKeyPackage(kpBytes) });
70911
71444
  }
70912
71445
  if (validReqs.length === 0) continue;
71446
+ const sharedTargets = validReqs.map((v22) => ({
71447
+ requestId: v22.req.id,
71448
+ deviceId: v22.req.requesting_device_id,
71449
+ keyPackage: v22.kp
71450
+ }));
70913
71451
  try {
70914
- const { commit, welcome } = await mlsGroup.addMembers(validReqs.map((v22) => v22.kp));
70915
- if (this._ws) {
70916
- this._ws.send(JSON.stringify({
70917
- event: "mls_commit",
70918
- data: {
70919
- group_id: entry.mlsGroupId,
70920
- epoch: Number(mlsGroup.epoch),
70921
- payload: Buffer.from(commit).toString("hex")
70922
- }
70923
- }));
70924
- }
70925
- if (welcome && this._ws) {
70926
- for (const { req } of validReqs) {
70927
- this._ws.send(JSON.stringify({
70928
- event: "mls_welcome",
70929
- data: {
70930
- target_device_id: req.requesting_device_id,
70931
- group_id: entry.mlsGroupId,
70932
- conversation_group_id: gid,
70933
- payload: Buffer.from(welcome).toString("hex")
70934
- }
70935
- }));
70936
- }
70937
- }
70938
- for (const { req } of validReqs) {
70939
- await fetch(
70940
- `${this.config.apiUrl}/api/v1/mls/groups/${entry.mlsGroupId}/fulfill-welcome`,
70941
- {
70942
- method: "POST",
70943
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70944
- body: JSON.stringify({ request_id: req.id })
70945
- }
70946
- ).catch(() => {
70947
- });
71452
+ const done = await this._fulfilWelcomeRequests({
71453
+ mlsGroup,
71454
+ mlsGroupId: entry.mlsGroupId,
71455
+ label: `shared group ${gid.slice(0, 8)}`,
71456
+ welcomeFields: { conversation_group_id: gid },
71457
+ targets: sharedTargets
71458
+ });
71459
+ if (done > 0) {
71460
+ console.log(`[SecureChannel] Batched Welcome for ${done}/${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70948
71461
  }
70949
- await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70950
- console.log(`[SecureChannel] Batched Welcome for ${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70951
71462
  } catch (batchErr) {
70952
71463
  console.warn(`[SecureChannel] Batched Welcome failed for shared group ${gid.slice(0, 8)}, falling back to individual:`, batchErr);
70953
- for (const { req, kp } of validReqs) {
71464
+ for (const target of sharedTargets) {
70954
71465
  try {
70955
- const { commit, welcome } = await mlsGroup.addMembers([kp]);
70956
- if (this._ws) {
70957
- this._ws.send(JSON.stringify({ event: "mls_commit", data: { group_id: entry.mlsGroupId, epoch: Number(mlsGroup.epoch), payload: Buffer.from(commit).toString("hex") } }));
70958
- }
70959
- if (welcome && this._ws) {
70960
- 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") } }));
70961
- }
70962
- await fetch(`${this.config.apiUrl}/api/v1/mls/groups/${entry.mlsGroupId}/fulfill-welcome`, {
70963
- method: "POST",
70964
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70965
- body: JSON.stringify({ request_id: req.id })
70966
- }).catch(() => {
71466
+ const done = await this._fulfilWelcomeRequests({
71467
+ mlsGroup,
71468
+ mlsGroupId: entry.mlsGroupId,
71469
+ label: `shared group ${gid.slice(0, 8)}`,
71470
+ welcomeFields: { conversation_group_id: gid },
71471
+ targets: [target]
70967
71472
  });
70968
- await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70969
- console.log(`[SecureChannel] Individual Welcome for ${req.requesting_device_id.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
71473
+ if (done > 0) {
71474
+ console.log(`[SecureChannel] Individual Welcome for ${target.deviceId.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
71475
+ }
70970
71476
  } catch (indivErr) {
70971
- console.warn(`[SecureChannel] Individual Welcome failed for ${req.requesting_device_id.slice(0, 8)}:`, indivErr);
71477
+ console.warn(`[SecureChannel] Individual Welcome failed for ${target.deviceId.slice(0, 8)}:`, indivErr);
70972
71478
  }
70973
71479
  }
70974
71480
  }
@@ -71000,38 +71506,20 @@ ${messageText}`;
71000
71506
  if (!kpHex) continue;
71001
71507
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
71002
71508
  const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
71003
- const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
71004
- if (this._ws) {
71005
- this._ws.send(JSON.stringify({
71006
- event: "mls_commit",
71007
- data: {
71008
- group_id: convEntry.mlsGroupId,
71009
- epoch: Number(mlsGroup.epoch),
71010
- payload: Buffer.from(commit).toString("hex")
71011
- }
71012
- }));
71013
- }
71014
- if (welcome && this._ws) {
71015
- this._ws.send(JSON.stringify({
71016
- event: "mls_welcome",
71017
- data: {
71018
- target_device_id: req.requesting_device_id,
71019
- group_id: convEntry.mlsGroupId,
71020
- conversation_id: convId,
71021
- payload: Buffer.from(welcome).toString("hex")
71022
- }
71023
- }));
71509
+ const done = await this._fulfilWelcomeRequests({
71510
+ mlsGroup,
71511
+ mlsGroupId: convEntry.mlsGroupId,
71512
+ label: `conv ${convId.slice(0, 8)}`,
71513
+ welcomeFields: { conversation_id: convId },
71514
+ targets: [{
71515
+ requestId: req.id,
71516
+ deviceId: req.requesting_device_id,
71517
+ keyPackage: memberKp
71518
+ }]
71519
+ });
71520
+ if (done > 0) {
71521
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
71024
71522
  }
71025
- await fetch(
71026
- `${this.config.apiUrl}/api/v1/mls/groups/${convEntry.mlsGroupId}/fulfill-welcome`,
71027
- {
71028
- method: "POST",
71029
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
71030
- body: JSON.stringify({ request_id: req.id })
71031
- }
71032
- );
71033
- await saveMlsState(this.config.dataDir, convEntry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
71034
- console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
71035
71523
  } catch (fulfillErr) {
71036
71524
  console.warn(`[SecureChannel] Conv Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
71037
71525
  }
@@ -71212,12 +71700,13 @@ ${messageText}`;
71212
71700
  } catch (distErr) {
71213
71701
  console.warn(`[SecureChannel] A2A distribute network error:`, distErr);
71214
71702
  }
71215
- if (!distributed && this._ws) {
71216
- this._ws.send(JSON.stringify({
71703
+ if (!distributed && this._wsIsOpen()) {
71704
+ const a2aLabel = `A2A ${chId.slice(0, 8)}`;
71705
+ const commitSent = await this._sendFrameConfirmed({
71217
71706
  event: "mls_commit",
71218
71707
  data: { group_id: chState.mlsGroupId, epoch: Number(mlsGroup.epoch), payload: commitHex }
71219
- }));
71220
- this._ws.send(JSON.stringify({
71708
+ }, `${a2aLabel} commit`);
71709
+ const welcomeSent = commitSent && await this._sendFrameConfirmed({
71221
71710
  event: "mls_welcome",
71222
71711
  data: {
71223
71712
  target_device_id: req.requesting_device_id,
@@ -71225,7 +71714,11 @@ ${messageText}`;
71225
71714
  a2a_channel_id: chId,
71226
71715
  payload: welcomeHex
71227
71716
  }
71228
- }));
71717
+ }, `${a2aLabel} welcome`);
71718
+ if (!welcomeSent) {
71719
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome frames were NOT written \u2014 leaving the request pending`);
71720
+ continue;
71721
+ }
71229
71722
  try {
71230
71723
  await fetch(
71231
71724
  `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
@@ -71248,16 +71741,15 @@ ${messageText}`;
71248
71741
  console.warn(`[SecureChannel] Cannot deliver Welcome \u2014 HTTP distribute and WS both unavailable`);
71249
71742
  continue;
71250
71743
  }
71251
- await fetch(
71252
- `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/fulfill-welcome`,
71253
- {
71254
- method: "POST",
71255
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
71256
- body: JSON.stringify({ request_id: req.id })
71257
- }
71744
+ const a2aFulfilled = await this._postFulfilWelcome(
71745
+ chState.mlsGroupId,
71746
+ req.id,
71747
+ `A2A ${chId.slice(0, 8)}`
71258
71748
  );
71259
71749
  await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
71260
- console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
71750
+ if (a2aFulfilled) {
71751
+ console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
71752
+ }
71261
71753
  } catch (fulfillErr) {
71262
71754
  console.log(`[SecureChannel] A2A Welcome fulfill failed: ${fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr)}`);
71263
71755
  }
@@ -71431,12 +71923,12 @@ ${messageText}`;
71431
71923
  console.log(`[SecureChannel] MLS sync: re-join needed for group ${groupId?.slice(0, 8)}`);
71432
71924
  if (groupId) {
71433
71925
  await deleteMlsState(this.config.dataDir, groupId);
71434
- for (const [roomId, room] of Object.entries(this._persisted?.rooms ?? {})) {
71435
- if (room.mlsGroupId === groupId) {
71436
- this._mlsGroups.delete(roomId);
71437
- break;
71438
- }
71926
+ const target = this._resolveGroupFamily(groupId);
71927
+ if (target) {
71928
+ this._mlsGroups.delete(target.managerKey);
71929
+ console.log(`[SecureChannel] MLS sync rejoin: dropped local state for ${target.label}`);
71439
71930
  }
71931
+ void this._requestWelcomeSelfHeal(groupId);
71440
71932
  }
71441
71933
  } else if (action === "replay_complete") {
71442
71934
  console.log(`[SecureChannel] MLS sync complete for group ${groupId?.slice(0, 8)} (${data.count} messages replayed)`);
@@ -71448,6 +71940,7 @@ ${messageText}`;
71448
71940
  */
71449
71941
  async _handleA2AMessageMLS(data) {
71450
71942
  const groupId = data.group_id;
71943
+ this._noteGroupEpoch(groupId, data.epoch);
71451
71944
  const channelId = data.channel_id ?? data.a2a_channel_id;
71452
71945
  let a2aChannelId = channelId;
71453
71946
  if (!a2aChannelId) {
@@ -72580,6 +73073,45 @@ var init_gateway_send = __esm2({
72580
73073
  init_openclaw_compat();
72581
73074
  }
72582
73075
  });
73076
+ function parseMentions(text) {
73077
+ const mentions = [];
73078
+ const re22 = /@(\w[\w]*)/gi;
73079
+ let match;
73080
+ while ((match = re22.exec(text)) !== null) {
73081
+ mentions.push(match[1].toLowerCase());
73082
+ }
73083
+ return mentions;
73084
+ }
73085
+ function owesRoomReply(plaintext, agentName, accountId, senderIsAgent, roomDisplayName, ownDisplayName, selfResolved = true) {
73086
+ const mentions = parseMentions(plaintext);
73087
+ if (mentions.length === 0) return false;
73088
+ if (mentions.includes("all") || mentions.includes("everyone")) {
73089
+ return senderIsAgent !== true;
73090
+ }
73091
+ const ownNames = ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName);
73092
+ if (mentions.some((m22) => ownNames.has(m22))) return true;
73093
+ if (!selfResolved) return true;
73094
+ return false;
73095
+ }
73096
+ function ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName) {
73097
+ const names = /* @__PURE__ */ new Set();
73098
+ const add4 = (n22) => {
73099
+ if (!n22) return;
73100
+ const lower = n22.toLowerCase();
73101
+ names.add(lower);
73102
+ names.add(lower.split(/[\s(]/)[0]);
73103
+ };
73104
+ add4(agentName);
73105
+ add4(accountId);
73106
+ add4(roomDisplayName);
73107
+ add4(ownDisplayName);
73108
+ return names;
73109
+ }
73110
+ var init_room_protocol = __esm2({
73111
+ "src/room-protocol.ts"() {
73112
+ "use strict";
73113
+ }
73114
+ });
72583
73115
  var init_llm_response_parser = __esm2({
72584
73116
  "src/llm-response-parser.ts"() {
72585
73117
  "use strict";
@@ -72613,6 +73145,7 @@ var isUsingManagedRoutes;
72613
73145
  var init_openclaw_entry = __esm2({
72614
73146
  "src/openclaw-entry.ts"() {
72615
73147
  "use strict";
73148
+ init_room_protocol();
72616
73149
  init_account_config();
72617
73150
  init_fetch_interceptor();
72618
73151
  init_http_handlers();
@@ -98671,7 +99204,8 @@ var init_index = __esm2({
98671
99204
  init_skill_invoker();
98672
99205
  await init_skill_telemetry();
98673
99206
  await init_policy_enforcer();
98674
- VERSION = true ? "0.23.23" : "0.0.0-dev";
99207
+ init_room_protocol();
99208
+ VERSION = true ? "0.23.29" : "0.0.0-dev";
98675
99209
  }
98676
99210
  });
98677
99211
  await init_index();
@@ -134839,9 +135373,32 @@ function roomTurnPrefix(roomId, senderName, roomName) {
134839
135373
  const room = roomName ? `"${roomName}"` : roomId.slice(0, 8);
134840
135374
  return `[room ${room} | ${senderName}]`;
134841
135375
  }
135376
+ function _makeOwesReply(agentName, channel, log) {
135377
+ let warnedNoIdentity = false;
135378
+ return (e7) => {
135379
+ const own = agentName ?? "";
135380
+ const hub = channel.ownDisplayName ?? "";
135381
+ if (!own && !hub && !warnedNoIdentity) {
135382
+ warnedNoIdentity = true;
135383
+ log(
135384
+ "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."
135385
+ );
135386
+ }
135387
+ return owesRoomReply(
135388
+ e7.plaintext,
135389
+ own,
135390
+ own,
135391
+ e7.senderIsAgent,
135392
+ void 0,
135393
+ hub,
135394
+ Boolean(own || hub)
135395
+ );
135396
+ };
135397
+ }
134842
135398
  function wireBridge(channel, session, target, opts = {}) {
134843
135399
  const log = opts.log ?? (() => {
134844
135400
  });
135401
+ const _owesReply = _makeOwesReply(opts.agentName, channel, log);
134845
135402
  let workAllowed = false;
134846
135403
  const workAllowedGetter = () => workAllowed;
134847
135404
  opts.onWorkAllowed?.(workAllowedGetter);
@@ -134893,7 +135450,7 @@ function wireBridge(channel, session, target, opts = {}) {
134893
135450
  target.setRoom(e7.roomId);
134894
135451
  session.push(`${roomTurnPrefix(e7.roomId, e7.senderName, roomNames.get(e7.roomId))}: ${e7.plaintext}`, target.snapshotReply(channel, log), {
134895
135452
  autoReplyOnText: false,
134896
- replyExpected: e7.senderIsAgent === false,
135453
+ replyExpected: _owesReply(e7),
134897
135454
  armed: () => arming.isArmed(e7.roomId)
134898
135455
  });
134899
135456
  });
@@ -135052,7 +135609,7 @@ async function main() {
135052
135609
  "[bridge] warning: passing the invite token on the command line is visible to other local users via 'ps'. Prefer: AV_INVITE_TOKEN=\u2026 npx @agentvault/claude-bridge"
135053
135610
  );
135054
135611
  }
135055
- logLine(`[bridge] version: ${true ? "0.8.0" : "dev"}`);
135612
+ logLine(`[bridge] version: ${true ? "0.8.2" : "dev"}`);
135056
135613
  logLine(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
135057
135614
  logLine(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
135058
135615
  if (cfg.armRoom) {
@@ -135084,7 +135641,7 @@ async function main() {
135084
135641
  // its default would render "@agentvault/agentvault@0.7.x" — the wrong
135085
135642
  // package name attached to the bridge's version number, which is worse
135086
135643
  // than either alone.
135087
- clientVersion: `@agentvault/claude-bridge@${true ? "0.8.0" : "dev"}`
135644
+ clientVersion: `@agentvault/claude-bridge@${true ? "0.8.2" : "dev"}`
135088
135645
  });
135089
135646
  const agentSystemPrompt = cfg.systemPrompt ?? `You are ${cfg.agentName}, an AI agent on AgentVault. You talk with your owner in 1:1 direct messages and collaborate with other agents in shared rooms. That is your identity \u2014 introduce yourself by that name and do not claim to be any other agent. To speak, call the say tool. In a 1:1 with your owner, reply to what they say. In a room you see every message \u2014 call say only when you have something worth adding, and otherwise stay silent. Keep messages concise.`;
135090
135647
  const deviceJwt = () => {
@@ -135164,6 +135721,9 @@ async function main() {
135164
135721
  {
135165
135722
  roomFilter: cfg.roomFilter,
135166
135723
  armRoom: cfg.armRoom,
135724
+ // #1107: needed to recognise this agent's own @mention. Without it the
135725
+ // agent can only answer @all.
135726
+ agentName: cfg.agentName,
135167
135727
  log: (m6) => logLine("[bridge] " + m6),
135168
135728
  // Slice 2 Plan C (T11): live arm/disarm + local-approval poll + heartbeat.
135169
135729
  workspaceDir: cfg.workspaceDir,