@agentvault/grok-bridge 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +580 -230
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -45899,6 +45899,11 @@ var init_ratchet = __esm2({
45899
45899
  return { header, headerSignature, ciphertext, nonce };
45900
45900
  }
45901
45901
  decrypt(message) {
45902
+ if (this.state.peerIdentityPublicKey) {
45903
+ if (!verifyHeaderSignature(message.header, message.headerSignature, this.state.peerIdentityPublicKey)) {
45904
+ throw new Error("Header signature verification failed");
45905
+ }
45906
+ }
45902
45907
  const isV2 = message.envelopeVersion === "2.0.0" && message.encryptedHeader != null && message.headerNonce != null;
45903
45908
  const skippedResult = this.trySkippedKeys(message, isV2);
45904
45909
  if (skippedResult !== null) {
@@ -45912,14 +45917,14 @@ var init_ratchet = __esm2({
45912
45917
  if (message.header.messageNumber === 0) {
45913
45918
  try {
45914
45919
  const { messageKey: testKey, headerKey: testHeaderKey, nextChainKey: nextChainKey2 } = kdfChainKey(this.state.rootKey);
45915
- let ad2;
45920
+ let ad;
45916
45921
  if (isV2) {
45917
45922
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, testHeaderKey);
45918
- ad2 = message.encryptedHeader;
45923
+ ad = message.encryptedHeader;
45919
45924
  } else {
45920
- ad2 = serializeHeader(message.header);
45925
+ ad = serializeHeader(message.header);
45921
45926
  }
45922
- const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad2, message.nonce, testKey);
45927
+ const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, testKey);
45923
45928
  this.state.dhReceivingPublicKey = message.header.dhPublicKey;
45924
45929
  this.state.receivingChain = {
45925
45930
  chainKey: nextChainKey2,
@@ -45939,13 +45944,7 @@ var init_ratchet = __esm2({
45939
45944
  this.skipMessages(this.state.receivingChain, message.header.messageNumber, message.header.dhPublicKey, isV2);
45940
45945
  const chain = this.state.receivingChain;
45941
45946
  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
- }
45947
+ let plaintextBytes;
45949
45948
  if (isV2) {
45950
45949
  try {
45951
45950
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, headerKey);
@@ -45953,19 +45952,21 @@ var init_ratchet = __esm2({
45953
45952
  throw new Error("V2 header decryption failed");
45954
45953
  }
45955
45954
  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);
45955
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, message.encryptedHeader, message.nonce, messageKey);
45958
45956
  } catch {
45959
45957
  throw new Error("V2 decryption failed: ciphertext tampered or wrong key");
45960
45958
  }
45959
+ } else {
45960
+ const ad = serializeHeader(message.header);
45961
+ try {
45962
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, messageKey);
45963
+ } catch {
45964
+ throw new Error("Decryption failed: ciphertext tampered or wrong key");
45965
+ }
45961
45966
  }
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
- }
45967
+ chain.chainKey = nextChainKey;
45968
+ chain.messageNumber++;
45969
+ return libsodium_wrappers_default.to_string(plaintextBytes);
45969
45970
  }
45970
45971
  dhRatchetReceive(theirDhPublic) {
45971
45972
  this.state.previousSendingChainLength = this.state.sendingChain?.messageNumber ?? 0;
@@ -64139,6 +64140,23 @@ var init_mls_delivery_order = __esm2({
64139
64140
  "use strict";
64140
64141
  }
64141
64142
  });
64143
+ function noteObservedEpoch(current, seen) {
64144
+ const n2 = Number(seen);
64145
+ if (!Number.isInteger(n2) || n2 < 0)
64146
+ return current;
64147
+ return n2 > current ? n2 : current;
64148
+ }
64149
+ function needsPreSendResync(localEpoch, observedEpoch) {
64150
+ if (!Number.isInteger(localEpoch) || !Number.isInteger(observedEpoch)) {
64151
+ return false;
64152
+ }
64153
+ return observedEpoch > localEpoch;
64154
+ }
64155
+ var init_mls_presend_epoch = __esm2({
64156
+ "../crypto/dist/mls-presend-epoch.js"() {
64157
+ "use strict";
64158
+ }
64159
+ });
64142
64160
  var dist_exports = {};
64143
64161
  __export2(dist_exports, {
64144
64162
  AV_CREDENTIAL_CONTEXT: () => AV_CREDENTIAL_CONTEXT,
@@ -64208,7 +64226,9 @@ __export2(dist_exports, {
64208
64226
  hexTransportToEncryptedMessage: () => hexTransportToEncryptedMessage,
64209
64227
  issueCredential: () => issueCredential,
64210
64228
  multibaseToPublicKey: () => multibaseToPublicKey,
64229
+ needsPreSendResync: () => needsPreSendResync,
64211
64230
  normalizeBackupCode: () => normalizeBackupCode,
64231
+ noteObservedEpoch: () => noteObservedEpoch,
64212
64232
  orderDeliveryBatch: () => orderDeliveryBatch,
64213
64233
  parseTraceparent: () => parseTraceparent,
64214
64234
  performX3DH: () => performX3DH,
@@ -64248,6 +64268,7 @@ var init_dist = __esm2({
64248
64268
  init_mls_group();
64249
64269
  await init_owner_sync();
64250
64270
  init_mls_delivery_order();
64271
+ init_mls_presend_epoch();
64251
64272
  }
64252
64273
  });
64253
64274
  async function ensureSecureDir(dir) {
@@ -64440,7 +64461,7 @@ var init_mls_kp_pool = __esm2({
64440
64461
  }
64441
64462
  });
64442
64463
  function ownIdentity() {
64443
- const v2 = true ? "0.23.23" : FALLBACK;
64464
+ const v2 = true ? "0.23.27" : FALLBACK;
64444
64465
  return `${PACKAGE}@${v2}`;
64445
64466
  }
64446
64467
  function buildClientVersion(override) {
@@ -65356,6 +65377,7 @@ var init_channel = __esm2({
65356
65377
  await init_libsodium_wrappers();
65357
65378
  await init_dist();
65358
65379
  await init_dist();
65380
+ init_mls_presend_epoch();
65359
65381
  init_mls_state();
65360
65382
  await init_mls_kp_pool();
65361
65383
  init_client_version();
@@ -65469,6 +65491,17 @@ var init_channel = __esm2({
65469
65491
  * self-heal rather than be logged and dropped. */
65470
65492
  _mlsCommitFailCounts = /* @__PURE__ */ new Map();
65471
65493
  static MAX_MLS_DECRYPT_FAILS = 3;
65494
+ /**
65495
+ * #1084: consecutive resyncs that found NOTHING to apply, per group.
65496
+ *
65497
+ * "No new commits" means the server holds no commit past our epoch — we are
65498
+ * NOT BEHIND. Treating that as corruption and re-keying is what drove the
65499
+ * epoch churn (see `_resyncOnDivergence`). But a state that is genuinely
65500
+ * broken while its epoch counter happens to match must still recover, so the
65501
+ * suppression is bounded rather than absolute.
65502
+ */
65503
+ _resyncNoOpStrikes = /* @__PURE__ */ new Map();
65504
+ static MAX_RESYNC_NOOPS = 3;
65472
65505
  /** Cached MLS KeyPackage bundle for this device (regenerated on each connect). */
65473
65506
  _mlsKeyPackage = null;
65474
65507
  /** Pending KeyPackage bundle from request-Welcome flow (used by _handleMlsWelcome). */
@@ -65506,6 +65539,13 @@ var init_channel = __esm2({
65506
65539
  _kpPoolFilling = false;
65507
65540
  /** Buffer for MLS commits received before Welcome (keyed by groupId, sorted by epoch). */
65508
65541
  _pendingMlsCommits = /* @__PURE__ */ new Map();
65542
+ /**
65543
+ * #1013: the highest group epoch any inbound frame has reported, per MLS
65544
+ * group id. Every inbound frame carries the epoch the group was at when it
65545
+ * was produced, so each is a LOWER BOUND on the group's current epoch — the
65546
+ * hint is monotonic and only ever rises.
65547
+ */
65548
+ _observedGroupEpochs = /* @__PURE__ */ new Map();
65509
65549
  /** In-memory credential store for renter-provided credentials (never persisted). */
65510
65550
  _credentialStore = new CredentialStore();
65511
65551
  /** Rooms whose roster has been refreshed from the backend at least once in
@@ -66190,16 +66230,44 @@ var init_channel = __esm2({
66190
66230
  const pendingWsSends = [];
66191
66231
  const sentSharedGroupIds = /* @__PURE__ */ new Set();
66192
66232
  const addressedMlsGroupIds = [];
66233
+ if (!(this._persisted?.mlsGroups && this._state === "ready" && this._ws)) {
66234
+ console.warn(
66235
+ `[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"}`
66236
+ );
66237
+ }
66193
66238
  if (this._persisted?.mlsGroups && this._state === "ready" && this._ws) {
66194
66239
  const targetSharedGid = targetConvId ? this._sessionGroupIds?.get(targetConvId) : void 0;
66240
+ console.log(
66241
+ `[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)"}`
66242
+ );
66243
+ const skipped = [];
66195
66244
  for (const [gid, entry] of Object.entries(this._persisted.mlsGroups)) {
66196
- if (targetConvId && gid !== targetSharedGid) continue;
66197
- if (!entry.mlsGroupId) continue;
66245
+ if (targetConvId && gid !== targetSharedGid) {
66246
+ skipped.push(`${gid.slice(0, 8)}:not-target`);
66247
+ continue;
66248
+ }
66249
+ if (!entry.mlsGroupId) {
66250
+ skipped.push(`${gid.slice(0, 8)}:no-mlsGroupId`);
66251
+ continue;
66252
+ }
66198
66253
  const mlsGroup = this._mlsGroups.get(`1to1-group:${gid}`);
66199
- if (!mlsGroup?.isInitialized || Number(mlsGroup.epoch) <= 0) continue;
66254
+ if (!mlsGroup?.isInitialized) {
66255
+ skipped.push(`${gid.slice(0, 8)}:not-in-memory`);
66256
+ continue;
66257
+ }
66258
+ if (Number(mlsGroup.epoch) <= 0) {
66259
+ skipped.push(`${gid.slice(0, 8)}:epoch<=0`);
66260
+ continue;
66261
+ }
66200
66262
  try {
66201
66263
  const plaintextBytes = new TextEncoder().encode(plaintext);
66202
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66264
+ const cipherBytes = await this._encryptWithCatchUp(
66265
+ mlsGroup,
66266
+ `1to1-group:${gid}`,
66267
+ entry.mlsGroupId,
66268
+ `1:1 group ${gid.slice(0, 8)}`,
66269
+ plaintextBytes
66270
+ );
66203
66271
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66204
66272
  const payload = {
66205
66273
  group_id: entry.mlsGroupId,
@@ -66221,8 +66289,12 @@ var init_channel = __esm2({
66221
66289
  console.log(`[SecureChannel] Shared MLS group send for group ${gid.slice(0, 8)} (${entry.mlsGroupId.slice(0, 8)})`);
66222
66290
  } catch (err) {
66223
66291
  console.error(`[SecureChannel] Shared MLS group send failed for ${gid.slice(0, 8)}:`, err);
66292
+ skipped.push(`${gid.slice(0, 8)}:encrypt-threw`);
66224
66293
  }
66225
66294
  }
66295
+ if (skipped.length > 0) {
66296
+ console.log(`[SecureChannel] send(): skipped ${skipped.length} group(s) \u2014 ${skipped.join(", ")}`);
66297
+ }
66226
66298
  }
66227
66299
  for (const [convId, session] of this._sessions) {
66228
66300
  if (!session.activated) continue;
@@ -66242,7 +66314,14 @@ var init_channel = __esm2({
66242
66314
  }
66243
66315
  if (mlsGroup?.isInitialized && mlsGroupId && Number(mlsGroup.epoch) > 0 && this._state === "ready" && this._ws) {
66244
66316
  const plaintextBytes = new TextEncoder().encode(plaintext);
66245
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66317
+ const sessionGroupKey = convGroupId && this._mlsGroups.has(`1to1-group:${convGroupId}`) ? `1to1-group:${convGroupId}` : `conv:${convId}`;
66318
+ const cipherBytes = await this._encryptWithCatchUp(
66319
+ mlsGroup,
66320
+ sessionGroupKey,
66321
+ mlsGroupId,
66322
+ `conversation ${convId.slice(0, 8)}`,
66323
+ plaintextBytes
66324
+ );
66246
66325
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66247
66326
  const payload = {
66248
66327
  conversation_id: convId,
@@ -66337,7 +66416,14 @@ var init_channel = __esm2({
66337
66416
  }
66338
66417
  try {
66339
66418
  const plaintextBytes = new TextEncoder().encode(plaintext);
66340
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66419
+ const mlsOnlyGroupKey = mlsOnlyConvGroupId && this._mlsGroups.has(`1to1-group:${mlsOnlyConvGroupId}`) ? `1to1-group:${mlsOnlyConvGroupId}` : `conv:${mlsConvId}`;
66420
+ const cipherBytes = await this._encryptWithCatchUp(
66421
+ mlsGroup,
66422
+ mlsOnlyGroupKey,
66423
+ resolvedMlsGroupId,
66424
+ `conversation ${mlsConvId.slice(0, 8)}`,
66425
+ plaintextBytes
66426
+ );
66341
66427
  await saveMlsState(this.config.dataDir, resolvedMlsGroupId, JSON.stringify(mlsGroup.exportState()));
66342
66428
  const payload = {
66343
66429
  conversation_id: mlsConvId,
@@ -66363,13 +66449,26 @@ var init_channel = __esm2({
66363
66449
  }
66364
66450
  }
66365
66451
  }
66366
- if (sentCount === 0 && this._sessions.size > 0) {
66367
- console.warn("[SecureChannel] send() delivered to 0 sessions (all skipped or failed)");
66452
+ if (sentCount === 0) {
66453
+ console.warn(
66454
+ `[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})`
66455
+ );
66368
66456
  }
66369
66457
  await this._persistState();
66458
+ const wsReady = this._ws?.readyState === 1;
66459
+ if (pendingWsSends.length > 0 && !wsReady) {
66460
+ console.warn(
66461
+ `[SecureChannel] send(): ${pendingWsSends.length} frame(s) NOT written \u2014 socket readyState=${this._ws?.readyState ?? "null"} (1=OPEN)`
66462
+ );
66463
+ }
66370
66464
  for (const frame of pendingWsSends) {
66371
66465
  this._ws.send(frame);
66372
66466
  }
66467
+ if (pendingWsSends.length > 0) {
66468
+ console.log(
66469
+ `[SecureChannel] send(): wrote ${pendingWsSends.length} frame(s) to the socket (sentCount=${sentCount}, readyState=${this._ws?.readyState ?? "null"})`
66470
+ );
66471
+ }
66373
66472
  if (!options?.isResend) {
66374
66473
  for (const mlsGroupId of addressedMlsGroupIds) {
66375
66474
  this._rememberRetryCandidate(mlsGroupId, plaintext, options);
@@ -66419,7 +66518,7 @@ var init_channel = __esm2({
66419
66518
  */
66420
66519
  sendActivitySpan(spanData) {
66421
66520
  if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
66422
- const pluginVersion = true ? "0.23.23" : "0.0.0-dev";
66521
+ const pluginVersion = true ? "0.23.27" : "0.0.0-dev";
66423
66522
  const agentName = this.config.agentName ?? "Agent";
66424
66523
  const resource = {
66425
66524
  "service.name": "agentvault-agent",
@@ -66629,7 +66728,13 @@ var init_channel = __esm2({
66629
66728
  if (mlsGroup?.isInitialized) {
66630
66729
  try {
66631
66730
  const plaintextBytes = new TextEncoder().encode(plaintext);
66632
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
66731
+ const ciphertext = await this._encryptWithCatchUp(
66732
+ mlsGroup,
66733
+ roomId,
66734
+ room.mlsGroupId,
66735
+ `room ${roomId.slice(0, 8)}`,
66736
+ plaintextBytes
66737
+ );
66633
66738
  await saveMlsState(this.config.dataDir, room.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66634
66739
  if (this._state === "ready" && this._ws) {
66635
66740
  this._ws.send(JSON.stringify({
@@ -66880,11 +66985,17 @@ var init_channel = __esm2({
66880
66985
  const before = this._persisted.groupId;
66881
66986
  this._sessionGroupIds = new Map(mine.map((r2) => [r2.id, r2.group_id]));
66882
66987
  this._persisted.conversationGroupIds = Object.fromEntries(this._sessionGroupIds);
66883
- const primary = mine.find((r2) => r2.id === this._persisted.primaryConversationId) ?? mine[0];
66988
+ const joined = mine.find((r2) => this._persisted.mlsGroups?.[r2.group_id]);
66989
+ const primary = mine.find((r2) => r2.id === this._persisted.primaryConversationId) ?? joined ?? mine[0];
66990
+ if (!mine.some((r2) => r2.id === this._persisted.primaryConversationId)) {
66991
+ console.log(
66992
+ `[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"}`
66993
+ );
66994
+ }
66884
66995
  this._persisted.groupId = primary.group_id;
66885
66996
  this._persisted.primaryConversationId = primary.id;
66886
66997
  const liveGroupIds = new Set(mine.map((r2) => r2.group_id));
66887
- if (!liveGroupIds.has(staleGid)) {
66998
+ if (staleGid && staleMlsGroupId && !liveGroupIds.has(staleGid)) {
66888
66999
  try {
66889
67000
  await deleteMlsState(this.config.dataDir, staleMlsGroupId);
66890
67001
  } catch {
@@ -66898,9 +67009,9 @@ var init_channel = __esm2({
66898
67009
  }
66899
67010
  await this._persistState();
66900
67011
  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))`
67012
+ `[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
67013
  );
66903
- return this._persisted.groupId !== before && before === staleGid;
67014
+ return staleGid !== void 0 && this._persisted.groupId !== before && before === staleGid;
66904
67015
  }
66905
67016
  /**
66906
67017
  * Resend the message that a now-reconciled `unknown group` refusal killed (#732).
@@ -67808,7 +67919,13 @@ var init_channel = __esm2({
67808
67919
  const mlsGroup = this._mlsGroups.get(`a2a:${channelEntry.channelId}`);
67809
67920
  if (mlsGroup?.isInitialized) {
67810
67921
  const plaintextBytes = new TextEncoder().encode(text);
67811
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
67922
+ const ciphertext = await this._encryptWithCatchUp(
67923
+ mlsGroup,
67924
+ `a2a:${channelEntry.channelId}`,
67925
+ channelEntry.mlsGroupId,
67926
+ `A2A ${channelEntry.channelId.slice(0, 8)}`,
67927
+ plaintextBytes
67928
+ );
67812
67929
  await saveMlsState(
67813
67930
  this.config.dataDir,
67814
67931
  channelEntry.mlsGroupId,
@@ -67856,7 +67973,13 @@ var init_channel = __esm2({
67856
67973
  const mlsGroup = this._mlsGroups.get(`a2a:${channelId}`);
67857
67974
  if (mlsGroup?.isInitialized) {
67858
67975
  const plaintextBytes = new TextEncoder().encode(text);
67859
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
67976
+ const ciphertext = await this._encryptWithCatchUp(
67977
+ mlsGroup,
67978
+ `a2a:${channelId}`,
67979
+ entry.mlsGroupId,
67980
+ `A2A ${channelId.slice(0, 8)}`,
67981
+ plaintextBytes
67982
+ );
67860
67983
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
67861
67984
  this._ws.send(JSON.stringify({
67862
67985
  event: "a2a_message_mls",
@@ -68302,6 +68425,9 @@ var init_channel = __esm2({
68302
68425
  await this._pullDrDeliveryQueue();
68303
68426
  await this._flushOutboundQueue();
68304
68427
  this._setState("ready");
68428
+ void this._reconcileConversationGroups().catch((err) => {
68429
+ console.warn("[SecureChannel] Conversation-group reconcile failed:", err);
68430
+ });
68305
68431
  void this._reconcileRoomsWithServer().catch(
68306
68432
  (err) => console.warn(`[SecureChannel] room reconcile failed (ignored): ${err instanceof Error ? err.message : String(err)}`)
68307
68433
  ).then(() => this._quarantineOrphanedMlsGroups()).catch(
@@ -68329,7 +68455,7 @@ var init_channel = __esm2({
68329
68455
  agentVersion: this.config.agentVersion ?? "0.0.0",
68330
68456
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68331
68457
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68332
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68458
+ pluginVersion: true ? "0.23.27" : "0.0.0-dev"
68333
68459
  });
68334
68460
  this._telemetryReporter.startAutoFlush(3e4);
68335
68461
  }
@@ -68653,7 +68779,7 @@ var init_channel = __esm2({
68653
68779
  agentVersion: this.config.agentVersion ?? "0.0.0",
68654
68780
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68655
68781
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68656
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68782
+ pluginVersion: true ? "0.23.27" : "0.0.0-dev"
68657
68783
  });
68658
68784
  this._telemetryReporter.startAutoFlush(3e4);
68659
68785
  }
@@ -69019,6 +69145,7 @@ var init_channel = __esm2({
69019
69145
  const convGroupId = data.conversation_group_id;
69020
69146
  const groupId = data.group_id;
69021
69147
  const senderDeviceId = data.sender_device_id;
69148
+ this._noteGroupEpoch(groupId, data.epoch);
69022
69149
  if (senderDeviceId === this._deviceId) return;
69023
69150
  let mgr;
69024
69151
  let mgrKey;
@@ -69096,9 +69223,15 @@ var init_channel = __esm2({
69096
69223
  })),
69097
69224
  no_history: filtered.length === 0
69098
69225
  });
69099
- if (mgr && mlsGroupId) {
69226
+ if (mgr && mgrKey && mlsGroupId) {
69100
69227
  const responseBytes = new TextEncoder().encode(responsePayload);
69101
- const cipher = await mgr.encrypt(responseBytes);
69228
+ const cipher = await this._encryptWithCatchUp(
69229
+ mgr,
69230
+ mgrKey,
69231
+ mlsGroupId,
69232
+ `history catch-up ${mlsGroupId.slice(0, 8)}`,
69233
+ responseBytes
69234
+ );
69102
69235
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mgr.exportState()));
69103
69236
  if (this._ws) {
69104
69237
  this._ws.send(JSON.stringify({
@@ -69841,9 +69974,36 @@ ${messageText}`;
69841
69974
  // ---------------------------------------------------------------------------
69842
69975
  // MLS room message handlers
69843
69976
  // ---------------------------------------------------------------------------
69977
+ /**
69978
+ * #1014: hand a room-framed frame to the handler that owns its group family.
69979
+ *
69980
+ * Returns true when the frame was routed. Routing means "give it to the right
69981
+ * handler" — NOT "decrypt it as a room message". Decrypting under the wrong
69982
+ * group is how MLS state gets corrupted, so an unplaceable group is left for
69983
+ * the caller to self-heal rather than guessed at.
69984
+ */
69985
+ async _routeNonRoomGroupMessage(data, groupId) {
69986
+ const target = this._resolveGroupFamily(groupId);
69987
+ if (!target || target.family === "room") return false;
69988
+ console.log(
69989
+ `[SecureChannel] Room-framed message belongs to ${target.label} \u2014 routing to the handler that owns it (#1014)`
69990
+ );
69991
+ switch (target.family) {
69992
+ case "shared1to1":
69993
+ await this._handleMessageMLS({ ...data, conversation_group_id: target.id });
69994
+ return true;
69995
+ case "conversation":
69996
+ await this._handleMessageMLS({ ...data, conversation_id: target.id });
69997
+ return true;
69998
+ case "a2a":
69999
+ await this._handleA2AMessageMLS({ ...data, a2a_channel_id: target.id });
70000
+ return true;
70001
+ }
70002
+ }
69844
70003
  async _handleRoomMessageMLS(data) {
69845
70004
  const groupId = data.group_id;
69846
70005
  const senderDeviceId = data.sender_device_id;
70006
+ this._noteGroupEpoch(groupId, data.epoch);
69847
70007
  if (senderDeviceId === this._deviceId) return;
69848
70008
  let roomId;
69849
70009
  for (const [rid, room] of Object.entries(this._persisted?.rooms ?? {})) {
@@ -69857,7 +70017,9 @@ ${messageText}`;
69857
70017
  roomId = data.room_id;
69858
70018
  console.log(`[SecureChannel] Room ${roomId.slice(0, 8)} matched by room_id (group_id ${groupId?.slice(0, 8)} mismatch)`);
69859
70019
  } else {
70020
+ if (await this._routeNonRoomGroupMessage(data, groupId)) return;
69860
70021
  console.warn(`[SecureChannel] No room found for MLS group ${groupId?.slice(0, 8)}`);
70022
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
69861
70023
  return;
69862
70024
  }
69863
70025
  }
@@ -70045,63 +70207,63 @@ ${messageText}`;
70045
70207
  console.warn(`[SecureChannel] Failed to send history catchup response:`, sendErr);
70046
70208
  }
70047
70209
  }
70210
+ /**
70211
+ * Apply an inbound MLS commit to whichever group it addresses.
70212
+ *
70213
+ * Returns TRUE when the commit was applied, buffered, or handed to the
70214
+ * failure ladder — i.e. when this agent has taken responsibility for it and
70215
+ * the delivery-queue row may safely be acked. Returns FALSE when the commit
70216
+ * names a group we hold no state for at all, so the caller must NACK and let
70217
+ * the row survive.
70218
+ *
70219
+ * #1012: this used to loop `rooms` then `a2aChannels` and then END. A shared
70220
+ * 1:1 commit (`mlsGroups`) and a legacy per-conversation commit
70221
+ * (`mlsConversations`) matched neither. The call fell off the bottom with no
70222
+ * log, no buffer and no error — and because it did not THROW,
70223
+ * `_pullDeliveryQueue` acked the row and the server deleted it. An MLS commit
70224
+ * cannot be regenerated, so the agent stayed at epoch N while the group moved
70225
+ * to N+1 and every later send() encrypted at an epoch nobody could decrypt.
70226
+ *
70227
+ * The families are enumerated in ONE list resolved by ONE loop, because two
70228
+ * hand-copied loops are exactly how the 1:1 path fell two families behind.
70229
+ */
70048
70230
  async _handleMlsCommit(data) {
70049
70231
  const groupId = data.group_id;
70050
70232
  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;
70233
+ this._noteGroupEpoch(groupId, data.epoch);
70234
+ const target = this._resolveGroupFamily(groupId);
70235
+ if (target) {
70236
+ const { managerKey: groupKey, label } = target;
70237
+ const mlsGroup = this._mlsGroups.get(groupKey);
70238
+ if (!mlsGroup?.isInitialized) {
70239
+ this._bufferMlsCommit(groupId, epoch, data);
70240
+ console.log(`[SecureChannel] Buffered MLS commit for ${label} (epoch=${epoch}, group not initialized)`);
70241
+ return true;
70076
70242
  }
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;
70243
+ try {
70244
+ const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
70245
+ await mlsGroup.processCommit(commitBytes);
70246
+ await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
70247
+ console.log(`[SecureChannel] MLS commit processed for ${label} (epoch=${mlsGroup.epoch})`);
70248
+ this._mlsCommitFailCounts.delete(groupKey);
70249
+ } catch (err) {
70250
+ await this._onCommitFailure(
70251
+ groupKey,
70252
+ groupId,
70253
+ err,
70254
+ label,
70255
+ data.epoch,
70256
+ mlsGroup.epoch
70257
+ );
70103
70258
  }
70259
+ return true;
70104
70260
  }
70261
+ this._bufferMlsCommit(groupId, epoch, data);
70262
+ console.warn(
70263
+ `[SecureChannel] MLS commit for UNKNOWN group ${String(groupId).slice(0, 8)} (epoch=${epoch}) \u2014 buffered, requesting a Welcome, NOT acked`
70264
+ );
70265
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
70266
+ return false;
70105
70267
  }
70106
70268
  /** Buffer an MLS commit for replay after Welcome join. Max 50 per group. */
70107
70269
  _bufferMlsCommit(groupId, epoch, data) {
@@ -70291,6 +70453,229 @@ ${messageText}`;
70291
70453
  * `1to1-group:<gid>`, `a2a:<channelId>`, …) — also the decrypt-fail-count key.
70292
70454
  * @param groupId the server-side MLS group id used in the /sync URL.
70293
70455
  */
70456
+ /**
70457
+ * #1015 — the ONLY place this package fulfils a Welcome request.
70458
+ *
70459
+ * Add the members, transmit, and record `fulfilled` **only for the targets
70460
+ * whose frames actually went out**. Every one of the five sites that used to
70461
+ * do this by hand followed the same sequence and had the same three holes:
70462
+ * `this._ws` null (both sends skipped by an `if`), a CLOSING socket (`send`
70463
+ * returns without throwing), and a fulfil POST wrapped in `.catch(() => {})`.
70464
+ *
70465
+ * A fulfilled request is never re-requested. So a Welcome that evaporated
70466
+ * left the requester locked out, the fulfiller at epoch N+1, everyone else at
70467
+ * N, and the bridging commit untransmitted and impossible to regenerate — a
70468
+ * permanently wedged group in which every signal read success.
70469
+ *
70470
+ * Returns the number of requests actually fulfilled.
70471
+ */
70472
+ async _fulfilWelcomeRequests(opts) {
70473
+ const { mlsGroup, mlsGroupId, label, welcomeFields, targets } = opts;
70474
+ if (targets.length === 0) return 0;
70475
+ if (!this._wsIsOpen()) {
70476
+ console.warn(
70477
+ `[SecureChannel] ${label}: socket is not OPEN \u2014 NOT advancing the group; ${targets.length} request(s) stay pending for the next pull`
70478
+ );
70479
+ return 0;
70480
+ }
70481
+ const snapshot = mlsGroup.exportState();
70482
+ const { commit, welcome } = await mlsGroup.addMembers(targets.map((t2) => t2.keyPackage));
70483
+ const commitSent = await this._sendFrameConfirmed(
70484
+ {
70485
+ event: "mls_commit",
70486
+ data: {
70487
+ group_id: mlsGroupId,
70488
+ epoch: Number(mlsGroup.epoch),
70489
+ payload: Buffer.from(commit).toString("hex")
70490
+ }
70491
+ },
70492
+ `${label} commit`
70493
+ );
70494
+ if (!commitSent) {
70495
+ mlsGroup.importState(snapshot);
70496
+ console.warn(
70497
+ `[SecureChannel] ${label}: commit was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing fulfilled, requests stay pending`
70498
+ );
70499
+ return 0;
70500
+ }
70501
+ await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70502
+ if (!welcome) {
70503
+ console.warn(
70504
+ `[SecureChannel] ${label}: addMembers produced NO Welcome \u2014 ${targets.length} request(s) left pending rather than marked fulfilled`
70505
+ );
70506
+ return 0;
70507
+ }
70508
+ let fulfilled = 0;
70509
+ for (const t2 of targets) {
70510
+ const sent = await this._sendFrameConfirmed(
70511
+ {
70512
+ event: "mls_welcome",
70513
+ data: {
70514
+ target_device_id: t2.deviceId,
70515
+ group_id: mlsGroupId,
70516
+ ...welcomeFields,
70517
+ payload: Buffer.from(welcome).toString("hex")
70518
+ }
70519
+ },
70520
+ `${label} welcome\u2192${t2.deviceId.slice(0, 8)}`
70521
+ );
70522
+ if (!sent) {
70523
+ console.warn(
70524
+ `[SecureChannel] ${label}: Welcome for ${t2.deviceId.slice(0, 8)} was NOT written \u2014 leaving the request PENDING so the next pull retries it`
70525
+ );
70526
+ continue;
70527
+ }
70528
+ if (await this._postFulfilWelcome(mlsGroupId, t2.requestId, `${label} ${t2.deviceId.slice(0, 8)}`)) {
70529
+ fulfilled++;
70530
+ }
70531
+ }
70532
+ return fulfilled;
70533
+ }
70534
+ /**
70535
+ * Resolve an MLS group id to the family that owns it.
70536
+ *
70537
+ * There are four: rooms, A2A channels, shared 1:1 groups, and the legacy
70538
+ * per-conversation groups. They all store the MLS group id under
70539
+ * `mlsGroupId`; they differ only in the key their live `MLSGroupManager` sits
70540
+ * under in `_mlsGroups`.
70541
+ *
70542
+ * ONE resolver, because hand-copying this enumeration is what produced
70543
+ * #1012 (`_handleMlsCommit` handled two families of four), #1014 (a
70544
+ * room-framed 1:1 replay matched nothing) and #1016 (a rejoin dropped only
70545
+ * the room manager). Three bugs, one missing abstraction.
70546
+ */
70547
+ _resolveGroupFamily(mlsGroupId) {
70548
+ if (!mlsGroupId) return null;
70549
+ const families = [
70550
+ { family: "room", entries: this._persisted?.rooms, managerKey: (id) => id, label: "room" },
70551
+ { family: "a2a", entries: this._persisted?.a2aChannels, managerKey: (id) => `a2a:${id}`, label: "A2A" },
70552
+ { family: "shared1to1", entries: this._persisted?.mlsGroups, managerKey: (id) => `1to1-group:${id}`, label: "1:1 group" },
70553
+ { family: "conversation", entries: this._persisted?.mlsConversations, managerKey: (id) => `conv:${id}`, label: "conversation" }
70554
+ ];
70555
+ for (const f2 of families) {
70556
+ for (const [id, entry] of Object.entries(f2.entries ?? {})) {
70557
+ if (entry?.mlsGroupId !== mlsGroupId) continue;
70558
+ return {
70559
+ family: f2.family,
70560
+ id,
70561
+ managerKey: f2.managerKey(id),
70562
+ label: `${f2.label} ${id.slice(0, 8)}`
70563
+ };
70564
+ }
70565
+ }
70566
+ return null;
70567
+ }
70568
+ /** #1015: is the socket in a state that can actually take a write? */
70569
+ _wsIsOpen() {
70570
+ return this._ws?.readyState === 1;
70571
+ }
70572
+ /**
70573
+ * #1015: write one frame and report whether the socket took it.
70574
+ *
70575
+ * `ws.send()` on a CLOSING or CLOSED socket returns WITHOUT throwing — the
70576
+ * error surfaces only through the callback, and every fulfilment site passed
70577
+ * none. So a Welcome could be "sent" into a dead socket and the requester
70578
+ * still marked `fulfilled`, which it never re-requests.
70579
+ */
70580
+ _sendFrameConfirmed(frame, label) {
70581
+ const ws = this._ws;
70582
+ if (!ws || ws.readyState !== 1) {
70583
+ console.warn(
70584
+ `[SecureChannel] ${label}: socket is not OPEN (readyState=${ws?.readyState ?? "null"}) \u2014 frame NOT written`
70585
+ );
70586
+ return Promise.resolve(false);
70587
+ }
70588
+ return new Promise((resolve3) => {
70589
+ try {
70590
+ ws.send(JSON.stringify(frame), (err) => {
70591
+ if (err) {
70592
+ console.error(`[SecureChannel] ${label}: socket refused the frame:`, err);
70593
+ resolve3(false);
70594
+ } else {
70595
+ resolve3(true);
70596
+ }
70597
+ });
70598
+ } catch (err) {
70599
+ console.error(`[SecureChannel] ${label}: send threw:`, err);
70600
+ resolve3(false);
70601
+ }
70602
+ });
70603
+ }
70604
+ /**
70605
+ * #1015: record a Welcome as fulfilled — and say so when it fails.
70606
+ *
70607
+ * The batched shared-1:1 site posted this with `.catch(() => {})`, so an HTTP
70608
+ * failure here was invisible on top of the sends being invisible.
70609
+ */
70610
+ async _postFulfilWelcome(mlsGroupId, requestId, label) {
70611
+ try {
70612
+ const res = await fetch(
70613
+ `${this.config.apiUrl}/api/v1/mls/groups/${mlsGroupId}/fulfill-welcome`,
70614
+ {
70615
+ method: "POST",
70616
+ headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70617
+ body: JSON.stringify({ request_id: requestId })
70618
+ }
70619
+ );
70620
+ if (!res.ok) {
70621
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome returned ${res.status}`);
70622
+ return false;
70623
+ }
70624
+ return true;
70625
+ } catch (err) {
70626
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome failed:`, err);
70627
+ return false;
70628
+ }
70629
+ }
70630
+ /**
70631
+ * #1013: fold an epoch seen on an inbound frame into the running hint.
70632
+ *
70633
+ * Anything unusable (missing, non-numeric, negative, fractional) is ignored
70634
+ * rather than defaulted. A defaulted epoch here would be a guess, and a guess
70635
+ * that reads LOW silently disables the check it exists to drive.
70636
+ */
70637
+ _noteGroupEpoch(mlsGroupId, epoch) {
70638
+ if (typeof mlsGroupId !== "string" || !mlsGroupId) return;
70639
+ const current = this._observedGroupEpochs.get(mlsGroupId) ?? 0;
70640
+ const next = noteObservedEpoch(current, epoch);
70641
+ if (next > current) this._observedGroupEpochs.set(mlsGroupId, next);
70642
+ }
70643
+ /**
70644
+ * #1013 — the ONLY place this package encrypts an MLS application message.
70645
+ *
70646
+ * Catch up first when the group is known to have moved past us, then encrypt.
70647
+ * Hermes has done this since #440 and the browser since #996; the plugin —
70648
+ * the implementation that actually ships to customers — did not, so an agent
70649
+ * that missed one commit encrypted every later message at an epoch nobody
70650
+ * could read, while the server stored and relayed it and every signal read
70651
+ * 200.
70652
+ *
70653
+ * Funnelling every sender through one method is the point. A guard applied at
70654
+ * seven call sites decays to a guard applied at six: that is exactly how
70655
+ * `_handleMlsCommit` ended up handling two group families out of four (#1012).
70656
+ *
70657
+ * Best-effort by design. A catch-up that cannot replay — or that throws — must
70658
+ * NOT block the send; we fall through and encrypt at the local epoch, which is
70659
+ * precisely today's behaviour, so the worst case is no regression.
70660
+ */
70661
+ async _encryptWithCatchUp(mlsGroup, groupKey, mlsGroupId, label, plaintextBytes) {
70662
+ const observed = this._observedGroupEpochs.get(mlsGroupId);
70663
+ if (observed !== void 0 && needsPreSendResync(Number(mlsGroup.epoch), observed)) {
70664
+ console.warn(
70665
+ `[SecureChannel] ${label}: local epoch ${Number(mlsGroup.epoch)} is behind the observed group epoch ${observed} \u2014 catching up before encrypt (#1013)`
70666
+ );
70667
+ try {
70668
+ if (!await this._resyncOnDivergence(groupKey, mlsGroupId)) {
70669
+ console.warn(
70670
+ `[SecureChannel] ${label}: catch-up could not replay \u2014 sending at epoch ${Number(mlsGroup.epoch)}. Peers past this epoch will not read it.`
70671
+ );
70672
+ }
70673
+ } catch (err) {
70674
+ console.warn(`[SecureChannel] ${label}: catch-up failed:`, err);
70675
+ }
70676
+ }
70677
+ return await mlsGroup.encrypt(plaintextBytes);
70678
+ }
70294
70679
  async _resyncOnDivergence(groupKey, groupId) {
70295
70680
  const mgr = this._mlsGroups.get(groupKey);
70296
70681
  if (!mgr?.isInitialized) return false;
@@ -70317,11 +70702,24 @@ ${messageText}`;
70317
70702
  applied++;
70318
70703
  }
70319
70704
  if (applied === 0) {
70320
- console.log(`[SecureChannel] Resync for ${groupId.slice(0, 8)}: no new commits to apply (epoch=${mgr.epoch})`);
70705
+ const strikes = (this._resyncNoOpStrikes.get(groupKey) ?? 0) + 1;
70706
+ this._resyncNoOpStrikes.set(groupKey, strikes);
70707
+ if (strikes < _SecureChannel.MAX_RESYNC_NOOPS) {
70708
+ this._mlsDecryptFailCounts.delete(groupKey);
70709
+ console.log(
70710
+ `[SecureChannel] Resync for ${groupId.slice(0, 8)}: already current at epoch=${mgr.epoch} (strike ${strikes}/${_SecureChannel.MAX_RESYNC_NOOPS}) \u2014 NOT re-keying (#1084)`
70711
+ );
70712
+ return true;
70713
+ }
70714
+ this._resyncNoOpStrikes.delete(groupKey);
70715
+ console.warn(
70716
+ `[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`
70717
+ );
70321
70718
  return false;
70322
70719
  }
70323
70720
  await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mgr.exportState()));
70324
70721
  this._mlsDecryptFailCounts.delete(groupKey);
70722
+ this._resyncNoOpStrikes.delete(groupKey);
70325
70723
  console.log(
70326
70724
  `[SecureChannel] Resync-on-divergence: applied ${applied} missed commit(s) for ${groupId.slice(0, 8)}, now epoch=${mgr.epoch}`
70327
70725
  );
@@ -70708,6 +71106,7 @@ ${messageText}`;
70708
71106
  const ackedIds = [];
70709
71107
  const nackedIds = [];
70710
71108
  for (const msg of messages) {
71109
+ let handled = true;
70711
71110
  try {
70712
71111
  if (msg.sender_device_id === this._deviceId) {
70713
71112
  ackedIds.push(msg.queue_id);
@@ -70724,7 +71123,7 @@ ${messageText}`;
70724
71123
  a2a_channel_id: msg.a2a_channel_id
70725
71124
  });
70726
71125
  } else if (msg.message_type === "commit") {
70727
- await this._handleMlsCommit({
71126
+ handled = await this._handleMlsCommit({
70728
71127
  group_id: msg.group_id,
70729
71128
  sender_device_id: msg.sender_device_id,
70730
71129
  epoch: msg.epoch,
@@ -70762,9 +71161,18 @@ ${messageText}`;
70762
71161
  conversation_group_id: msg.conversation_group_id,
70763
71162
  created_at: msg.created_at
70764
71163
  });
71164
+ } else {
71165
+ handled = false;
71166
+ console.warn(
71167
+ `[SecureChannel] Delivery application row ${String(msg.message_id).slice(0, 8)} names no room, A2A channel or conversation \u2014 NOT acked`
71168
+ );
70765
71169
  }
70766
71170
  }
70767
- ackedIds.push(msg.queue_id);
71171
+ if (handled) {
71172
+ ackedIds.push(msg.queue_id);
71173
+ } else {
71174
+ nackedIds.push(msg.queue_id);
71175
+ }
70768
71176
  } catch (err) {
70769
71177
  console.warn(`[SecureChannel] Delivery ${msg.message_type} processing failed:`, err);
70770
71178
  nackedIds.push(msg.queue_id);
@@ -70833,38 +71241,20 @@ ${messageText}`;
70833
71241
  if (!kpHex) continue;
70834
71242
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
70835
71243
  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
- }));
71244
+ const done = await this._fulfilWelcomeRequests({
71245
+ mlsGroup,
71246
+ mlsGroupId: roomState.mlsGroupId,
71247
+ label: `room ${roomId.slice(0, 8)}`,
71248
+ welcomeFields: { room_id: roomId },
71249
+ targets: [{
71250
+ requestId: req.id,
71251
+ deviceId: req.requesting_device_id,
71252
+ keyPackage: memberKp
71253
+ }]
71254
+ });
71255
+ if (done > 0) {
71256
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in room ${roomId.slice(0, 8)}`);
70857
71257
  }
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
71258
  } catch (fulfillErr) {
70869
71259
  console.warn(`[SecureChannel] Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
70870
71260
  }
@@ -70903,65 +71293,38 @@ ${messageText}`;
70903
71293
  validReqs.push({ req, kp: MLSGroupManager.deserializeKeyPackage(kpBytes) });
70904
71294
  }
70905
71295
  if (validReqs.length === 0) continue;
71296
+ const sharedTargets = validReqs.map((v2) => ({
71297
+ requestId: v2.req.id,
71298
+ deviceId: v2.req.requesting_device_id,
71299
+ keyPackage: v2.kp
71300
+ }));
70906
71301
  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
- });
71302
+ const done = await this._fulfilWelcomeRequests({
71303
+ mlsGroup,
71304
+ mlsGroupId: entry.mlsGroupId,
71305
+ label: `shared group ${gid.slice(0, 8)}`,
71306
+ welcomeFields: { conversation_group_id: gid },
71307
+ targets: sharedTargets
71308
+ });
71309
+ if (done > 0) {
71310
+ console.log(`[SecureChannel] Batched Welcome for ${done}/${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70941
71311
  }
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
71312
  } catch (batchErr) {
70945
71313
  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) {
71314
+ for (const target of sharedTargets) {
70947
71315
  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(() => {
71316
+ const done = await this._fulfilWelcomeRequests({
71317
+ mlsGroup,
71318
+ mlsGroupId: entry.mlsGroupId,
71319
+ label: `shared group ${gid.slice(0, 8)}`,
71320
+ welcomeFields: { conversation_group_id: gid },
71321
+ targets: [target]
70960
71322
  });
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)}`);
71323
+ if (done > 0) {
71324
+ console.log(`[SecureChannel] Individual Welcome for ${target.deviceId.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
71325
+ }
70963
71326
  } catch (indivErr) {
70964
- console.warn(`[SecureChannel] Individual Welcome failed for ${req.requesting_device_id.slice(0, 8)}:`, indivErr);
71327
+ console.warn(`[SecureChannel] Individual Welcome failed for ${target.deviceId.slice(0, 8)}:`, indivErr);
70965
71328
  }
70966
71329
  }
70967
71330
  }
@@ -70993,38 +71356,20 @@ ${messageText}`;
70993
71356
  if (!kpHex) continue;
70994
71357
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
70995
71358
  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
- }));
71359
+ const done = await this._fulfilWelcomeRequests({
71360
+ mlsGroup,
71361
+ mlsGroupId: convEntry.mlsGroupId,
71362
+ label: `conv ${convId.slice(0, 8)}`,
71363
+ welcomeFields: { conversation_id: convId },
71364
+ targets: [{
71365
+ requestId: req.id,
71366
+ deviceId: req.requesting_device_id,
71367
+ keyPackage: memberKp
71368
+ }]
71369
+ });
71370
+ if (done > 0) {
71371
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
71017
71372
  }
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
71373
  } catch (fulfillErr) {
71029
71374
  console.warn(`[SecureChannel] Conv Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
71030
71375
  }
@@ -71205,12 +71550,13 @@ ${messageText}`;
71205
71550
  } catch (distErr) {
71206
71551
  console.warn(`[SecureChannel] A2A distribute network error:`, distErr);
71207
71552
  }
71208
- if (!distributed && this._ws) {
71209
- this._ws.send(JSON.stringify({
71553
+ if (!distributed && this._wsIsOpen()) {
71554
+ const a2aLabel = `A2A ${chId.slice(0, 8)}`;
71555
+ const commitSent = await this._sendFrameConfirmed({
71210
71556
  event: "mls_commit",
71211
71557
  data: { group_id: chState.mlsGroupId, epoch: Number(mlsGroup.epoch), payload: commitHex }
71212
- }));
71213
- this._ws.send(JSON.stringify({
71558
+ }, `${a2aLabel} commit`);
71559
+ const welcomeSent = commitSent && await this._sendFrameConfirmed({
71214
71560
  event: "mls_welcome",
71215
71561
  data: {
71216
71562
  target_device_id: req.requesting_device_id,
@@ -71218,7 +71564,11 @@ ${messageText}`;
71218
71564
  a2a_channel_id: chId,
71219
71565
  payload: welcomeHex
71220
71566
  }
71221
- }));
71567
+ }, `${a2aLabel} welcome`);
71568
+ if (!welcomeSent) {
71569
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome frames were NOT written \u2014 leaving the request pending`);
71570
+ continue;
71571
+ }
71222
71572
  try {
71223
71573
  await fetch(
71224
71574
  `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
@@ -71241,16 +71591,15 @@ ${messageText}`;
71241
71591
  console.warn(`[SecureChannel] Cannot deliver Welcome \u2014 HTTP distribute and WS both unavailable`);
71242
71592
  continue;
71243
71593
  }
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
- }
71594
+ const a2aFulfilled = await this._postFulfilWelcome(
71595
+ chState.mlsGroupId,
71596
+ req.id,
71597
+ `A2A ${chId.slice(0, 8)}`
71251
71598
  );
71252
71599
  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)}`);
71600
+ if (a2aFulfilled) {
71601
+ console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
71602
+ }
71254
71603
  } catch (fulfillErr) {
71255
71604
  console.log(`[SecureChannel] A2A Welcome fulfill failed: ${fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr)}`);
71256
71605
  }
@@ -71424,12 +71773,12 @@ ${messageText}`;
71424
71773
  console.log(`[SecureChannel] MLS sync: re-join needed for group ${groupId?.slice(0, 8)}`);
71425
71774
  if (groupId) {
71426
71775
  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
- }
71776
+ const target = this._resolveGroupFamily(groupId);
71777
+ if (target) {
71778
+ this._mlsGroups.delete(target.managerKey);
71779
+ console.log(`[SecureChannel] MLS sync rejoin: dropped local state for ${target.label}`);
71432
71780
  }
71781
+ void this._requestWelcomeSelfHeal(groupId);
71433
71782
  }
71434
71783
  } else if (action === "replay_complete") {
71435
71784
  console.log(`[SecureChannel] MLS sync complete for group ${groupId?.slice(0, 8)} (${data.count} messages replayed)`);
@@ -71441,6 +71790,7 @@ ${messageText}`;
71441
71790
  */
71442
71791
  async _handleA2AMessageMLS(data) {
71443
71792
  const groupId = data.group_id;
71793
+ this._noteGroupEpoch(groupId, data.epoch);
71444
71794
  const channelId = data.channel_id ?? data.a2a_channel_id;
71445
71795
  let a2aChannelId = channelId;
71446
71796
  if (!a2aChannelId) {
@@ -98664,7 +99014,7 @@ var init_index = __esm2({
98664
99014
  init_skill_invoker();
98665
99015
  await init_skill_telemetry();
98666
99016
  await init_policy_enforcer();
98667
- VERSION = true ? "0.23.23" : "0.0.0-dev";
99017
+ VERSION = true ? "0.23.27" : "0.0.0-dev";
98668
99018
  }
98669
99019
  });
98670
99020
  await init_index();
@@ -99567,7 +99917,7 @@ async function main() {
99567
99917
  const info = parseInfoFlags(process.argv.slice(2));
99568
99918
  if (info.showVersion || info.showHelp) {
99569
99919
  const version2 = buildClientVersion2(
99570
- true ? "0.4.0" : void 0
99920
+ true ? "0.4.1" : void 0
99571
99921
  );
99572
99922
  if (info.showVersion) printLine(version2);
99573
99923
  if (info.showHelp) {
@@ -99620,7 +99970,7 @@ async function main() {
99620
99970
  agentName: cfg.agentName,
99621
99971
  platform: "node",
99622
99972
  clientVersion: buildClientVersion2(
99623
- true ? "0.4.0" : void 0
99973
+ true ? "0.4.1" : void 0
99624
99974
  )
99625
99975
  });
99626
99976
  channelRef = channel;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/grok-bridge",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "AgentVault Grok Bridge — daemon bridging a Grok agent into E2E-encrypted AgentVault direct messages and rooms.",
6
6
  "main": "dist/index.js",