@agentvault/claude-bridge 0.7.10 → 0.7.12

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 +198 -5
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -63791,6 +63791,35 @@ async function deleteMlsState(dataDir, groupId) {
63791
63791
  } catch {
63792
63792
  }
63793
63793
  }
63794
+ async function quarantineOrphanedMlsState(dataDir, isStillMember, opts = {}) {
63795
+ const max = opts.max ?? 20;
63796
+ const quarantined = [];
63797
+ let names;
63798
+ try {
63799
+ names = await readdir(dataDir);
63800
+ } catch {
63801
+ return quarantined;
63802
+ }
63803
+ for (const name of names) {
63804
+ if (quarantined.length >= max) break;
63805
+ const m22 = /^mls-(?!kp-pending-)([A-Za-z0-9_-]+)\.json$/.exec(name);
63806
+ if (!m22) continue;
63807
+ const groupId = m22[1];
63808
+ let member;
63809
+ try {
63810
+ member = await isStillMember(groupId);
63811
+ } catch {
63812
+ continue;
63813
+ }
63814
+ if (member === null || member === true) continue;
63815
+ try {
63816
+ await rename(join(dataDir, name), join(dataDir, `${name}.orphaned`));
63817
+ quarantined.push(groupId);
63818
+ } catch {
63819
+ }
63820
+ }
63821
+ return quarantined;
63822
+ }
63794
63823
  var SYNC_LOCK_STALE_MS;
63795
63824
  var pendingKpPath;
63796
63825
  var init_mls_state = __esm2({
@@ -64149,6 +64178,16 @@ var init_a2a_log_gate = __esm2({
64149
64178
  };
64150
64179
  }
64151
64180
  });
64181
+ function mlsMembershipFromStatus(status) {
64182
+ if (status === 200) return true;
64183
+ if (status === 403 || status === 404) return false;
64184
+ return null;
64185
+ }
64186
+ var init_mls_membership_probe = __esm2({
64187
+ "src/mls-membership-probe.ts"() {
64188
+ "use strict";
64189
+ }
64190
+ });
64152
64191
  var openclaw_compat_exports = {};
