@agentvault/grok-bridge 0.4.13 → 0.4.15

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/bridge.d.ts CHANGED
@@ -14,6 +14,9 @@ export interface RoomMessage {
14
14
  roomId: string;
15
15
  senderName: string;
16
16
  plaintext: string;
17
+ /** #1223 — the text the sender typed, before any attachment was folded into
18
+ * `plaintext`. The @ gate reads this; absent on emitters < 0.23.42. */
19
+ caption?: string;
17
20
  /** True when the sender is another agent (SecureChannel derives this from the
18
21
  * room roster). Used to decide reply expectation: a human/owner speaking in a
19
22
  * room always expects a reply; agent-authored traffic does not (the agent may
package/dist/index.js CHANGED
@@ -64657,11 +64657,11 @@ var init_mls_kp_pool = __esm2({
64657
64657
  }
64658
64658
  });
64659
64659
  function ownIdentity() {
64660
- const v2 = true ? "0.23.40" : FALLBACK;
64660
+ const v2 = true ? "0.23.42" : FALLBACK;
64661
64661
  return `${PACKAGE}@${v2}`;
64662
64662
  }
64663
64663
  function buildSha() {
64664
- const raw = true ? "dbac15d-dirty" : "";
64664
+ const raw = true ? "d9d53a0-dirty" : "";
64665
64665
  const cleaned = (raw ?? "").trim();
64666
64666
  return !cleaned || cleaned === "unknown" ? null : cleaned;
64667
64667
  }
@@ -65568,6 +65568,7 @@ var CREDENTIAL_MESSAGE_TYPES;
65568
65568
  var CATCHUP_MESSAGE_TYPES;
65569
65569
  var POLL_INTERVAL_MS;
65570
65570
  var RECONNECT_BASE_MS;
65571
+ var UUID_RE;
65571
65572
  var RECONNECT_MAX_MS;
65572
65573
  var PENDING_POLL_INTERVAL_MS;
65573
65574
  var OUTBOUND_DEDUPE_WINDOW_MS;
@@ -65615,6 +65616,7 @@ var init_channel = __esm2({
65615
65616
  ]);
65616
65617
  POLL_INTERVAL_MS = 6e3;
65617
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;
65618
65620
  RECONNECT_MAX_MS = 3e4;
65619
65621
  PENDING_POLL_INTERVAL_MS = 15e3;
65620
65622
  OUTBOUND_DEDUPE_WINDOW_MS = 3e3;
@@ -66910,7 +66912,7 @@ ${text}`;
66910
66912
  */
66911
66913
  sendActivitySpan(spanData) {
66912
66914
  if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
66913
- const pluginVersion = true ? "0.23.40" : "0.0.0-dev";
66915
+ const pluginVersion = true ? "0.23.42" : "0.0.0-dev";
66914
66916
  const agentName = this.config.agentName ?? "Agent";
66915
66917
  const resource = {
66916
66918
  "service.name": "agentvault-agent",
@@ -68848,7 +68850,7 @@ ${text}`;
68848
68850
  agentVersion: this.config.agentVersion ?? "0.0.0",
68849
68851
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68850
68852
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68851
- pluginVersion: true ? "0.23.40" : "0.0.0-dev"
68853
+ pluginVersion: true ? "0.23.42" : "0.0.0-dev"
68852
68854
  });
68853
68855
  this._telemetryReporter.startAutoFlush(3e4);
68854
68856
  }
@@ -69172,7 +69174,7 @@ ${text}`;
69172
69174
  agentVersion: this.config.agentVersion ?? "0.0.0",
69173
69175
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
69174
69176
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
69175
- pluginVersion: true ? "0.23.40" : "0.0.0-dev"
69177
+ pluginVersion: true ? "0.23.42" : "0.0.0-dev"
69176
69178
  });
69177
69179
  this._telemetryReporter.startAutoFlush(3e4);
69178
69180
  }
@@ -69387,45 +69389,78 @@ ${text}`;
69387
69389
  const mlsGroup = this._mlsGroups.get(`a2a:${channelId}`);
69388
69390
  if (mlsGroup?.isInitialized) {
69389
69391
  try {
69390
- const kpRes = await fetch(
69391
- `${this.config.apiUrl}/api/v1/mls/key-packages?hub_id=${encodeURIComponent(participant.hub_id)}`,
69392
- { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
69393
- );
69394
- if (kpRes.ok) {
69395
- const kpData = await kpRes.json();
69396
- if (kpData.key_package) {
69397
- const kpBytes = new Uint8Array(Buffer.from(kpData.key_package, "hex"));
69398
- const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
69399
- const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
69400
- if (welcome && this._ws) {
69401
- this._ws.send(JSON.stringify({
69402
- event: "mls_commit",
69403
- data: {
69404
- group_id: entry.mlsGroupId,
69405
- epoch: Number(mlsGroup.epoch),
69406
- payload: Buffer.from(commit).toString("hex")
69407
- }
69408
- }));
69409
- const newDeviceId = participant.device_id || null;
69410
- if (!newDeviceId) {
69411
- console.warn(
69412
- `[SecureChannel] Skipping MLS Welcome to new participant ${participant.hub_address} for A2A ${channelId.slice(0, 8)}: no device_id on participant_joined payload.`
69413
- );
69414
- } else {
69415
- this._ws.send(JSON.stringify({
69416
- event: "mls_welcome",
69417
- data: {
69418
- group_id: entry.mlsGroupId,
69419
- payload: Buffer.from(welcome).toString("hex"),
69420
- target_device_id: newDeviceId,
69421
- a2a_channel_id: channelId,
69422
- target_hub_id: participant.hub_id
69423
- // 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
+ }
69424
69459
  }
69425
- }));
69426
- await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
69427
- console.log(`[SecureChannel] Sent MLS Welcome to new participant ${participant.hub_address} (device ${String(newDeviceId).slice(0, 8)})`);
69460
+ }
69428
69461
  }
69462
+ } finally {
69463
+ await this._releaseCommit(entry.mlsGroupId, inFlight, a2aLabel);
69429
69464
  }
69430
69465
  }
69431
69466
  }
@@ -69902,7 +69937,13 @@ ${messageText}`;
69902
69937
  messageType: msgData.message_type ?? "text",
