@agentvault/muse-bridge 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +189 -8
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -11465,11 +11465,11 @@ async function joinWithFirstMatchingKp(welcomeBytes, candidates) {
11465
11465
  return null;
11466
11466
  }
11467
11467
  function ownIdentity() {
11468
- const v2 = true ? "0.23.30" : FALLBACK;
11468
+ const v2 = true ? "0.23.31" : FALLBACK;
11469
11469
  return `${PACKAGE}@${v2}`;
11470
11470
  }
11471
11471
  function buildSha() {
11472
- const raw = true ? "1fff5d6" : "";
11472
+ const raw = true ? "3e1243a-dirty" : "";
11473
11473
  const cleaned = (raw ?? "").trim();
11474
11474
  return !cleaned || cleaned === "unknown" ? null : cleaned;
11475
11475
  }
@@ -69702,6 +69702,118 @@ var init_dist = __esm({
69702
69702
  * Encrypt and send a message to ALL owner devices (fanout).
69703
69703
  * Each session gets the same plaintext encrypted independently.
69704
69704
  */
69705
+ /**
69706
+ * #952 — sends held because MLS is expected for this conversation but the
69707
+ * group is not joined yet.
69708
+ *
69709
+ * ⚠️ IN MEMORY ONLY. These entries hold PLAINTEXT. `_persisted` is written to
69710
+ * disk, and the product's entire premise is that plaintext does not rest
69711
+ * where it does not have to; a crash losing a held greeting is the correct
69712
+ * trade. This is why the existing `outboundQueue` could not be reused — that
69713
+ * one is persisted, and holds already-DR-encrypted bytes, which is exactly
69714
+ * the encryption we are trying not to perform yet.
69715
+ */
69716
+ _mlsHold = /* @__PURE__ */ new Map();
69717
+ /** How long a send waits for the Welcome. The two measured bursts were each
69718
+ * confined to a single minute, with the group already existing server-side
69719
+ * in the same second, so this is generous rather than tight. */
69720
+ static MLS_HOLD_MS = 3e4;
69721
+ /** Bound on held sends per group — see `_holdForMls`. */
69722
+ static MLS_HOLD_MAX = 50;
69723
+ /**
69724
+ * Should this 1:1 send wait for MLS rather than go out over Double Ratchet?
69725
+ *
69726
+ * The discriminator exists at the moment of the bug: `_sessionGroupIds`
69727
+ * already carries the conversation's group id — the server told us the group
69728
+ * exists — while `_mlsGroups` has no usable manager for it. Group expected,
69729
+ * group not ready.
69730
+ *
69731
+ * The negative case is load-bearing. A conversation with NO group id is
69732
+ * legitimately Double Ratchet, and holding it would break owner-to-agent
69733
+ * messaging outright, which is far worse than the bug this fixes. "Present"
69734
+ * is also not "ready": a manager parked at epoch 0 has not joined (#1092),
69735
+ * and reading presence as readiness is how #991 shipped.
69736
+ */
69737
+ _shouldHoldForMls(convGroupId, mlsGroup, mlsGroupId) {
69738
+ if (!convGroupId) return false;
69739
+ const ready = Boolean(mlsGroup?.isInitialized) && Boolean(mlsGroupId) && Number(mlsGroup?.epoch) > 0;
69740
+ return !ready;
69741
+ }
69742
+ /** Park a send until the Welcome lands, or until the hold expires. */
69743
+ _holdForMls(convGroupId, convId, plaintext, options) {
69744
+ let entry = this._mlsHold.get(convGroupId);
69745
+ if (!entry) {
69746
+ const timer = setTimeout(() => {
69747
+ void this._releaseHeldOverDr(convGroupId);
69748
+ }, _SecureChannel.MLS_HOLD_MS);
69749
+ timer.unref?.();
69750
+ entry = { items: [], timer };
69751
+ this._mlsHold.set(convGroupId, entry);
69752
+ }
69753
+ if (entry.items.length >= _SecureChannel.MLS_HOLD_MAX) {
69754
+ entry.items.shift();
69755
+ console.warn(
69756
+ `[SecureChannel] MLS hold full for group ${convGroupId.slice(0, 8)} \u2014 dropped the oldest held message`
69757
+ );
69758
+ }
69759
+ entry.items.push({ convId, plaintext, options });
69760
+ console.log(
69761
+ `[SecureChannel] Holding send for group ${convGroupId.slice(0, 8)} until its Welcome lands (held=${entry.items.length})`
69762
+ );
69763
+ }
69764
+ _takeHold(convGroupId) {
69765
+ const entry = this._mlsHold.get(convGroupId);
69766
+ if (!entry) return null;
69767
+ clearTimeout(entry.timer);
69768
+ this._mlsHold.delete(convGroupId);
69769
+ return entry;
69770
+ }
69771
+ /** Re-drive one held send through the normal path, now that MLS is ready. */
69772
+ async _resendHeld(item) {
69773
+ await this.send(item.plaintext, {
69774
+ ...item.options ?? {},
69775
+ conversationId: item.convId,
69776
+ bypassMlsHold: true
69777
+ });
69778
+ }
69779
+ /** The Welcome landed — release everything held for this group, in order. */
69780
+ async _flushMlsHold(convGroupId) {
69781
+ const entry = this._takeHold(convGroupId);
69782
+ if (!entry) return;
69783
+ console.log(
69784
+ `[SecureChannel] Welcome landed for group ${convGroupId.slice(0, 8)} \u2014 releasing ${entry.items.length} held message(s) over MLS`
69785
+ );
69786
+ for (const item of entry.items) {
69787
+ try {
69788
+ await this._resendHeld(item);
69789
+ } catch (err) {
69790
+ console.warn("[SecureChannel] Held message failed to send after Welcome:", err);
69791
+ }
69792
+ }
69793
+ }
69794
+ /**
69795
+ * The hold expired. Send over Double Ratchet exactly as before.
69796
+ *
69797
+ * This is deliberately NOT a drop. #853 and #831 are Welcomes that never
69798
+ * arrive; under those an unbounded hold turns a lost minute into an agent
69799
+ * that never speaks again. The hold removes the race — it must not invent a
69800
+ * new failure mode. The warning is the part that is new: today the loss is
69801
+ * silent.
69802
+ */
69803
+ async _releaseHeldOverDr(convGroupId) {
69804
+ const entry = this._takeHold(convGroupId);
69805
+ if (!entry) return;
69806
+ console.warn(
69807
+ `[SecureChannel] No Welcome for group ${convGroupId.slice(0, 8)} within ${_SecureChannel.MLS_HOLD_MS}ms \u2014 releasing ${entry.items.length} message(s) over Double Ratchet. The recipient may not hold a ratchet with this device, in which case they are undeliverable (#952).`
69808
+ );
69809
+ for (const item of entry.items) {
69810
+ try {
69811
+ await this._resendHeld(item);
69812
+ } catch (err) {
69813
+ console.warn("[SecureChannel] Held message failed on Double Ratchet release:", err);
69814
+ }
69815
+ }
69816
+ }
69705
69817
  async send(plaintext, options) {
69706
69818
  if (isAbstention(plaintext)) return;
69707
69819
  if (this._state === "error" || this._state === "idle") {
@@ -69863,6 +69975,10 @@ var init_dist = __esm({
69863
69975
  );
69864
69976
  addressedMlsGroupIds.push(mlsGroupId);
69865
69977
  if (convGroupId) sentSharedGroupIds.add(convGroupId);
69978
+ } else if (!options?.bypassMlsHold && this._shouldHoldForMls(convGroupId, mlsGroup, mlsGroupId)) {
69979
+ this._holdForMls(convGroupId, convId, plaintext, options);
69980
+ sentCount++;
69981
+ continue;
69866
69982
  } else {
69867
69983
  const encrypted = session.ratchet.encrypt(plaintext);
69868
69984
  const transport = encryptedMessageToTransport(encrypted);
@@ -70032,7 +70148,7 @@ var init_dist = __esm({
70032
70148
  */
70033
70149
  sendActivitySpan(spanData) {
70034
70150
  if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
70035
- const pluginVersion = true ? "0.23.30" : "0.0.0-dev";
70151
+ const pluginVersion = true ? "0.23.31" : "0.0.0-dev";
70036
70152
  const agentName = this.config.agentName ?? "Agent";
70037
70153
  const resource = {
70038
70154
  "service.name": "agentvault-agent",
@@ -71970,7 +72086,7 @@ var init_dist = __esm({
71970
72086
  agentVersion: this.config.agentVersion ?? "0.0.0",
71971
72087
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
71972
72088
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
71973
- pluginVersion: true ? "0.23.30" : "0.0.0-dev"
72089
+ pluginVersion: true ? "0.23.31" : "0.0.0-dev"
71974
72090
  });
71975
72091
  this._telemetryReporter.startAutoFlush(3e4);
71976
72092
  }
@@ -72294,7 +72410,7 @@ var init_dist = __esm({
72294
72410
  agentVersion: this.config.agentVersion ?? "0.0.0",
72295
72411
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
72296
72412
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
72297
- pluginVersion: true ? "0.23.30" : "0.0.0-dev"
72413
+ pluginVersion: true ? "0.23.31" : "0.0.0-dev"
72298
72414
  });
72299
72415
  this._telemetryReporter.startAutoFlush(3e4);
72300
72416
  }
@@ -72710,11 +72826,13 @@ var init_dist = __esm({
72710
72826
  let text = plaintext;
72711
72827
  let messageType = "text";
72712
72828
  let topicId = data.topic_id;
72829
+ let mlsAttachment = null;
72713
72830
  try {
72714
72831
  const parsed = JSON.parse(plaintext);
72715
72832
  text = parsed.text ?? plaintext;
72716
72833
  messageType = parsed.type ?? "text";
72717
72834
  topicId = parsed.topicId ?? topicId;
72835
+ if (parsed.attachment) mlsAttachment = parsed.attachment;
72718
72836
  } catch {
72719
72837
  }
72720
72838
  if (messageType === "history_catchup_request") {
@@ -72781,6 +72899,9 @@ var init_dist = __esm({
72781
72899
  console.log(`[SecureChannel] Session ${convId.slice(0, 8)} activated via MLS message`);
72782
72900
  }
72783
72901
  }
72902
+ if (mlsAttachment) {
72903
+ text = await this._augmentWithAttachment(mlsAttachment, text);
72904
+ }
72784
72905
  const metadata = {
72785
72906
  messageId: data.message_id ?? `mls_${Date.now()}`,
72786
72907
  conversationId: convId ?? this._primaryConversationId,
@@ -73030,6 +73151,65 @@ ${messageText}`;
73030
73151
  * Download an encrypted attachment blob, decrypt it, verify integrity,
73031
73152
  * and save the plaintext file to disk.
73032
73153
  */
73154
+ /** Where an attachment may be written — always directly inside `attachDir` (#1143).
73155
+ *
73156
+ * The filename arrives INSIDE the envelope and is chosen by whoever sent the
73157
+ * message. `join(attachDir, info.filename)` resolves `..` normally, so
73158
+ * `../../agentvault.json` wrote over the device credential store. Only the
73159
+ * final path component is honoured, and anything that is not a usable name
73160
+ * falls back to a fixed one.
73161
+ */
73162
+ _safeAttachmentPath(attachDir, filename) {
73163
+ const raw = String(filename ?? "");
73164
+ const last = raw.split(/[\\/]/).pop() ?? "";
73165
+ const cleaned = last.replace(/\0/g, "").trim();
73166
+ const safe = cleaned === "" || cleaned === "." || cleaned === ".." ? "attachment.bin" : cleaned;
73167
+ return join42(attachDir, safe);
73168
+ }
73169
+ /** Download an attachment and fold it into the text the agent sees (#1139).
73170
+ *
73171
+ * Extracted so the MLS receive path can use it too. It used to live inline in
73172
+ * `_handleIncomingMessage`, which meant attachments worked on the Double
73173
+ * Ratchet path ONLY — and hermes drops DR by design (`secure_channel.py:594`,
73174
+ * "DR fallback is logged-and-dropped"), so no single transport could serve
73175
+ * both client families. That is why an attachment reached ohtani and never
73176
+ * reached smartcoder2 on 2026-08-31.
73177
+ *
73178
+ * Returns the text unchanged when there is no attachment or the fetch fails —
73179
+ * but a failure is LOGGED and announced in the text, never silent.
73180
+ */
73181
+ async _augmentWithAttachment(attachmentInfo, baseText) {
73182
+ if (!attachmentInfo) return baseText;
73183
+ try {
73184
+ const { filePath, decrypted } = await this._downloadAndDecryptAttachment(attachmentInfo);
73185
+ const mime = String(attachmentInfo.mime ?? "application/octet-stream");
73186
+ const name = String(attachmentInfo.filename ?? "attachment");
73187
+ const textMimes = ["text/", "application/json", "application/xml", "application/csv"];
73188
+ if (textMimes.some((m2) => mime.startsWith(m2))) {
73189
+ const content = new TextDecoder().decode(decrypted);
73190
+ return `[Attachment: ${name} (${mime}) saved to ${filePath}]
73191
+ ---
73192
+ ${content}
73193
+ ---
73194
+
73195
+ ${baseText}`;
73196
+ }
73197
+ if (mime.startsWith("image/")) {
73198
+ return `[Image attachment: ${name} saved to ${filePath}]
73199
+ Use your Read tool to view this image file.
73200
+
73201
+ ${baseText}`;
73202
+ }
73203
+ return `[Attachment: ${name} saved to ${filePath}]
73204
+
73205
+ ${baseText}`;
73206
+ } catch (err) {
73207
+ console.error("[SecureChannel] Failed to download attachment:", err);
73208
+ return `[Attachment: ${String(attachmentInfo.filename ?? "file")} was sent but could not be retrieved: ${err}]
73209
+
73210
+ ${baseText}`;
73211
+ }
73212
+ }
73033
73213
  async _downloadAndDecryptAttachment(info) {
73034
73214
  const attachDir = join42(this.config.dataDir, "attachments");
73035
73215
  await mkdir3(attachDir, { recursive: true });
@@ -73049,7 +73229,7 @@ ${messageText}`;
73049
73229
  const fileKey = base64ToBytes(info.fileKey);
73050
73230
  const fileNonce = base64ToBytes(info.fileNonce);
73051
73231
  const decrypted = decryptFile(encryptedData, fileKey, fileNonce);
73052
- const filePath = join42(attachDir, info.filename);
73232
+ const filePath = this._safeAttachmentPath(attachDir, info.filename);
73053
73233
  await writeFile3(filePath, decrypted);
73054
73234
  console.log(`[SecureChannel] Attachment saved: ${filePath} (${decrypted.length} bytes)`);
73055
73235
  return { filePath, decrypted };
@@ -74470,6 +74650,7 @@ ${messageText}`;
74470
74650
  await this._persistState();
74471
74651
  }
74472
74652
  console.log(`[SecureChannel] Joined shared 1:1 MLS group for group ${conversationGroupId.slice(0, 8)} via Welcome (epoch=${mgr.epoch})`);
74653
+ await this._flushMlsHold(conversationGroupId);
74473
74654
  return;
74474
74655
  }
74475
74656
  if (conversationId) {
@@ -97536,7 +97717,7 @@ data:
97536
97717
  await init_skill_telemetry();
97537
97718
  await init_policy_enforcer();
97538
97719
  init_room_protocol();
97539
- VERSION = true ? "0.23.30" : "0.0.0-dev";
97720
+ VERSION = true ? "0.23.31" : "0.0.0-dev";
97540
97721
  }
97541
97722
  });
97542
97723
  await init_index();
@@ -98912,7 +99093,7 @@ function mkdir700(path2) {
98912
99093
  chmodSync5(path2, 448);
98913
99094
  }
98914
99095
  function packageVersion() {
98915
- return true ? "0.1.5" : void 0;
99096
+ return true ? "0.1.6" : void 0;
98916
99097
  }
98917
99098
  function helpText(version2) {
98918
99099
  return [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/muse-bridge",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "AgentVault Muse Bridge — daemon bridging a Muse agent into E2E-encrypted AgentVault direct messages and rooms.",
6
6
  "main": "dist/index.js",