64153
64192
  __export2(openclaw_compat_exports, {
64154
64193
  AGENT_EVENT_CANDIDATES: () => AGENT_EVENT_CANDIDATES,
@@ -64709,6 +64748,7 @@ var init_channel = __esm2({
64709
64748
  await init_state();
64710
64749
  init_transport2();
64711
64750
  init_a2a_log_gate();
64751
+ init_mls_membership_probe();
64712
64752
  ROOM_AGENT_TYPES = /* @__PURE__ */ new Set([
64713
64753
  "message",
64714
64754
  "text",
@@ -64788,6 +64828,17 @@ var init_channel = __esm2({
64788
64828
  _pendingPollTimer = null;
64789
64829
  _syncMessageIds = null;
64790
64830
  _deliveryHeartbeat = null;
64831
+ /**
64832
+ * How often to re-check the KeyPackage pool against the server (#826).
64833
+ *
64834
+ * KeyPackages expire after 7 days and, before this, only a reconnect
64835
+ * republished them — so an agent that simply stayed up ran to zero usable
64836
+ * and silently stopped accepting new MLS 1:1 conversations while looking
64837
+ * perfectly healthy. 6h gives ~28 attempts inside one lifetime, so a run of
64838
+ * transient failures cannot age the pool out. Cost is one GET per interval.
64839
+ */
64840
+ static _KP_REPLENISH_INTERVAL_MS = 6 * 60 * 60 * 1e3;
64841
+ _kpReplenishTimer = null;
64791
64842
  _a2aReconcileTimer = null;
64792
64843
  _deliveryPulling = false;
64793
64844
  _drDeliveryPulling = false;
@@ -65752,7 +65803,7 @@ var init_channel = __esm2({
65752
65803
  */
65753
65804
  sendActivitySpan(spanData) {
65754
65805
  if (!this._ws || this._ws.readyState !== WebSocket2.OPEN) return;
65755
- const pluginVersion = true ? "0.23.19" : "0.0.0-dev";
65806
+ const pluginVersion = true ? "0.23.21" : "0.0.0-dev";
65756
65807
  const agentName = this.config.agentName ?? "Agent";
65757
65808
  const resource = {
65758
65809
  "service.name": "agentvault-agent",
@@ -66072,6 +66123,47 @@ var init_channel = __esm2({
66072
66123
  );
66073
66124
  this.emit("rooms_reconciled", { pruned: stale });
66074
66125
  }
66126
+ /**
66127
+ * #629 — quarantine MLS state for groups this device is no longer in.
66128
+ *
66129
+ * `_reconcileRoomsWithServer` above covers ROOMS: anything absent from
66130
+ * `GET /rooms` is pruned. It cannot see a group that was never a room — a 1:1
66131
+ * conversation group or an A2A channel group — so that state lingered with
66132
+ * nothing to reconcile it. Measured on loopita 2026-08-08: one group-state
66133
+ * file whose group had no server-side row at all.
66134
+ *
66135
+ * `quarantineOrphanedMlsState` was written for exactly this and shipped with
66136
+ * 11 passing tests, but nothing ever called it. This is that call site.
66137
+ *
66138
+ * Runs AFTER the room reconcile so rooms are already gone and this only sees
66139
+ * genuine non-room orphans.
66140
+ *
66141
+ * Quarantine RENAMES to `.orphaned` rather than deleting: if the verdict is
66142
+ * ever wrong the state is recoverable by hand, which deletion would not be.
66143
+ */
66144
+ async _quarantineOrphanedMlsGroups() {
66145
+ if (!this._deviceJwt) return;
66146
+ const quarantined = await quarantineOrphanedMlsState(
66147
+ this.config.dataDir,
66148
+ async (groupId) => {
66149
+ try {
66150
+ const res = await fetch(
66151
+ `${this.config.apiUrl}/api/v1/mls/groups/${encodeURIComponent(groupId)}`,
66152
+ { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
66153
+ );
66154
+ return mlsMembershipFromStatus(res.status);
66155
+ } catch {
66156
+ return null;
66157
+ }
66158
+ }
66159
+ );
66160
+ if (quarantined.length > 0) {
66161
+ console.log(
66162
+ `[SecureChannel] Quarantined ${quarantined.length} orphaned MLS group state file(s): ` + quarantined.map((g22) => g22.slice(0, 8)).join(", ")
66163
+ );
66164
+ this.emit("mls_state_quarantined", { groupIds: quarantined });
66165
+ }
66166
+ }
66075
66167
  /**
66076
66168
  * Handle an in-band `{event:"error"}` frame from the server.
66077
66169
  *
@@ -67596,6 +67688,8 @@ var init_channel = __esm2({
67596
67688
  this._setState("ready");
67597
67689
  void this._reconcileRoomsWithServer().catch(
67598
67690
  (err) => console.warn(`[SecureChannel] room reconcile failed (ignored): ${err instanceof Error ? err.message : String(err)}`)
67691
+ ).then(() => this._quarantineOrphanedMlsGroups()).catch(
67692
+ (err) => console.warn(`[SecureChannel] MLS orphan quarantine failed (ignored): ${err instanceof Error ? err.message : String(err)}`)
67599
67693
  );
67600
67694
  if (this.config.enableScanning) {
67601
67695
  this._scanEngine = new ScanEngine();
@@ -67619,7 +67713,7 @@ var init_channel = __esm2({
67619
67713
  agentVersion: this.config.agentVersion ?? "0.0.0",
67620
67714
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67621
67715
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67622
- pluginVersion: true ? "0.23.19" : "0.0.0-dev"
67716
+ pluginVersion: true ? "0.23.21" : "0.0.0-dev"
67623
67717
  });
67624
67718
  this._telemetryReporter.startAutoFlush(3e4);
67625
67719
  }
@@ -67628,6 +67722,7 @@ var init_channel = __esm2({
67628
67722
  console.warn("[SecureChannel] A2A channel sync on connect failed:", err);
67629
67723
  });
67630
67724
  await this._ensureKpPoolPublished();
67725
+ this._startKpReplenishTimer();
67631
67726
  this._pullDeliveryQueue().catch((err) => {
67632
67727
  console.warn("[SecureChannel] Initial delivery pull failed:", err);
67633
67728
  });
@@ -67942,7 +68037,7 @@ var init_channel = __esm2({
67942
68037
  agentVersion: this.config.agentVersion ?? "0.0.0",
67943
68038
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67944
68039
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67945
- pluginVersion: true ? "0.23.19" : "0.0.0-dev"
68040
+ pluginVersion: true ? "0.23.21" : "0.0.0-dev"
67946
68041
  });
67947
68042
  this._telemetryReporter.startAutoFlush(3e4);
67948
68043
  }
@@ -68285,6 +68380,7 @@ var init_channel = __esm2({
68285
68380
  this._deliveryHeartbeat = null;
68286
68381
  }
68287
68382
  this._stopA2aReconcileTimer();
68383
+ this._stopKpReplenishTimer();
68288
68384
  if (this._stopped) return;
68289
68385
  this._setState("disconnected");
68290
68386
  this._scheduleReconnect();
@@ -69678,6 +69774,103 @@ ${messageText}`;
69678
69774
  this._kpPoolFilling = false;
69679
69775
  }
69680
69776
  }
69777
+ /** Start the periodic KeyPackage replenish (#826). Idempotent. */
69778
+ _startKpReplenishTimer() {
69779
+ this._stopKpReplenishTimer();
69780
+ this._kpReplenishTimer = setInterval(() => {
69781
+ void this._replenishKpPoolFromServer();
69782
+ }, _SecureChannel._KP_REPLENISH_INTERVAL_MS);
69783
+ }
69784
+ _stopKpReplenishTimer() {
69785
+ if (this._kpReplenishTimer) {
69786
+ clearInterval(this._kpReplenishTimer);
69787
+ this._kpReplenishTimer = null;
69788
+ }
69789
+ }
69790
+ /**
69791
+ * The server's EXPIRY-AWARE usable KeyPackage count, or null when unknown.
69792
+ *
69793
+ * `count_unconsumed` filters `expires_at > utcnow()`, which is the only count
69794
+ * that reflects what a peer can actually fetch. Returns null (not 0) on any
69795
+ * failure so callers can tell "server says empty" from "we could not ask" —
69796
+ * guessing 0 here would publish a fresh pool on every transient 503.
69797
+ */
69798
+ async _fetchServerKpCount() {
69799
+ try {
69800
+ const res = await fetch(
69801
+ `${this.config.apiUrl}/api/v1/mls/key-packages/count?device_id=${this._deviceId}`,
69802
+ { headers: { Authorization: `Bearer ${this._deviceJwt}` } }
69803
+ );
69804
+ if (!res.ok) return null;
69805
+ const body = await res.json();
69806
+ const n22 = body?.count;
69807
+ return typeof n22 === "number" && Number.isFinite(n22) ? n22 : null;
69808
+ } catch {
69809
+ return null;
69810
+ }
69811
+ }
69812
+ /**
69813
+ * Top the pool up against the SERVER's count (#826).
69814
+ *
69815
+ * ⚠️ DO NOT replace this with a periodic call to `_ensureKpPoolPublished()`.
69816
+ * That function early-returns on `_pendingKpBundles.length >= _KP_POOL_TARGET`,
69817
+ * and that array holds PRIVATE bundles which never expire locally — so on an
69818
+ * agent that has been connected longer than the KeyPackage lifetime it is
69819
+ * still full while the server has zero usable. Measured on prod 2026-08-09:
69820
+ * cortina and eclaude were online and green with 10 unconsumed / 0 usable,
69821
+ * unable to accept any new MLS 1:1. A timer over the local guard would have
69822
+ * run forever and changed nothing.
69823
+ *
69824
+ * Publishes only the shortfall, and never throws — this runs on a timer and
69825
+ * must not be able to take the channel down.
69826
+ */
69827
+ async _replenishKpPoolFromServer() {
69828
+ try {
69829
+ if (this._kpPoolFilling) return;
69830
+ const usable = await this._fetchServerKpCount();
69831
+ if (usable === null) return;
69832
+ const shortfall = _SecureChannel._KP_POOL_TARGET - usable;
69833
+ if (shortfall <= 0) return;
69834
+ this._kpPoolFilling = true;
69835
+ try {
69836
+ const identity = new TextEncoder().encode(this._deviceId);
69837
+ let published = 0;
69838
+ for (let i2 = 0; i2 < shortfall; i2++) {
69839
+ const mlsMgr = new MLSGroupManager();
69840
+ const kp = await mlsMgr.generateKeyPackage(identity);
69841
+ const kpHex = Buffer.from(
69842
+ MLSGroupManager.serializeKeyPackage(kp.publicPackage)
69843
+ ).toString("hex");
69844
+ const res = await fetch(`${this.config.apiUrl}/api/v1/mls/key-packages`, {
69845
+ method: "POST",
69846
+ headers: {
69847
+ Authorization: `Bearer ${this._deviceJwt}`,
69848
+ "Content-Type": "application/json"
69849
+ },
69850
+ body: JSON.stringify({ key_package: kpHex })
69851
+ });
69852
+ if (!res.ok) {
69853
+ console.warn(
69854
+ `[SecureChannel] KeyPackage replenish rejected (status ${res.status}) after ${published}/${shortfall}`
69855
+ );
69856
+ break;
69857
+ }
69858
+ this._recordPublishedKp(kp);
69859
+ if (!this._mlsKeyPackage) this._mlsKeyPackage = kp;
69860
+ published++;
69861
+ }
69862
+ if (published > 0) {
69863
+ console.log(
69864
+ `[SecureChannel] MLS KeyPackage pool replenished (${published} new; server had ${usable}/${_SecureChannel._KP_POOL_TARGET} usable)`
69865
+ );
69866
+ }
69867
+ } finally {
69868
+ this._kpPoolFilling = false;
69869
+ }
69870
+ } catch (err) {
69871
+ console.warn("[SecureChannel] KeyPackage replenish failed:", err);
69872
+ }
69873
+ }
69681
69874
  async _handleMlsWelcome(data) {
69682
69875
  const groupId = data.group_id;
69683
69876
  const conversationId = data.conversation_id;
@@ -97831,7 +98024,7 @@ var init_index = __esm2({
97831
98024
  init_skill_invoker();
97832
98025
  await init_skill_telemetry();
97833
98026
  await init_policy_enforcer();
97834
- VERSION = true ? "0.23.19" : "0.0.0-dev";
98027
+ VERSION = true ? "0.23.21" : "0.0.0-dev";
97835
98028
  }
97836
98029
  });
97837
98030
  await init_index();
@@ -134185,7 +134378,7 @@ async function main() {
134185
134378
  "[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"
134186
134379
  );
134187
134380
  }
134188
- logLine(`[bridge] version: ${true ? "0.7.10" : "dev"}`);
134381
+ logLine(`[bridge] version: ${true ? "0.7.12" : "dev"}`);
134189
134382
  logLine(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
134190
134383
  logLine(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
134191
134384
  if (cfg.armRoom) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentvault/claude-bridge",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
4
4
  "type": "module",
5
5
  "description": "AgentVault Claude Bridge \u2014 daemon for bridging a Claude agent into secure E2E-encrypted AgentVault 1:1 direct messages and rooms.",
6
6
  "main": "dist/index.js",