69903
69938
  priority: msgData.priority ?? "normal",
69904
69939
  envelopeVersion: msgData.envelope_version ?? "1.0.0",
69905
- roomId
69940
+ roomId,
69941
+ // #1223 — the pre-fold text. This DR `message` path also sets `roomId`
69942
+ // and folds an attachment into `emitText`, so without this a room
69943
+ // message that reaches the gate here would fall back to the folded
69944
+ // text (the same bug the MLS room path fixes). `messageText` is the
69945
+ // envelope text before the fold; `emitText` is the folded copy.
69946
+ caption: messageText
69906
69947
  };
69907
69948
  this.emit("message", emitText, metadata);
69908
69949
  Promise.resolve(this.config.onMessage?.(emitText, metadata)).catch((err) => {
@@ -70581,6 +70622,7 @@ ${baseText}`;
70581
70622
  await this._refreshRoomRoster(resolvedRoomId);
70582
70623
  }
70583
70624
  }
70625
+ const caption = messageText;
70584
70626
  const addressed = parseMentions(messageText).length > 0;
70585
70627
  if (roomAttachment && !addressed) {
70586
70628
  this._deferRoomAttachment(resolvedRoomId, roomAttachment);
@@ -70607,7 +70649,8 @@ ${baseText}`;
70607
70649
  senderDeviceId,
70608
70650
  senderIsAgent,
70609
70651
  senderName: senderMember?.displayName || "unknown",
70610
- roomName: this._persisted?.rooms?.[resolvedRoomId]?.name
70652
+ roomName: this._persisted?.rooms?.[resolvedRoomId]?.name,
70653
+ caption
70611
70654
  };
70612
70655
  const senderLabel = senderMember?.displayName || "someone";
70613
70656
  this._appendHistory("owner", messageText, `room:${resolvedRoomId}`);
@@ -70616,6 +70659,9 @@ ${baseText}`;
70616
70659
  senderDeviceId,
70617
70660
  senderName: senderLabel,
70618
70661
  plaintext: messageText,
70662
+ // #1223 — what the sender typed, before the attachment fold. The
70663
+ // bridges gate on this (falling back to `plaintext` when absent).
70664
+ caption,
70619
70665
  messageType,
70620
70666
  timestamp: data.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
70621
70667
  // Native bridge (claude-room-bridge) consumes this to decide reply
@@ -70955,9 +71001,34 @@ ${baseText}`;
70955
71001
  *
70956
71002
  * Returns the number of requests actually fulfilled.
70957
71003
  */
71004
+ /**
71005
+ * #1226: the roster to submit on a commit frame — the caller's known members,
71006
+ * the devices being admitted, and ALWAYS ourselves — deduplicated, UUIDs
71007
+ * only. Self is non-negotiable: once a roster is non-empty the server
71008
+ * requires the committer in it (#469), and our own head read
71009
+ * (`assert_group_member`) requires it too. So with no device id of our own
71010
+ * this returns `[]` and the frame carries NO roster (the pre-#1226 shape)
71011
+ * rather than a roster that locks us out. Non-UUID entries are dropped:
71012
+ * `known` can come off an inbound Welcome frame, and a malformed id in the
71013
+ * outbound frame is refused by the server (`MEMBER_DEVICE_IDS_MALFORMED`).
71014
+ * @internal exposed for tests
71015
+ */
71016
+ _commitRoster(known, targets) {
71017
+ if (!this._deviceId) return [];
71018
+ const out = [];
71019
+ const seen = /* @__PURE__ */ new Set();
71020
+ for (const id of [...known ?? [], ...targets, this._deviceId]) {
71021
+ if (typeof id !== "string" || !UUID_RE.test(id) || seen.has(id)) continue;
71022
+ seen.add(id);
71023
+ out.push(id);
71024
+ }
71025
+ return out;
71026
+ }
70958
71027
  async _fulfilWelcomeRequests(opts) {
70959
71028
  const { mlsGroup, mlsGroupId, label, welcomeFields, targets } = opts;
70960
71029
  if (targets.length === 0) return 0;
71030
+ const rosterBefore = this._commitRoster(opts.memberDeviceIds, []);
71031
+ const rosterAfter = this._commitRoster(opts.memberDeviceIds, targets.map((t2) => t2.deviceId));
70961
71032
  if (!this._wsIsOpen()) {
70962
71033
  console.warn(
70963
71034
  `[SecureChannel] ${label}: socket is not OPEN \u2014 NOT advancing the group; ${targets.length} request(s) stay pending for the next pull`
@@ -70970,9 +71041,16 @@ ${baseText}`;
70970
71041
  console.warn(
70971
71042
  `[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`
70972
71043
  );
71044
+ await this._catchUpAfterPreflightSkip(mlsGroupId, preHead.epoch, Number(mlsGroup.epoch), label);
70973
71045
  return 0;
70974
71046
  }
70975
71047
  let parent2 = preHead.ok ? preHead.digest : void 0;
71048
+ if (this._commitInFlight.has(mlsGroupId)) {
71049
+ console.warn(
71050
+ `[SecureChannel] ${label}: another commit for this group is in flight \u2014 NOT advancing; ${targets.length} request(s) stay pending for the next pull`
71051
+ );
71052
+ return 0;
71053
+ }
70976
71054
  const inFlight = { frames: [], sent: [] };
70977
71055
  this._commitInFlight.set(mlsGroupId, inFlight);
70978
71056
  try {
@@ -70997,7 +71075,8 @@ ${baseText}`;
70997
71075
  // only knows the last one, which is why the result carries them.
70998
71076
  epoch: Number(c2.epoch),
70999
71077
  payload: Buffer.from(c2.commit).toString("hex"),
71000
- ...parent2 ? { parent_digest: parent2 } : {}
71078
+ ...parent2 ? { parent_digest: parent2 } : {},
71079
+ ...(sentCommits === commits.length - 1 ? rosterAfter : rosterBefore).length > 0 ? { member_device_ids: sentCommits === commits.length - 1 ? rosterAfter : rosterBefore } : {}
71001
71080
  }
71002
71081
  },
71003
71082
  `${label} commit ${sentCommits + 1}/${commits.length}`
@@ -71145,6 +71224,126 @@ ${baseText}`;
71145
71224
  return this._ws?.readyState === 1;
71146
71225
  }
71147
71226
  /** SHA-256 hex of a commit's bytes — its identity on the server (#1175/#1177). */
71227
+ /**
71228
+ * #1225: send ONE commit over the socket and wait for the server's verdict,
71229
+ * with the #1184 machinery — an in-flight record (so a `… commit rejected`
71230
+ * frame is correlated to it and a concurrent commit for the group is held
71231
+ * and replayed), the head poll, and rollback. The three A2A commit sites
71232
+ * (`a2a_participant_joined`, the `/distribute` WS fallback, `_foundA2AGroup`)
71233
+ * used a raw `ws.send` and treated "the socket took it" as "the server
71234
+ * stored it" — two of them then PERSISTED a state the server never saw.
71235
+ *
71236
+ * Returns the verdict. On `refused` / `unsent` the manager has been restored
71237
+ * to `stateBefore` and nothing must be persisted or minted from it.
71238
+ * `unverifiable` (head unreadable on every try AND no refusal) proceeds
71239
+ * UNCHECKED, the same deliberate choice as the fulfil site.
71240
+ */
71241
+ /**
71242
+ * #1225 (review F1): reserve the single in-flight slot for a group. The
71243
+ * record is what `_noteCommitRefusal` correlates a refusal to and what
71244
+ * `_handleMlsCommit` holds concurrent socket commits behind; a second run on
71245
+ * the same group would clobber both (measured: two overlapping joins → two
71246
+ * commits claiming one epoch, one Welcome instead of two, the manager AHEAD
71247
+ * of the server). Taken BEFORE the commit is generated; released by
71248
+ * `_releaseCommit` after the last step that depends on it.
71249
+ */
71250
+ /**
71251
+ * A pre-flight skip must not be a dead end. The skip happens because the
71252
+ * server's head is somewhere else; before this the group "caught up through
71253
+ * the normal divergence path", which only runs when a message for the group
71254
+ * arrives — so a group nobody writes to stayed diverged and its pending
71255
+ * Welcome requests were never fulfilled (named in PR #1227). When the server
71256
+ * is AHEAD, replay the missing commits now so the next pull can commit; when
71257
+ * WE are ahead there is nothing to replay (the fork-ahead direction, #972).
71258
+ */
71259
+ async _catchUpAfterPreflightSkip(mlsGroupId, serverEpoch, localEpoch, label) {
71260
+ if (serverEpoch < localEpoch) {
71261
+ console.warn(`[SecureChannel] ${label}: we are AHEAD of the server (${localEpoch} > ${serverEpoch}) \u2014 nothing to replay`);
71262
+ return;
71263
+ }
71264
+ const fam = this._resolveGroupFamily(mlsGroupId);
71265
+ if (!fam) return;
71266
+ try {
71267
+ const caughtUp = await this._resyncOnDivergence(fam.managerKey, mlsGroupId);
71268
+ console.log(`[SecureChannel] ${label}: catch-up after pre-flight skip ${caughtUp ? "replayed the missing commits" : "could not replay"}`);
71269
+ } catch (err) {
71270
+ console.warn(`[SecureChannel] ${label}: catch-up after pre-flight skip failed:`, err);
71271
+ }
71272
+ }
71273
+ _reserveCommit(groupId, label) {
71274
+ if (this._commitInFlight.has(groupId)) {
71275
+ console.warn(
71276
+ `[SecureChannel] ${label}: another commit for group ${groupId.slice(0, 8)} is in flight \u2014 NOT advancing the group (a Welcome can be requested again)`
71277
+ );
71278
+ return null;
71279
+ }
71280
+ const rec = { frames: [], sent: [] };
71281
+ this._commitInFlight.set(groupId, rec);
71282
+ return rec;
71283
+ }
71284
+ /** Release the slot taken by `_reserveCommit` and replay the socket commits held meanwhile. */
71285
+ async _releaseCommit(groupId, rec, label) {
71286
+ if (this._commitInFlight.get(groupId) === rec) this._commitInFlight.delete(groupId);
71287
+ for (const frame of rec.frames) {
71288
+ try {
71289
+ await this._handleMlsCommit(frame);
71290
+ } catch (err) {
71291
+ console.error(`[SecureChannel] ${label}: replaying a held MLS commit failed:`, err);
71292
+ }
71293
+ }
71294
+ }
71295
+ /**
71296
+ * #1225: send ONE commit over the socket and wait for the server's verdict
71297
+ * with the #1184 machinery (refusal frame correlated through the reserved
71298
+ * in-flight record, head poll). The three A2A commit sites
71299
+ * (`a2a_participant_joined`, the `/distribute` WS fallback, `_foundA2AGroup`)
71300
+ * used a raw `ws.send` and treated "the socket took it" as "the server
71301
+ * stored it" — two of them then PERSISTED a state the server never saw.
71302
+ *
71303
+ * Only `confirmed` proceeds. `unverifiable` (head unreadable on every try,
71304
+ * no refusal) is a ROLLBACK here, unlike the fulfil site: that site proceeds
71305
+ * UNCHECKED because 79 of 80 production rooms 403 the head read on an empty
71306
+ * roster; an A2A head read admits any active participant (observer
71307
+ * fallback), so five failed reads there mean an outage, not a routine 403.
71308
+ * On anything but `confirmed` the manager is back at `stateBefore`.
71309
+ */
71310
+ async _sendCommitConfirmed(opts) {
71311
+ const { mlsGroup, mlsGroupId, label, commit, epoch, parent: parent2, memberDeviceIds, stateBefore, inFlight } = opts;
71312
+ const ours = this._commitDigest(commit);
71313
+ inFlight.sent.push({ epoch, digest: ours, verified: false, stateBefore });
71314
+ const ok = await this._sendFrameConfirmed(
71315
+ {
71316
+ event: "mls_commit",
71317
+ data: {
71318
+ group_id: mlsGroupId,
71319
+ epoch,
71320
+ payload: Buffer.from(commit).toString("hex"),
71321
+ ...parent2 ? { parent_digest: parent2 } : {},
71322
+ ...memberDeviceIds && memberDeviceIds.length > 0 ? { member_device_ids: memberDeviceIds } : {}
71323
+ }
71324
+ },
71325
+ `${label} commit`
71326
+ );
71327
+ if (!ok) {
71328
+ inFlight.sent.pop();
71329
+ mlsGroup.importState(stateBefore);
71330
+ console.warn(
71331
+ `[SecureChannel] ${label}: commit at epoch ${epoch} was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}`
71332
+ );
71333
+ return "unsent";
71334
+ }
71335
+ const verdict = await this._confirmCommitAtHead(mlsGroupId, epoch, ours, inFlight, label);
71336
+ if (inFlight.refusal === void 0 && verdict === "confirmed") {
71337
+ inFlight.sent[inFlight.sent.length - 1].verified = true;
71338
+ return "confirmed";
71339
+ }
71340
+ const why = inFlight.refusal?.detail ?? verdict.toUpperCase();
71341
+ mlsGroup.importState(stateBefore);
71342
+ console.warn(
71343
+ `[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`
71344
+ );
71345
+ return "refused";
71346
+ }
71148
71347
  _commitDigest(bytes) {
71149
71348
  return createHash("sha256").update(bytes).digest("hex");
71150
71349
  }
@@ -72098,7 +72297,9 @@ ${baseText}`;
72098
72297
  mlsGroupId: roomState.mlsGroupId,
72099
72298
  label: `room ${roomId.slice(0, 8)}`,
72100
72299
  welcomeFields: { room_id: roomId },
72101
- targets: roomTargets
72300
+ targets: roomTargets,
72301
+ memberDeviceIds: (roomState.members ?? []).map((m2) => m2.deviceId)
72302
+ // #1226
72102
72303
  });
72103
72304
  if (done > 0) {
72104
72305
  console.log(
@@ -72153,7 +72354,9 @@ ${baseText}`;
72153
72354
  mlsGroupId: entry.mlsGroupId,
72154
72355
  label: `shared group ${gid.slice(0, 8)}`,
72155
72356
  welcomeFields: { conversation_group_id: gid },
72156
- targets: sharedTargets
72357
+ targets: sharedTargets,
72358
+ memberDeviceIds: entry.memberDeviceIds ?? []
72359
+ // #1226
72157
72360
  });
72158
72361
  if (done > 0) {
72159
72362
  console.log(`[SecureChannel] Batched Welcome for ${done}/${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
@@ -72167,7 +72370,9 @@ ${baseText}`;
72167
72370
  mlsGroupId: entry.mlsGroupId,
72168
72371
  label: `shared group ${gid.slice(0, 8)}`,
72169
72372
  welcomeFields: { conversation_group_id: gid },
72170
- targets: [target]
72373
+ targets: [target],
72374
+ memberDeviceIds: entry.memberDeviceIds ?? []
72375
+ // #1226
72171
72376
  });
72172
72377
  if (done > 0) {
72173
72378
  console.log(`[SecureChannel] Individual Welcome for ${target.deviceId.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
@@ -72214,7 +72419,11 @@ ${baseText}`;
72214
72419
  requestId: req.id,
72215
72420
  deviceId: req.requesting_device_id,
72216
72421
  keyPackage: memberKp
72217
- }]
72422
+ }],
72423
+ // #1226: a 1:1 roster is [self, owner] — the web's shape too.
72424
+ memberDeviceIds: [this._persisted?.sessions?.[convId]?.ownerDeviceId].filter(
72425
+ (d2) => typeof d2 === "string"
72426
+ )
72218
72427
  });
72219
72428
  if (done > 0) {
72220
72429
  console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
@@ -72272,192 +72481,8 @@ ${baseText}`;
72272
72481
  console.log(`[SecureChannel] ${pending.length} pending A2A Welcome requests for channel ${chId.slice(0, 8)}`);
72273
72482
  for (const req of pending) {
72274
72483
  try {
72275
- const kpRes = await fetch(
72276
- `${this.config.apiUrl}/api/v1/mls/key-packages/batch?device_ids=${req.requesting_device_id}`,
72277
- { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
72278
- );
72279
- if (!kpRes.ok) continue;
72280
- const kpData = await kpRes.json();
72281
- const kpHex = kpData[req.requesting_device_id];
72282
- if (!kpHex) continue;
72283
- const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
72284
- const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
72285
- console.log(`[SecureChannel] A2A addMembers for ${req.requesting_device_id.slice(0, 8)} group=${chState.mlsGroupId.slice(0, 8)} epoch=${mlsGroup.epoch}`);
72286
- let parentDigest = await this._readGroupHeadDigest(chState.mlsGroupId);
72287
- let commit;
72288
- let welcome;
72289
- try {
72290
- const result = await mlsGroup.addMembers([memberKp]);
72291
- commit = result.commit;
72292
- welcome = result.welcome;
72293
- } catch (addErr) {
72294
- const errMsg = addErr instanceof Error ? addErr.message : String(addErr);
72295
- if (errMsg.includes("already in the group")) {
72296
- console.log(`[SecureChannel] A2A member already in group \u2014 removing and re-adding ${req.requesting_device_id.slice(0, 8)}`);
72297
- const preRemoveState = JSON.stringify(mlsGroup.exportState());
72298
- let removeCommitPublished = false;
72299
- try {
72300
- const identityBytes = new TextEncoder().encode(req.requesting_device_id);
72301
- const leafIndex = mlsGroup.memberLeafIndex(identityBytes);
72302
- if (leafIndex === null) {
72303
- console.log(`[SecureChannel] A2A re-add: no leaf index for ${req.requesting_device_id.slice(0, 8)} \u2014 cannot remove`);
72304
- continue;
72305
- }
72306
- const removeResult = await mlsGroup.removeMember(leafIndex);
72307
- try {
72308
- const removeResp = await fetch(
72309
- `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
72310
- {
72311
- method: "POST",
72312
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
72313
- body: JSON.stringify({
72314
- group_id: chState.mlsGroupId,
72315
- device_id: this._deviceId,
72316
- commit: Buffer.from(removeResult.commit).toString("hex"),
72317
- commit_epoch: Number(mlsGroup.epoch),
72318
- ...parentDigest ? { parent_digest: parentDigest } : {}
72319
- })
72320
- }
72321
- );
72322
- if (removeResp.ok) {
72323
- removeCommitPublished = true;
72324
- parentDigest = this._commitDigest(removeResult.commit);
72325
- } else {
72326
- const detail = await removeResp.text().catch(() => "");
72327
- console.warn(`[SecureChannel] A2A remove-commit distribute failed (${removeResp.status}): ${detail}`);
72328
- }
72329
- } catch (distErr) {
72330
- console.warn(`[SecureChannel] A2A remove-commit distribute network error:`, distErr);
72331
- }
72332
- if (!removeCommitPublished) {
72333
- mlsGroup.importState(JSON.parse(preRemoveState));
72334
- continue;
72335
- }
72336
- const kpRes2 = await fetch(
72337
- `${this.config.apiUrl}/api/v1/mls/key-packages/batch?device_ids=${req.requesting_device_id}`,
72338
- { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
72339
- );
72340
- if (kpRes2.ok) {
72341
- const kpData2 = await kpRes2.json();
72342
- const kpHex2 = kpData2[req.requesting_device_id];
72343
- if (kpHex2) {
72344
- const kpBytes2 = new Uint8Array(Buffer.from(kpHex2, "hex"));
72345
- const memberKp2 = MLSGroupManager.deserializeKeyPackage(kpBytes2);
72346
- const result2 = await mlsGroup.addMembers([memberKp2]);
72347
- commit = result2.commit;
72348
- welcome = result2.welcome;
72349
- } else {
72350
- console.log(`[SecureChannel] A2A re-add: no fresh KeyPackage for ${req.requesting_device_id.slice(0, 8)}`);
72351
- continue;
72352
- }
72353
- } else {
72354
- console.log(`[SecureChannel] A2A re-add: KeyPackage fetch failed (${kpRes2.status})`);
72355
- continue;
72356
- }
72357
- } catch (readdErr) {
72358
- console.log(`[SecureChannel] A2A remove+re-add FAILED: ${readdErr instanceof Error ? readdErr.message : String(readdErr)}`);
72359
- if (!removeCommitPublished) {
72360
- try {
72361
- mlsGroup.importState(JSON.parse(preRemoveState));
72362
- } catch {
72363
- }
72364
- }
72365
- continue;
72366
- }
72367
- } else {
72368
- console.log(`[SecureChannel] A2A addMembers FAILED: ${errMsg}`);
72369
- continue;
72370
- }
72371
- }
72372
- if (!welcome) {
72373
- console.warn(`[SecureChannel] A2A addMembers produced no Welcome for ${req.requesting_device_id.slice(0, 8)}`);
72374
- continue;
72375
- }
72376
- const commitHex = Buffer.from(commit).toString("hex");
72377
- const welcomeHex = Buffer.from(welcome).toString("hex");
72378
- let distributed = false;
72379
- const updatedMembers = [this._deviceId, req.requesting_device_id];
72380
- try {
72381
- const distResp = await fetch(
72382
- `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
72383
- {
72384
- method: "POST",
72385
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
72386
- body: JSON.stringify({
72387
- group_id: chState.mlsGroupId,
72388
- device_id: this._deviceId,
72389
- commit: commitHex,
72390
- commit_epoch: Number(mlsGroup.epoch),
72391
- welcomes: [{ target_device_id: req.requesting_device_id, payload: welcomeHex }],
72392
- member_device_ids: updatedMembers,
72393
- ...parentDigest ? { parent_digest: parentDigest } : {}
72394
- })
72395
- }
72396
- );
72397
- if (distResp.ok) {
72398
- distributed = true;
72399
- } else {
72400
- const detail = await distResp.text().catch(() => "");
72401
- console.warn(`[SecureChannel] A2A distribute failed (${distResp.status}): ${detail}`);
72402
- }
72403
- } catch (distErr) {
72404
- console.warn(`[SecureChannel] A2A distribute network error:`, distErr);
72405
- }
72406
- if (!distributed && this._wsIsOpen()) {
72407
- const a2aLabel = `A2A ${chId.slice(0, 8)}`;
72408
- const commitSent = await this._sendFrameConfirmed({
72409
- event: "mls_commit",
72410
- data: {
72411
- group_id: chState.mlsGroupId,
72412
- epoch: Number(mlsGroup.epoch),
72413
- payload: commitHex,
72414
- ...parentDigest ? { parent_digest: parentDigest } : {}
72415
- }
72416
- }, `${a2aLabel} commit`);
72417
- const welcomeSent = commitSent && await this._sendFrameConfirmed({
72418
- event: "mls_welcome",
72419
- data: {
72420
- target_device_id: req.requesting_device_id,
72421
- group_id: chState.mlsGroupId,
72422
- a2a_channel_id: chId,
72423
- payload: welcomeHex
72424
- }
72425
- }, `${a2aLabel} welcome`);
72426
- if (!welcomeSent) {
72427
- console.warn(`[SecureChannel] ${a2aLabel}: Welcome frames were NOT written \u2014 leaving the request pending`);
72428
- continue;
72429
- }
72430
- try {
72431
- await fetch(
72432
- `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
72433
- {
72434
- method: "POST",
72435
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
72436
- body: JSON.stringify({
72437
- group_id: chState.mlsGroupId,
72438
- device_id: this._deviceId,
72439
- commit_epoch: Number(mlsGroup.epoch),
72440
- member_device_ids: updatedMembers
72441
- })
72442
- }
72443
- );
72444
- } catch {
72445
- }
72446
- distributed = true;
72447
- }
72448
- if (!distributed) {
72449
- console.warn(`[SecureChannel] Cannot deliver Welcome \u2014 HTTP distribute and WS both unavailable`);
72450
- continue;
72451
- }
72452
- const a2aFulfilled = await this._postFulfilWelcome(
72453
- chState.mlsGroupId,
72454
- req.id,
72455
- `A2A ${chId.slice(0, 8)}`
72456
- );
72457
- await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
72458
- if (a2aFulfilled) {
72459
- console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
72460
- }
72484
+ const next = await this._fulfilA2AWelcome(chId, chState, mlsGroup, req);
72485
+ if (next === "stop") break;
72461
72486
  } catch (fulfillErr) {
72462
72487
  console.log(`[SecureChannel] A2A Welcome fulfill failed: ${fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr)}`);
72463
72488
  }
@@ -72728,6 +72753,227 @@ ${baseText}`;
72728
72753
  }
72729
72754
  }
72730
72755
  }
72756
+ /**
72757
+ * #1225: fulfil ONE pending A2A Welcome request — the `/distribute` path.
72758
+ * Extracted from the delivery pull so the in-flight slot (review F1) is held
72759
+ * for exactly this request and released on every exit. Returns "stop" when
72760
+ * nothing else on this channel can succeed this pull (review F2).
72761
+ */
72762
+ async _fulfilA2AWelcome(chId, chState, mlsGroup, req) {
72763
+ const a2aLabel = `A2A ${chId.slice(0, 8)}`;
72764
+ const preHead = await this._readGroupHead(chState.mlsGroupId);
72765
+ if (preHead.ok && preHead.epoch !== Number(mlsGroup.epoch)) {
72766
+ console.warn(
72767
+ `[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`
72768
+ );
72769
+ await this._catchUpAfterPreflightSkip(chState.mlsGroupId, preHead.epoch, Number(mlsGroup.epoch), a2aLabel);
72770
+ return "stop";
72771
+ }
72772
+ const inFlight = this._reserveCommit(chState.mlsGroupId, a2aLabel);
72773
+ if (!inFlight) return "stop";
72774
+ try {
72775
+ const kpRes = await fetch(
72776
+ `${this.config.apiUrl}/api/v1/mls/key-packages/batch?device_ids=${req.requesting_device_id}`,
72777
+ { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
72778
+ );
72779
+ if (!kpRes.ok) return "next";
72780
+ const kpData = await kpRes.json();
72781
+ const kpHex = kpData[req.requesting_device_id];
72782
+ if (!kpHex) return "next";
72783
+ const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
72784
+ const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
72785
+ console.log(`[SecureChannel] A2A addMembers for ${req.requesting_device_id.slice(0, 8)} group=${chState.mlsGroupId.slice(0, 8)} epoch=${mlsGroup.epoch}`);
72786
+ let parentDigest = preHead.ok ? preHead.digest : void 0;
72787
+ let commit;
72788
+ let welcome;
72789
+ let preAddState = mlsGroup.exportState();
72790
+ try {
72791
+ const result = await mlsGroup.addMembers([memberKp]);
72792
+ commit = result.commit;
72793
+ welcome = result.welcome;
72794
+ } catch (addErr) {
72795
+ const errMsg = addErr instanceof Error ? addErr.message : String(addErr);
72796
+ if (errMsg.includes("already in the group")) {
72797
+ console.log(`[SecureChannel] A2A member already in group \u2014 removing and re-adding ${req.requesting_device_id.slice(0, 8)}`);
72798
+ const preRemoveState = JSON.stringify(mlsGroup.exportState());
72799
+ let removeCommitPublished = false;
72800
+ try {
72801
+ const identityBytes = new TextEncoder().encode(req.requesting_device_id);
72802
+ const leafIndex = mlsGroup.memberLeafIndex(identityBytes);
72803
+ if (leafIndex === null) {
72804
+ console.log(`[SecureChannel] A2A re-add: no leaf index for ${req.requesting_device_id.slice(0, 8)} \u2014 cannot remove`);
72805
+ return "next";
72806
+ }
72807
+ const removeResult = await mlsGroup.removeMember(leafIndex);
72808
+ try {
72809
+ const removeResp = await fetch(
72810
+ `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
72811
+ {
72812
+ method: "POST",
72813
+ headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
72814
+ body: JSON.stringify({
72815
+ group_id: chState.mlsGroupId,
72816
+ device_id: this._deviceId,
72817
+ commit: Buffer.from(removeResult.commit).toString("hex"),
72818
+ commit_epoch: Number(mlsGroup.epoch),
72819
+ expected_epoch: Number(mlsGroup.epoch) - 1,
72820
+ // #1225: exact-step, like the WS path
72821
+ ...parentDigest ? { parent_digest: parentDigest } : {}
72822
+ })
72823
+ }
72824
+ );
72825
+ if (removeResp.ok) {
72826
+ removeCommitPublished = true;
72827
+ await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
72828
+ parentDigest = this._commitDigest(removeResult.commit);
72829
+ } else {
72830
+ const detail = await removeResp.text().catch(() => "");
72831
+ console.warn(`[SecureChannel] A2A remove-commit distribute failed (${removeResp.status}): ${detail}`);
72832
+ }
72833
+ } catch (distErr) {
72834
+ console.warn(`[SecureChannel] A2A remove-commit distribute network error:`, distErr);
72835
+ }
72836
+ if (!removeCommitPublished) {
72837
+ mlsGroup.importState(JSON.parse(preRemoveState));
72838
+ return "next";
72839
+ }
72840
+ const kpRes2 = await fetch(
72841
+ `${this.config.apiUrl}/api/v1/mls/key-packages/batch?device_ids=${req.requesting_device_id}`,
72842
+ { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
72843
+ );
72844
+ if (kpRes2.ok) {
72845
+ const kpData2 = await kpRes2.json();
72846
+ const kpHex2 = kpData2[req.requesting_device_id];
72847
+ if (kpHex2) {
72848
+ const kpBytes2 = new Uint8Array(Buffer.from(kpHex2, "hex"));
72849
+ const memberKp2 = MLSGroupManager.deserializeKeyPackage(kpBytes2);
72850
+ preAddState = mlsGroup.exportState();
72851
+ const result2 = await mlsGroup.addMembers([memberKp2]);
72852
+ commit = result2.commit;
72853
+ welcome = result2.welcome;
72854
+ } else {
72855
+ console.log(`[SecureChannel] A2A re-add: no fresh KeyPackage for ${req.requesting_device_id.slice(0, 8)}`);
72856
+ return "next";
72857
+ }
72858
+ } else {
72859
+ console.log(`[SecureChannel] A2A re-add: KeyPackage fetch failed (${kpRes2.status})`);
72860
+ return "next";
72861
+ }
72862
+ } catch (readdErr) {
72863
+ console.log(`[SecureChannel] A2A remove+re-add FAILED: ${readdErr instanceof Error ? readdErr.message : String(readdErr)}`);
72864
+ if (!removeCommitPublished) {
72865
+ try {
72866
+ mlsGroup.importState(JSON.parse(preRemoveState));
72867
+ } catch {
72868
+ }
72869
+ }
72870
+ return "next";
72871
+ }
72872
+ } else {
72873
+ console.log(`[SecureChannel] A2A addMembers FAILED: ${errMsg}`);
72874
+ return "next";
72875
+ }
72876
+ }
72877
+ if (!welcome) {
72878
+ console.warn(`[SecureChannel] A2A addMembers produced no Welcome for ${req.requesting_device_id.slice(0, 8)}`);
72879
+ return "next";
72880
+ }
72881
+ const commitHex = Buffer.from(commit).toString("hex");
72882
+ const welcomeHex = Buffer.from(welcome).toString("hex");
72883
+ let distributed = false;
72884
+ let transportFailed = false;
72885
+ const updatedMembers = this._commitRoster([this._deviceId], [req.requesting_device_id]);
72886
+ try {
72887
+ const distResp = await fetch(
72888
+ `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
72889
+ {
72890
+ method: "POST",
72891
+ headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
72892
+ body: JSON.stringify({
72893
+ group_id: chState.mlsGroupId,
72894
+ device_id: this._deviceId,
72895
+ commit: commitHex,
72896
+ commit_epoch: Number(mlsGroup.epoch),
72897
+ welcomes: [{ target_device_id: req.requesting_device_id, payload: welcomeHex }],
72898
+ member_device_ids: updatedMembers,
72899
+ expected_epoch: Number(mlsGroup.epoch) - 1,
72900
+ // #1225: exact-step, like the WS path
72901
+ ...parentDigest ? { parent_digest: parentDigest } : {}
72902
+ })
72903
+ }
72904
+ );
72905
+ if (distResp.ok) {
72906
+ distributed = true;
72907
+ } else {
72908
+ const detail = await distResp.text().catch(() => "");
72909
+ console.warn(`[SecureChannel] A2A distribute failed (${distResp.status}): ${detail}`);
72910
+ if (distResp.status >= 500) {
72911
+ transportFailed = true;
72912
+ } else if (distResp.status === 409) {
72913
+ mlsGroup.importState(preAddState);
72914
+ console.warn(
72915
+ `[SecureChannel] ${a2aLabel}: add commit lost the race (409) \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, request stays pending`
72916
+ );
72917
+ return "next";
72918
+ } else {
72919
+ mlsGroup.importState(preAddState);
72920
+ console.error(
72921
+ `[SecureChannel] ${a2aLabel}: add commit refused with ${distResp.status} \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}; not retrying this channel this pull`
72922
+ );
72923
+ return "stop";
72924
+ }
72925
+ }
72926
+ } catch (distErr) {
72927
+ console.warn(`[SecureChannel] A2A distribute network error:`, distErr);
72928
+ transportFailed = true;
72929
+ }
72930
+ if (!distributed && transportFailed && this._wsIsOpen()) {
72931
+ const verdict = await this._sendCommitConfirmed({
72932
+ mlsGroup,
72933
+ mlsGroupId: chState.mlsGroupId,
72934
+ label: a2aLabel,
72935
+ commit,
72936
+ epoch: Number(mlsGroup.epoch),
72937
+ parent: parentDigest,
72938
+ memberDeviceIds: updatedMembers,
72939
+ stateBefore: preAddState,
72940
+ inFlight
72941
+ });
72942
+ if (verdict !== "confirmed") return "next";
72943
+ const welcomeSent = await this._sendFrameConfirmed({
72944
+ event: "mls_welcome",
72945
+ data: {
72946
+ target_device_id: req.requesting_device_id,
72947
+ group_id: chState.mlsGroupId,
72948
+ a2a_channel_id: chId,
72949
+ payload: welcomeHex
72950
+ }
72951
+ }, `${a2aLabel} welcome`);
72952
+ if (!welcomeSent) {
72953
+ await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
72954
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome frame was NOT written \u2014 leaving the request pending`);
72955
+ return "next";
72956
+ }
72957
+ distributed = true;
72958
+ }
72959
+ if (!distributed) {
72960
+ console.warn(`[SecureChannel] Cannot deliver Welcome \u2014 HTTP distribute and WS both unavailable`);
72961
+ return "next";
72962
+ }
72963
+ const a2aFulfilled = await this._postFulfilWelcome(
72964
+ chState.mlsGroupId,
72965
+ req.id,
72966
+ `A2A ${chId.slice(0, 8)}`
72967
+ );
72968
+ await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
72969
+ if (a2aFulfilled) {
72970
+ console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
72971
+ }
72972
+ return "next";
72973
+ } finally {
72974
+ await this._releaseCommit(chState.mlsGroupId, inFlight, a2aLabel);
72975
+ }
72976
+ }
72731
72977
  /**
72732
72978
  * Found the MLS group for an A2A channel as the creator agent.
72733
72979
  * Calls /mls/a2a/{channelId}/init, creates the local MLS group, and sends
@@ -72770,48 +73016,77 @@ ${baseText}`;
72770
73016
  await saveMlsState(this.config.dataDir, mlsData.group_id, JSON.stringify(mgr.exportState()));
72771
73017
  for (const p2 of participants) {
72772
73018
  if (p2.status !== "active" && p2.status !== "invited") continue;
73019
+ const a2aLabel = `A2A ${channelId.slice(0, 8)} found\u2192${p2.hubAddress}`;
72773
73020
  try {
72774
- const kpRes = await fetch(
72775
- `${this.config.apiUrl}/api/v1/mls/key-packages?hub_id=${encodeURIComponent(p2.hubId)}`,
72776
- { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
72777
- );
72778
- if (kpRes.ok) {
72779
- const kpData = await kpRes.json();
72780
- if (kpData.key_package) {
72781
- const kpBytes = new Uint8Array(Buffer.from(kpData.key_package, "hex"));
72782
- const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
72783
- const { commit, welcome } = await mgr.addMembers([memberKp]);
72784
- if (welcome && this._ws) {
72785
- this._ws.send(JSON.stringify({
72786
- event: "mls_commit",
73021
+ if (!p2.deviceId) {
73022
+ console.warn(
73023
+ `[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.`
73024
+ );
73025
+ continue;
73026
+ }
73027
+ if (!this._wsIsOpen()) {
73028
+ console.warn(`[SecureChannel] ${a2aLabel}: socket is not OPEN \u2014 NOT advancing the group`);
73029
+ continue;
73030
+ }
73031
+ const head = await this._readGroupHead(mlsData.group_id);
73032
+ if (head.ok && head.epoch !== Number(mgr.epoch)) {
73033
+ console.warn(`[SecureChannel] ${a2aLabel}: server head is at epoch ${head.epoch}, we are at ${Number(mgr.epoch)} \u2014 NOT committing`);
73034
+ await this._catchUpAfterPreflightSkip(mlsData.group_id, head.epoch, Number(mgr.epoch), a2aLabel);
73035
+ continue;
73036
+ }
73037
+ const inFlight = this._reserveCommit(mlsData.group_id, a2aLabel);
73038
+ if (!inFlight) continue;
73039
+ try {
73040
+ const kpRes = await fetch(
73041
+ `${this.config.apiUrl}/api/v1/mls/key-packages?hub_id=${encodeURIComponent(p2.hubId)}`,
73042
+ { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
73043
+ );
73044
+ if (kpRes.ok) {
73045
+ const kpData = await kpRes.json();
73046
+ if (kpData.key_package) {
73047
+ const kpBytes = new Uint8Array(Buffer.from(kpData.key_package, "hex"));
73048
+ const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
73049
+ const stateBefore = mgr.exportState();
73050
+ const { commit, welcome } = await mgr.addMembers([memberKp]);
73051
+ const committedEpoch = Number(mgr.epoch);
73052
+ if (!welcome) {
73053
+ mgr.importState(stateBefore);
73054
+ console.warn(`[SecureChannel] ${a2aLabel}: addMembers produced no Welcome \u2014 rolled back`);
73055
+ continue;
73056
+ }
73057
+ const verdict = await this._sendCommitConfirmed({
73058
+ mlsGroup: mgr,
73059
+ mlsGroupId: mlsData.group_id,
73060
+ label: a2aLabel,
73061
+ commit,
73062
+ epoch: committedEpoch,
73063
+ parent: head.ok ? head.digest : void 0,
73064
+ memberDeviceIds: this._commitRoster(participants.map((q22) => q22.deviceId), [p2.deviceId]),
73065
+ stateBefore,
73066
+ inFlight
73067
+ });
73068
+ if (verdict !== "confirmed") continue;
73069
+ const sent = await this._sendFrameConfirmed({
73070
+ event: "mls_welcome",
72787
73071
  data: {
72788
73072
  group_id: mlsData.group_id,
72789
- epoch: Number(mgr.epoch),
72790
- payload: Buffer.from(commit).toString("hex")
73073
+ payload: Buffer.from(welcome).toString("hex"),
73074
+ target_device_id: p2.deviceId,
73075
+ a2a_channel_id: channelId,
73076
+ target_hub_id: p2.hubId
73077
+ // observability only — the backend ignores it
72791
73078
  }
72792
- }));
72793
- if (!p2.deviceId) {
72794
- console.warn(
72795
- `[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.`
72796
- );
72797
- } else {
72798
- this._ws.send(JSON.stringify({
72799
- event: "mls_welcome",
72800
- data: {
72801
- group_id: mlsData.group_id,
72802
- payload: Buffer.from(welcome).toString("hex"),
72803
- target_device_id: p2.deviceId,
72804
- a2a_channel_id: channelId,
72805
- // Kept for observability — backend ignores but it's
72806
- // useful in logs/wire captures to correlate to a hub.
72807
- target_hub_id: p2.hubId
72808
- }
72809
- }));
73079
+ }, `${a2aLabel} welcome`);
73080
+ await saveMlsState(this.config.dataDir, mlsData.group_id, JSON.stringify(mgr.exportState()));
73081
+ if (sent) {
72810
73082
  console.log(`[SecureChannel] Sent MLS Welcome to ${p2.hubAddress} (device ${p2.deviceId.slice(0, 8)}) for A2A ${channelId.slice(0, 8)}`);
73083
+ } else {
73084
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome was NOT written \u2014 the participant will request one`);
72811
73085
  }
72812
73086
  }
72813
- await saveMlsState(this.config.dataDir, mlsData.group_id, JSON.stringify(mgr.exportState()));
72814
73087
  }
73088
+ } finally {
73089
+ await this._releaseCommit(mlsData.group_id, inFlight, a2aLabel);
72815
73090
  }
72816
73091
  } catch (kpErr) {
72817
73092
  console.warn(`[SecureChannel] Failed to Welcome participant ${p2.hubAddress}:`, kpErr);
@@ -99883,7 +100158,7 @@ var init_index = __esm2({
99883
100158
  await init_skill_telemetry();
99884
100159
  await init_policy_enforcer();
99885
100160
  init_room_protocol();
99886
- VERSION = true ? "0.23.40" : "0.0.0-dev";
100161
+ VERSION = true ? "0.23.42" : "0.0.0-dev";
99887
100162
  }
99888
100163
  });
99889
100164
  await init_index();
@@ -100659,7 +100934,7 @@ function _makeOwesReply(agentName, channel, log) {
100659
100934
  );
100660
100935
  }
100661
100936
  return owesRoomReply(
100662
- e.plaintext,
100937
+ e.caption ?? e.plaintext,
100663
100938
  own,
100664
100939
  own,
100665
100940
  e.senderIsAgent,
@@ -100820,7 +101095,7 @@ async function main() {
100820
101095
  const info = parseInfoFlags(process.argv.slice(2));
100821
101096
  if (info.showVersion || info.showHelp) {
100822
101097
  const version2 = buildClientVersion2(
100823
- true ? "0.4.13" : void 0
101098
+ true ? "0.4.15" : void 0
100824
101099
  );
100825
101100
  if (info.showVersion) printLine(version2);
100826
101101
  if (info.showHelp) {
@@ -100873,7 +101148,7 @@ async function main() {
100873
101148
  agentName: cfg.agentName,
100874
101149
  platform: "node",
100875
101150
  clientVersion: buildClientVersion2(
100876
- true ? "0.4.13" : void 0
101151
+ true ? "0.4.15" : void 0
100877
101152
  )
100878
101153
  });
100879
101154
  channelRef = channel;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/grok-bridge",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "type": "module",
5
5
  "description": "AgentVault Grok Bridge — daemon bridging a Grok agent into E2E-encrypted AgentVault direct messages and rooms.",
6
6
  "main": "dist/index.js",