@agentvault/claude-bridge 0.8.0 → 0.8.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
@@ -45906,6 +45906,11 @@ var init_ratchet = __esm2({
45906
45906
  return { header, headerSignature, ciphertext, nonce };
45907
45907
  }
45908
45908
  decrypt(message) {
45909
+ if (this.state.peerIdentityPublicKey) {
45910
+ if (!verifyHeaderSignature(message.header, message.headerSignature, this.state.peerIdentityPublicKey)) {
45911
+ throw new Error("Header signature verification failed");
45912
+ }
45913
+ }
45909
45914
  const isV2 = message.envelopeVersion === "2.0.0" && message.encryptedHeader != null && message.headerNonce != null;
45910
45915
  const skippedResult = this.trySkippedKeys(message, isV2);
45911
45916
  if (skippedResult !== null) {
@@ -45919,14 +45924,14 @@ var init_ratchet = __esm2({
45919
45924
  if (message.header.messageNumber === 0) {
45920
45925
  try {
45921
45926
  const { messageKey: testKey, headerKey: testHeaderKey, nextChainKey: nextChainKey2 } = kdfChainKey(this.state.rootKey);
45922
- let ad2;
45927
+ let ad;
45923
45928
  if (isV2) {
45924
45929
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, testHeaderKey);
45925
- ad2 = message.encryptedHeader;
45930
+ ad = message.encryptedHeader;
45926
45931
  } else {
45927
- ad2 = serializeHeader(message.header);
45932
+ ad = serializeHeader(message.header);
45928
45933
  }
45929
- const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad2, message.nonce, testKey);
45934
+ const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, testKey);
45930
45935
  this.state.dhReceivingPublicKey = message.header.dhPublicKey;
45931
45936
  this.state.receivingChain = {
45932
45937
  chainKey: nextChainKey2,
@@ -45946,13 +45951,7 @@ var init_ratchet = __esm2({
45946
45951
  this.skipMessages(this.state.receivingChain, message.header.messageNumber, message.header.dhPublicKey, isV2);
45947
45952
  const chain = this.state.receivingChain;
45948
45953
  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
- }
45954
+ let plaintextBytes;
45956
45955
  if (isV2) {
45957
45956
  try {
45958
45957
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, headerKey);
@@ -45960,19 +45959,21 @@ var init_ratchet = __esm2({
45960
45959
  throw new Error("V2 header decryption failed");
45961
45960
  }
45962
45961
  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);
45962
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, message.encryptedHeader, message.nonce, messageKey);
45965
45963
  } catch {
45966
45964
  throw new Error("V2 decryption failed: ciphertext tampered or wrong key");
45967
45965
  }
45966
+ } else {
45967
+ const ad = serializeHeader(message.header);
45968
+ try {
45969
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, messageKey);
45970
+ } catch {
45971
+ throw new Error("Decryption failed: ciphertext tampered or wrong key");
45972
+ }
45968
45973
  }
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
- }
45974
+ chain.chainKey = nextChainKey;
45975
+ chain.messageNumber++;
45976
+ return libsodium_wrappers_default.to_string(plaintextBytes);
45976
45977
  }
45977
45978
  dhRatchetReceive(theirDhPublic) {
45978
45979
  this.state.previousSendingChainLength = this.state.sendingChain?.messageNumber ?? 0;
@@ -64146,6 +64147,23 @@ var init_mls_delivery_order = __esm2({
64146
64147
  "use strict";
64147
64148
  }
64148
64149
  });
64150
+ function noteObservedEpoch(current, seen) {
64151
+ const n22 = Number(seen);
64152
+ if (!Number.isInteger(n22) || n22 < 0)
64153
+ return current;
64154
+ return n22 > current ? n22 : current;
64155
+ }
64156
+ function needsPreSendResync(localEpoch, observedEpoch) {
64157
+ if (!Number.isInteger(localEpoch) || !Number.isInteger(observedEpoch)) {
64158
+ return false;
64159
+ }
64160
+ return observedEpoch > localEpoch;
64161
+ }
64162
+ var init_mls_presend_epoch = __esm2({
64163
+ "../crypto/dist/mls-presend-epoch.js"() {
64164
+ "use strict";
64165
+ }
64166
+ });
64149
64167
  var dist_exports = {};
64150
64168
  __export2(dist_exports, {
64151
64169
  AV_CREDENTIAL_CONTEXT: () => AV_CREDENTIAL_CONTEXT,
@@ -64215,7 +64233,9 @@ __export2(dist_exports, {
64215
64233
  hexTransportToEncryptedMessage: () => hexTransportToEncryptedMessage,
64216
64234
  issueCredential: () => issueCredential,
64217
64235
  multibaseToPublicKey: () => multibaseToPublicKey,
64236
+ needsPreSendResync: () => needsPreSendResync,
64218
64237
  normalizeBackupCode: () => normalizeBackupCode,
64238
+ noteObservedEpoch: () => noteObservedEpoch,
64219
64239
  orderDeliveryBatch: () => orderDeliveryBatch,
64220
64240
  parseTraceparent: () => parseTraceparent,
64221
64241
  performX3DH: () => performX3DH,
@@ -64255,6 +64275,7 @@ var init_dist = __esm2({
64255
64275
  init_mls_group();
64256
64276
  await init_owner_sync();
64257
64277
  init_mls_delivery_order();
64278
+ init_mls_presend_epoch();
64258
64279
  }
64259
64280
  });
64260
64281
  async function ensureSecureDir(dir) {
@@ -64447,7 +64468,7 @@ var init_mls_kp_pool = __esm2({
64447
64468
  }
64448
64469
  });
64449
64470
  function ownIdentity() {
64450
- const v22 = true ? "0.23.23" : FALLBACK;
64471
+ const v22 = true ? "0.23.27" : FALLBACK;
64451
64472
  return `${PACKAGE}@${v22}`;
64452
64473
  }
64453
64474
  function buildClientVersion(override) {
@@ -65363,6 +65384,7 @@ var init_channel = __esm2({
65363
65384
  await init_libsodium_wrappers();
65364
65385
  await init_dist();
65365
65386
  await init_dist();
65387
+ init_mls_presend_epoch();
65366
65388
  init_mls_state();
65367
65389
  await init_mls_kp_pool();
65368
65390
  init_client_version();
@@ -65476,6 +65498,17 @@ var init_channel = __esm2({
65476
65498
  * self-heal rather than be logged and dropped. */
65477
65499
  _mlsCommitFailCounts = /* @__PURE__ */ new Map();
65478
65500
  static MAX_MLS_DECRYPT_FAILS = 3;
65501
+ /**
65502
+ * #1084: consecutive resyncs that found NOTHING to apply, per group.
65503
+ *
65504
+ * "No new commits" means the server holds no commit past our epoch — we are
65505
+ * NOT BEHIND. Treating that as corruption and re-keying is what drove the
65506
+ * epoch churn (see `_resyncOnDivergence`). But a state that is genuinely
65507
+ * broken while its epoch counter happens to match must still recover, so the
65508
+ * suppression is bounded rather than absolute.
65509
+ */
65510
+ _resyncNoOpStrikes = /* @__PURE__ */ new Map();
65511
+ static MAX_RESYNC_NOOPS = 3;
65479
65512
  /** Cached MLS KeyPackage bundle for this device (regenerated on each connect). */
65480
65513
  _mlsKeyPackage = null;
65481
65514
  /** Pending KeyPackage bundle from request-Welcome flow (used by _handleMlsWelcome). */
@@ -65513,6 +65546,13 @@ var init_channel = __esm2({
65513
65546
  _kpPoolFilling = false;
65514
65547
  /** Buffer for MLS commits received before Welcome (keyed by groupId, sorted by epoch). */
65515
65548
  _pendingMlsCommits = /* @__PURE__ */ new Map();
65549
+ /**
65550
+ * #1013: the highest group epoch any inbound frame has reported, per MLS
65551
+ * group id. Every inbound frame carries the epoch the group was at when it
65552
+ * was produced, so each is a LOWER BOUND on the group's current epoch — the
65553
+ * hint is monotonic and only ever rises.
65554
+ */
65555
+ _observedGroupEpochs = /* @__PURE__ */ new Map();
65516
65556
  /** In-memory credential store for renter-provided credentials (never persisted). */
65517
65557
  _credentialStore = new CredentialStore();
65518
65558
  /** Rooms whose roster has been refreshed from the backend at least once in
@@ -66197,16 +66237,44 @@ var init_channel = __esm2({
66197
66237
  const pendingWsSends = [];
66198
66238
  const sentSharedGroupIds = /* @__PURE__ */ new Set();
66199
66239
  const addressedMlsGroupIds = [];
66240
+ if (!(this._persisted?.mlsGroups && this._state === "ready" && this._ws)) {
66241
+ console.warn(
66242
+ `[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"}`
66243
+ );
66244
+ }
66200
66245
  if (this._persisted?.mlsGroups && this._state === "ready" && this._ws) {
66201
66246
  const targetSharedGid = targetConvId ? this._sessionGroupIds?.get(targetConvId) : void 0;
66247
+ console.log(
66248
+ `[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)"}`
66249
+ );
66250
+ const skipped = [];
66202
66251
  for (const [gid, entry] of Object.entries(this._persisted.mlsGroups)) {
66203
- if (targetConvId && gid !== targetSharedGid) continue;
66204
- if (!entry.mlsGroupId) continue;
66252
+ if (targetConvId && gid !== targetSharedGid) {
66253
+ skipped.push(`${gid.slice(0, 8)}:not-target`);
66254
+ continue;
66255
+ }
66256
+ if (!entry.mlsGroupId) {
66257
+ skipped.push(`${gid.slice(0, 8)}:no-mlsGroupId`);
66258
+ continue;
66259
+ }
66205
66260
  const mlsGroup = this._mlsGroups.get(`1to1-group:${gid}`);
66206
- if (!mlsGroup?.isInitialized || Number(mlsGroup.epoch) <= 0) continue;
66261
+ if (!mlsGroup?.isInitialized) {
66262
+ skipped.push(`${gid.slice(0, 8)}:not-in-memory`);
66263
+ continue;
66264
+ }
66265
+ if (Number(mlsGroup.epoch) <= 0) {
66266
+ skipped.push(`${gid.slice(0, 8)}:epoch<=0`);
66267
+ continue;
66268
+ }
66207
66269
  try {
66208
66270
  const plaintextBytes = new TextEncoder().encode(plaintext);
66209
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66271
+ const cipherBytes = await this._encryptWithCatchUp(
66272
+ mlsGroup,
66273
+ `1to1-group:${gid}`,
66274
+ entry.mlsGroupId,
66275
+ `1:1 group ${gid.slice(0, 8)}`,
66276
+ plaintextBytes
66277
+ );
66210
66278
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66211
66279
  const payload = {
66212
66280
  group_id: entry.mlsGroupId,
@@ -66228,8 +66296,12 @@ var init_channel = __esm2({
66228
66296
  console.log(`[SecureChannel] Shared MLS group send for group ${gid.slice(0, 8)} (${entry.mlsGroupId.slice(0, 8)})`);
66229
66297
  } catch (err) {
66230
66298
  console.error(`[SecureChannel] Shared MLS group send failed for ${gid.slice(0, 8)}:`, err);
66299
+ skipped.push(`${gid.slice(0, 8)}:encrypt-threw`);
66231
66300
  }
66232
66301
  }
66302
+ if (skipped.length > 0) {
66303
+ console.log(`[SecureChannel] send(): skipped ${skipped.length} group(s) \u2014 ${skipped.join(", ")}`);
66304
+ }
66233
66305
  }
66234
66306
  for (const [convId, session] of this._sessions) {
66235
66307
  if (!session.activated) continue;
@@ -66249,7 +66321,14 @@ var init_channel = __esm2({
66249
66321
  }
66250
66322
  if (mlsGroup?.isInitialized && mlsGroupId && Number(mlsGroup.epoch) > 0 && this._state === "ready" && this._ws) {
66251
66323
  const plaintextBytes = new TextEncoder().encode(plaintext);
66252
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66324
+ const sessionGroupKey = convGroupId && this._mlsGroups.has(`1to1-group:${convGroupId}`) ? `1to1-group:${convGroupId}` : `conv:${convId}`;
66325
+ const cipherBytes = await this._encryptWithCatchUp(
66326
+ mlsGroup,
66327
+ sessionGroupKey,
66328
+ mlsGroupId,
66329
+ `conversation ${convId.slice(0, 8)}`,
66330
+ plaintextBytes
66331
+ );
66253
66332
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66254
66333
  const payload = {
66255
66334
  conversation_id: convId,
@@ -66344,7 +66423,14 @@ var init_channel = __esm2({
66344
66423
  }
66345
66424
  try {
66346
66425
  const plaintextBytes = new TextEncoder().encode(plaintext);
66347
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66426
+ const mlsOnlyGroupKey = mlsOnlyConvGroupId && this._mlsGroups.has(`1to1-group:${mlsOnlyConvGroupId}`) ? `1to1-group:${mlsOnlyConvGroupId}` : `conv:${mlsConvId}`;
66427
+ const cipherBytes = await this._encryptWithCatchUp(
66428
+ mlsGroup,
66429
+ mlsOnlyGroupKey,
66430
+ resolvedMlsGroupId,
66431
+ `conversation ${mlsConvId.slice(0, 8)}`,
66432
+ plaintextBytes
66433
+ );
66348
66434
  await saveMlsState(this.config.dataDir, resolvedMlsGroupId, JSON.stringify(mlsGroup.exportState()));
66349
66435
  const payload = {
66350
66436
  conversation_id: mlsConvId,
@@ -66370,13 +66456,26 @@ var init_channel = __esm2({
66370
66456
  }
66371
66457
  }
66372
66458
  }
66373
- if (sentCount === 0 && this._sessions.size > 0) {
66374
- console.warn("[SecureChannel] send() delivered to 0 sessions (all skipped or failed)");
66459
+ if (sentCount === 0) {
66460
+ console.warn(
66461
+ `[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})`
66462
+ );
66375
66463
  }
66376
66464
  await this._persistState();
66465
+ const wsReady = this._ws?.readyState === 1;
66466
+ if (pendingWsSends.length > 0 && !wsReady) {
66467
+ console.warn(
66468
+ `[SecureChannel] send(): ${pendingWsSends.length} frame(s) NOT written \u2014 socket readyState=${this._ws?.readyState ?? "null"} (1=OPEN)`
66469
+ );
66470
+ }
66377
66471
  for (const frame of pendingWsSends) {
66378
66472
  this._ws.send(frame);
66379
66473
  }
66474
+ if (pendingWsSends.length > 0) {
66475
+ console.log(
66476
+ `[SecureChannel] send(): wrote ${pendingWsSends.length} frame(s) to the socket (sentCount=${sentCount}, readyState=${this._ws?.readyState ?? "null"})`
66477
+ );
66478
+ }
66380
66479
  if (!options?.isResend) {
66381
66480
  for (const mlsGroupId of addressedMlsGroupIds) {
66382
66481
  this._rememberRetryCandidate(mlsGroupId, plaintext, options);
@@ -66426,7 +66525,7 @@ var init_channel = __esm2({
66426
66525
  */
66427
66526
  sendActivitySpan(spanData) {
66428
66527
  if (!this._ws || this._ws.readyState !== WebSocket2.OPEN) return;
66429
- const pluginVersion = true ? "0.23.23" : "0.0.0-dev";
66528
+ const pluginVersion = true ? "0.23.27" : "0.0.0-dev";
66430
66529
  const agentName = this.config.agentName ?? "Agent";
66431
66530
  const resource = {
66432
66531
  "service.name": "agentvault-agent",
@@ -66636,7 +66735,13 @@ var init_channel = __esm2({
66636
66735
  if (mlsGroup?.isInitialized) {
66637
66736
  try {
66638
66737
  const plaintextBytes = new TextEncoder().encode(plaintext);
66639
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
66738
+ const ciphertext = await this._encryptWithCatchUp(
66739
+ mlsGroup,
66740
+ roomId,
66741
+ room.mlsGroupId,
66742
+ `room ${roomId.slice(0, 8)}`,
66743
+ plaintextBytes
66744
+ );
66640
66745
  await saveMlsState(this.config.dataDir, room.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66641
66746
  if (this._state === "ready" && this._ws) {
66642
66747
  this._ws.send(JSON.stringify({
@@ -66887,11 +66992,17 @@ var init_channel = __esm2({
66887
66992
  const before = this._persisted.groupId;
66888
66993
  this._sessionGroupIds = new Map(mine.map((r22) => [r22.id, r22.group_id]));
66889
66994
  this._persisted.conversationGroupIds = Object.fromEntries(this._sessionGroupIds);
66890
- const primary = mine.find((r22) => r22.id === this._persisted.primaryConversationId) ?? mine[0];
66995
+ const joined = mine.find((r22) => this._persisted.mlsGroups?.[r22.group_id]);
66996
+ const primary = mine.find((r22) => r22.id === this._persisted.primaryConversationId) ?? joined ?? mine[0];
66997
+ if (!mine.some((r22) => r22.id === this._persisted.primaryConversationId)) {
66998
+ console.log(
66999
+ `[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"}`
67000
+ );
67001
+ }
66891
67002
  this._persisted.groupId = primary.group_id;
66892
67003
  this._persisted.primaryConversationId = primary.id;
66893
67004
  const liveGroupIds = new Set(mine.map((r22) => r22.group_id));
66894
- if (!liveGroupIds.has(staleGid)) {
67005
+ if (staleGid && staleMlsGroupId && !liveGroupIds.has(staleGid)) {
66895
67006
  try {
66896
67007
  await deleteMlsState(this.config.dataDir, staleMlsGroupId);
66897
67008
  } catch {
@@ -66905,9 +67016,9 @@ var init_channel = __esm2({
66905
67016
  }
66906
67017
  await this._persistState();
66907
67018
  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))`
67019
+ `[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
67020
  );
66910
- return this._persisted.groupId !== before && before === staleGid;
67021
+ return staleGid !== void 0 && this._persisted.groupId !== before && before === staleGid;
66911
67022
  }
66912
67023
  /**
66913
67024
  * Resend the message that a now-reconciled `unknown group` refusal killed (#732).
@@ -67815,7 +67926,13 @@ var init_channel = __esm2({
67815
67926
  const mlsGroup = this._mlsGroups.get(`a2a:${channelEntry.channelId}`);
67816
67927
  if (mlsGroup?.isInitialized) {
67817
67928
  const plaintextBytes = new TextEncoder().encode(text);
67818
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
67929
+ const ciphertext = await this._encryptWithCatchUp(
67930
+ mlsGroup,
67931
+ `a2a:${channelEntry.channelId}`,
67932
+ channelEntry.mlsGroupId,
67933
+ `A2A ${channelEntry.channelId.slice(0, 8)}`,
67934
+ plaintextBytes
67935
+ );
67819
67936
  await saveMlsState(
67820
67937
  this.config.dataDir,
67821
67938
  channelEntry.mlsGroupId,
@@ -67863,7 +67980,13 @@ var init_channel = __esm2({
67863
67980
  const mlsGroup = this._mlsGroups.get(`a2a:${channelId}`);
67864
67981
  if (mlsGroup?.isInitialized) {
67865
67982
  const plaintextBytes = new TextEncoder().encode(text);
67866
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
67983
+ const ciphertext = await this._encryptWithCatchUp(
67984
+ mlsGroup,
67985
+ `a2a:${channelId}`,
67986
+ entry.mlsGroupId,
67987
+ `A2A ${channelId.slice(0, 8)}`,
67988
+ plaintextBytes
67989
+ );
67867
67990
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
67868
67991
  this._ws.send(JSON.stringify({
67869
67992
  event: "a2a_message_mls",
@@ -68309,6 +68432,9 @@ var init_channel = __esm2({
68309
68432
  await this._pullDrDeliveryQueue();
68310
68433
  await this._flushOutboundQueue();
68311
68434
  this._setState("ready");
68435
+ void this._reconcileConversationGroups().catch((err) => {
68436
+ console.warn("[SecureChannel] Conversation-group reconcile failed:", err);
68437
+ });
68312
68438
  void this._reconcileRoomsWithServer().catch(
68313
68439
  (err) => console.warn(`[SecureChannel] room reconcile failed (ignored): ${err instanceof Error ? err.message : String(err)}`)
68314
68440
  ).then(() => this._quarantineOrphanedMlsGroups()).catch(
@@ -68336,7 +68462,7 @@ var init_channel = __esm2({
68336
68462
  agentVersion: this.config.agentVersion ?? "0.0.0",
68337
68463
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68338
68464
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68339
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68465
+ pluginVersion: true ? "0.23.27" : "0.0.0-dev"
68340
68466
  });
68341
68467
  this._telemetryReporter.startAutoFlush(3e4);
68342
68468
  }
@@ -68660,7 +68786,7 @@ var init_channel = __esm2({
68660
68786
  agentVersion: this.config.agentVersion ?? "0.0.0",
68661
68787
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68662
68788
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68663
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68789
+ pluginVersion: true ? "0.23.27" : "0.0.0-dev"
68664
68790
  });
68665
68791
  this._telemetryReporter.startAutoFlush(3e4);
68666
68792
  }
@@ -69026,6 +69152,7 @@ var init_channel = __esm2({
69026
69152
  const convGroupId = data.conversation_group_id;
69027
69153
  const groupId = data.group_id;
69028
69154
  const senderDeviceId = data.sender_device_id;
69155
+ this._noteGroupEpoch(groupId, data.epoch);
69029
69156
  if (senderDeviceId === this._deviceId) return;
69030
69157
  let mgr;
69031
69158
  let mgrKey;
@@ -69103,9 +69230,15 @@ var init_channel = __esm2({
69103
69230
  })),
69104
69231
  no_history: filtered.length === 0
69105
69232
  });
69106
- if (mgr && mlsGroupId) {
69233
+ if (mgr && mgrKey && mlsGroupId) {
69107
69234
  const responseBytes = new TextEncoder().encode(responsePayload);
69108
- const cipher = await mgr.encrypt(responseBytes);
69235
+ const cipher = await this._encryptWithCatchUp(
69236
+ mgr,
69237
+ mgrKey,
69238
+ mlsGroupId,
69239
+ `history catch-up ${mlsGroupId.slice(0, 8)}`,
69240
+ responseBytes
69241
+ );
69109
69242
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mgr.exportState()));
69110
69243
  if (this._ws) {
69111
69244
  this._ws.send(JSON.stringify({
@@ -69848,9 +69981,36 @@ ${messageText}`;
69848
69981
  // ---------------------------------------------------------------------------
69849
69982
  // MLS room message handlers
69850
69983
  // ---------------------------------------------------------------------------
69984
+ /**
69985
+ * #1014: hand a room-framed frame to the handler that owns its group family.
69986
+ *
69987
+ * Returns true when the frame was routed. Routing means "give it to the right
69988
+ * handler" — NOT "decrypt it as a room message". Decrypting under the wrong
69989
+ * group is how MLS state gets corrupted, so an unplaceable group is left for
69990
+ * the caller to self-heal rather than guessed at.
69991
+ */
69992
+ async _routeNonRoomGroupMessage(data, groupId) {
69993
+ const target = this._resolveGroupFamily(groupId);
69994
+ if (!target || target.family === "room") return false;
69995
+ console.log(
69996
+ `[SecureChannel] Room-framed message belongs to ${target.label} \u2014 routing to the handler that owns it (#1014)`
69997
+ );
69998
+ switch (target.family) {
69999
+ case "shared1to1":
70000
+ await this._handleMessageMLS({ ...data, conversation_group_id: target.id });
70001
+ return true;
70002
+ case "conversation":
70003
+ await this._handleMessageMLS({ ...data, conversation_id: target.id });
70004
+ return true;
70005
+ case "a2a":
70006
+ await this._handleA2AMessageMLS({ ...data, a2a_channel_id: target.id });
70007
+ return true;
70008
+ }
70009
+ }
69851
70010
  async _handleRoomMessageMLS(data) {
69852
70011
  const groupId = data.group_id;
69853
70012
  const senderDeviceId = data.sender_device_id;
70013
+ this._noteGroupEpoch(groupId, data.epoch);
69854
70014
  if (senderDeviceId === this._deviceId) return;
69855
70015
  let roomId;
69856
70016
  for (const [rid, room] of Object.entries(this._persisted?.rooms ?? {})) {
@@ -69864,7 +70024,9 @@ ${messageText}`;
69864
70024
  roomId = data.room_id;
69865
70025
  console.log(`[SecureChannel] Room ${roomId.slice(0, 8)} matched by room_id (group_id ${groupId?.slice(0, 8)} mismatch)`);
69866
70026
  } else {
70027
+ if (await this._routeNonRoomGroupMessage(data, groupId)) return;
69867
70028
  console.warn(`[SecureChannel] No room found for MLS group ${groupId?.slice(0, 8)}`);
70029
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
69868
70030
  return;
69869
70031
  }
69870
70032
  }
@@ -70052,63 +70214,63 @@ ${messageText}`;
70052
70214
  console.warn(`[SecureChannel] Failed to send history catchup response:`, sendErr);
70053
70215
  }
70054
70216
  }
70217
+ /**
70218
+ * Apply an inbound MLS commit to whichever group it addresses.
70219
+ *
70220
+ * Returns TRUE when the commit was applied, buffered, or handed to the
70221
+ * failure ladder — i.e. when this agent has taken responsibility for it and
70222
+ * the delivery-queue row may safely be acked. Returns FALSE when the commit
70223
+ * names a group we hold no state for at all, so the caller must NACK and let
70224
+ * the row survive.
70225
+ *
70226
+ * #1012: this used to loop `rooms` then `a2aChannels` and then END. A shared
70227
+ * 1:1 commit (`mlsGroups`) and a legacy per-conversation commit
70228
+ * (`mlsConversations`) matched neither. The call fell off the bottom with no
70229
+ * log, no buffer and no error — and because it did not THROW,
70230
+ * `_pullDeliveryQueue` acked the row and the server deleted it. An MLS commit
70231
+ * cannot be regenerated, so the agent stayed at epoch N while the group moved
70232
+ * to N+1 and every later send() encrypted at an epoch nobody could decrypt.
70233
+ *
70234
+ * The families are enumerated in ONE list resolved by ONE loop, because two
70235
+ * hand-copied loops are exactly how the 1:1 path fell two families behind.
70236
+ */
70055
70237
  async _handleMlsCommit(data) {
70056
70238
  const groupId = data.group_id;
70057
70239
  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;
70240
+ this._noteGroupEpoch(groupId, data.epoch);
70241
+ const target = this._resolveGroupFamily(groupId);
70242
+ if (target) {
70243
+ const { managerKey: groupKey, label } = target;
70244
+ const mlsGroup = this._mlsGroups.get(groupKey);
70245
+ if (!mlsGroup?.isInitialized) {
70246
+ this._bufferMlsCommit(groupId, epoch, data);
70247
+ console.log(`[SecureChannel] Buffered MLS commit for ${label} (epoch=${epoch}, group not initialized)`);
70248
+ return true;
70083
70249
  }
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;
70250
+ try {
70251
+ const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
70252
+ await mlsGroup.processCommit(commitBytes);
70253
+ await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
70254
+ console.log(`[SecureChannel] MLS commit processed for ${label} (epoch=${mlsGroup.epoch})`);
70255
+ this._mlsCommitFailCounts.delete(groupKey);
70256
+ } catch (err) {
70257
+ await this._onCommitFailure(
70258
+ groupKey,
70259
+ groupId,
70260
+ err,
70261
+ label,
70262
+ data.epoch,
70263
+ mlsGroup.epoch
70264
+ );
70110
70265
  }
70266
+ return true;
70111
70267
  }
70268
+ this._bufferMlsCommit(groupId, epoch, data);
70269
+ console.warn(
70270
+ `[SecureChannel] MLS commit for UNKNOWN group ${String(groupId).slice(0, 8)} (epoch=${epoch}) \u2014 buffered, requesting a Welcome, NOT acked`
70271
+ );
70272
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
70273
+ return false;
70112
70274
  }
70113
70275
  /** Buffer an MLS commit for replay after Welcome join. Max 50 per group. */
70114
70276
  _bufferMlsCommit(groupId, epoch, data) {
@@ -70298,6 +70460,229 @@ ${messageText}`;
70298
70460
  * `1to1-group:<gid>`, `a2a:<channelId>`, …) — also the decrypt-fail-count key.
70299
70461
  * @param groupId the server-side MLS group id used in the /sync URL.
70300
70462
  */
70463
+ /**
70464
+ * #1015 — the ONLY place this package fulfils a Welcome request.
70465
+ *
70466
+ * Add the members, transmit, and record `fulfilled` **only for the targets
70467
+ * whose frames actually went out**. Every one of the five sites that used to
70468
+ * do this by hand followed the same sequence and had the same three holes:
70469
+ * `this._ws` null (both sends skipped by an `if`), a CLOSING socket (`send`
70470
+ * returns without throwing), and a fulfil POST wrapped in `.catch(() => {})`.
70471
+ *
70472
+ * A fulfilled request is never re-requested. So a Welcome that evaporated
70473
+ * left the requester locked out, the fulfiller at epoch N+1, everyone else at
70474
+ * N, and the bridging commit untransmitted and impossible to regenerate — a
70475
+ * permanently wedged group in which every signal read success.
70476
+ *
70477
+ * Returns the number of requests actually fulfilled.
70478
+ */
70479
+ async _fulfilWelcomeRequests(opts) {
70480
+ const { mlsGroup, mlsGroupId, label, welcomeFields, targets } = opts;
70481
+ if (targets.length === 0) return 0;
70482
+ if (!this._wsIsOpen()) {
70483
+ console.warn(
70484
+ `[SecureChannel] ${label}: socket is not OPEN \u2014 NOT advancing the group; ${targets.length} request(s) stay pending for the next pull`
70485
+ );
70486
+ return 0;
70487
+ }
70488
+ const snapshot = mlsGroup.exportState();
70489
+ const { commit, welcome } = await mlsGroup.addMembers(targets.map((t22) => t22.keyPackage));
70490
+ const commitSent = await this._sendFrameConfirmed(
70491
+ {
70492
+ event: "mls_commit",
70493
+ data: {
70494
+ group_id: mlsGroupId,
70495
+ epoch: Number(mlsGroup.epoch),
70496
+ payload: Buffer.from(commit).toString("hex")
70497
+ }
70498
+ },
70499
+ `${label} commit`
70500
+ );
70501
+ if (!commitSent) {
70502
+ mlsGroup.importState(snapshot);
70503
+ console.warn(
70504
+ `[SecureChannel] ${label}: commit was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing fulfilled, requests stay pending`
70505
+ );
70506
+ return 0;
70507
+ }
70508
+ await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70509
+ if (!welcome) {
70510
+ console.warn(
70511
+ `[SecureChannel] ${label}: addMembers produced NO Welcome \u2014 ${targets.length} request(s) left pending rather than marked fulfilled`
70512
+ );
70513
+ return 0;
70514
+ }
70515
+ let fulfilled = 0;
70516
+ for (const t22 of targets) {
70517
+ const sent = await this._sendFrameConfirmed(
70518
+ {
70519
+ event: "mls_welcome",
70520
+ data: {
70521
+ target_device_id: t22.deviceId,
70522
+ group_id: mlsGroupId,
70523
+ ...welcomeFields,
70524
+ payload: Buffer.from(welcome).toString("hex")
70525
+ }
70526
+ },
70527
+ `${label} welcome\u2192${t22.deviceId.slice(0, 8)}`
70528
+ );
70529
+ if (!sent) {
70530
+ console.warn(
70531
+ `[SecureChannel] ${label}: Welcome for ${t22.deviceId.slice(0, 8)} was NOT written \u2014 leaving the request PENDING so the next pull retries it`
70532
+ );
70533
+ continue;
70534
+ }
70535
+ if (await this._postFulfilWelcome(mlsGroupId, t22.requestId, `${label} ${t22.deviceId.slice(0, 8)}`)) {
70536
+ fulfilled++;
70537
+ }
70538
+ }
70539
+ return fulfilled;
70540
+ }
70541
+ /**
70542
+ * Resolve an MLS group id to the family that owns it.
70543
+ *
70544
+ * There are four: rooms, A2A channels, shared 1:1 groups, and the legacy
70545
+ * per-conversation groups. They all store the MLS group id under
70546
+ * `mlsGroupId`; they differ only in the key their live `MLSGroupManager` sits
70547
+ * under in `_mlsGroups`.
70548
+ *
70549
+ * ONE resolver, because hand-copying this enumeration is what produced
70550
+ * #1012 (`_handleMlsCommit` handled two families of four), #1014 (a
70551
+ * room-framed 1:1 replay matched nothing) and #1016 (a rejoin dropped only
70552
+ * the room manager). Three bugs, one missing abstraction.
70553
+ */
70554
+ _resolveGroupFamily(mlsGroupId) {
70555
+ if (!mlsGroupId) return null;
70556
+ const families = [
70557
+ { family: "room", entries: this._persisted?.rooms, managerKey: (id) => id, label: "room" },
70558
+ { family: "a2a", entries: this._persisted?.a2aChannels, managerKey: (id) => `a2a:${id}`, label: "A2A" },
70559
+ { family: "shared1to1", entries: this._persisted?.mlsGroups, managerKey: (id) => `1to1-group:${id}`, label: "1:1 group" },
70560
+ { family: "conversation", entries: this._persisted?.mlsConversations, managerKey: (id) => `conv:${id}`, label: "conversation" }
70561
+ ];
70562
+ for (const f22 of families) {
70563
+ for (const [id, entry] of Object.entries(f22.entries ?? {})) {
70564
+ if (entry?.mlsGroupId !== mlsGroupId) continue;
70565
+ return {
70566
+ family: f22.family,
70567
+ id,
70568
+ managerKey: f22.managerKey(id),
70569
+ label: `${f22.label} ${id.slice(0, 8)}`
70570
+ };
70571
+ }
70572
+ }
70573
+ return null;
70574
+ }
70575
+ /** #1015: is the socket in a state that can actually take a write? */
70576
+ _wsIsOpen() {
70577
+ return this._ws?.readyState === 1;
70578
+ }
70579
+ /**
70580
+ * #1015: write one frame and report whether the socket took it.
70581
+ *
70582
+ * `ws.send()` on a CLOSING or CLOSED socket returns WITHOUT throwing — the
70583
+ * error surfaces only through the callback, and every fulfilment site passed
70584
+ * none. So a Welcome could be "sent" into a dead socket and the requester
70585
+ * still marked `fulfilled`, which it never re-requests.
70586
+ */
70587
+ _sendFrameConfirmed(frame, label) {
70588
+ const ws = this._ws;
70589
+ if (!ws || ws.readyState !== 1) {
70590
+ console.warn(
70591
+ `[SecureChannel] ${label}: socket is not OPEN (readyState=${ws?.readyState ?? "null"}) \u2014 frame NOT written`
70592
+ );
70593
+ return Promise.resolve(false);
70594
+ }
70595
+ return new Promise((resolve32) => {
70596
+ try {
70597
+ ws.send(JSON.stringify(frame), (err) => {
70598
+ if (err) {
70599
+ console.error(`[SecureChannel] ${label}: socket refused the frame:`, err);
70600
+ resolve32(false);
70601
+ } else {
70602
+ resolve32(true);
70603
+ }
70604
+ });
70605
+ } catch (err) {
70606
+ console.error(`[SecureChannel] ${label}: send threw:`, err);
70607
+ resolve32(false);
70608
+ }
70609
+ });
70610
+ }
70611
+ /**
70612
+ * #1015: record a Welcome as fulfilled — and say so when it fails.
70613
+ *
70614
+ * The batched shared-1:1 site posted this with `.catch(() => {})`, so an HTTP
70615
+ * failure here was invisible on top of the sends being invisible.
70616
+ */
70617
+ async _postFulfilWelcome(mlsGroupId, requestId, label) {
70618
+ try {
70619
+ const res = await fetch(
70620
+ `${this.config.apiUrl}/api/v1/mls/groups/${mlsGroupId}/fulfill-welcome`,
70621
+ {
70622
+ method: "POST",
70623
+ headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70624
+ body: JSON.stringify({ request_id: requestId })
70625
+ }
70626
+ );
70627
+ if (!res.ok) {
70628
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome returned ${res.status}`);
70629
+ return false;
70630
+ }
70631
+ return true;
70632
+ } catch (err) {
70633
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome failed:`, err);
70634
+ return false;
70635
+ }
70636
+ }
70637
+ /**
70638
+ * #1013: fold an epoch seen on an inbound frame into the running hint.
70639
+ *
70640
+ * Anything unusable (missing, non-numeric, negative, fractional) is ignored
70641
+ * rather than defaulted. A defaulted epoch here would be a guess, and a guess
70642
+ * that reads LOW silently disables the check it exists to drive.
70643
+ */
70644
+ _noteGroupEpoch(mlsGroupId, epoch) {
70645
+ if (typeof mlsGroupId !== "string" || !mlsGroupId) return;
70646
+ const current = this._observedGroupEpochs.get(mlsGroupId) ?? 0;
70647
+ const next = noteObservedEpoch(current, epoch);
70648
+ if (next > current) this._observedGroupEpochs.set(mlsGroupId, next);
70649
+ }
70650
+ /**
70651
+ * #1013 — the ONLY place this package encrypts an MLS application message.
70652
+ *
70653
+ * Catch up first when the group is known to have moved past us, then encrypt.
70654
+ * Hermes has done this since #440 and the browser since #996; the plugin —
70655
+ * the implementation that actually ships to customers — did not, so an agent
70656
+ * that missed one commit encrypted every later message at an epoch nobody
70657
+ * could read, while the server stored and relayed it and every signal read
70658
+ * 200.
70659
+ *
70660
+ * Funnelling every sender through one method is the point. A guard applied at
70661
+ * seven call sites decays to a guard applied at six: that is exactly how
70662
+ * `_handleMlsCommit` ended up handling two group families out of four (#1012).
70663
+ *
70664
+ * Best-effort by design. A catch-up that cannot replay — or that throws — must
70665
+ * NOT block the send; we fall through and encrypt at the local epoch, which is
70666
+ * precisely today's behaviour, so the worst case is no regression.
70667
+ */
70668
+ async _encryptWithCatchUp(mlsGroup, groupKey, mlsGroupId, label, plaintextBytes) {
70669
+ const observed = this._observedGroupEpochs.get(mlsGroupId);
70670
+ if (observed !== void 0 && needsPreSendResync(Number(mlsGroup.epoch), observed)) {
70671
+ console.warn(
70672
+ `[SecureChannel] ${label}: local epoch ${Number(mlsGroup.epoch)} is behind the observed group epoch ${observed} \u2014 catching up before encrypt (#1013)`
70673
+ );
70674
+ try {
70675
+ if (!await this._resyncOnDivergence(groupKey, mlsGroupId)) {
70676
+ console.warn(
70677
+ `[SecureChannel] ${label}: catch-up could not replay \u2014 sending at epoch ${Number(mlsGroup.epoch)}. Peers past this epoch will not read it.`
70678
+ );
70679
+ }
70680
+ } catch (err) {
70681
+ console.warn(`[SecureChannel] ${label}: catch-up failed:`, err);
70682
+ }
70683
+ }
70684
+ return await mlsGroup.encrypt(plaintextBytes);
70685
+ }
70301
70686
  async _resyncOnDivergence(groupKey, groupId) {
70302
70687
  const mgr = this._mlsGroups.get(groupKey);
70303
70688
  if (!mgr?.isInitialized) return false;
@@ -70324,11 +70709,24 @@ ${messageText}`;
70324
70709
  applied++;
70325
70710
  }
70326
70711
  if (applied === 0) {
70327
- console.log(`[SecureChannel] Resync for ${groupId.slice(0, 8)}: no new commits to apply (epoch=${mgr.epoch})`);
70712
+ const strikes = (this._resyncNoOpStrikes.get(groupKey) ?? 0) + 1;
70713
+ this._resyncNoOpStrikes.set(groupKey, strikes);
70714
+ if (strikes < _SecureChannel.MAX_RESYNC_NOOPS) {
70715
+ this._mlsDecryptFailCounts.delete(groupKey);
70716
+ console.log(
70717
+ `[SecureChannel] Resync for ${groupId.slice(0, 8)}: already current at epoch=${mgr.epoch} (strike ${strikes}/${_SecureChannel.MAX_RESYNC_NOOPS}) \u2014 NOT re-keying (#1084)`
70718
+ );
70719
+ return true;
70720
+ }
70721
+ this._resyncNoOpStrikes.delete(groupKey);
70722
+ console.warn(
70723
+ `[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`
70724
+ );
70328
70725
  return false;
70329
70726
  }
70330
70727
  await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mgr.exportState()));
70331
70728
  this._mlsDecryptFailCounts.delete(groupKey);
70729
+ this._resyncNoOpStrikes.delete(groupKey);
70332
70730
  console.log(
70333
70731
  `[SecureChannel] Resync-on-divergence: applied ${applied} missed commit(s) for ${groupId.slice(0, 8)}, now epoch=${mgr.epoch}`
70334
70732
  );
@@ -70715,6 +71113,7 @@ ${messageText}`;
70715
71113
  const ackedIds = [];
70716
71114
  const nackedIds = [];
70717
71115
  for (const msg of messages) {
71116
+ let handled = true;
70718
71117
  try {
70719
71118
  if (msg.sender_device_id === this._deviceId) {
70720
71119
  ackedIds.push(msg.queue_id);
@@ -70731,7 +71130,7 @@ ${messageText}`;
70731
71130
  a2a_channel_id: msg.a2a_channel_id
70732
71131
  });
70733
71132
  } else if (msg.message_type === "commit") {
70734
- await this._handleMlsCommit({
71133
+ handled = await this._handleMlsCommit({
70735
71134
  group_id: msg.group_id,
70736
71135
  sender_device_id: msg.sender_device_id,
70737
71136
  epoch: msg.epoch,
@@ -70769,9 +71168,18 @@ ${messageText}`;
70769
71168
  conversation_group_id: msg.conversation_group_id,
70770
71169
  created_at: msg.created_at
70771
71170
  });
71171
+ } else {
71172
+ handled = false;
71173
+ console.warn(
71174
+ `[SecureChannel] Delivery application row ${String(msg.message_id).slice(0, 8)} names no room, A2A channel or conversation \u2014 NOT acked`
71175
+ );
70772
71176
  }
70773
71177
  }
70774
- ackedIds.push(msg.queue_id);
71178
+ if (handled) {
71179
+ ackedIds.push(msg.queue_id);
71180
+ } else {
71181
+ nackedIds.push(msg.queue_id);
71182
+ }
70775
71183
  } catch (err) {
70776
71184
  console.warn(`[SecureChannel] Delivery ${msg.message_type} processing failed:`, err);
70777
71185
  nackedIds.push(msg.queue_id);
@@ -70840,38 +71248,20 @@ ${messageText}`;
70840
71248
  if (!kpHex) continue;
70841
71249
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
70842
71250
  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
- }));
71251
+ const done = await this._fulfilWelcomeRequests({
71252
+ mlsGroup,
71253
+ mlsGroupId: roomState.mlsGroupId,
71254
+ label: `room ${roomId.slice(0, 8)}`,
71255
+ welcomeFields: { room_id: roomId },
71256
+ targets: [{
71257
+ requestId: req.id,
71258
+ deviceId: req.requesting_device_id,
71259
+ keyPackage: memberKp
71260
+ }]
71261
+ });
71262
+ if (done > 0) {
71263
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in room ${roomId.slice(0, 8)}`);
70864
71264
  }
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
71265
  } catch (fulfillErr) {
70876
71266
  console.warn(`[SecureChannel] Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
70877
71267
  }
@@ -70910,65 +71300,38 @@ ${messageText}`;
70910
71300
  validReqs.push({ req, kp: MLSGroupManager.deserializeKeyPackage(kpBytes) });
70911
71301
  }
70912
71302
  if (validReqs.length === 0) continue;
71303
+ const sharedTargets = validReqs.map((v22) => ({
71304
+ requestId: v22.req.id,
71305
+ deviceId: v22.req.requesting_device_id,
71306
+ keyPackage: v22.kp
71307
+ }));
70913
71308
  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
- });
71309
+ const done = await this._fulfilWelcomeRequests({
71310
+ mlsGroup,
71311
+ mlsGroupId: entry.mlsGroupId,
71312
+ label: `shared group ${gid.slice(0, 8)}`,
71313
+ welcomeFields: { conversation_group_id: gid },
71314
+ targets: sharedTargets
71315
+ });
71316
+ if (done > 0) {
71317
+ console.log(`[SecureChannel] Batched Welcome for ${done}/${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70948
71318
  }
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
71319
  } catch (batchErr) {
70952
71320
  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) {
71321
+ for (const target of sharedTargets) {
70954
71322
  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(() => {
71323
+ const done = await this._fulfilWelcomeRequests({
71324
+ mlsGroup,
71325
+ mlsGroupId: entry.mlsGroupId,
71326
+ label: `shared group ${gid.slice(0, 8)}`,
71327
+ welcomeFields: { conversation_group_id: gid },
71328
+ targets: [target]
70967
71329
  });
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)}`);
71330
+ if (done > 0) {
71331
+ console.log(`[SecureChannel] Individual Welcome for ${target.deviceId.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
71332
+ }
70970
71333
  } catch (indivErr) {
70971
- console.warn(`[SecureChannel] Individual Welcome failed for ${req.requesting_device_id.slice(0, 8)}:`, indivErr);
71334
+ console.warn(`[SecureChannel] Individual Welcome failed for ${target.deviceId.slice(0, 8)}:`, indivErr);
70972
71335
  }
70973
71336
  }
70974
71337
  }
@@ -71000,38 +71363,20 @@ ${messageText}`;
71000
71363
  if (!kpHex) continue;
71001
71364
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
71002
71365
  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
- }));
71366
+ const done = await this._fulfilWelcomeRequests({
71367
+ mlsGroup,
71368
+ mlsGroupId: convEntry.mlsGroupId,
71369
+ label: `conv ${convId.slice(0, 8)}`,
71370
+ welcomeFields: { conversation_id: convId },
71371
+ targets: [{
71372
+ requestId: req.id,
71373
+ deviceId: req.requesting_device_id,
71374
+ keyPackage: memberKp
71375
+ }]
71376
+ });
71377
+ if (done > 0) {
71378
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
71024
71379
  }
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
71380
  } catch (fulfillErr) {
71036
71381
  console.warn(`[SecureChannel] Conv Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
71037
71382
  }
@@ -71212,12 +71557,13 @@ ${messageText}`;
71212
71557
  } catch (distErr) {
71213
71558
  console.warn(`[SecureChannel] A2A distribute network error:`, distErr);
71214
71559
  }
71215
- if (!distributed && this._ws) {
71216
- this._ws.send(JSON.stringify({
71560
+ if (!distributed && this._wsIsOpen()) {
71561
+ const a2aLabel = `A2A ${chId.slice(0, 8)}`;
71562
+ const commitSent = await this._sendFrameConfirmed({
71217
71563
  event: "mls_commit",
71218
71564
  data: { group_id: chState.mlsGroupId, epoch: Number(mlsGroup.epoch), payload: commitHex }
71219
- }));
71220
- this._ws.send(JSON.stringify({
71565
+ }, `${a2aLabel} commit`);
71566
+ const welcomeSent = commitSent && await this._sendFrameConfirmed({
71221
71567
  event: "mls_welcome",
71222
71568
  data: {
71223
71569
  target_device_id: req.requesting_device_id,
@@ -71225,7 +71571,11 @@ ${messageText}`;
71225
71571
  a2a_channel_id: chId,
71226
71572
  payload: welcomeHex
71227
71573
  }
71228
- }));
71574
+ }, `${a2aLabel} welcome`);
71575
+ if (!welcomeSent) {
71576
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome frames were NOT written \u2014 leaving the request pending`);
71577
+ continue;
71578
+ }
71229
71579
  try {
71230
71580
  await fetch(
71231
71581
  `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
@@ -71248,16 +71598,15 @@ ${messageText}`;
71248
71598
  console.warn(`[SecureChannel] Cannot deliver Welcome \u2014 HTTP distribute and WS both unavailable`);
71249
71599
  continue;
71250
71600
  }
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
- }
71601
+ const a2aFulfilled = await this._postFulfilWelcome(
71602
+ chState.mlsGroupId,
71603
+ req.id,
71604
+ `A2A ${chId.slice(0, 8)}`
71258
71605
  );
71259
71606
  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)}`);
71607
+ if (a2aFulfilled) {
71608
+ console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
71609
+ }
71261
71610
  } catch (fulfillErr) {
71262
71611
  console.log(`[SecureChannel] A2A Welcome fulfill failed: ${fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr)}`);
71263
71612
  }
@@ -71431,12 +71780,12 @@ ${messageText}`;
71431
71780
  console.log(`[SecureChannel] MLS sync: re-join needed for group ${groupId?.slice(0, 8)}`);
71432
71781
  if (groupId) {
71433
71782
  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
- }
71783
+ const target = this._resolveGroupFamily(groupId);
71784
+ if (target) {
71785
+ this._mlsGroups.delete(target.managerKey);
71786
+ console.log(`[SecureChannel] MLS sync rejoin: dropped local state for ${target.label}`);
71439
71787
  }
71788
+ void this._requestWelcomeSelfHeal(groupId);
71440
71789
  }
71441
71790
  } else if (action === "replay_complete") {
71442
71791
  console.log(`[SecureChannel] MLS sync complete for group ${groupId?.slice(0, 8)} (${data.count} messages replayed)`);
@@ -71448,6 +71797,7 @@ ${messageText}`;
71448
71797
  */
71449
71798
  async _handleA2AMessageMLS(data) {
71450
71799
  const groupId = data.group_id;
71800
+ this._noteGroupEpoch(groupId, data.epoch);
71451
71801
  const channelId = data.channel_id ?? data.a2a_channel_id;
71452
71802
  let a2aChannelId = channelId;
71453
71803
  if (!a2aChannelId) {
@@ -98671,7 +99021,7 @@ var init_index = __esm2({
98671
99021
  init_skill_invoker();
98672
99022
  await init_skill_telemetry();
98673
99023
  await init_policy_enforcer();
98674
- VERSION = true ? "0.23.23" : "0.0.0-dev";
99024
+ VERSION = true ? "0.23.27" : "0.0.0-dev";
98675
99025
  }
98676
99026
  });
98677
99027
  await init_index();
@@ -135052,7 +135402,7 @@ async function main() {
135052
135402
  "[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
135403
  );
135054
135404
  }
135055
- logLine(`[bridge] version: ${true ? "0.8.0" : "dev"}`);
135405
+ logLine(`[bridge] version: ${true ? "0.8.1" : "dev"}`);
135056
135406
  logLine(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
135057
135407
  logLine(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
135058
135408
  if (cfg.armRoom) {
@@ -135084,7 +135434,7 @@ async function main() {
135084
135434
  // its default would render "@agentvault/agentvault@0.7.x" — the wrong
135085
135435
  // package name attached to the bridge's version number, which is worse
135086
135436
  // than either alone.
135087
- clientVersion: `@agentvault/claude-bridge@${true ? "0.8.0" : "dev"}`
135437
+ clientVersion: `@agentvault/claude-bridge@${true ? "0.8.1" : "dev"}`
135088
135438
  });
135089
135439
  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
135440
  const deviceJwt = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/claude-bridge",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "AgentVault Claude Bridge — daemon for bridging a Claude agent into secure E2E-encrypted AgentVault 1:1 direct messages and rooms.",
6
6
  "main": "dist/index.js",