@agentvault/claude-bridge 0.8.3 → 0.8.4

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 +190 -9
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -64596,11 +64596,11 @@ var init_mls_kp_pool = __esm2({
64596
64596
  }
64597
64597
  });
64598
64598
  function ownIdentity() {
64599
- const v22 = true ? "0.23.30" : FALLBACK;
64599
+ const v22 = true ? "0.23.31" : FALLBACK;
64600
64600
  return `${PACKAGE}@${v22}`;
64601
64601
  }
64602
64602
  function buildSha() {
64603
- const raw = true ? "1fff5d6" : "";
64603
+ const raw = true ? "3e1243a-dirty" : "";
64604
64604
  const cleaned = (raw ?? "").trim();
64605
64605
  return !cleaned || cleaned === "unknown" ? null : cleaned;
64606
64606
  }
@@ -66348,6 +66348,118 @@ var init_channel = __esm2({
66348
66348
  * Encrypt and send a message to ALL owner devices (fanout).
66349
66349
  * Each session gets the same plaintext encrypted independently.
66350
66350
  */
66351
+ /**
66352
+ * #952 — sends held because MLS is expected for this conversation but the
66353
+ * group is not joined yet.
66354
+ *
66355
+ * ⚠️ IN MEMORY ONLY. These entries hold PLAINTEXT. `_persisted` is written to
66356
+ * disk, and the product's entire premise is that plaintext does not rest
66357
+ * where it does not have to; a crash losing a held greeting is the correct
66358
+ * trade. This is why the existing `outboundQueue` could not be reused — that
66359
+ * one is persisted, and holds already-DR-encrypted bytes, which is exactly
66360
+ * the encryption we are trying not to perform yet.
66361
+ */
66362
+ _mlsHold = /* @__PURE__ */ new Map();
66363
+ /** How long a send waits for the Welcome. The two measured bursts were each
66364
+ * confined to a single minute, with the group already existing server-side
66365
+ * in the same second, so this is generous rather than tight. */
66366
+ static MLS_HOLD_MS = 3e4;
66367
+ /** Bound on held sends per group — see `_holdForMls`. */
66368
+ static MLS_HOLD_MAX = 50;
66369
+ /**
66370
+ * Should this 1:1 send wait for MLS rather than go out over Double Ratchet?
66371
+ *
66372
+ * The discriminator exists at the moment of the bug: `_sessionGroupIds`
66373
+ * already carries the conversation's group id — the server told us the group
66374
+ * exists — while `_mlsGroups` has no usable manager for it. Group expected,
66375
+ * group not ready.
66376
+ *
66377
+ * The negative case is load-bearing. A conversation with NO group id is
66378
+ * legitimately Double Ratchet, and holding it would break owner-to-agent
66379
+ * messaging outright, which is far worse than the bug this fixes. "Present"
66380
+ * is also not "ready": a manager parked at epoch 0 has not joined (#1092),
66381
+ * and reading presence as readiness is how #991 shipped.
66382
+ */
66383
+ _shouldHoldForMls(convGroupId, mlsGroup, mlsGroupId) {
66384
+ if (!convGroupId) return false;
66385
+ const ready = Boolean(mlsGroup?.isInitialized) && Boolean(mlsGroupId) && Number(mlsGroup?.epoch) > 0;
66386
+ return !ready;
66387
+ }
66388
+ /** Park a send until the Welcome lands, or until the hold expires. */
66389
+ _holdForMls(convGroupId, convId, plaintext, options) {
66390
+ let entry = this._mlsHold.get(convGroupId);
66391
+ if (!entry) {
66392
+ const timer = setTimeout(() => {
66393
+ void this._releaseHeldOverDr(convGroupId);
66394
+ }, _SecureChannel.MLS_HOLD_MS);
66395
+ timer.unref?.();
66396
+ entry = { items: [], timer };
66397
+ this._mlsHold.set(convGroupId, entry);
66398
+ }
66399
+ if (entry.items.length >= _SecureChannel.MLS_HOLD_MAX) {
66400
+ entry.items.shift();
66401
+ console.warn(
66402
+ `[SecureChannel] MLS hold full for group ${convGroupId.slice(0, 8)} \u2014 dropped the oldest held message`
66403
+ );
66404
+ }
66405
+ entry.items.push({ convId, plaintext, options });
66406
+ console.log(
66407
+ `[SecureChannel] Holding send for group ${convGroupId.slice(0, 8)} until its Welcome lands (held=${entry.items.length})`
66408
+ );
66409
+ }
66410
+ _takeHold(convGroupId) {
66411
+ const entry = this._mlsHold.get(convGroupId);
66412
+ if (!entry) return null;
66413
+ clearTimeout(entry.timer);
66414
+ this._mlsHold.delete(convGroupId);
66415
+ return entry;
66416
+ }
66417
+ /** Re-drive one held send through the normal path, now that MLS is ready. */
66418
+ async _resendHeld(item) {
66419
+ await this.send(item.plaintext, {
66420
+ ...item.options ?? {},
66421
+ conversationId: item.convId,
66422
+ bypassMlsHold: true
66423
+ });
66424
+ }
66425
+ /** The Welcome landed — release everything held for this group, in order. */
66426
+ async _flushMlsHold(convGroupId) {
66427
+ const entry = this._takeHold(convGroupId);
66428
+ if (!entry) return;
66429
+ console.log(
66430
+ `[SecureChannel] Welcome landed for group ${convGroupId.slice(0, 8)} \u2014 releasing ${entry.items.length} held message(s) over MLS`
66431
+ );
66432
+ for (const item of entry.items) {
66433
+ try {
66434
+ await this._resendHeld(item);
66435
+ } catch (err) {
66436
+ console.warn("[SecureChannel] Held message failed to send after Welcome:", err);
66437
+ }
66438
+ }
66439
+ }
66440
+ /**
66441
+ * The hold expired. Send over Double Ratchet exactly as before.
66442
+ *
66443
+ * This is deliberately NOT a drop. #853 and #831 are Welcomes that never
66444
+ * arrive; under those an unbounded hold turns a lost minute into an agent
66445
+ * that never speaks again. The hold removes the race — it must not invent a
66446
+ * new failure mode. The warning is the part that is new: today the loss is
66447
+ * silent.
66448
+ */
66449
+ async _releaseHeldOverDr(convGroupId) {
66450
+ const entry = this._takeHold(convGroupId);
66451
+ if (!entry) return;
66452
+ console.warn(
66453
+ `[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).`
66454
+ );
66455
+ for (const item of entry.items) {
66456
+ try {
66457
+ await this._resendHeld(item);
66458
+ } catch (err) {
66459
+ console.warn("[SecureChannel] Held message failed on Double Ratchet release:", err);
66460
+ }
66461
+ }
66462
+ }
66351
66463
  async send(plaintext, options) {
66352
66464
  if (isAbstention(plaintext)) return;
66353
66465
  if (this._state === "error" || this._state === "idle") {
@@ -66509,6 +66621,10 @@ var init_channel = __esm2({
66509
66621
  );
66510
66622
  addressedMlsGroupIds.push(mlsGroupId);
66511
66623
  if (convGroupId) sentSharedGroupIds.add(convGroupId);
66624
+ } else if (!options?.bypassMlsHold && this._shouldHoldForMls(convGroupId, mlsGroup, mlsGroupId)) {
66625
+ this._holdForMls(convGroupId, convId, plaintext, options);
66626
+ sentCount++;
66627
+ continue;
66512
66628
  } else {
66513
66629
  const encrypted = session.ratchet.encrypt(plaintext);
66514
66630
  const transport = encryptedMessageToTransport(encrypted);
@@ -66678,7 +66794,7 @@ var init_channel = __esm2({
66678
66794
  */
66679
66795
  sendActivitySpan(spanData) {
66680
66796
  if (!this._ws || this._ws.readyState !== WebSocket2.OPEN) return;
66681
- const pluginVersion = true ? "0.23.30" : "0.0.0-dev";
66797
+ const pluginVersion = true ? "0.23.31" : "0.0.0-dev";
66682
66798
  const agentName = this.config.agentName ?? "Agent";
66683
66799
  const resource = {
66684
66800
  "service.name": "agentvault-agent",
@@ -68616,7 +68732,7 @@ var init_channel = __esm2({
68616
68732
  agentVersion: this.config.agentVersion ?? "0.0.0",
68617
68733
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68618
68734
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68619
- pluginVersion: true ? "0.23.30" : "0.0.0-dev"
68735
+ pluginVersion: true ? "0.23.31" : "0.0.0-dev"
68620
68736
  });
68621
68737
  this._telemetryReporter.startAutoFlush(3e4);
68622
68738
  }
@@ -68940,7 +69056,7 @@ var init_channel = __esm2({
68940
69056
  agentVersion: this.config.agentVersion ?? "0.0.0",
68941
69057
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68942
69058
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68943
- pluginVersion: true ? "0.23.30" : "0.0.0-dev"
69059
+ pluginVersion: true ? "0.23.31" : "0.0.0-dev"
68944
69060
  });
68945
69061
  this._telemetryReporter.startAutoFlush(3e4);
68946
69062
  }
@@ -69356,11 +69472,13 @@ var init_channel = __esm2({
69356
69472
  let text = plaintext;
69357
69473
  let messageType = "text";
69358
69474
  let topicId = data.topic_id;
69475
+ let mlsAttachment = null;
69359
69476
  try {
69360
69477
  const parsed = JSON.parse(plaintext);
69361
69478
  text = parsed.text ?? plaintext;
69362
69479
  messageType = parsed.type ?? "text";
69363
69480
  topicId = parsed.topicId ?? topicId;
69481
+ if (parsed.attachment) mlsAttachment = parsed.attachment;
69364
69482
  } catch {
69365
69483
  }
69366
69484
  if (messageType === "history_catchup_request") {
@@ -69427,6 +69545,9 @@ var init_channel = __esm2({
69427
69545
  console.log(`[SecureChannel] Session ${convId.slice(0, 8)} activated via MLS message`);
69428
69546
  }
69429
69547
  }
69548
+ if (mlsAttachment) {
69549
+ text = await this._augmentWithAttachment(mlsAttachment, text);
69550
+ }
69430
69551
  const metadata = {
69431
69552
  messageId: data.message_id ?? `mls_${Date.now()}`,
69432
69553
  conversationId: convId ?? this._primaryConversationId,
@@ -69676,6 +69797,65 @@ ${messageText}`;
69676
69797
  * Download an encrypted attachment blob, decrypt it, verify integrity,
69677
69798
  * and save the plaintext file to disk.
69678
69799
  */
69800
+ /** Where an attachment may be written — always directly inside `attachDir` (#1143).
69801
+ *
69802
+ * The filename arrives INSIDE the envelope and is chosen by whoever sent the
69803
+ * message. `join(attachDir, info.filename)` resolves `..` normally, so
69804
+ * `../../agentvault.json` wrote over the device credential store. Only the
69805
+ * final path component is honoured, and anything that is not a usable name
69806
+ * falls back to a fixed one.
69807
+ */
69808
+ _safeAttachmentPath(attachDir, filename) {
69809
+ const raw = String(filename ?? "");
69810
+ const last = raw.split(/[\\/]/).pop() ?? "";
69811
+ const cleaned = last.replace(/\0/g, "").trim();
69812
+ const safe = cleaned === "" || cleaned === "." || cleaned === ".." ? "attachment.bin" : cleaned;
69813
+ return join4(attachDir, safe);
69814
+ }
69815
+ /** Download an attachment and fold it into the text the agent sees (#1139).
69816
+ *
69817
+ * Extracted so the MLS receive path can use it too. It used to live inline in
69818
+ * `_handleIncomingMessage`, which meant attachments worked on the Double
69819
+ * Ratchet path ONLY — and hermes drops DR by design (`secure_channel.py:594`,
69820
+ * "DR fallback is logged-and-dropped"), so no single transport could serve
69821
+ * both client families. That is why an attachment reached ohtani and never
69822
+ * reached smartcoder2 on 2026-08-31.
69823
+ *
69824
+ * Returns the text unchanged when there is no attachment or the fetch fails —
69825
+ * but a failure is LOGGED and announced in the text, never silent.
69826
+ */
69827
+ async _augmentWithAttachment(attachmentInfo, baseText) {
69828
+ if (!attachmentInfo) return baseText;
69829
+ try {
69830
+ const { filePath, decrypted } = await this._downloadAndDecryptAttachment(attachmentInfo);
69831
+ const mime = String(attachmentInfo.mime ?? "application/octet-stream");
69832
+ const name = String(attachmentInfo.filename ?? "attachment");
69833
+ const textMimes = ["text/", "application/json", "application/xml", "application/csv"];
69834
+ if (textMimes.some((m22) => mime.startsWith(m22))) {
69835
+ const content = new TextDecoder().decode(decrypted);
69836
+ return `[Attachment: ${name} (${mime}) saved to ${filePath}]
69837
+ ---
69838
+ ${content}
69839
+ ---
69840
+
69841
+ ${baseText}`;
69842
+ }
69843
+ if (mime.startsWith("image/")) {
69844
+ return `[Image attachment: ${name} saved to ${filePath}]
69845
+ Use your Read tool to view this image file.
69846
+
69847
+ ${baseText}`;
69848
+ }
69849
+ return `[Attachment: ${name} saved to ${filePath}]
69850
+
69851
+ ${baseText}`;
69852
+ } catch (err) {
69853
+ console.error("[SecureChannel] Failed to download attachment:", err);
69854
+ return `[Attachment: ${String(attachmentInfo.filename ?? "file")} was sent but could not be retrieved: ${err}]
69855
+
69856
+ ${baseText}`;
69857
+ }
69858
+ }
69679
69859
  async _downloadAndDecryptAttachment(info) {
69680
69860
  const attachDir = join4(this.config.dataDir, "attachments");
69681
69861
  await mkdir3(attachDir, { recursive: true });
@@ -69695,7 +69875,7 @@ ${messageText}`;
69695
69875
  const fileKey = base64ToBytes(info.fileKey);
69696
69876
  const fileNonce = base64ToBytes(info.fileNonce);
69697
69877
  const decrypted = decryptFile(encryptedData, fileKey, fileNonce);
69698
- const filePath = join4(attachDir, info.filename);
69878
+ const filePath = this._safeAttachmentPath(attachDir, info.filename);
69699
69879
  await writeFile3(filePath, decrypted);
69700
69880
  console.log(`[SecureChannel] Attachment saved: ${filePath} (${decrypted.length} bytes)`);
69701
69881
  return { filePath, decrypted };
@@ -71116,6 +71296,7 @@ ${messageText}`;
71116
71296
  await this._persistState();
71117
71297
  }
71118
71298
  console.log(`[SecureChannel] Joined shared 1:1 MLS group for group ${conversationGroupId.slice(0, 8)} via Welcome (epoch=${mgr.epoch})`);
71299
+ await this._flushMlsHold(conversationGroupId);
71119
71300
  return;
71120
71301
  }
71121
71302
  if (conversationId) {
@@ -99229,7 +99410,7 @@ var init_index = __esm2({
99229
99410
  await init_skill_telemetry();
99230
99411
  await init_policy_enforcer();
99231
99412
  init_room_protocol();
99232
- VERSION = true ? "0.23.30" : "0.0.0-dev";
99413
+ VERSION = true ? "0.23.31" : "0.0.0-dev";
99233
99414
  }
99234
99415
  });
99235
99416
  await init_index();
@@ -135633,7 +135814,7 @@ async function main() {
135633
135814
  "[bridge] warning: passing the invite token on the command line is visible to other local users via 'ps'. Prefer: AV_INVITE_TOKEN=\u2026 npx @agentvault/claude-bridge"
135634
135815
  );
135635
135816
  }
135636
- logLine(`[bridge] version: ${true ? "0.8.3" : "dev"}`);
135817
+ logLine(`[bridge] version: ${true ? "0.8.4" : "dev"}`);
135637
135818
  logLine(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
135638
135819
  logLine(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
135639
135820
  if (cfg.armRoom) {
@@ -135665,7 +135846,7 @@ async function main() {
135665
135846
  // its default would render "@agentvault/agentvault@0.7.x" — the wrong
135666
135847
  // package name attached to the bridge's version number, which is worse
135667
135848
  // than either alone.
135668
- clientVersion: `@agentvault/claude-bridge@${true ? "0.8.3" : "dev"}`
135849
+ clientVersion: `@agentvault/claude-bridge@${true ? "0.8.4" : "dev"}`
135669
135850
  });
135670
135851
  const agentSystemPrompt = cfg.systemPrompt ?? `You are ${cfg.agentName}, an AI agent on AgentVault. You talk with your owner in 1:1 direct messages and collaborate with other agents in shared rooms. That is your identity \u2014 introduce yourself by that name and do not claim to be any other agent. To speak, call the say tool. In a 1:1 with your owner, reply to what they say. In a room you see every message \u2014 call say only when you have something worth adding, and otherwise stay silent. Keep messages concise.`;
135671
135852
  const deviceJwt = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/claude-bridge",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "type": "module",
5
5
  "description": "AgentVault Claude Bridge — daemon for bridging a Claude agent into secure E2E-encrypted AgentVault 1:1 direct messages and rooms.",
6
6
  "main": "dist/index.js",