@agentvault/grok-bridge 0.4.12 → 0.4.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1115,6 +1115,58 @@ var init_mls_commit_classify = __esm2({
1115
1115
  "use strict";
1116
1116
  }
1117
1117
  });
1118
+ function parseMentions(text) {
1119
+ const mentions = [];
1120
+ const re2 = new RegExp(MENTION_RE.source, MENTION_RE.flags);
1121
+ let match;
1122
+ while ((match = re2.exec(text)) !== null) {
1123
+ mentions.push(match[1].toLowerCase());
1124
+ }
1125
+ return mentions;
1126
+ }
1127
+ function resolveOwn(mention, names) {
1128
+ if (names.has(mention)) return mention;
1129
+ const parts = mention.split("-");
1130
+ for (let i2 = parts.length - 1; i2 > 0; i2--) {
1131
+ const candidate = parts.slice(0, i2).join("-");
1132
+ if (names.has(candidate)) return candidate;
1133
+ }
1134
+ return null;
1135
+ }
1136
+ function owesRoomReply(plaintext, agentName, accountId, senderIsAgent, roomDisplayName, ownDisplayName, selfResolved = true) {
1137
+ const mentions = parseMentions(plaintext);
1138
+ if (mentions.length === 0) return false;
1139
+ const ownNames = ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName);
1140
+ if (mentions.some((m2) => resolveOwn(m2, ownNames) !== null)) return true;
1141
+ if (mentions.some((m2) => resolveOwn(m2, BROADCAST_NAMES) !== null)) {
1142
+ return senderIsAgent !== true;
1143
+ }
1144
+ if (!selfResolved) return true;
1145
+ return false;
1146
+ }
1147
+ function ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName) {
1148
+ const names = /* @__PURE__ */ new Set();
1149
+ const add4 = (n2) => {
1150
+ if (!n2) return;
1151
+ const lower = n2.toLowerCase();
1152
+ names.add(lower);
1153
+ names.add(lower.split(/[\s(]/)[0]);
1154
+ };
1155
+ add4(agentName);
1156
+ add4(accountId);
1157
+ add4(roomDisplayName);
1158
+ add4(ownDisplayName);
1159
+ return names;
1160
+ }
1161
+ var MENTION_RE;
1162
+ var BROADCAST_NAMES;
1163
+ var init_room_protocol = __esm2({
1164
+ "src/room-protocol.ts"() {
1165
+ "use strict";
1166
+ MENTION_RE = /@([\w-]+)/gi;
1167
+ BROADCAST_NAMES = /* @__PURE__ */ new Set(["all", "everyone"]);
1168
+ }
1169
+ });
1118
1170
  var __filename;
1119
1171
  var __dirname;
1120
1172
  var url2;
@@ -64017,10 +64069,10 @@ var init_mls_group = __esm2({
64017
64069
  const commits = [];
64018
64070
  if (leavesToRemove.length > 0) {
64019
64071
  const { commit } = await this.removeMembers(leavesToRemove);
64020
- commits.push({ commit, epoch: this.epoch });
64072
+ commits.push({ commit, epoch: this.epoch, state: this.exportState() });
64021
64073
  }
64022
64074
  const added = await this.addMembers(toAdd);
64023
- commits.push({ commit: added.commit, epoch: this.epoch });
64075
+ commits.push({ commit: added.commit, epoch: this.epoch, state: this.exportState() });
64024
64076
  return { commits, welcome: added.welcome };
64025
64077
  }
64026
64078
  async removeMembers(memberIndices) {
@@ -64605,11 +64657,11 @@ var init_mls_kp_pool = __esm2({
64605
64657
  }
64606
64658
  });
64607
64659
  function ownIdentity() {
64608
- const v2 = true ? "0.23.39" : FALLBACK;
64660
+ const v2 = true ? "0.23.41" : FALLBACK;
64609
64661
  return `${PACKAGE}@${v2}`;
64610
64662
  }
64611
64663
  function buildSha() {
64612
- const raw = true ? "9982fc8-dirty" : "";
64664
+ const raw = true ? "916d6c5-dirty" : "";
64613
64665
  const cleaned = (raw ?? "").trim();
64614
64666
  return !cleaned || cleaned === "unknown" ? null : cleaned;
64615
64667
  }
@@ -65516,6 +65568,7 @@ var CREDENTIAL_MESSAGE_TYPES;
65516
65568
  var CATCHUP_MESSAGE_TYPES;
65517
65569
  var POLL_INTERVAL_MS;
65518
65570
  var RECONNECT_BASE_MS;
65571
+ var UUID_RE;
65519
65572
  var RECONNECT_MAX_MS;
65520
65573
  var PENDING_POLL_INTERVAL_MS;
65521
65574
  var OUTBOUND_DEDUPE_WINDOW_MS;
@@ -65531,6 +65584,7 @@ var init_channel = __esm2({
65531
65584
  "use strict";
65532
65585
  init_abstention();
65533
65586
  init_mls_commit_classify();
65587
+ init_room_protocol();
65534
65588
  await init_libsodium_wrappers();
65535
65589
  await init_dist();
65536
65590
  await init_dist();
@@ -65562,6 +65616,7 @@ var init_channel = __esm2({
65562
65616
  ]);
65563
65617
  POLL_INTERVAL_MS = 6e3;
65564
65618
  RECONNECT_BASE_MS = 1e3;
65619
+ UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
65565
65620
  RECONNECT_MAX_MS = 3e4;
65566
65621
  PENDING_POLL_INTERVAL_MS = 15e3;
65567
65622
  OUTBOUND_DEDUPE_WINDOW_MS = 3e3;
@@ -65733,6 +65788,35 @@ var init_channel = __esm2({
65733
65788
 
65734
65789
  ${text}`;
65735
65790
  }
65791
+ /**
65792
+ * #1161: room attachments whose caption addressed nobody, NOT yet fetched.
65793
+ * The reply gate lives in every consumer of `room_message` (openclaw-entry
65794
+ * and the three native bridges), but its first clause is shared and needs no
65795
+ * identity: a caption with no mention token owes nobody a reply (#1107), so
65796
+ * nobody needs the file yet. Kept per room, newest last, and fetched on the
65797
+ * room's next ADDRESSED turn — the owner attaches, then asks (#1159/#1181).
65798
+ * Hermes does the same (`_defer_room_attachment`).
65799
+ */
65800
+ _deferredRoomAttachments = /* @__PURE__ */ new Map();
65801
+ static DEFERRED_ROOM_ATTACHMENT_MAX = 5;
65802
+ static DEFERRED_ROOM_ATTACHMENT_MS = 60 * 60 * 1e3;
65803
+ _deferRoomAttachment(roomId, info) {
65804
+ const list = this._deferredRoomAttachments.get(roomId) ?? [];
65805
+ list.push({ info, at: Date.now() });
65806
+ while (list.length > _SecureChannel.DEFERRED_ROOM_ATTACHMENT_MAX) list.shift();
65807
+ this._deferredRoomAttachments.set(roomId, list);
65808
+ console.log(
65809
+ `[SecureChannel] room ${roomId.slice(0, 8)}: attachment ${String(info?.filename ?? "file")} deferred \u2014 caption addresses nobody, fetch on the next @-addressed turn (#1161)`
65810
+ );
65811
+ }
65812
+ /** Drain the room's deferred attachments that are still fresh (oldest first). */
65813
+ _takeDeferredRoomAttachments(roomId) {
65814
+ const list = this._deferredRoomAttachments.get(roomId);
65815
+ if (!list?.length) return [];
65816
+ this._deferredRoomAttachments.delete(roomId);
65817
+ const cutoff = Date.now() - _SecureChannel.DEFERRED_ROOM_ATTACHMENT_MS;
65818
+ return list.filter((d2) => d2.at >= cutoff).map((d2) => d2.info);
65819
+ }
65736
65820
  /** In-flight roster-refresh promises (per room) — dedup concurrent refreshes. */
65737
65821
  _roomRosterRefreshInFlight = /* @__PURE__ */ new Map();
65738
65822
  /** Dedup buffer for A2A message IDs (prevents double-delivery via direct + Redis) */
@@ -66828,7 +66912,7 @@ ${text}`;
66828
66912
  */
66829
66913
  sendActivitySpan(spanData) {
66830
66914
  if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
66831
- const pluginVersion = true ? "0.23.39" : "0.0.0-dev";
66915
+ const pluginVersion = true ? "0.23.41" : "0.0.0-dev";
66832
66916
  const agentName = this.config.agentName ?? "Agent";
66833
66917
  const resource = {
66834
66918
  "service.name": "agentvault-agent",
@@ -68766,7 +68850,7 @@ ${text}`;
68766
68850
  agentVersion: this.config.agentVersion ?? "0.0.0",
68767
68851
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68768
68852
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68769
- pluginVersion: true ? "0.23.39" : "0.0.0-dev"
68853
+ pluginVersion: true ? "0.23.41" : "0.0.0-dev"
68770
68854
  });
68771
68855
  this._telemetryReporter.startAutoFlush(3e4);
68772
68856
  }
@@ -69090,7 +69174,7 @@ ${text}`;
69090
69174
  agentVersion: this.config.agentVersion ?? "0.0.0",
69091
69175
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
69092
69176
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
69093
- pluginVersion: true ? "0.23.39" : "0.0.0-dev"
69177
+ pluginVersion: true ? "0.23.41" : "0.0.0-dev"
69094
69178
  });
69095
69179
  this._telemetryReporter.startAutoFlush(3e4);
69096
69180
  }
@@ -69305,45 +69389,78 @@ ${text}`;
69305
69389
  const mlsGroup = this._mlsGroups.get(`a2a:${channelId}`);
69306
69390
  if (mlsGroup?.isInitialized) {
69307
69391
  try {
69308
- const kpRes = await fetch(
69309
- `${this.config.apiUrl}/api/v1/mls/key-packages?hub_id=${encodeURIComponent(participant.hub_id)}`,
69310
- { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
69311
- );
69312
- if (kpRes.ok) {
69313
- const kpData = await kpRes.json();
69314
- if (kpData.key_package) {
69315
- const kpBytes = new Uint8Array(Buffer.from(kpData.key_package, "hex"));
69316
- const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
69317
- const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
69318
- if (welcome && this._ws) {
69319
- this._ws.send(JSON.stringify({
69320
- event: "mls_commit",
69321
- data: {
69322
- group_id: entry.mlsGroupId,
69323
- epoch: Number(mlsGroup.epoch),
69324
- payload: Buffer.from(commit).toString("hex")
69325
- }
69326
- }));
69327
- const newDeviceId = participant.device_id || null;
69328
- if (!newDeviceId) {
69329
- console.warn(
69330
- `[SecureChannel] Skipping MLS Welcome to new participant ${participant.hub_address} for A2A ${channelId.slice(0, 8)}: no device_id on participant_joined payload.`
69331
- );
69332
- } else {
69333
- this._ws.send(JSON.stringify({
69334
- event: "mls_welcome",
69335
- data: {
69336
- group_id: entry.mlsGroupId,
69337
- payload: Buffer.from(welcome).toString("hex"),
69338
- target_device_id: newDeviceId,
69339
- a2a_channel_id: channelId,
69340
- target_hub_id: participant.hub_id
69341
- // observability only
69392
+ const newDeviceId = participant.device_id || null;
69393
+ const a2aLabel = `A2A ${channelId.slice(0, 8)} participant_joined`;
69394
+ if (!newDeviceId) {
69395
+ console.warn(
69396
+ `[SecureChannel] ${a2aLabel}: no device_id on the payload for ${participant.hub_address} \u2014 NOT adding (a Welcome could not be addressed)`
69397
+ );
69398
+ } else if (!this._wsIsOpen()) {
69399
+ console.warn(`[SecureChannel] ${a2aLabel}: socket is not OPEN \u2014 NOT advancing the group`);
69400
+ } else {
69401
+ const preHead = await this._readGroupHead(entry.mlsGroupId);
69402
+ const inFlight = preHead.ok && preHead.epoch !== Number(mlsGroup.epoch) ? null : this._reserveCommit(entry.mlsGroupId, a2aLabel);
69403
+ if (preHead.ok && preHead.epoch !== Number(mlsGroup.epoch)) {
69404
+ console.warn(
69405
+ `[SecureChannel] ${a2aLabel}: server head is at epoch ${preHead.epoch}, we are at ${Number(mlsGroup.epoch)} \u2014 NOT committing (it would be refused); resync catches up first`
69406
+ );
69407
+ await this._catchUpAfterPreflightSkip(entry.mlsGroupId, preHead.epoch, Number(mlsGroup.epoch), a2aLabel);
69408
+ } else if (inFlight) {
69409
+ try {
69410
+ const kpRes = await fetch(
69411
+ `${this.config.apiUrl}/api/v1/mls/key-packages?hub_id=${encodeURIComponent(participant.hub_id)}`,
69412
+ { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
69413
+ );
69414
+ if (kpRes.ok) {
69415
+ const kpData = await kpRes.json();
69416
+ if (kpData.key_package) {
69417
+ const kpBytes = new Uint8Array(Buffer.from(kpData.key_package, "hex"));
69418
+ const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
69419
+ const stateBefore = mlsGroup.exportState();
69420
+ const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
69421
+ const committedEpoch = Number(mlsGroup.epoch);
69422
+ if (!welcome) {
69423
+ mlsGroup.importState(stateBefore);
69424
+ console.warn(`[SecureChannel] ${a2aLabel}: addMembers produced no Welcome \u2014 rolled back`);
69425
+ } else {
69426
+ const verdict = await this._sendCommitConfirmed({
69427
+ mlsGroup,
69428
+ mlsGroupId: entry.mlsGroupId,
69429
+ label: a2aLabel,
69430
+ commit,
69431
+ epoch: committedEpoch,
69432
+ parent: preHead.ok ? preHead.digest : void 0,
69433
+ memberDeviceIds: this._commitRoster(
69434
+ (entry.participants ?? []).map((p2) => p2.deviceId),
69435
+ [newDeviceId]
69436
+ ),
69437
+ stateBefore,
69438
+ inFlight
69439
+ });
69440
+ if (verdict === "confirmed") {
69441
+ const sent = await this._sendFrameConfirmed({
69442
+ event: "mls_welcome",
69443
+ data: {
69444
+ group_id: entry.mlsGroupId,
69445
+ payload: Buffer.from(welcome).toString("hex"),
69446
+ target_device_id: newDeviceId,
69447
+ a2a_channel_id: channelId,
69448
+ target_hub_id: participant.hub_id
69449
+ // observability only
69450
+ }
69451
+ }, `${a2aLabel} welcome`);
69452
+ await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
69453
+ if (sent) {
69454
+ console.log(`[SecureChannel] Sent MLS Welcome to new participant ${participant.hub_address} (device ${String(newDeviceId).slice(0, 8)})`);
69455
+ } else {
69456
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome to ${String(newDeviceId).slice(0, 8)} was NOT written \u2014 the participant will request one`);
69457
+ }
69458
+ }
69342
69459
  }
69343
- }));
69344
- await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
69345
- console.log(`[SecureChannel] Sent MLS Welcome to new participant ${participant.hub_address} (device ${String(newDeviceId).slice(0, 8)})`);
69460
+ }
69346
69461
  }
69462
+ } finally {
69463
+ await this._releaseCommit(entry.mlsGroupId, inFlight, a2aLabel);
69347
69464
  }
69348
69465
  }
69349
69466
  }
@@ -69404,6 +69521,7 @@ ${text}`;
69404
69521
  `[deliver] delivery_result=REJECTED group=${rejectedGroup || "unknown"} detail=${detail}`
69405
69522
  );
69406
69523
  console.error(`[SecureChannel] Server error: ${detail}`);
69524
+ this._noteCommitRefusal(data.data || data);
69407
69525
  await this._handleServerError(data.data || data);
69408
69526
  this.emit("error", new Error(`Server: ${detail}`));
69409
69527
  }
@@ -69705,7 +69823,7 @@ ${text}`;
69705
69823
  try {
69706
69824
  const parsed = JSON.parse(plaintext);
69707
69825
  messageType = parsed.type || "message";
69708
- messageText = parsed.text || plaintext;
69826
+ messageText = parsed.text ?? plaintext;
69709
69827
  if (parsed.attachment) {
69710
69828
  attachmentInfo = parsed.attachment;
69711
69829
  }
@@ -70457,7 +70575,7 @@ ${baseText}`;
70457
70575
  try {
70458
70576
  const parsed = JSON.parse(rawPlaintext);
70459
70577
  messageType = parsed.type || "message";
70460
- messageText = parsed.text || rawPlaintext;
70578
+ messageText = parsed.text ?? rawPlaintext;
70461
70579
  if (parsed.attachment) roomAttachment = parsed.attachment;
70462
70580
  } catch {
70463
70581
  messageType = "message";
@@ -70498,10 +70616,19 @@ ${baseText}`;
70498
70616
  await this._refreshRoomRoster(resolvedRoomId);
70499
70617
  }
70500
70618
  }
70501
- if (roomAttachment) {
70502
- messageText = await this._augmentWithAttachment(roomAttachment, messageText, resolvedRoomId);
70619
+ const addressed = parseMentions(messageText).length > 0;
70620
+ if (roomAttachment && !addressed) {
70621
+ this._deferRoomAttachment(resolvedRoomId, roomAttachment);
70503
70622
  } else {
70504
- messageText = this._withRecentRoomAttachmentNote(resolvedRoomId, messageText);
70623
+ const deferred = addressed ? this._takeDeferredRoomAttachments(resolvedRoomId) : [];
70624
+ for (const info of deferred) {
70625
+ messageText = await this._augmentWithAttachment(info, messageText, resolvedRoomId);
70626
+ }
70627
+ if (roomAttachment) {
70628
+ messageText = await this._augmentWithAttachment(roomAttachment, messageText, resolvedRoomId);
70629
+ } else if (deferred.length === 0) {
70630
+ messageText = this._withRecentRoomAttachmentNote(resolvedRoomId, messageText);
70631
+ }
70505
70632
  }
70506
70633
  const roomMembers = this._persisted?.rooms?.[resolvedRoomId]?.members ?? [];
70507
70634
  const senderMember = roomMembers.find((m2) => m2.deviceId === senderDeviceId);
@@ -70625,6 +70752,12 @@ ${baseText}`;
70625
70752
  const groupId = data.group_id;
70626
70753
  const epoch = typeof data.epoch === "number" ? data.epoch : 0;
70627
70754
  this._noteGroupEpoch(groupId, data.epoch);
70755
+ const held = this._commitInFlight.get(groupId);
70756
+ if (held) {
70757
+ held.frames.push(data);
70758
+ console.log(`[SecureChannel] MLS commit for ${groupId.slice(0, 8)} held (epoch=${epoch}) \u2014 our own commit is awaiting confirmation (#1184)`);
70759
+ return true;
70760
+ }
70628
70761
  const target = this._resolveGroupFamily(groupId);
70629
70762
  if (target) {
70630
70763
  const { managerKey: groupKey, label } = target;
@@ -70857,9 +70990,34 @@ ${baseText}`;
70857
70990
  *
70858
70991
  * Returns the number of requests actually fulfilled.
70859
70992
  */
70993
+ /**
70994
+ * #1226: the roster to submit on a commit frame — the caller's known members,
70995
+ * the devices being admitted, and ALWAYS ourselves — deduplicated, UUIDs
70996
+ * only. Self is non-negotiable: once a roster is non-empty the server
70997
+ * requires the committer in it (#469), and our own head read
70998
+ * (`assert_group_member`) requires it too. So with no device id of our own
70999
+ * this returns `[]` and the frame carries NO roster (the pre-#1226 shape)
71000
+ * rather than a roster that locks us out. Non-UUID entries are dropped:
71001
+ * `known` can come off an inbound Welcome frame, and a malformed id in the
71002
+ * outbound frame is refused by the server (`MEMBER_DEVICE_IDS_MALFORMED`).
71003
+ * @internal exposed for tests
71004
+ */
71005
+ _commitRoster(known, targets) {
71006
+ if (!this._deviceId) return [];
71007
+ const out = [];
71008
+ const seen = /* @__PURE__ */ new Set();
71009
+ for (const id of [...known ?? [], ...targets, this._deviceId]) {
71010
+ if (typeof id !== "string" || !UUID_RE.test(id) || seen.has(id)) continue;
71011
+ seen.add(id);
71012
+ out.push(id);
71013
+ }
71014
+ return out;
71015
+ }
70860
71016
  async _fulfilWelcomeRequests(opts) {
70861
71017
  const { mlsGroup, mlsGroupId, label, welcomeFields, targets } = opts;
70862
71018
  if (targets.length === 0) return 0;
71019
+ const rosterBefore = this._commitRoster(opts.memberDeviceIds, []);
71020
+ const rosterAfter = this._commitRoster(opts.memberDeviceIds, targets.map((t2) => t2.deviceId));
70863
71021
  if (!this._wsIsOpen()) {
70864
71022
  console.warn(
70865
71023
  `[SecureChannel] ${label}: socket is not OPEN \u2014 NOT advancing the group; ${targets.length} request(s) stay pending for the next pull`
@@ -70867,73 +71025,154 @@ ${baseText}`;
70867
71025
  return 0;
70868
71026
  }
70869
71027
  const snapshot = mlsGroup.exportState();
70870
- let parent2 = await this._readGroupHeadDigest(mlsGroupId);
70871
- const { commits, welcome } = await mlsGroup.reAdmitMembers(targets.map((t2) => t2.keyPackage));
70872
- let sentCommits = 0;
70873
- for (const c2 of commits) {
70874
- const ok = await this._sendFrameConfirmed(
70875
- {
70876
- event: "mls_commit",
70877
- data: {
70878
- group_id: mlsGroupId,
70879
- // The epoch this commit PRODUCED. After two commits the manager
70880
- // only knows the last one, which is why the result carries them.
70881
- epoch: Number(c2.epoch),
70882
- payload: Buffer.from(c2.commit).toString("hex"),
70883
- ...parent2 ? { parent_digest: parent2 } : {}
70884
- }
70885
- },
70886
- `${label} commit ${sentCommits + 1}/${commits.length}`
70887
- );
70888
- if (!ok) break;
70889
- parent2 = this._commitDigest(c2.commit);
70890
- sentCommits++;
70891
- }
70892
- if (sentCommits === 0) {
70893
- mlsGroup.importState(snapshot);
70894
- console.warn(
70895
- `[SecureChannel] ${label}: commit was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing fulfilled, requests stay pending`
70896
- );
70897
- return 0;
70898
- }
70899
- await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70900
- if (sentCommits < commits.length) {
71028
+ const preHead = await this._readGroupHead(mlsGroupId);
71029
+ if (preHead.ok && preHead.epoch !== Number(mlsGroup.epoch)) {
70901
71030
  console.warn(
70902
- `[SecureChannel] ${label}: only ${sentCommits}/${commits.length} commits reached the wire \u2014 requests stay PENDING for the next pull`
71031
+ `[SecureChannel] ${label}: server head is at epoch ${preHead.epoch}, we are at ${Number(mlsGroup.epoch)} \u2014 NOT committing (it would be refused, #1184); ${targets.length} request(s) stay pending`
70903
71032
  );
71033
+ await this._catchUpAfterPreflightSkip(mlsGroupId, preHead.epoch, Number(mlsGroup.epoch), label);
70904
71034
  return 0;
70905
71035
  }
70906
- if (!welcome) {
71036
+ let parent2 = preHead.ok ? preHead.digest : void 0;
71037
+ if (this._commitInFlight.has(mlsGroupId)) {
70907
71038
  console.warn(
70908
- `[SecureChannel] ${label}: addMembers produced NO Welcome \u2014 ${targets.length} request(s) left pending rather than marked fulfilled`
71039
+ `[SecureChannel] ${label}: another commit for this group is in flight \u2014 NOT advancing; ${targets.length} request(s) stay pending for the next pull`
70909
71040
  );
70910
71041
  return 0;
70911
71042
  }
70912
- let fulfilled = 0;
70913
- for (const t2 of targets) {
70914
- const sent = await this._sendFrameConfirmed(
70915
- {
70916
- event: "mls_welcome",
70917
- data: {
70918
- target_device_id: t2.deviceId,
70919
- group_id: mlsGroupId,
70920
- ...welcomeFields,
70921
- payload: Buffer.from(welcome).toString("hex")
70922
- }
70923
- },
70924
- `${label} welcome\u2192${t2.deviceId.slice(0, 8)}`
70925
- );
70926
- if (!sent) {
71043
+ const inFlight = { frames: [], sent: [] };
71044
+ this._commitInFlight.set(mlsGroupId, inFlight);
71045
+ try {
71046
+ const { commits, welcome } = await mlsGroup.reAdmitMembers(targets.map((t2) => t2.keyPackage));
71047
+ let sentCommits = 0;
71048
+ let refusedAt = -1;
71049
+ let verdict = "confirmed";
71050
+ for (const c2 of commits) {
71051
+ const ours = this._commitDigest(c2.commit);
71052
+ inFlight.sent.push({
71053
+ epoch: Number(c2.epoch),
71054
+ digest: ours,
71055
+ verified: false,
71056
+ stateBefore: sentCommits === 0 ? snapshot : commits[sentCommits - 1].state
71057
+ });
71058
+ const ok = await this._sendFrameConfirmed(
71059
+ {
71060
+ event: "mls_commit",
71061
+ data: {
71062
+ group_id: mlsGroupId,
71063
+ // The epoch this commit PRODUCED. After two commits the manager
71064
+ // only knows the last one, which is why the result carries them.
71065
+ epoch: Number(c2.epoch),
71066
+ payload: Buffer.from(c2.commit).toString("hex"),
71067
+ ...parent2 ? { parent_digest: parent2 } : {},
71068
+ ...(sentCommits === commits.length - 1 ? rosterAfter : rosterBefore).length > 0 ? { member_device_ids: sentCommits === commits.length - 1 ? rosterAfter : rosterBefore } : {}
71069
+ }
71070
+ },
71071
+ `${label} commit ${sentCommits + 1}/${commits.length}`
71072
+ );
71073
+ if (!ok) {
71074
+ verdict = "unsent";
71075
+ inFlight.sent.pop();
71076
+ break;
71077
+ }
71078
+ verdict = await this._confirmCommitAtHead(mlsGroupId, Number(c2.epoch), ours, inFlight, label);
71079
+ if (verdict === "confirmed") {
71080
+ inFlight.sent[sentCommits].verified = true;
71081
+ } else if (verdict === "unverifiable") {
71082
+ console.warn(
71083
+ `[SecureChannel] ${label}: commit ${sentCommits + 1}/${commits.length} at epoch ${Number(c2.epoch)} could not be verified and was not refused \u2014 proceeding UNCHECKED (#1184)`
71084
+ );
71085
+ } else {
71086
+ refusedAt = sentCommits;
71087
+ break;
71088
+ }
71089
+ parent2 = ours;
71090
+ sentCommits++;
71091
+ }
71092
+ const cutFromRefusal = () => {
71093
+ const r2 = inFlight.refusal;
71094
+ if (r2 === void 0) return refusedAt;
71095
+ return refusedAt === -1 ? r2.from : Math.min(refusedAt, r2.from);
71096
+ };
71097
+ const rollBackTo = async (cut2, stage, persisted) => {
71098
+ const why = inFlight.refusal?.detail ?? verdict.toUpperCase();
71099
+ mlsGroup.importState(inFlight.sent[cut2].stateBefore);
71100
+ sentCommits = cut2;
70927
71101
  console.warn(
70928
- `[SecureChannel] ${label}: Welcome for ${t2.deviceId.slice(0, 8)} was NOT written \u2014 leaving the request PENDING so the next pull retries it`
71102
+ `[SecureChannel] ${label}: commit ${cut2 + 1}/${commits.length} ${stage} \u2014 "${why}" \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}` + (cut2 === 0 ? ", nothing" : ` (after commit ${cut2}), only that`) + ` persisted, ${targets.length} request(s) stay pending`
70929
71103
  );
70930
- continue;
71104
+ if (persisted) await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
71105
+ };
71106
+ let cut = cutFromRefusal();
71107
+ if (cut !== -1) {
71108
+ await rollBackTo(cut, "was refused by the server", false);
71109
+ if (sentCommits === 0) return 0;
71110
+ }
71111
+ if (sentCommits === 0) {
71112
+ mlsGroup.importState(snapshot);
71113
+ console.warn(
71114
+ `[SecureChannel] ${label}: commit was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing fulfilled, requests stay pending`
71115
+ );
71116
+ return 0;
71117
+ }
71118
+ await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
71119
+ cut = cutFromRefusal();
71120
+ if (cut !== -1 && cut < sentCommits) {
71121
+ await rollBackTo(cut, "was refused by the server after the window (during persist)", true);
71122
+ return 0;
70931
71123
  }
70932
- if (await this._postFulfilWelcome(mlsGroupId, t2.requestId, `${label} ${t2.deviceId.slice(0, 8)}`)) {
70933
- fulfilled++;
71124
+ if (sentCommits < commits.length) {
71125
+ console.warn(
71126
+ `[SecureChannel] ${label}: only ${sentCommits}/${commits.length} commits were confirmed \u2014 requests stay PENDING for the next pull`
71127
+ );
71128
+ return 0;
71129
+ }
71130
+ if (!welcome) {
71131
+ console.warn(
71132
+ `[SecureChannel] ${label}: addMembers produced NO Welcome \u2014 ${targets.length} request(s) left pending rather than marked fulfilled`
71133
+ );
71134
+ return 0;
71135
+ }
71136
+ let fulfilled = 0;
71137
+ for (const t2 of targets) {
71138
+ const sent = await this._sendFrameConfirmed(
71139
+ {
71140
+ event: "mls_welcome",
71141
+ data: {
71142
+ target_device_id: t2.deviceId,
71143
+ group_id: mlsGroupId,
71144
+ ...welcomeFields,
71145
+ payload: Buffer.from(welcome).toString("hex")
71146
+ }
71147
+ },
71148
+ `${label} welcome\u2192${t2.deviceId.slice(0, 8)}`
71149
+ );
71150
+ if (!sent) {
71151
+ console.warn(
71152
+ `[SecureChannel] ${label}: Welcome for ${t2.deviceId.slice(0, 8)} was NOT written \u2014 leaving the request PENDING so the next pull retries it`
71153
+ );
71154
+ continue;
71155
+ }
71156
+ cut = cutFromRefusal();
71157
+ if (cut !== -1 && cut < sentCommits) {
71158
+ await rollBackTo(cut, "was refused by the server after the window (during the Welcome)", true);
71159
+ return fulfilled;
71160
+ }
71161
+ if (await this._postFulfilWelcome(mlsGroupId, t2.requestId, `${label} ${t2.deviceId.slice(0, 8)}`)) {
71162
+ fulfilled++;
71163
+ }
71164
+ }
71165
+ return fulfilled;
71166
+ } finally {
71167
+ this._commitInFlight.delete(mlsGroupId);
71168
+ for (const frame of inFlight.frames) {
71169
+ try {
71170
+ await this._handleMlsCommit(frame);
71171
+ } catch (err) {
71172
+ console.error(`[SecureChannel] ${label}: replaying a held MLS commit failed:`, err);
71173
+ }
70934
71174
  }
70935
71175
  }
70936
- return fulfilled;
70937
71176
  }
70938
71177
  /**
70939
71178
  * Resolve an MLS group id to the family that owns it.
@@ -70974,31 +71213,364 @@ ${baseText}`;
70974
71213
  return this._ws?.readyState === 1;
70975
71214
  }
70976
71215
  /** SHA-256 hex of a commit's bytes — its identity on the server (#1175/#1177). */
71216
+ /**
71217
+ * #1225: send ONE commit over the socket and wait for the server's verdict,
71218
+ * with the #1184 machinery — an in-flight record (so a `… commit rejected`
71219
+ * frame is correlated to it and a concurrent commit for the group is held
71220
+ * and replayed), the head poll, and rollback. The three A2A commit sites
71221
+ * (`a2a_participant_joined`, the `/distribute` WS fallback, `_foundA2AGroup`)
71222
+ * used a raw `ws.send` and treated "the socket took it" as "the server
71223
+ * stored it" — two of them then PERSISTED a state the server never saw.
71224
+ *
71225
+ * Returns the verdict. On `refused` / `unsent` the manager has been restored
71226
+ * to `stateBefore` and nothing must be persisted or minted from it.
71227
+ * `unverifiable` (head unreadable on every try AND no refusal) proceeds
71228
+ * UNCHECKED, the same deliberate choice as the fulfil site.
71229
+ */
71230
+ /**
71231
+ * #1225 (review F1): reserve the single in-flight slot for a group. The
71232
+ * record is what `_noteCommitRefusal` correlates a refusal to and what
71233
+ * `_handleMlsCommit` holds concurrent socket commits behind; a second run on
71234
+ * the same group would clobber both (measured: two overlapping joins → two
71235
+ * commits claiming one epoch, one Welcome instead of two, the manager AHEAD
71236
+ * of the server). Taken BEFORE the commit is generated; released by
71237
+ * `_releaseCommit` after the last step that depends on it.
71238
+ */
71239
+ /**
71240
+ * A pre-flight skip must not be a dead end. The skip happens because the
71241
+ * server's head is somewhere else; before this the group "caught up through
71242
+ * the normal divergence path", which only runs when a message for the group
71243
+ * arrives — so a group nobody writes to stayed diverged and its pending
71244
+ * Welcome requests were never fulfilled (named in PR #1227). When the server
71245
+ * is AHEAD, replay the missing commits now so the next pull can commit; when
71246
+ * WE are ahead there is nothing to replay (the fork-ahead direction, #972).
71247
+ */
71248
+ async _catchUpAfterPreflightSkip(mlsGroupId, serverEpoch, localEpoch, label) {
71249
+ if (serverEpoch < localEpoch) {
71250
+ console.warn(`[SecureChannel] ${label}: we are AHEAD of the server (${localEpoch} > ${serverEpoch}) \u2014 nothing to replay`);
71251
+ return;
71252
+ }
71253
+ const fam = this._resolveGroupFamily(mlsGroupId);
71254
+ if (!fam) return;
71255
+ try {
71256
+ const caughtUp = await this._resyncOnDivergence(fam.managerKey, mlsGroupId);
71257
+ console.log(`[SecureChannel] ${label}: catch-up after pre-flight skip ${caughtUp ? "replayed the missing commits" : "could not replay"}`);
71258
+ } catch (err) {
71259
+ console.warn(`[SecureChannel] ${label}: catch-up after pre-flight skip failed:`, err);
71260
+ }
71261
+ }
71262
+ _reserveCommit(groupId, label) {
71263
+ if (this._commitInFlight.has(groupId)) {
71264
+ console.warn(
71265
+ `[SecureChannel] ${label}: another commit for group ${groupId.slice(0, 8)} is in flight \u2014 NOT advancing the group (a Welcome can be requested again)`
71266
+ );
71267
+ return null;
71268
+ }
71269
+ const rec = { frames: [], sent: [] };
71270
+ this._commitInFlight.set(groupId, rec);
71271
+ return rec;
71272
+ }
71273
+ /** Release the slot taken by `_reserveCommit` and replay the socket commits held meanwhile. */
71274
+ async _releaseCommit(groupId, rec, label) {
71275
+ if (this._commitInFlight.get(groupId) === rec) this._commitInFlight.delete(groupId);
71276
+ for (const frame of rec.frames) {
71277
+ try {
71278
+ await this._handleMlsCommit(frame);
71279
+ } catch (err) {
71280
+ console.error(`[SecureChannel] ${label}: replaying a held MLS commit failed:`, err);
71281
+ }
71282
+ }
71283
+ }
71284
+ /**
71285
+ * #1225: send ONE commit over the socket and wait for the server's verdict
71286
+ * with the #1184 machinery (refusal frame correlated through the reserved
71287
+ * in-flight record, head poll). The three A2A commit sites
71288
+ * (`a2a_participant_joined`, the `/distribute` WS fallback, `_foundA2AGroup`)
71289
+ * used a raw `ws.send` and treated "the socket took it" as "the server
71290
+ * stored it" — two of them then PERSISTED a state the server never saw.
71291
+ *
71292
+ * Only `confirmed` proceeds. `unverifiable` (head unreadable on every try,
71293
+ * no refusal) is a ROLLBACK here, unlike the fulfil site: that site proceeds
71294
+ * UNCHECKED because 79 of 80 production rooms 403 the head read on an empty
71295
+ * roster; an A2A head read admits any active participant (observer
71296
+ * fallback), so five failed reads there mean an outage, not a routine 403.
71297
+ * On anything but `confirmed` the manager is back at `stateBefore`.
71298
+ */
71299
+ async _sendCommitConfirmed(opts) {
71300
+ const { mlsGroup, mlsGroupId, label, commit, epoch, parent: parent2, memberDeviceIds, stateBefore, inFlight } = opts;
71301
+ const ours = this._commitDigest(commit);
71302
+ inFlight.sent.push({ epoch, digest: ours, verified: false, stateBefore });
71303
+ const ok = await this._sendFrameConfirmed(
71304
+ {
71305
+ event: "mls_commit",
71306
+ data: {
71307
+ group_id: mlsGroupId,
71308
+ epoch,
71309
+ payload: Buffer.from(commit).toString("hex"),
71310
+ ...parent2 ? { parent_digest: parent2 } : {},
71311
+ ...memberDeviceIds && memberDeviceIds.length > 0 ? { member_device_ids: memberDeviceIds } : {}
71312
+ }
71313
+ },
71314
+ `${label} commit`
71315
+ );
71316
+ if (!ok) {
71317
+ inFlight.sent.pop();
71318
+ mlsGroup.importState(stateBefore);
71319
+ console.warn(
71320
+ `[SecureChannel] ${label}: commit at epoch ${epoch} was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}`
71321
+ );
71322
+ return "unsent";
71323
+ }
71324
+ const verdict = await this._confirmCommitAtHead(mlsGroupId, epoch, ours, inFlight, label);
71325
+ if (inFlight.refusal === void 0 && verdict === "confirmed") {
71326
+ inFlight.sent[inFlight.sent.length - 1].verified = true;
71327
+ return "confirmed";
71328
+ }
71329
+ const why = inFlight.refusal?.detail ?? verdict.toUpperCase();
71330
+ mlsGroup.importState(stateBefore);
71331
+ console.warn(
71332
+ `[SecureChannel] ${label}: commit at epoch ${epoch} was ${verdict === "unverifiable" ? "not verified (head unreadable)" : "refused by the server"} \u2014 "${why}" \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing persisted`
71333
+ );
71334
+ return "refused";
71335
+ }
70977
71336
  _commitDigest(bytes) {
70978
71337
  return createHash("sha256").update(bytes).digest("hex");
70979
71338
  }
70980
71339
  /**
70981
71340
  * #1177: the digest of the commit at the server's head, or undefined.
70982
71341
  *
70983
- * Undefined covers "no head yet", "older backend", AND "could not read" —
70984
- * deliberately the same value. The commit is sent either way: the server
70985
- * skips the parent check for an ABSENT field and refuses a MALFORMED one,
70986
- * so a read failure must degrade to "unchecked", never to "blocked" (a
70987
- * rescue that cannot run is the 2026-09-04 lockout) and never to `null`.
71342
+ * Undefined covers "no head yet" (a pruned group: epoch known, digest NULL —
71343
+ * 29 in production) AND "could not read" — deliberately the same value. The
71344
+ * commit is sent either way: the server skips the parent check for an
71345
+ * ABSENT field and refuses a MALFORMED one, so a read failure must degrade
71346
+ * to "unchecked", never to "blocked" (a rescue that cannot run is the
71347
+ * 2026-09-04 lockout) and never to `null`.
70988
71348
  */
70989
71349
  async _readGroupHeadDigest(groupId) {
71350
+ const head = await this._readGroupHead(groupId);
71351
+ return head.ok ? head.digest : void 0;
71352
+ }
71353
+ /**
71354
+ * #1184: the server's head — its `current_epoch` and the digest of the commit
71355
+ * that produced it.
71356
+ *
71357
+ * - `{ ok: true, epoch, digest? }` the server described its head. `digest`
71358
+ * is absent on a pruned group (NULL
71359
+ * `last_commit_digest`).
71360
+ * - `{ ok: false, why }` the read failed: a non-2xx (the 403
71361
+ * `assert_group_member` returns on an
71362
+ * EMPTY roster — 79 of 80 production
71363
+ * rooms — or a 5xx), a timeout, or a body
71364
+ * with no `current_epoch`. Callers may
71365
+ * retry; a failure is never a verdict.
71366
+ *
71367
+ * `current_epoch` is a required field of `MLSGroupResponse`, so "200 with
71368
+ * no head" is not a shape any deployed backend produces; it is folded into
71369
+ * `ok: false` rather than given a branch of its own (the first cut of #1184
71370
+ * had one, and it was the only branch the suite exercised).
71371
+ */
71372
+ async _readGroupHead(groupId) {
70990
71373
  try {
70991
71374
  const res = await fetch(`${this.config.apiUrl}/api/v1/mls/groups/${groupId}`, {
70992
- headers: { Authorization: `Bearer ${this._deviceJwt}` }
71375
+ headers: { Authorization: `Bearer ${this._deviceJwt}` },
71376
+ // The confirm loop holds `_deliveryPulling`; a black-holed read must
71377
+ // not hold it for undici's 300 s default. A timeout is a failed read.
71378
+ signal: AbortSignal.timeout(_SecureChannel.HEAD_READ_TIMEOUT_MS)
70993
71379
  });
70994
- if (!res.ok) return void 0;
71380
+ if (!res.ok) return { ok: false, why: `HTTP ${res.status}` };
70995
71381
  const head = await res.json();
70996
- const d2 = head?.last_commit_digest;
70997
- return typeof d2 === "string" && /^[0-9a-f]{64}$/.test(d2) ? d2 : void 0;
70998
- } catch {
70999
- return void 0;
71382
+ if (typeof head?.current_epoch !== "number") return { ok: false, why: "no current_epoch in body" };
71383
+ const d2 = head.last_commit_digest;
71384
+ return { ok: true, epoch: head.current_epoch, digest: typeof d2 === "string" && /^[0-9a-f]{64}$/.test(d2) ? d2 : void 0 };
71385
+ } catch (err) {
71386
+ return { ok: false, why: err instanceof Error ? err.name : String(err) };
71000
71387
  }
71001
71388
  }
71389
+ /**
71390
+ * #1184: after each read of the head, how long to wait before the next.
71391
+ * The web's schedule (`lib/mls-commit-confirm.ts`) plus one immediate read:
71392
+ * five reads, ~3.75 s, no resend — an MLS commit cannot be regenerated. The
71393
+ * immediate read catches the common case where the server stored the frame
71394
+ * before the GET lands. Instance-level so tests can run the decision logic
71395
+ * without the clock; an empty schedule still reads once (`_confirmCommitAtHead`).
71396
+ */
71397
+ _commitConfirmDelaysMs = [0, 250, 500, 1e3, 2e3];
71398
+ static HEAD_READ_TIMEOUT_MS = 5e3;
71399
+ /**
71400
+ * #1184: groups with a commit of OURS between "built" and "confirmed or rolled
71401
+ * back". Each record holds the `mls_commit` frames the socket delivered for
71402
+ * that group meanwhile — before the confirm loop that window was
71403
+ * microseconds; now it is up to ~3.75 s, and a commit applied to our
71404
+ * unconfirmed local state would be lost by the rollback (in memory) after
71405
+ * being persisted (on disk). Held frames are replayed, in order, once the
71406
+ * group is back on a real state. The record also carries the server's
71407
+ * REFUSAL, when one arrives on the socket (`_noteCommitRefusal`), and a
71408
+ * `wake` the confirm loop installs so a refusal ends its wait at once.
71409
+ */
71410
+ _commitInFlight = /* @__PURE__ */ new Map();
71411
+ /** #1184: a pull was requested while one was running — run once more after. */
71412
+ _deliveryPullRequested = false;
71413
+ /**
71414
+ * #1184: an `error` frame naming a group we have a commit in flight for, with
71415
+ * a `… commit rejected` detail, is the server's refusal of one of those
71416
+ * commits — every rejection the `mls_commit` WS handler sends (malformed
71417
+ * parent, PARENT_MISMATCH, EPOCH_CONFLICT, not-a-member, no-participants)
71418
+ * ends with those words and carries `group_id` (pinned by
71419
+ * `tests/test_1184_commit_reject_frame_contract.py`). It is sent on OUR
71420
+ * socket after the server processed OUR frame, so it is deterministic in a
71421
+ * way the head poll is not, and it is the only refusal signal on a group
71422
+ * whose head we cannot read (403).
71423
+ *
71424
+ * WHICH commit, and what else the frame proves (re-review of #1227):
71425
+ * - EPOCH_CONFLICT carries `expected_epoch` (the epoch we claimed to build
71426
+ * on) and `current_epoch` (the server's); PARENT_MISMATCH carries
71427
+ * `current_epoch` (the head, which equalled our claim or the epoch check
71428
+ * would have fired first) and `head_digest`. The refused commit is the
71429
+ * one built on that epoch. A frame naming an epoch none of our in-flight
71430
+ * commits claimed is a refusal of some EARLIER attempt — ignored.
71431
+ * - the server's `current_epoch` bounds our earlier commits too: any of
71432
+ * ours ABOVE it did not land.
71433
+ * - `head_digest` at `current_epoch` is our commit at that epoch → that one
71434
+ * positively LANDED; someone else's → ours at that epoch did not.
71435
+ * - the un-numbered shapes refuse all of ours alike: the cut is the first
71436
+ * commit the head poll has not positively confirmed.
71437
+ * Recorded as the index of the first commit shown not to hold; the fulfil
71438
+ * path honours it whenever it arrives. A frame for a group with nothing in
71439
+ * flight is somebody else's business.
71440
+ */
71441
+ _noteCommitRefusal(payload) {
71442
+ const groupId = typeof payload?.group_id === "string" ? payload.group_id : void 0;
71443
+ const detail = typeof payload?.detail === "string" ? payload.detail : "";
71444
+ if (!groupId || !detail.endsWith("commit rejected")) return;
71445
+ const inFlight = this._commitInFlight.get(groupId);
71446
+ if (!inFlight) return;
71447
+ const num = (v2) => typeof v2 === "number" && Number.isFinite(v2) ? v2 : void 0;
71448
+ const current = num(payload.current_epoch);
71449
+ const claimed = num(payload.expected_epoch) ?? current;
71450
+ const headDigest = typeof payload.head_digest === "string" ? payload.head_digest : void 0;
71451
+ const sent = inFlight.sent;
71452
+ const tag = `[SecureChannel] refusal for ${groupId.slice(0, 8)} ("${detail}")`;
71453
+ let from = -1;
71454
+ if (claimed !== void 0) {
71455
+ from = sent.findIndex((c2) => c2.epoch === claimed + 1);
71456
+ if (from === -1) {
71457
+ console.warn(`${tag} names epoch ${claimed + 1}, which no commit in flight claimed \u2014 an earlier attempt's; ignored`);
71458
+ return;
71459
+ }
71460
+ }
71461
+ if (current !== void 0) {
71462
+ const above = sent.findIndex((c2) => c2.epoch > current && !c2.verified);
71463
+ if (above !== -1 && (from === -1 || above < from)) from = above;
71464
+ }
71465
+ if (headDigest !== void 0 && current !== void 0) {
71466
+ const at = sent.findIndex((c2) => c2.epoch === current);
71467
+ if (at !== -1) {
71468
+ if (sent[at].digest === headDigest) {
71469
+ sent[at].verified = true;
71470
+ } else if (from === -1 || at < from) {
71471
+ from = at;
71472
+ }
71473
+ }
71474
+ }
71475
+ if (from === -1) from = sent.findIndex((c2) => !c2.verified);
71476
+ if (from === -1 || sent[from].verified) {
71477
+ console.warn(`${tag} contradicts a head read that showed our commit \u2014 stale; ignored`);
71478
+ return;
71479
+ }
71480
+ if (inFlight.refusal !== void 0 && inFlight.refusal.from <= from) return;
71481
+ inFlight.refusal = { detail, from };
71482
+ inFlight.wake?.();
71483
+ }
71484
+ /**
71485
+ * #1184: a commit is CONFIRMED when the server's head shows OUR digest at OUR
71486
+ * epoch — never when the socket took the bytes (#1015 covers only that). The
71487
+ * server sends no ack for a stored commit, so, like the web, we read the
71488
+ * head back; but it DOES send a refusal on the socket (`_noteCommitRefusal`),
71489
+ * and that is checked first, before every read and during every wait.
71490
+ *
71491
+ * confirmed head.epoch === ours && head.digest === ours
71492
+ * conflict the server refused ours on the socket, OR the head is at
71493
+ * our epoch with ANOTHER digest, OR already past us
71494
+ * unconfirmed the head was READ (at least once) but never reached our
71495
+ * epoch within the window — the server described its state
71496
+ * and ours is not in it
71497
+ * unverifiable the head could not be read on ANY try (403 on an empty
71498
+ * roster, an outage) and no refusal arrived — or it is at
71499
+ * our epoch but keeps no digest (a pruned group). Nothing
71500
+ * can be decided on; the caller proceeds UNCHECKED (#1177's
71501
+ * rule: a rescue that cannot run is the 2026-09-04 lockout)
71502
+ *
71503
+ * The first cut returned `unverifiable` only for a body with no
71504
+ * `current_epoch`, which no deployed backend produces, and rolled back on
71505
+ * every real read failure — the opposite of the rule it stated.
71506
+ */
71507
+ async _confirmCommitAtHead(groupId, epoch, ours, inFlight, label) {
71508
+ const refused = () => {
71509
+ if (inFlight.refusal === void 0) return void 0;
71510
+ console.warn(`[SecureChannel] ${label}: server refused our commit at epoch ${epoch} \u2014 "${inFlight.refusal.detail}"`);
71511
+ return "conflict";
71512
+ };
71513
+ let lastSeen;
71514
+ let described = false;
71515
+ let lastFailure = "";
71516
+ const delays = this._commitConfirmDelaysMs.length > 0 ? this._commitConfirmDelaysMs : [0];
71517
+ for (const delay of delays) {
71518
+ if (delay > 0) {
71519
+ await new Promise((resolve3) => {
71520
+ const timer = setTimeout(() => {
71521
+ inFlight.wake = void 0;
71522
+ resolve3();
71523
+ }, delay);
71524
+ inFlight.wake = () => {
71525
+ clearTimeout(timer);
71526
+ inFlight.wake = void 0;
71527
+ resolve3();
71528
+ };
71529
+ });
71530
+ }
71531
+ const early = refused();
71532
+ if (early) return early;
71533
+ const head = await this._readGroupHead(groupId);
71534
+ const duringRead = refused();
71535
+ if (duringRead) return duringRead;
71536
+ if (!head.ok) {
71537
+ lastFailure = head.why;
71538
+ continue;
71539
+ }
71540
+ described = true;
71541
+ lastSeen = head.epoch;
71542
+ if (head.epoch === epoch) {
71543
+ if (head.digest === ours) return "confirmed";
71544
+ if (head.digest === void 0) return "unverifiable";
71545
+ console.warn(
71546
+ `[SecureChannel] ${label}: head at epoch ${epoch} is NOT our commit (${head.digest.slice(0, 12)}\u2026 vs ours ${ours.slice(0, 12)}\u2026) \u2014 ours was refused`
71547
+ );
71548
+ return "conflict";
71549
+ }
71550
+ if (head.epoch > epoch) {
71551
+ console.warn(`[SecureChannel] ${label}: head already at epoch ${head.epoch}, past our ${epoch} \u2014 ours was refused`);
71552
+ return "conflict";
71553
+ }
71554
+ }
71555
+ const late2 = refused();
71556
+ if (late2) return late2;
71557
+ if (!described && !this._wsIsOpen()) {
71558
+ console.warn(
71559
+ `[SecureChannel] ${label}: the socket closed during the confirm window and the head could not be read \u2014 a refusal of commit at epoch ${epoch} may have been lost; treating as refused`
71560
+ );
71561
+ return "unconfirmed";
71562
+ }
71563
+ if (!described) {
71564
+ console.warn(
71565
+ `[SecureChannel] ${label}: could not read the head on any of ${delays.length} tries (last: ${lastFailure}) and the server sent no refusal \u2014 cannot verify commit at epoch ${epoch}`
71566
+ );
71567
+ return "unverifiable";
71568
+ }
71569
+ console.warn(
71570
+ `[SecureChannel] ${label}: commit at epoch ${epoch} never appeared at the head (last seen ${lastSeen}) \u2014 treating as refused`
71571
+ );
71572
+ return "unconfirmed";
71573
+ }
71002
71574
  /**
71003
71575
  * #1015: write one frame and report whether the socket took it.
71004
71576
  *
@@ -71519,7 +72091,11 @@ ${baseText}`;
71519
72091
  * Called on WS connect, on mls_delivery ping, and every 30s heartbeat.
71520
72092
  */
71521
72093
  async _pullDeliveryQueue() {
71522
- if (!this._deviceId || !this._deviceJwt || this._deliveryPulling) return;
72094
+ if (!this._deviceId || !this._deviceJwt) return;
72095
+ if (this._deliveryPulling) {
72096
+ this._deliveryPullRequested = true;
72097
+ return;
72098
+ }
71523
72099
  this._deliveryPulling = true;
71524
72100
  try {
71525
72101
  const res = await fetch(
@@ -71710,7 +72286,9 @@ ${baseText}`;
71710
72286
  mlsGroupId: roomState.mlsGroupId,
71711
72287
  label: `room ${roomId.slice(0, 8)}`,
71712
72288
  welcomeFields: { room_id: roomId },
71713
- targets: roomTargets
72289
+ targets: roomTargets,
72290
+ memberDeviceIds: (roomState.members ?? []).map((m2) => m2.deviceId)
72291
+ // #1226
71714
72292
  });
71715
72293
  if (done > 0) {
71716
72294
  console.log(
@@ -71765,7 +72343,9 @@ ${baseText}`;
71765
72343
  mlsGroupId: entry.mlsGroupId,
71766
72344
  label: `shared group ${gid.slice(0, 8)}`,
71767
72345
  welcomeFields: { conversation_group_id: gid },
71768
- targets: sharedTargets
72346
+ targets: sharedTargets,
72347
+ memberDeviceIds: entry.memberDeviceIds ?? []
72348
+ // #1226
71769
72349
  });
71770
72350
  if (done > 0) {
71771
72351
  console.log(`[SecureChannel] Batched Welcome for ${done}/${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
@@ -71779,7 +72359,9 @@ ${baseText}`;
71779
72359
  mlsGroupId: entry.mlsGroupId,
71780
72360
  label: `shared group ${gid.slice(0, 8)}`,
71781
72361
  welcomeFields: { conversation_group_id: gid },
71782
- targets: [target]
72362
+ targets: [target],
72363
+ memberDeviceIds: entry.memberDeviceIds ?? []
72364
+ // #1226
71783
72365
  });
71784
72366
  if (done > 0) {
71785
72367
  console.log(`[SecureChannel] Individual Welcome for ${target.deviceId.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
@@ -71826,7 +72408,11 @@ ${baseText}`;
71826
72408
  requestId: req.id,
71827
72409
  deviceId: req.requesting_device_id,
71828
72410
  keyPackage: memberKp
71829
- }]
72411
+ }],
72412
+ // #1226: a 1:1 roster is [self, owner] — the web's shape too.
72413
+ memberDeviceIds: [this._persisted?.sessions?.[convId]?.ownerDeviceId].filter(
72414
+ (d2) => typeof d2 === "string"
72415
+ )
71830
72416
  });
71831
72417
  if (done > 0) {
71832
72418
  console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
@@ -71884,192 +72470,8 @@ ${baseText}`;
71884
72470
  console.log(`[SecureChannel] ${pending.length} pending A2A Welcome requests for channel ${chId.slice(0, 8)}`);
71885
72471
  for (const req of pending) {
71886
72472
  try {
71887
- const kpRes = await fetch(
71888
- `${this.config.apiUrl}/api/v1/mls/key-packages/batch?device_ids=${req.requesting_device_id}`,
71889
- { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
71890
- );
71891
- if (!kpRes.ok) continue;
71892
- const kpData = await kpRes.json();
71893
- const kpHex = kpData[req.requesting_device_id];
71894
- if (!kpHex) continue;
71895
- const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
71896
- const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
71897
- console.log(`[SecureChannel] A2A addMembers for ${req.requesting_device_id.slice(0, 8)} group=${chState.mlsGroupId.slice(0, 8)} epoch=${mlsGroup.epoch}`);
71898
- let parentDigest = await this._readGroupHeadDigest(chState.mlsGroupId);
71899
- let commit;
71900
- let welcome;
71901
- try {
71902
- const result = await mlsGroup.addMembers([memberKp]);
71903
- commit = result.commit;
71904
- welcome = result.welcome;
71905
- } catch (addErr) {
71906
- const errMsg = addErr instanceof Error ? addErr.message : String(addErr);
71907
- if (errMsg.includes("already in the group")) {
71908
- console.log(`[SecureChannel] A2A member already in group \u2014 removing and re-adding ${req.requesting_device_id.slice(0, 8)}`);
71909
- const preRemoveState = JSON.stringify(mlsGroup.exportState());
71910
- let removeCommitPublished = false;
71911
- try {
71912
- const identityBytes = new TextEncoder().encode(req.requesting_device_id);
71913
- const leafIndex = mlsGroup.memberLeafIndex(identityBytes);
71914
- if (leafIndex === null) {
71915
- console.log(`[SecureChannel] A2A re-add: no leaf index for ${req.requesting_device_id.slice(0, 8)} \u2014 cannot remove`);
71916
- continue;
71917
- }
71918
- const removeResult = await mlsGroup.removeMember(leafIndex);
71919
- try {
71920
- const removeResp = await fetch(
71921
- `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
71922
- {
71923
- method: "POST",
71924
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
71925
- body: JSON.stringify({
71926
- group_id: chState.mlsGroupId,
71927
- device_id: this._deviceId,
71928
- commit: Buffer.from(removeResult.commit).toString("hex"),
71929
- commit_epoch: Number(mlsGroup.epoch),
71930
- ...parentDigest ? { parent_digest: parentDigest } : {}
71931
- })
71932
- }
71933
- );
71934
- if (removeResp.ok) {
71935
- removeCommitPublished = true;
71936
- parentDigest = this._commitDigest(removeResult.commit);
71937
- } else {
71938
- const detail = await removeResp.text().catch(() => "");
71939
- console.warn(`[SecureChannel] A2A remove-commit distribute failed (${removeResp.status}): ${detail}`);
71940
- }
71941
- } catch (distErr) {
71942
- console.warn(`[SecureChannel] A2A remove-commit distribute network error:`, distErr);
71943
- }
71944
- if (!removeCommitPublished) {
71945
- mlsGroup.importState(JSON.parse(preRemoveState));
71946
- continue;
71947
- }
71948
- const kpRes2 = await fetch(
71949
- `${this.config.apiUrl}/api/v1/mls/key-packages/batch?device_ids=${req.requesting_device_id}`,
71950
- { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
71951
- );
71952
- if (kpRes2.ok) {
71953
- const kpData2 = await kpRes2.json();
71954
- const kpHex2 = kpData2[req.requesting_device_id];
71955
- if (kpHex2) {
71956
- const kpBytes2 = new Uint8Array(Buffer.from(kpHex2, "hex"));
71957
- const memberKp2 = MLSGroupManager.deserializeKeyPackage(kpBytes2);
71958
- const result2 = await mlsGroup.addMembers([memberKp2]);
71959
- commit = result2.commit;
71960
- welcome = result2.welcome;
71961
- } else {
71962
- console.log(`[SecureChannel] A2A re-add: no fresh KeyPackage for ${req.requesting_device_id.slice(0, 8)}`);
71963
- continue;
71964
- }
71965
- } else {
71966
- console.log(`[SecureChannel] A2A re-add: KeyPackage fetch failed (${kpRes2.status})`);
71967
- continue;
71968
- }
71969
- } catch (readdErr) {
71970
- console.log(`[SecureChannel] A2A remove+re-add FAILED: ${readdErr instanceof Error ? readdErr.message : String(readdErr)}`);
71971
- if (!removeCommitPublished) {
71972
- try {
71973
- mlsGroup.importState(JSON.parse(preRemoveState));
71974
- } catch {
71975
- }
71976
- }
71977
- continue;
71978
- }
71979
- } else {
71980
- console.log(`[SecureChannel] A2A addMembers FAILED: ${errMsg}`);
71981
- continue;
71982
- }
71983
- }
71984
- if (!welcome) {
71985
- console.warn(`[SecureChannel] A2A addMembers produced no Welcome for ${req.requesting_device_id.slice(0, 8)}`);
71986
- continue;
71987
- }
71988
- const commitHex = Buffer.from(commit).toString("hex");
71989
- const welcomeHex = Buffer.from(welcome).toString("hex");
71990
- let distributed = false;
71991
- const updatedMembers = [this._deviceId, req.requesting_device_id];
71992
- try {
71993
- const distResp = await fetch(
71994
- `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
71995
- {
71996
- method: "POST",
71997
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
71998
- body: JSON.stringify({
71999
- group_id: chState.mlsGroupId,
72000
- device_id: this._deviceId,
72001
- commit: commitHex,
72002
- commit_epoch: Number(mlsGroup.epoch),
72003
- welcomes: [{ target_device_id: req.requesting_device_id, payload: welcomeHex }],
72004
- member_device_ids: updatedMembers,
72005
- ...parentDigest ? { parent_digest: parentDigest } : {}
72006
- })
72007
- }
72008
- );
72009
- if (distResp.ok) {
72010
- distributed = true;
72011
- } else {
72012
- const detail = await distResp.text().catch(() => "");
72013
- console.warn(`[SecureChannel] A2A distribute failed (${distResp.status}): ${detail}`);
72014
- }
72015
- } catch (distErr) {
72016
- console.warn(`[SecureChannel] A2A distribute network error:`, distErr);
72017
- }
72018
- if (!distributed && this._wsIsOpen()) {
72019
- const a2aLabel = `A2A ${chId.slice(0, 8)}`;
72020
- const commitSent = await this._sendFrameConfirmed({
72021
- event: "mls_commit",
72022
- data: {
72023
- group_id: chState.mlsGroupId,
72024
- epoch: Number(mlsGroup.epoch),
72025
- payload: commitHex,
72026
- ...parentDigest ? { parent_digest: parentDigest } : {}
72027
- }
72028
- }, `${a2aLabel} commit`);
72029
- const welcomeSent = commitSent && await this._sendFrameConfirmed({
72030
- event: "mls_welcome",
72031
- data: {
72032
- target_device_id: req.requesting_device_id,
72033
- group_id: chState.mlsGroupId,
72034
- a2a_channel_id: chId,
72035
- payload: welcomeHex
72036
- }
72037
- }, `${a2aLabel} welcome`);
72038
- if (!welcomeSent) {
72039
- console.warn(`[SecureChannel] ${a2aLabel}: Welcome frames were NOT written \u2014 leaving the request pending`);
72040
- continue;
72041
- }
72042
- try {
72043
- await fetch(
72044
- `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
72045
- {
72046
- method: "POST",
72047
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
72048
- body: JSON.stringify({
72049
- group_id: chState.mlsGroupId,
72050
- device_id: this._deviceId,
72051
- commit_epoch: Number(mlsGroup.epoch),
72052
- member_device_ids: updatedMembers
72053
- })
72054
- }
72055
- );
72056
- } catch {
72057
- }
72058
- distributed = true;
72059
- }
72060
- if (!distributed) {
72061
- console.warn(`[SecureChannel] Cannot deliver Welcome \u2014 HTTP distribute and WS both unavailable`);
72062
- continue;
72063
- }
72064
- const a2aFulfilled = await this._postFulfilWelcome(
72065
- chState.mlsGroupId,
72066
- req.id,
72067
- `A2A ${chId.slice(0, 8)}`
72068
- );
72069
- await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
72070
- if (a2aFulfilled) {
72071
- console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
72072
- }
72473
+ const next = await this._fulfilA2AWelcome(chId, chState, mlsGroup, req);
72474
+ if (next === "stop") break;
72073
72475
  } catch (fulfillErr) {
72074
72476
  console.log(`[SecureChannel] A2A Welcome fulfill failed: ${fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr)}`);
72075
72477
  }
@@ -72081,6 +72483,10 @@ ${baseText}`;
72081
72483
  console.warn("[SecureChannel] Delivery pull error:", err);
72082
72484
  } finally {
72083
72485
  this._deliveryPulling = false;
72486
+ if (this._deliveryPullRequested) {
72487
+ this._deliveryPullRequested = false;
72488
+ void this._pullDeliveryQueue().catch((err) => console.warn("[SecureChannel] Coalesced delivery pull error:", err));
72489
+ }
72084
72490
  }
72085
72491
  }
72086
72492
  /**
@@ -72164,7 +72570,7 @@ ${baseText}`;
72164
72570
  try {
72165
72571
  const parsed = JSON.parse(plaintext);
72166
72572
  messageType = parsed.type || "message";
72167
- messageText = parsed.text || plaintext;
72573
+ messageText = parsed.text ?? plaintext;
72168
72574
  } catch {
72169
72575
  messageType = "message";
72170
72576
  messageText = plaintext;
@@ -72336,6 +72742,227 @@ ${baseText}`;
72336
72742
  }
72337
72743
  }
72338
72744
  }
72745
+ /**
72746
+ * #1225: fulfil ONE pending A2A Welcome request — the `/distribute` path.
72747
+ * Extracted from the delivery pull so the in-flight slot (review F1) is held
72748
+ * for exactly this request and released on every exit. Returns "stop" when
72749
+ * nothing else on this channel can succeed this pull (review F2).
72750
+ */
72751
+ async _fulfilA2AWelcome(chId, chState, mlsGroup, req) {
72752
+ const a2aLabel = `A2A ${chId.slice(0, 8)}`;
72753
+ const preHead = await this._readGroupHead(chState.mlsGroupId);
72754
+ if (preHead.ok && preHead.epoch !== Number(mlsGroup.epoch)) {
72755
+ console.warn(
72756
+ `[SecureChannel] ${a2aLabel}: server head is at epoch ${preHead.epoch}, we are at ${Number(mlsGroup.epoch)} \u2014 NOT committing (it would be refused); resync catches up first`
72757
+ );
72758
+ await this._catchUpAfterPreflightSkip(chState.mlsGroupId, preHead.epoch, Number(mlsGroup.epoch), a2aLabel);
72759
+ return "stop";
72760
+ }
72761
+ const inFlight = this._reserveCommit(chState.mlsGroupId, a2aLabel);
72762
+ if (!inFlight) return "stop";
72763
+ try {
72764
+ const kpRes = await fetch(
72765
+ `${this.config.apiUrl}/api/v1/mls/key-packages/batch?device_ids=${req.requesting_device_id}`,
72766
+ { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
72767
+ );
72768
+ if (!kpRes.ok) return "next";
72769
+ const kpData = await kpRes.json();
72770
+ const kpHex = kpData[req.requesting_device_id];
72771
+ if (!kpHex) return "next";
72772
+ const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
72773
+ const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
72774
+ console.log(`[SecureChannel] A2A addMembers for ${req.requesting_device_id.slice(0, 8)} group=${chState.mlsGroupId.slice(0, 8)} epoch=${mlsGroup.epoch}`);
72775
+ let parentDigest = preHead.ok ? preHead.digest : void 0;
72776
+ let commit;
72777
+ let welcome;
72778
+ let preAddState = mlsGroup.exportState();
72779
+ try {
72780
+ const result = await mlsGroup.addMembers([memberKp]);
72781
+ commit = result.commit;
72782
+ welcome = result.welcome;
72783
+ } catch (addErr) {
72784
+ const errMsg = addErr instanceof Error ? addErr.message : String(addErr);
72785
+ if (errMsg.includes("already in the group")) {
72786
+ console.log(`[SecureChannel] A2A member already in group \u2014 removing and re-adding ${req.requesting_device_id.slice(0, 8)}`);
72787
+ const preRemoveState = JSON.stringify(mlsGroup.exportState());
72788
+ let removeCommitPublished = false;
72789
+ try {
72790
+ const identityBytes = new TextEncoder().encode(req.requesting_device_id);
72791
+ const leafIndex = mlsGroup.memberLeafIndex(identityBytes);
72792
+ if (leafIndex === null) {
72793
+ console.log(`[SecureChannel] A2A re-add: no leaf index for ${req.requesting_device_id.slice(0, 8)} \u2014 cannot remove`);
72794
+ return "next";
72795
+ }
72796
+ const removeResult = await mlsGroup.removeMember(leafIndex);
72797
+ try {
72798
+ const removeResp = await fetch(
72799
+ `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
72800
+ {
72801
+ method: "POST",
72802
+ headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
72803
+ body: JSON.stringify({
72804
+ group_id: chState.mlsGroupId,
72805
+ device_id: this._deviceId,
72806
+ commit: Buffer.from(removeResult.commit).toString("hex"),
72807
+ commit_epoch: Number(mlsGroup.epoch),
72808
+ expected_epoch: Number(mlsGroup.epoch) - 1,
72809
+ // #1225: exact-step, like the WS path
72810
+ ...parentDigest ? { parent_digest: parentDigest } : {}
72811
+ })
72812
+ }
72813
+ );
72814
+ if (removeResp.ok) {
72815
+ removeCommitPublished = true;
72816
+ await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
72817
+ parentDigest = this._commitDigest(removeResult.commit);
72818
+ } else {
72819
+ const detail = await removeResp.text().catch(() => "");
72820
+ console.warn(`[SecureChannel] A2A remove-commit distribute failed (${removeResp.status}): ${detail}`);
72821
+ }
72822
+ } catch (distErr) {
72823
+ console.warn(`[SecureChannel] A2A remove-commit distribute network error:`, distErr);
72824
+ }
72825
+ if (!removeCommitPublished) {
72826
+ mlsGroup.importState(JSON.parse(preRemoveState));
72827
+ return "next";
72828
+ }
72829
+ const kpRes2 = await fetch(
72830
+ `${this.config.apiUrl}/api/v1/mls/key-packages/batch?device_ids=${req.requesting_device_id}`,
72831
+ { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
72832
+ );
72833
+ if (kpRes2.ok) {
72834
+ const kpData2 = await kpRes2.json();
72835
+ const kpHex2 = kpData2[req.requesting_device_id];
72836
+ if (kpHex2) {
72837
+ const kpBytes2 = new Uint8Array(Buffer.from(kpHex2, "hex"));
72838
+ const memberKp2 = MLSGroupManager.deserializeKeyPackage(kpBytes2);
72839
+ preAddState = mlsGroup.exportState();
72840
+ const result2 = await mlsGroup.addMembers([memberKp2]);
72841
+ commit = result2.commit;
72842
+ welcome = result2.welcome;
72843
+ } else {
72844
+ console.log(`[SecureChannel] A2A re-add: no fresh KeyPackage for ${req.requesting_device_id.slice(0, 8)}`);
72845
+ return "next";
72846
+ }
72847
+ } else {
72848
+ console.log(`[SecureChannel] A2A re-add: KeyPackage fetch failed (${kpRes2.status})`);
72849
+ return "next";
72850
+ }
72851
+ } catch (readdErr) {
72852
+ console.log(`[SecureChannel] A2A remove+re-add FAILED: ${readdErr instanceof Error ? readdErr.message : String(readdErr)}`);
72853
+ if (!removeCommitPublished) {
72854
+ try {
72855
+ mlsGroup.importState(JSON.parse(preRemoveState));
72856
+ } catch {
72857
+ }
72858
+ }
72859
+ return "next";
72860
+ }
72861
+ } else {
72862
+ console.log(`[SecureChannel] A2A addMembers FAILED: ${errMsg}`);
72863
+ return "next";
72864
+ }
72865
+ }
72866
+ if (!welcome) {
72867
+ console.warn(`[SecureChannel] A2A addMembers produced no Welcome for ${req.requesting_device_id.slice(0, 8)}`);
72868
+ return "next";
72869
+ }
72870
+ const commitHex = Buffer.from(commit).toString("hex");
72871
+ const welcomeHex = Buffer.from(welcome).toString("hex");
72872
+ let distributed = false;
72873
+ let transportFailed = false;
72874
+ const updatedMembers = this._commitRoster([this._deviceId], [req.requesting_device_id]);
72875
+ try {
72876
+ const distResp = await fetch(
72877
+ `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
72878
+ {
72879
+ method: "POST",
72880
+ headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
72881
+ body: JSON.stringify({
72882
+ group_id: chState.mlsGroupId,
72883
+ device_id: this._deviceId,
72884
+ commit: commitHex,
72885
+ commit_epoch: Number(mlsGroup.epoch),
72886
+ welcomes: [{ target_device_id: req.requesting_device_id, payload: welcomeHex }],
72887
+ member_device_ids: updatedMembers,
72888
+ expected_epoch: Number(mlsGroup.epoch) - 1,
72889
+ // #1225: exact-step, like the WS path
72890
+ ...parentDigest ? { parent_digest: parentDigest } : {}
72891
+ })
72892
+ }
72893
+ );
72894
+ if (distResp.ok) {
72895
+ distributed = true;
72896
+ } else {
72897
+ const detail = await distResp.text().catch(() => "");
72898
+ console.warn(`[SecureChannel] A2A distribute failed (${distResp.status}): ${detail}`);
72899
+ if (distResp.status >= 500) {
72900
+ transportFailed = true;
72901
+ } else if (distResp.status === 409) {
72902
+ mlsGroup.importState(preAddState);
72903
+ console.warn(
72904
+ `[SecureChannel] ${a2aLabel}: add commit lost the race (409) \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, request stays pending`
72905
+ );
72906
+ return "next";
72907
+ } else {
72908
+ mlsGroup.importState(preAddState);
72909
+ console.error(
72910
+ `[SecureChannel] ${a2aLabel}: add commit refused with ${distResp.status} \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}; not retrying this channel this pull`
72911
+ );
72912
+ return "stop";
72913
+ }
72914
+ }
72915
+ } catch (distErr) {
72916
+ console.warn(`[SecureChannel] A2A distribute network error:`, distErr);
72917
+ transportFailed = true;
72918
+ }
72919
+ if (!distributed && transportFailed && this._wsIsOpen()) {
72920
+ const verdict = await this._sendCommitConfirmed({
72921
+ mlsGroup,
72922
+ mlsGroupId: chState.mlsGroupId,
72923
+ label: a2aLabel,
72924
+ commit,
72925
+ epoch: Number(mlsGroup.epoch),
72926
+ parent: parentDigest,
72927
+ memberDeviceIds: updatedMembers,
72928
+ stateBefore: preAddState,
72929
+ inFlight
72930
+ });
72931
+ if (verdict !== "confirmed") return "next";
72932
+ const welcomeSent = await this._sendFrameConfirmed({
72933
+ event: "mls_welcome",
72934
+ data: {
72935
+ target_device_id: req.requesting_device_id,
72936
+ group_id: chState.mlsGroupId,
72937
+ a2a_channel_id: chId,
72938
+ payload: welcomeHex
72939
+ }
72940
+ }, `${a2aLabel} welcome`);
72941
+ if (!welcomeSent) {
72942
+ await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
72943
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome frame was NOT written \u2014 leaving the request pending`);
72944
+ return "next";
72945
+ }
72946
+ distributed = true;
72947
+ }
72948
+ if (!distributed) {
72949
+ console.warn(`[SecureChannel] Cannot deliver Welcome \u2014 HTTP distribute and WS both unavailable`);
72950
+ return "next";
72951
+ }
72952
+ const a2aFulfilled = await this._postFulfilWelcome(
72953
+ chState.mlsGroupId,
72954
+ req.id,
72955
+ `A2A ${chId.slice(0, 8)}`
72956
+ );
72957
+ await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
72958
+ if (a2aFulfilled) {
72959
+ console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
72960
+ }
72961
+ return "next";
72962
+ } finally {
72963
+ await this._releaseCommit(chState.mlsGroupId, inFlight, a2aLabel);
72964
+ }
72965
+ }
72339
72966
  /**
72340
72967
  * Found the MLS group for an A2A channel as the creator agent.
72341
72968
  * Calls /mls/a2a/{channelId}/init, creates the local MLS group, and sends
@@ -72378,48 +73005,77 @@ ${baseText}`;
72378
73005
  await saveMlsState(this.config.dataDir, mlsData.group_id, JSON.stringify(mgr.exportState()));
72379
73006
  for (const p2 of participants) {
72380
73007
  if (p2.status !== "active" && p2.status !== "invited") continue;
73008
+ const a2aLabel = `A2A ${channelId.slice(0, 8)} found\u2192${p2.hubAddress}`;
72381
73009
  try {
72382
- const kpRes = await fetch(
72383
- `${this.config.apiUrl}/api/v1/mls/key-packages?hub_id=${encodeURIComponent(p2.hubId)}`,
72384
- { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
72385
- );
72386
- if (kpRes.ok) {
72387
- const kpData = await kpRes.json();
72388
- if (kpData.key_package) {
72389
- const kpBytes = new Uint8Array(Buffer.from(kpData.key_package, "hex"));
72390
- const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
72391
- const { commit, welcome } = await mgr.addMembers([memberKp]);
72392
- if (welcome && this._ws) {
72393
- this._ws.send(JSON.stringify({
72394
- event: "mls_commit",
73010
+ if (!p2.deviceId) {
73011
+ console.warn(
73012
+ `[SecureChannel] ${a2aLabel}: no deviceId \u2014 NOT adding (a Welcome could not be addressed). Backend a2a_channel_approved payload may be missing device_id for this participant.`
73013
+ );
73014
+ continue;
73015
+ }
73016
+ if (!this._wsIsOpen()) {
73017
+ console.warn(`[SecureChannel] ${a2aLabel}: socket is not OPEN \u2014 NOT advancing the group`);
73018
+ continue;
73019
+ }
73020
+ const head = await this._readGroupHead(mlsData.group_id);
73021
+ if (head.ok && head.epoch !== Number(mgr.epoch)) {
73022
+ console.warn(`[SecureChannel] ${a2aLabel}: server head is at epoch ${head.epoch}, we are at ${Number(mgr.epoch)} \u2014 NOT committing`);
73023
+ await this._catchUpAfterPreflightSkip(mlsData.group_id, head.epoch, Number(mgr.epoch), a2aLabel);
73024
+ continue;
73025
+ }
73026
+ const inFlight = this._reserveCommit(mlsData.group_id, a2aLabel);
73027
+ if (!inFlight) continue;
73028
+ try {
73029
+ const kpRes = await fetch(
73030
+ `${this.config.apiUrl}/api/v1/mls/key-packages?hub_id=${encodeURIComponent(p2.hubId)}`,
73031
+ { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
73032
+ );
73033
+ if (kpRes.ok) {
73034
+ const kpData = await kpRes.json();
73035
+ if (kpData.key_package) {
73036
+ const kpBytes = new Uint8Array(Buffer.from(kpData.key_package, "hex"));
73037
+ const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
73038
+ const stateBefore = mgr.exportState();
73039
+ const { commit, welcome } = await mgr.addMembers([memberKp]);
73040
+ const committedEpoch = Number(mgr.epoch);
73041
+ if (!welcome) {
73042
+ mgr.importState(stateBefore);
73043
+ console.warn(`[SecureChannel] ${a2aLabel}: addMembers produced no Welcome \u2014 rolled back`);
73044
+ continue;
73045
+ }
73046
+ const verdict = await this._sendCommitConfirmed({
73047
+ mlsGroup: mgr,
73048
+ mlsGroupId: mlsData.group_id,
73049
+ label: a2aLabel,
73050
+ commit,
73051
+ epoch: committedEpoch,
73052
+ parent: head.ok ? head.digest : void 0,
73053
+ memberDeviceIds: this._commitRoster(participants.map((q22) => q22.deviceId), [p2.deviceId]),
73054
+ stateBefore,
73055
+ inFlight
73056
+ });
73057
+ if (verdict !== "confirmed") continue;
73058
+ const sent = await this._sendFrameConfirmed({
73059
+ event: "mls_welcome",
72395
73060
  data: {
72396
73061
  group_id: mlsData.group_id,
72397
- epoch: Number(mgr.epoch),
72398
- payload: Buffer.from(commit).toString("hex")
73062
+ payload: Buffer.from(welcome).toString("hex"),
73063
+ target_device_id: p2.deviceId,
73064
+ a2a_channel_id: channelId,
73065
+ target_hub_id: p2.hubId
73066
+ // observability only — the backend ignores it
72399
73067
  }
72400
- }));
72401
- if (!p2.deviceId) {
72402
- console.warn(
72403
- `[SecureChannel] Skipping MLS Welcome to ${p2.hubAddress} for A2A ${channelId.slice(0, 8)}: no deviceId. Backend a2a_channel_approved payload may be missing device_id for this participant.`
72404
- );
72405
- } else {
72406
- this._ws.send(JSON.stringify({
72407
- event: "mls_welcome",
72408
- data: {
72409
- group_id: mlsData.group_id,
72410
- payload: Buffer.from(welcome).toString("hex"),
72411
- target_device_id: p2.deviceId,
72412
- a2a_channel_id: channelId,
72413
- // Kept for observability — backend ignores but it's
72414
- // useful in logs/wire captures to correlate to a hub.
72415
- target_hub_id: p2.hubId
72416
- }
72417
- }));
73068
+ }, `${a2aLabel} welcome`);
73069
+ await saveMlsState(this.config.dataDir, mlsData.group_id, JSON.stringify(mgr.exportState()));
73070
+ if (sent) {
72418
73071
  console.log(`[SecureChannel] Sent MLS Welcome to ${p2.hubAddress} (device ${p2.deviceId.slice(0, 8)}) for A2A ${channelId.slice(0, 8)}`);
73072
+ } else {
73073
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome was NOT written \u2014 the participant will request one`);
72419
73074
  }
72420
73075
  }
72421
- await saveMlsState(this.config.dataDir, mlsData.group_id, JSON.stringify(mgr.exportState()));
72422
73076
  }
73077
+ } finally {
73078
+ await this._releaseCommit(mlsData.group_id, inFlight, a2aLabel);
72423
73079
  }
72424
73080
  } catch (kpErr) {
72425
73081
  console.warn(`[SecureChannel] Failed to Welcome participant ${p2.hubAddress}:`, kpErr);
@@ -72513,7 +73169,7 @@ ${baseText}`;
72513
73169
  try {
72514
73170
  const parsed = JSON.parse(plaintext);
72515
73171
  messageType = parsed.type || "message";
72516
- messageText = parsed.text || plaintext;
73172
+ messageText = parsed.text ?? plaintext;
72517
73173
  } catch {
72518
73174
  messageType = "message";
72519
73175
  messageText = plaintext;
@@ -72798,7 +73454,7 @@ ${baseText}`;
72798
73454
  try {
72799
73455
  const parsed = JSON.parse(plaintext);
72800
73456
  messageType = parsed.type || "message";
72801
- messageText = parsed.text || plaintext;
73457
+ messageText = parsed.text ?? plaintext;
72802
73458
  } catch {
72803
73459
  messageType = "message";
72804
73460
  messageText = plaintext;
@@ -73398,58 +74054,6 @@ var init_gateway_send = __esm2({
73398
74054
  init_openclaw_compat();
73399
74055
  }
73400
74056
  });
73401
- function parseMentions(text) {
73402
- const mentions = [];
73403
- const re2 = new RegExp(MENTION_RE.source, MENTION_RE.flags);
73404
- let match;
73405
- while ((match = re2.exec(text)) !== null) {
73406
- mentions.push(match[1].toLowerCase());
73407
- }
73408
- return mentions;
73409
- }
73410
- function resolveOwn(mention, names) {
73411
- if (names.has(mention)) return mention;
73412
- const parts = mention.split("-");
73413
- for (let i2 = parts.length - 1; i2 > 0; i2--) {
73414
- const candidate = parts.slice(0, i2).join("-");
73415
- if (names.has(candidate)) return candidate;
73416
- }
73417
- return null;
73418
- }
73419
- function owesRoomReply(plaintext, agentName, accountId, senderIsAgent, roomDisplayName, ownDisplayName, selfResolved = true) {
73420
- const mentions = parseMentions(plaintext);
73421
- if (mentions.length === 0) return false;
73422
- const ownNames = ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName);
73423
- if (mentions.some((m2) => resolveOwn(m2, ownNames) !== null)) return true;
73424
- if (mentions.some((m2) => resolveOwn(m2, BROADCAST_NAMES) !== null)) {
73425
- return senderIsAgent !== true;
73426
- }
73427
- if (!selfResolved) return true;
73428
- return false;
73429
- }
73430
- function ownRoomNames(agentName, accountId, roomDisplayName, ownDisplayName) {
73431
- const names = /* @__PURE__ */ new Set();
73432
- const add4 = (n2) => {
73433
- if (!n2) return;
73434
- const lower = n2.toLowerCase();
73435
- names.add(lower);
73436
- names.add(lower.split(/[\s(]/)[0]);
73437
- };
73438
- add4(agentName);
73439
- add4(accountId);
73440
- add4(roomDisplayName);
73441
- add4(ownDisplayName);
73442
- return names;
73443
- }
73444
- var MENTION_RE;
73445
- var BROADCAST_NAMES;
73446
- var init_room_protocol = __esm2({
73447
- "src/room-protocol.ts"() {
73448
- "use strict";
73449
- MENTION_RE = /@([\w-]+)/gi;
73450
- BROADCAST_NAMES = /* @__PURE__ */ new Set(["all", "everyone"]);
73451
- }
73452
- });
73453
74057
  var init_llm_response_parser = __esm2({
73454
74058
  "src/llm-response-parser.ts"() {
73455
74059
  "use strict";
@@ -99543,7 +100147,7 @@ var init_index = __esm2({
99543
100147
  await init_skill_telemetry();
99544
100148
  await init_policy_enforcer();
99545
100149
  init_room_protocol();
99546
- VERSION = true ? "0.23.39" : "0.0.0-dev";
100150
+ VERSION = true ? "0.23.41" : "0.0.0-dev";
99547
100151
  }
99548
100152
  });
99549
100153
  await init_index();
@@ -100480,7 +101084,7 @@ async function main() {
100480
101084
  const info = parseInfoFlags(process.argv.slice(2));
100481
101085
  if (info.showVersion || info.showHelp) {
100482
101086
  const version2 = buildClientVersion2(
100483
- true ? "0.4.12" : void 0
101087
+ true ? "0.4.14" : void 0
100484
101088
  );
100485
101089
  if (info.showVersion) printLine(version2);
100486
101090
  if (info.showHelp) {
@@ -100533,7 +101137,7 @@ async function main() {
100533
101137
  agentName: cfg.agentName,
100534
101138
  platform: "node",
100535
101139
  clientVersion: buildClientVersion2(
100536
- true ? "0.4.12" : void 0
101140
+ true ? "0.4.14" : void 0
100537
101141
  )
100538
101142
  });
100539
101143
  channelRef = channel;