@agentvault/claude-bridge 0.7.16 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9,36 +9,138 @@ var __export = (target, all) => {
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
11
 
12
+ // src/pending-invite.ts
13
+ import { existsSync, mkdirSync, chmodSync, readFileSync as readFileSync2, writeFileSync, rmSync } from "node:fs";
14
+ import { join as join5 } from "node:path";
15
+ function pendingInvitePath(dataDir) {
16
+ return join5(dataDir, PENDING_INVITE_FILE);
17
+ }
18
+ function writePendingInvite(dataDir, token) {
19
+ mkdirSync(dataDir, { recursive: true, mode: DIR_MODE2 });
20
+ chmodSync(dataDir, DIR_MODE2);
21
+ const p2 = pendingInvitePath(dataDir);
22
+ writeFileSync(p2, token, { mode: FILE_MODE2 });
23
+ chmodSync(p2, FILE_MODE2);
24
+ }
25
+ function readPendingInvite(dataDir) {
26
+ const p2 = pendingInvitePath(dataDir);
27
+ if (!existsSync(p2)) return void 0;
28
+ try {
29
+ const raw = readFileSync2(p2, "utf-8").trim();
30
+ return raw.length > 0 ? raw : void 0;
31
+ } catch {
32
+ return void 0;
33
+ }
34
+ }
35
+ function clearPendingInvite(dataDir) {
36
+ try {
37
+ rmSync(pendingInvitePath(dataDir), { force: true });
38
+ } catch {
39
+ }
40
+ }
41
+ function clearPendingInviteIfConsumed(dataDir, credsExist) {
42
+ if (!existsSync(pendingInvitePath(dataDir))) return false;
43
+ if (!credsExist(dataDir)) return false;
44
+ clearPendingInvite(dataDir);
45
+ return true;
46
+ }
47
+ var DIR_MODE2, FILE_MODE2, PENDING_INVITE_FILE;
48
+ var init_pending_invite = __esm({
49
+ "src/pending-invite.ts"() {
50
+ "use strict";
51
+ DIR_MODE2 = 448;
52
+ FILE_MODE2 = 384;
53
+ PENDING_INVITE_FILE = "pending-invite";
54
+ }
55
+ });
56
+
57
+ // src/log.ts
58
+ import { format } from "node:util";
59
+ function stamp(message) {
60
+ const ts2 = (/* @__PURE__ */ new Date()).toISOString();
61
+ return message.split("\n").map((line) => `${ts2} ${line}`).join("\n");
62
+ }
63
+ function logLine(...args) {
64
+ console.error(stamp(format(...args)));
65
+ }
66
+ function printLine(text) {
67
+ console.log(text);
68
+ }
69
+ var init_log = __esm({
70
+ "src/log.ts"() {
71
+ "use strict";
72
+ }
73
+ });
74
+
12
75
  // src/config.ts
13
- import { existsSync, readFileSync as readFileSync2, mkdirSync, chmodSync } from "node:fs";
14
- import { join as join5, resolve as resolve2, sep, dirname } from "node:path";
76
+ import { existsSync as existsSync2, readFileSync as readFileSync3, mkdirSync as mkdirSync2, chmodSync as chmodSync2 } from "node:fs";
77
+ import { join as join6, resolve as resolve2, sep, dirname } from "node:path";
78
+ import { tmpdir } from "node:os";
79
+ function isEphemeralEntrypoint(entrypoint) {
80
+ return entrypoint.includes("/_npx/") || entrypoint.includes("/dlx-") || entrypoint.includes("/.bun/install/cache/") || entrypoint.startsWith(tmpdir() + sep);
81
+ }
82
+ function assertInstallableEntrypoint(entrypoint) {
83
+ if (isEphemeralEntrypoint(entrypoint)) {
84
+ throw new Error(
85
+ `cannot install a background service from an ephemeral package cache (${entrypoint}) \u2014 it is deleted on cleanup and the service would silently die later. Install globally first:
86
+ npm i -g @agentvault/claude-bridge && agentvault-claude-bridge install`
87
+ );
88
+ }
89
+ }
15
90
  function hasRecoverableBackup(dataDir) {
16
91
  try {
17
- const parsed = JSON.parse(readFileSync2(join5(dataDir, BACKUP_FILE), "utf-8"));
92
+ const parsed = JSON.parse(readFileSync3(join6(dataDir, BACKUP_FILE), "utf-8"));
18
93
  return !!(parsed && parsed.deviceId && parsed.deviceJwt && parsed.sessions && Object.keys(parsed.sessions).length > 0);
19
94
  } catch {
20
95
  return false;
21
96
  }
22
97
  }
98
+ function readPersistedDeviceId(dataDir) {
99
+ for (const f7 of [...CRED_FILES, BACKUP_FILE]) {
100
+ try {
101
+ const parsed = JSON.parse(readFileSync3(join6(dataDir, f7), "utf-8"));
102
+ const id = parsed?.deviceId;
103
+ if (typeof id === "string" && id) return id;
104
+ } catch {
105
+ }
106
+ }
107
+ return null;
108
+ }
23
109
  function hasPersistedCreds(dataDir) {
24
- if (CRED_FILES.some((f7) => existsSync(join5(dataDir, f7)))) return true;
110
+ if (CRED_FILES.some((f7) => existsSync2(join6(dataDir, f7)))) return true;
25
111
  return hasRecoverableBackup(dataDir);
26
112
  }
27
113
  function slugify2(name) {
28
114
  return name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
29
115
  }
30
116
  function resolveDataDir(env) {
31
- if (env.AV_DATA_DIR) return { dataDir: env.AV_DATA_DIR, source: "explicit" };
32
- const base = join5(env.HOME ?? "", ".agentvault", "claude-room-bridge");
33
- const perAgent = join5(base, slugify2(env.AV_AGENT_NAME ?? "claude"));
34
- if (hasPersistedCreds(perAgent)) return { dataDir: perAgent, source: "per-agent" };
35
- if (hasPersistedCreds(base)) return { dataDir: base, source: "legacy" };
36
- return { dataDir: perAgent, source: "per-agent" };
117
+ const supplied = env.AV_AGENT_NAME?.trim();
118
+ const agentName = supplied || LEGACY_DEFAULT_AGENT_NAME;
119
+ const agentNameSource = supplied ? "supplied" : "defaulted";
120
+ if (env.AV_DATA_DIR)
121
+ return { dataDir: env.AV_DATA_DIR, source: "explicit", agentName, agentNameSource };
122
+ const base = join6(env.HOME ?? "", ".agentvault", "claude-room-bridge");
123
+ const perAgent = join6(base, slugify2(agentName));
124
+ if (hasPersistedCreds(perAgent))
125
+ return { dataDir: perAgent, source: "per-agent", agentName, agentNameSource };
126
+ if (hasPersistedCreds(base))
127
+ return { dataDir: base, source: "legacy", agentName, agentNameSource };
128
+ if (!supplied) {
129
+ throw new Error(
130
+ `AV_AGENT_NAME is required \u2014 it names WHICH agent this bridge runs as, and therefore where its credentials live. No credentials were found at ${perAgent} or ${base}, so there is no existing install to infer a name from. Re-run with AV_AGENT_NAME=<agent-name> set.`
131
+ );
132
+ }
133
+ return { dataDir: perAgent, source: "per-agent", agentName, agentNameSource };
37
134
  }
38
135
  function loadConfig(env, argv = []) {
39
- const { dataDir, source: dataDirSource } = resolveDataDir(env);
40
- const inviteToken = (argv[0] && !argv[0].startsWith("-") ? argv[0] : "") || env.AV_INVITE_TOKEN || "";
41
- const workspaceDir = env.AV_WORKSPACE_DIR || join5(dirname(dataDir), "workspaces", slugify2(env.AV_AGENT_NAME ?? "claude"));
136
+ const { dataDir, source: dataDirSource, agentName, agentNameSource } = resolveDataDir(env);
137
+ if (agentNameSource === "defaulted") {
138
+ logLine(
139
+ `[config] AV_AGENT_NAME was not set \u2014 running as "${agentName}" from the existing credentials at ${dataDir}. Set AV_AGENT_NAME to name this agent explicitly; a future install will require it.`
140
+ );
141
+ }
142
+ const inviteToken = (argv[0] && !argv[0].startsWith("-") ? argv[0] : "") || env.AV_INVITE_TOKEN || readPendingInvite(dataDir) || "";
143
+ const workspaceDir = env.AV_WORKSPACE_DIR || join6(dirname(dataDir), "workspaces", slugify2(agentName));
42
144
  const wsReal = resolve2(workspaceDir);
43
145
  const ddReal = resolve2(dataDir);
44
146
  if (wsReal === ddReal || wsReal.startsWith(ddReal + sep) || ddReal.startsWith(wsReal + sep)) {
@@ -47,8 +149,8 @@ function loadConfig(env, argv = []) {
47
149
  );
48
150
  }
49
151
  try {
50
- mkdirSync(workspaceDir, { recursive: true, mode: 448 });
51
- chmodSync(workspaceDir, 448);
152
+ mkdirSync2(workspaceDir, { recursive: true, mode: 448 });
153
+ chmodSync2(workspaceDir, 448);
52
154
  } catch (e7) {
53
155
  throw new Error(
54
156
  `Failed to create/secure the agent workspace at ${workspaceDir} (override with AV_WORKSPACE_DIR): ${e7.message}`
@@ -83,8 +185,10 @@ function loadConfig(env, argv = []) {
83
185
  apiUrl: env.AV_API_URL ?? "https://api.agentvault.chat",
84
186
  // Identity shown to the agent's own session. Set this to the agent's
85
187
  // AgentVault name via AV_AGENT_NAME (the native connect command passes it).
86
- // The neutral default avoids impersonating any specific named agent when unset.
87
- agentName: env.AV_AGENT_NAME ?? "Claude",
188
+ // Resolved in resolveDataDir so the name, the data dir and the workspace dir
189
+ // can never disagree about who this agent is (#921/#927).
190
+ agentName,
191
+ agentNameSource,
88
192
  roomFilter: env.AV_ROOM_ID || void 0,
89
193
  model: env.AV_CLAUDE_MODEL || void 0,
90
194
  systemPrompt: env.AV_SYSTEM_PROMPT || void 0,
@@ -94,12 +198,15 @@ function loadConfig(env, argv = []) {
94
198
  osIsolated
95
199
  };
96
200
  }
97
- var CRED_FILES, BACKUP_FILE;
201
+ var CRED_FILES, BACKUP_FILE, LEGACY_DEFAULT_AGENT_NAME;
98
202
  var init_config = __esm({
99
203
  "src/config.ts"() {
100
204
  "use strict";
205
+ init_pending_invite();
206
+ init_log();
101
207
  CRED_FILES = ["agentvault.json", "secure-channel.json"];
102
208
  BACKUP_FILE = "agentvault.json.bak";
209
+ LEGACY_DEFAULT_AGENT_NAME = "Claude";
103
210
  }
104
211
  });
105
212
 
@@ -227,6 +334,37 @@ ${envEntries}
227
334
  </plist>
228
335
  `;
229
336
  }
337
+ function parsePlistEnv(xml) {
338
+ const out = {};
339
+ const keyIdx = xml.indexOf("<key>EnvironmentVariables</key>");
340
+ if (keyIdx < 0) return out;
341
+ const open = xml.indexOf("<dict>", keyIdx);
342
+ if (open < 0) return out;
343
+ let depth = 0;
344
+ let i2 = open;
345
+ let close = -1;
346
+ const tag = /<(\/?)dict>/g;
347
+ tag.lastIndex = open;
348
+ let m6;
349
+ while (m6 = tag.exec(xml)) {
350
+ depth += m6[1] ? -1 : 1;
351
+ if (depth === 0) {
352
+ close = m6.index;
353
+ break;
354
+ }
355
+ i2 = m6.index;
356
+ }
357
+ void i2;
358
+ if (close < 0) return out;
359
+ const body = xml.slice(open, close);
360
+ const pair = /<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g;
361
+ let p2;
362
+ while (p2 = pair.exec(body)) out[unesc(p2[1])] = unesc(p2[2]);
363
+ return out;
364
+ }
365
+ function unesc(s10) {
366
+ return s10.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
367
+ }
230
368
  var LaunchdBackend;
231
369
  var init_launchd = __esm({
232
370
  "src/service/launchd.ts"() {
@@ -281,6 +419,26 @@ var init_launchd = __esm({
281
419
  const r7 = this.deps.exec("launchctl", ["print", `${this.domain()}/${label}`]);
282
420
  return { loaded: r7.code === 0, detail: r7.stdout };
283
421
  }
422
+ /** #913 item 3 — every installed service whose label starts with `prefix`.
423
+ *
424
+ * Reads the plist directory rather than `launchctl list`, because an ORPHAN
425
+ * is typically installed-but-not-loaded: the revoked device exits 0 and
426
+ * KeepAlive leaves it down. `launchctl list` would not show the very thing
427
+ * doctor exists to find. */
428
+ list(prefix) {
429
+ const dir = join9(this.deps.home, "Library", "LaunchAgents");
430
+ const out = [];
431
+ for (const name of this.deps.readdir(dir)) {
432
+ if (!name.startsWith(prefix) || !name.endsWith(".plist")) continue;
433
+ const path2 = join9(dir, name);
434
+ out.push({
435
+ label: name.slice(0, -".plist".length),
436
+ path: path2,
437
+ env: parsePlistEnv(this.deps.readFile(path2))
438
+ });
439
+ }
440
+ return out.sort((a2, b5) => a2.label.localeCompare(b5.label));
441
+ }
284
442
  };
285
443
  }
286
444
  });
@@ -312,6 +470,24 @@ StandardError=append:${spec.logErr}
312
470
  WantedBy=default.target
313
471
  `;
314
472
  }
473
+ function parseUnitEnv(text) {
474
+ const out = {};
475
+ for (const line of text.split("\n")) {
476
+ const m6 = /^Environment="([\s\S]*)"\s*$/.exec(line.trim());
477
+ if (!m6) continue;
478
+ const eq2 = unq(m6[1]);
479
+ const at = eq2.indexOf("=");
480
+ if (at <= 0) continue;
481
+ out[eq2.slice(0, at)] = eq2.slice(at + 1);
482
+ }
483
+ return out;
484
+ }
485
+ function unq(s10) {
486
+ return s10.replace(/%%/g, "%").replace(
487
+ /\\([\s\S])/g,
488
+ (_4, c4) => c4 === "n" ? "\n" : c4 === "r" ? "\r" : c4 === "t" ? " " : c4
489
+ );
490
+ }
315
491
  var SystemdBackend;
316
492
  var init_systemd = __esm({
317
493
  "src/service/systemd.ts"() {
@@ -353,13 +529,33 @@ var init_systemd = __esm({
353
529
  const active = this.deps.exec("systemctl", ["--user", "is-active", `${label}.service`]);
354
530
  return { loaded: enabled.code === 0, detail: `${enabled.stdout.trim() || "?"} / ${active.stdout.trim() || "?"}` };
355
531
  }
532
+ /** #913 item 3 — every installed unit whose label starts with `prefix`.
533
+ *
534
+ * Reads the unit directory rather than `systemctl list-units`, because an
535
+ * ORPHAN is installed-but-inactive: the revoked device exits 0, which
536
+ * SuccessExitStatus=0 treats as success, so it is never restarted. A
537
+ * running-units listing would not show the thing doctor exists to find. */
538
+ list(prefix) {
539
+ const dir = join10(this.deps.home, ".config", "systemd", "user");
540
+ const out = [];
541
+ for (const name of this.deps.readdir(dir)) {
542
+ if (!name.startsWith(prefix) || !name.endsWith(".service")) continue;
543
+ const path2 = join10(dir, name);
544
+ out.push({
545
+ label: name.slice(0, -".service".length),
546
+ path: path2,
547
+ env: parseUnitEnv(this.deps.readFile(path2))
548
+ });
549
+ }
550
+ return out.sort((a2, b5) => a2.label.localeCompare(b5.label));
551
+ }
356
552
  };
357
553
  }
358
554
  });
359
555
 
360
556
  // src/service/backend.ts
361
557
  import { spawnSync } from "node:child_process";
362
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3, rmSync as rmSync4, existsSync as existsSync4, chmodSync as chmodSync3 } from "node:fs";
558
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3, rmSync as rmSync4, existsSync as existsSync4, chmodSync as chmodSync4, readdirSync as readdirSync3, readFileSync as readFileSync6 } from "node:fs";
363
559
  import { userInfo } from "node:os";
364
560
  function defaultDeps() {
365
561
  return {
@@ -372,9 +568,23 @@ function defaultDeps() {
372
568
  },
373
569
  writeFile: (p2, d10) => writeFileSync3(p2, d10),
374
570
  mkdir: (p2) => mkdirSync5(p2, { recursive: true }),
375
- chmod: (p2, m6) => chmodSync3(p2, m6),
571
+ chmod: (p2, m6) => chmodSync4(p2, m6),
376
572
  rm: (p2) => rmSync4(p2, { force: true }),
377
- exists: (p2) => existsSync4(p2)
573
+ exists: (p2) => existsSync4(p2),
574
+ readdir: (p2) => {
575
+ try {
576
+ return readdirSync3(p2);
577
+ } catch {
578
+ return [];
579
+ }
580
+ },
581
+ readFile: (p2) => {
582
+ try {
583
+ return readFileSync6(p2, "utf8");
584
+ } catch {
585
+ return "";
586
+ }
587
+ }
378
588
  };
379
589
  }
380
590
  function selectBackend(platform, deps) {
@@ -397,7 +607,8 @@ var init_backend = __esm({
397
607
  // src/service/subcommand.ts
398
608
  var subcommand_exports = {};
399
609
  __export(subcommand_exports, {
400
- maybeRunServiceSubcommand: () => maybeRunServiceSubcommand
610
+ maybeRunServiceSubcommand: () => maybeRunServiceSubcommand,
611
+ resolveLabel: () => resolveLabel
401
612
  });
402
613
  function requireAgentName(env) {
403
614
  const name = env.AV_AGENT_NAME?.trim();
@@ -429,7 +640,9 @@ function maybeRunServiceSubcommand(argv, env, deps) {
429
640
  const looksLikeFlag = cmd.startsWith("-");
430
641
  if (!looksLikeToken && !looksLikeFlag) {
431
642
  throw new Error(
432
- `unknown subcommand: ${cmd} \u2014 expected one of ${[...SERVICE_CMDS].join(", ")}, an invite token, or no argument at all`
643
+ // "doctor" is dispatched earlier, in index.ts, so it never reaches here.
644
+ // Named anyway: a customer who typed `doctr` should see it offered.
645
+ `unknown subcommand: ${cmd} \u2014 expected one of ${[...SERVICE_CMDS, "doctor"].join(", ")}, an invite token, or no argument at all`
433
646
  );
434
647
  }
435
648
  return false;
@@ -440,11 +653,11 @@ function maybeRunServiceSubcommand(argv, env, deps) {
440
653
  try {
441
654
  if (cmd === "install") {
442
655
  requireAgentName(env);
656
+ assertInstallableEntrypoint(entrypoint);
443
657
  const cfg = loadConfig(env, []);
444
- if (!hasPersistedCreds(cfg.dataDir)) {
445
- throw new Error(
446
- "no credentials found \u2014 enroll first by running the bridge once (AV_INVITE_TOKEN=\u2026 npx @agentvault/claude-bridge), then re-run install"
447
- );
658
+ const firstBoot = !hasPersistedCreds(cfg.dataDir) && !!cfg.inviteToken;
659
+ if (firstBoot) {
660
+ writePendingInvite(cfg.dataDir, cfg.inviteToken);
448
661
  }
449
662
  const spec = buildServiceSpec(cfg, {
450
663
  entrypoint,
@@ -455,7 +668,11 @@ function maybeRunServiceSubcommand(argv, env, deps) {
455
668
  path: env.PATH ?? ""
456
669
  });
457
670
  backend.install(spec);
458
- log(`installed service ${spec.label} \u2014 logs: ${spec.logOut}`);
671
+ log(`installed service ${spec.label} \u2014 logs: ${spec.logErr}`);
672
+ if (firstBoot) {
673
+ log("now APPROVE the device in AgentVault \u2014 the service is waiting to enrol");
674
+ log("if it does not connect within a minute, the log above says why");
675
+ }
459
676
  log(
460
677
  `check it with: AV_AGENT_NAME=${cfg.agentName} agentvault-claude-bridge status`
461
678
  );
@@ -487,14 +704,324 @@ var init_subcommand = __esm({
487
704
  "use strict";
488
705
  init_config();
489
706
  init_spec();
707
+ init_pending_invite();
490
708
  init_backend();
491
709
  SERVICE_CMDS = /* @__PURE__ */ new Set(["install", "uninstall", "restart", "status", "logs"]);
492
710
  }
493
711
  });
494
712
 
713
+ // src/service/doctor.ts
714
+ import { basename as basename2 } from "node:path";
715
+ async function buildDoctorReport(d10) {
716
+ const installed = d10.labelPrefixes.flatMap((p2) => d10.backend.list(p2));
717
+ const services = [];
718
+ for (const s10 of installed) services.push(await inspect(s10, d10));
719
+ const claimed = new Set(installed.map((s10) => s10.env.AV_DATA_DIR).filter(Boolean));
720
+ const unsupervised = [];
721
+ for (const dataDir of d10.credDirs()) {
722
+ if (claimed.has(dataDir)) continue;
723
+ unsupervised.push(await inspectUnsupervised(dataDir, d10));
724
+ }
725
+ return {
726
+ labelPrefixes: d10.labelPrefixes,
727
+ services,
728
+ unsupervised,
729
+ notChecked: d10.otherLabels()
730
+ };
731
+ }
732
+ async function inspect(s10, d10) {
733
+ const agentName = s10.env.AV_AGENT_NAME ?? "";
734
+ const dataDir = s10.env.AV_DATA_DIR ?? "";
735
+ let loaded = false;
736
+ try {
737
+ loaded = d10.backend.status(s10.label).loaded;
738
+ } catch {
739
+ }
740
+ const base = { label: s10.label, agentName, dataDir, loaded, deviceId: "" };
741
+ if (!dataDir) {
742
+ return {
743
+ ...base,
744
+ verdict: "WARN",
745
+ detail: "the service definition bakes no AV_DATA_DIR, so doctor cannot find its credentials and cannot check its device"
746
+ };
747
+ }
748
+ const creds = d10.readCreds(dataDir);
749
+ if (!creds?.deviceId) {
750
+ return {
751
+ ...base,
752
+ verdict: "WARN",
753
+ detail: `no credentials at ${dataDir} \u2014 installed, but this agent never enrolled`
754
+ };
755
+ }
756
+ const deviceId = creds.deviceId;
757
+ let probe;
758
+ try {
759
+ probe = await d10.fetchStatus(d10.apiUrl, deviceId);
760
+ } catch (e7) {
761
+ return { ...base, deviceId, verdict: "SKIP", detail: `could not check the device: ${e7.message}` };
762
+ }
763
+ if (probe.inconclusive) {
764
+ return { ...base, deviceId, verdict: "SKIP", detail: `could not check the device: ${probe.inconclusive}` };
765
+ }
766
+ if (probe.gone) {
767
+ return {
768
+ ...base,
769
+ deviceId,
770
+ verdict: "FAIL",
771
+ detail: "the device no longer exists \u2014 this service is an orphan",
772
+ fix: d10.uninstallCommand(agentName)
773
+ };
774
+ }
775
+ switch (probe.status) {
776
+ case "ACTIVE":
777
+ return { ...base, deviceId, verdict: "PASS", detail: "device ACTIVE" };
778
+ case "PENDING":
779
+ return {
780
+ ...base,
781
+ deviceId,
782
+ verdict: "WARN",
783
+ detail: "device PENDING \u2014 enrolled, but never approved in AgentVault"
784
+ };
785
+ case "REVOKED":
786
+ return {
787
+ ...base,
788
+ deviceId,
789
+ verdict: "FAIL",
790
+ detail: "the device was REVOKED \u2014 this service is an orphan",
791
+ fix: d10.uninstallCommand(agentName)
792
+ };
793
+ default:
794
+ return {
795
+ ...base,
796
+ deviceId,
797
+ verdict: "SKIP",
798
+ detail: `the server reported a status this build does not recognise (${probe.status || "none"})`
799
+ };
800
+ }
801
+ }
802
+ async function inspectUnsupervised(dataDir, d10) {
803
+ const agentName = basename2(dataDir);
804
+ const base = { dataDir, agentName, deviceId: "" };
805
+ const deviceId = d10.readCreds(dataDir)?.deviceId;
806
+ if (!deviceId) {
807
+ return { ...base, verdict: "WARN", detail: "credentials present but they hold no device id" };
808
+ }
809
+ let probe;
810
+ try {
811
+ probe = await d10.fetchStatus(d10.apiUrl, deviceId);
812
+ } catch (e7) {
813
+ return { ...base, deviceId, verdict: "SKIP", detail: `could not check the device: ${e7.message}` };
814
+ }
815
+ if (probe.inconclusive) {
816
+ return { ...base, deviceId, verdict: "SKIP", detail: `could not check the device: ${probe.inconclusive}` };
817
+ }
818
+ if (probe.gone) {
819
+ return {
820
+ ...base,
821
+ deviceId,
822
+ verdict: "WARN",
823
+ detail: "the device no longer exists \u2014 these are leftover credentials for a deleted agent"
824
+ };
825
+ }
826
+ if (probe.status === "ACTIVE") {
827
+ return {
828
+ ...base,
829
+ deviceId,
830
+ verdict: "WARN",
831
+ detail: "the device is ACTIVE but nothing supervises it \u2014 this agent is not running",
832
+ fix: d10.installCommand(agentName)
833
+ };
834
+ }
835
+ if (probe.status === "PENDING") {
836
+ return {
837
+ ...base,
838
+ deviceId,
839
+ verdict: "WARN",
840
+ detail: "device PENDING \u2014 enrolled, but never approved in AgentVault"
841
+ };
842
+ }
843
+ if (probe.status === "REVOKED") {
844
+ return {
845
+ ...base,
846
+ deviceId,
847
+ verdict: "WARN",
848
+ detail: "the device was REVOKED \u2014 these are leftover credentials"
849
+ };
850
+ }
851
+ return {
852
+ ...base,
853
+ deviceId,
854
+ verdict: "SKIP",
855
+ detail: `the server reported a status this build does not recognise (${probe.status || "none"})`
856
+ };
857
+ }
858
+ function formatDoctorReport(r7) {
859
+ const out = [];
860
+ out.push(`Services installed for this bridge (${r7.labelPrefixes.map((p2) => p2 + "*").join(", ")})`);
861
+ if (r7.services.length === 0) {
862
+ out.push(` No services installed for this bridge on this machine.`);
863
+ }
864
+ for (const s10 of r7.services) {
865
+ out.push(
866
+ ` ${s10.verdict.padEnd(PAD)}${s10.label} [${s10.agentName || "unnamed"}] ${s10.loaded ? "loaded" : "not loaded"} \u2014 ${s10.detail}`
867
+ );
868
+ if (s10.fix) out.push(` ${"".padEnd(PAD)}fix: ${s10.fix}`);
869
+ }
870
+ if (r7.unsupervised.length) {
871
+ out.push("");
872
+ out.push("Credentials on disk that no installed service points at");
873
+ for (const u2 of r7.unsupervised) {
874
+ out.push(` ${u2.verdict.padEnd(PAD)}${u2.agentName} \u2014 ${u2.detail}`);
875
+ out.push(` ${"".padEnd(PAD)}${u2.dataDir}`);
876
+ if (u2.fix) out.push(` ${"".padEnd(PAD)}fix: ${u2.fix}`);
877
+ }
878
+ }
879
+ if (r7.notChecked.length) {
880
+ out.push("");
881
+ out.push("Found on this machine, NOT checked by this command");
882
+ for (const l10 of r7.notChecked) out.push(` ${l10}`);
883
+ out.push(
884
+ " These belong to other AgentVault agent families. This command can only read the credentials of its own, so it says nothing about their health."
885
+ );
886
+ }
887
+ const counts = { PASS: 0, WARN: 0, FAIL: 0, SKIP: 0 };
888
+ for (const s10 of r7.services) counts[s10.verdict]++;
889
+ for (const u2 of r7.unsupervised) counts[u2.verdict]++;
890
+ out.push("");
891
+ out.push(
892
+ `Summary: ${counts.PASS} PASS, ${counts.WARN} WARN, ${counts.FAIL} FAIL, ${counts.SKIP} SKIP` + (counts.SKIP ? " (SKIP = could not check, NOT a problem found)" : "")
893
+ );
894
+ if (counts.FAIL) {
895
+ out.push(
896
+ "`uninstall` removes the SERVICE only \u2014 your credentials stay on disk, so a mistake here costs a re-install, not an identity."
897
+ );
898
+ }
899
+ return out;
900
+ }
901
+ var PAD;
902
+ var init_doctor = __esm({
903
+ "src/service/doctor.ts"() {
904
+ "use strict";
905
+ PAD = 6;
906
+ }
907
+ });
908
+
909
+ // src/service/doctor-cli.ts
910
+ var doctor_cli_exports = {};
911
+ __export(doctor_cli_exports, {
912
+ fetchDeviceStatus: () => fetchDeviceStatus,
913
+ maybeRunDoctorSubcommand: () => maybeRunDoctorSubcommand,
914
+ realDoctorDeps: () => realDoctorDeps
915
+ });
916
+ import { join as join12 } from "node:path";
917
+ import { readdirSync as readdirSync5 } from "node:fs";
918
+ async function fetchDeviceStatus(apiUrl, deviceId, doFetch = fetch) {
919
+ const res = await doFetch(`${apiUrl}/api/v1/devices/${deviceId}/status`, {
920
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
921
+ });
922
+ if (res.status === 404) {
923
+ try {
924
+ await res.json();
925
+ } catch {
926
+ return {
927
+ inconclusive: `${apiUrl} answered 404 with a non-JSON body \u2014 check AV_API_URL points at the API`
928
+ };
929
+ }
930
+ return { gone: true };
931
+ }
932
+ if (res.status === 429) return { inconclusive: "rate limited by the API (429)" };
933
+ if (!res.ok) return { inconclusive: `the API answered ${res.status}` };
934
+ let body;
935
+ try {
936
+ body = await res.json();
937
+ } catch {
938
+ return { inconclusive: `${apiUrl} answered 200 with a non-JSON body \u2014 check AV_API_URL` };
939
+ }
940
+ return { status: body?.status };
941
+ }
942
+ function listCredDirs(base) {
943
+ const out = [];
944
+ if (hasPersistedCreds(base)) out.push(base);
945
+ let names;
946
+ try {
947
+ names = readdirSync5(base);
948
+ } catch {
949
+ return out;
950
+ }
951
+ for (const name of names) {
952
+ const dir = join12(base, name);
953
+ if (hasPersistedCreds(dir)) out.push(dir);
954
+ }
955
+ return out;
956
+ }
957
+ function otherFamilyLabels(backend) {
958
+ const out = [];
959
+ for (const p2 of OTHER_FAMILY_PREFIXES) {
960
+ try {
961
+ for (const s10 of backend.list(p2)) out.push(s10.label);
962
+ } catch {
963
+ }
964
+ }
965
+ return out.sort();
966
+ }
967
+ function realDoctorDeps(env) {
968
+ const backend = selectBackend(process.platform);
969
+ const base = join12(env.HOME ?? "", ".agentvault", "claude-room-bridge");
970
+ return {
971
+ backend,
972
+ labelPrefixes: [LABEL_PREFIX, LEGACY_LABEL_PREFIX],
973
+ base,
974
+ // Same default as config.ts. Read from the env directly rather than through
975
+ // loadConfig, which now REFUSES without AV_AGENT_NAME (#921) — and doctor's
976
+ // whole job is to run when you do not know which agents exist.
977
+ apiUrl: env.AV_API_URL ?? "https://api.agentvault.chat",
978
+ uninstallCommand: (n10) => `AV_AGENT_NAME=${n10 || "<agent-name>"} agentvault-claude-bridge uninstall`,
979
+ installCommand: (n10) => `AV_AGENT_NAME=${n10 || "<agent-name>"} agentvault-claude-bridge install`,
980
+ readCreds: (dir) => {
981
+ const deviceId = readPersistedDeviceId(dir);
982
+ return deviceId ? { deviceId } : null;
983
+ },
984
+ credDirs: () => listCredDirs(base),
985
+ fetchStatus: (apiUrl, deviceId) => fetchDeviceStatus(apiUrl, deviceId),
986
+ otherLabels: () => otherFamilyLabels(backend)
987
+ };
988
+ }
989
+ async function maybeRunDoctorSubcommand(argv, opts = {}) {
990
+ if (argv[2] !== "doctor") return false;
991
+ const log = opts.log ?? printLine;
992
+ try {
993
+ const report = await buildDoctorReport(opts.deps ?? realDoctorDeps(opts.env ?? process.env));
994
+ for (const line of formatDoctorReport(report)) log(line);
995
+ if (report.services.some((s10) => s10.verdict === "FAIL")) process.exitCode = 1;
996
+ } catch (e7) {
997
+ log(`doctor could not run: ${e7.message}`);
998
+ process.exitCode = 1;
999
+ }
1000
+ return true;
1001
+ }
1002
+ var LABEL_PREFIX, LEGACY_LABEL_PREFIX, OTHER_FAMILY_PREFIXES, PROBE_TIMEOUT_MS;
1003
+ var init_doctor_cli = __esm({
1004
+ "src/service/doctor-cli.ts"() {
1005
+ "use strict";
1006
+ init_backend();
1007
+ init_config();
1008
+ init_log();
1009
+ init_doctor();
1010
+ LABEL_PREFIX = "dev.agentvault.bridge-";
1011
+ LEGACY_LABEL_PREFIX = "com.agentvault.claude-bridge.";
1012
+ OTHER_FAMILY_PREFIXES = [
1013
+ "dev.agentvault.grok-",
1014
+ // grok bridge
1015
+ "ai.hermes.gateway"
1016
+ // hermes, one service per profile
1017
+ ];
1018
+ PROBE_TIMEOUT_MS = 8e3;
1019
+ }
1020
+ });
1021
+
495
1022
  // src/index.ts
496
- import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync6 } from "node:fs";
497
- import { join as join11 } from "node:path";
1023
+ import { appendFileSync as appendFileSync2, mkdirSync as mkdirSync7 } from "node:fs";
1024
+ import { join as join13 } from "node:path";
498
1025
 
499
1026
  // ../plugin/dist/index.js
500
1027
  import * as nc from "node:crypto";
@@ -45379,6 +45906,11 @@ var init_ratchet = __esm2({
45379
45906
  return { header, headerSignature, ciphertext, nonce };
45380
45907
  }
45381
45908
  decrypt(message) {
45909
+ if (this.state.peerIdentityPublicKey) {
45910
+ if (!verifyHeaderSignature(message.header, message.headerSignature, this.state.peerIdentityPublicKey)) {
45911
+ throw new Error("Header signature verification failed");
45912
+ }
45913
+ }
45382
45914
  const isV2 = message.envelopeVersion === "2.0.0" && message.encryptedHeader != null && message.headerNonce != null;
45383
45915
  const skippedResult = this.trySkippedKeys(message, isV2);
45384
45916
  if (skippedResult !== null) {
@@ -45392,14 +45924,14 @@ var init_ratchet = __esm2({
45392
45924
  if (message.header.messageNumber === 0) {
45393
45925
  try {
45394
45926
  const { messageKey: testKey, headerKey: testHeaderKey, nextChainKey: nextChainKey2 } = kdfChainKey(this.state.rootKey);
45395
- let ad2;
45927
+ let ad;
45396
45928
  if (isV2) {
45397
45929
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, testHeaderKey);
45398
- ad2 = message.encryptedHeader;
45930
+ ad = message.encryptedHeader;
45399
45931
  } else {
45400
- ad2 = serializeHeader(message.header);
45932
+ ad = serializeHeader(message.header);
45401
45933
  }
45402
- const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad2, message.nonce, testKey);
45934
+ const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, testKey);
45403
45935
  this.state.dhReceivingPublicKey = message.header.dhPublicKey;
45404
45936
  this.state.receivingChain = {
45405
45937
  chainKey: nextChainKey2,
@@ -45419,13 +45951,7 @@ var init_ratchet = __esm2({
45419
45951
  this.skipMessages(this.state.receivingChain, message.header.messageNumber, message.header.dhPublicKey, isV2);
45420
45952
  const chain = this.state.receivingChain;
45421
45953
  const { nextChainKey, messageKey, headerKey } = kdfChainKey(chain.chainKey);
45422
- chain.chainKey = nextChainKey;
45423
- chain.messageNumber++;
45424
- if (this.state.peerIdentityPublicKey) {
45425
- if (!verifyHeaderSignature(message.header, message.headerSignature, this.state.peerIdentityPublicKey)) {
45426
- throw new Error("Header signature verification failed");
45427
- }
45428
- }
45954
+ let plaintextBytes;
45429
45955
  if (isV2) {
45430
45956
  try {
45431
45957
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, headerKey);
@@ -45433,19 +45959,21 @@ var init_ratchet = __esm2({
45433
45959
  throw new Error("V2 header decryption failed");
45434
45960
  }
45435
45961
  try {
45436
- const plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, message.encryptedHeader, message.nonce, messageKey);
45437
- return libsodium_wrappers_default.to_string(plaintextBytes);
45962
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, message.encryptedHeader, message.nonce, messageKey);
45438
45963
  } catch {
45439
45964
  throw new Error("V2 decryption failed: ciphertext tampered or wrong key");
45440
45965
  }
45966
+ } else {
45967
+ const ad = serializeHeader(message.header);
45968
+ try {
45969
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, messageKey);
45970
+ } catch {
45971
+ throw new Error("Decryption failed: ciphertext tampered or wrong key");
45972
+ }
45441
45973
  }
45442
- const ad = serializeHeader(message.header);
45443
- try {
45444
- const plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, messageKey);
45445
- return libsodium_wrappers_default.to_string(plaintextBytes);
45446
- } catch {
45447
- throw new Error("Decryption failed: ciphertext tampered or wrong key");
45448
- }
45974
+ chain.chainKey = nextChainKey;
45975
+ chain.messageNumber++;
45976
+ return libsodium_wrappers_default.to_string(plaintextBytes);
45449
45977
  }
45450
45978
  dhRatchetReceive(theirDhPublic) {
45451
45979
  this.state.previousSendingChainLength = this.state.sendingChain?.messageNumber ?? 0;
@@ -63619,6 +64147,23 @@ var init_mls_delivery_order = __esm2({
63619
64147
  "use strict";
63620
64148
  }
63621
64149
  });
64150
+ function noteObservedEpoch(current, seen) {
64151
+ const n22 = Number(seen);
64152
+ if (!Number.isInteger(n22) || n22 < 0)
64153
+ return current;
64154
+ return n22 > current ? n22 : current;
64155
+ }
64156
+ function needsPreSendResync(localEpoch, observedEpoch) {
64157
+ if (!Number.isInteger(localEpoch) || !Number.isInteger(observedEpoch)) {
64158
+ return false;
64159
+ }
64160
+ return observedEpoch > localEpoch;
64161
+ }
64162
+ var init_mls_presend_epoch = __esm2({
64163
+ "../crypto/dist/mls-presend-epoch.js"() {
64164
+ "use strict";
64165
+ }
64166
+ });
63622
64167
  var dist_exports = {};
63623
64168
  __export2(dist_exports, {
63624
64169
  AV_CREDENTIAL_CONTEXT: () => AV_CREDENTIAL_CONTEXT,
@@ -63688,7 +64233,9 @@ __export2(dist_exports, {
63688
64233
  hexTransportToEncryptedMessage: () => hexTransportToEncryptedMessage,
63689
64234
  issueCredential: () => issueCredential,
63690
64235
  multibaseToPublicKey: () => multibaseToPublicKey,
64236
+ needsPreSendResync: () => needsPreSendResync,
63691
64237
  normalizeBackupCode: () => normalizeBackupCode,
64238
+ noteObservedEpoch: () => noteObservedEpoch,
63692
64239
  orderDeliveryBatch: () => orderDeliveryBatch,
63693
64240
  parseTraceparent: () => parseTraceparent,
63694
64241
  performX3DH: () => performX3DH,
@@ -63728,6 +64275,7 @@ var init_dist = __esm2({
63728
64275
  init_mls_group();
63729
64276
  await init_owner_sync();
63730
64277
  init_mls_delivery_order();
64278
+ init_mls_presend_epoch();
63731
64279
  }
63732
64280
  });
63733
64281
  async function ensureSecureDir(dir) {
@@ -63920,7 +64468,7 @@ var init_mls_kp_pool = __esm2({
63920
64468
  }
63921
64469
  });
63922
64470
  function ownIdentity() {
63923
- const v22 = true ? "0.23.23" : FALLBACK;
64471
+ const v22 = true ? "0.23.27" : FALLBACK;
63924
64472
  return `${PACKAGE}@${v22}`;
63925
64473
  }
63926
64474
  function buildClientVersion(override) {
@@ -64836,6 +65384,7 @@ var init_channel = __esm2({
64836
65384
  await init_libsodium_wrappers();
64837
65385
  await init_dist();
64838
65386
  await init_dist();
65387
+ init_mls_presend_epoch();
64839
65388
  init_mls_state();
64840
65389
  await init_mls_kp_pool();
64841
65390
  init_client_version();
@@ -64949,6 +65498,17 @@ var init_channel = __esm2({
64949
65498
  * self-heal rather than be logged and dropped. */
64950
65499
  _mlsCommitFailCounts = /* @__PURE__ */ new Map();
64951
65500
  static MAX_MLS_DECRYPT_FAILS = 3;
65501
+ /**
65502
+ * #1084: consecutive resyncs that found NOTHING to apply, per group.
65503
+ *
65504
+ * "No new commits" means the server holds no commit past our epoch — we are
65505
+ * NOT BEHIND. Treating that as corruption and re-keying is what drove the
65506
+ * epoch churn (see `_resyncOnDivergence`). But a state that is genuinely
65507
+ * broken while its epoch counter happens to match must still recover, so the
65508
+ * suppression is bounded rather than absolute.
65509
+ */
65510
+ _resyncNoOpStrikes = /* @__PURE__ */ new Map();
65511
+ static MAX_RESYNC_NOOPS = 3;
64952
65512
  /** Cached MLS KeyPackage bundle for this device (regenerated on each connect). */
64953
65513
  _mlsKeyPackage = null;
64954
65514
  /** Pending KeyPackage bundle from request-Welcome flow (used by _handleMlsWelcome). */
@@ -64986,6 +65546,13 @@ var init_channel = __esm2({
64986
65546
  _kpPoolFilling = false;
64987
65547
  /** Buffer for MLS commits received before Welcome (keyed by groupId, sorted by epoch). */
64988
65548
  _pendingMlsCommits = /* @__PURE__ */ new Map();
65549
+ /**
65550
+ * #1013: the highest group epoch any inbound frame has reported, per MLS
65551
+ * group id. Every inbound frame carries the epoch the group was at when it
65552
+ * was produced, so each is a LOWER BOUND on the group's current epoch — the
65553
+ * hint is monotonic and only ever rises.
65554
+ */
65555
+ _observedGroupEpochs = /* @__PURE__ */ new Map();
64989
65556
  /** In-memory credential store for renter-provided credentials (never persisted). */
64990
65557
  _credentialStore = new CredentialStore();
64991
65558
  /** Rooms whose roster has been refreshed from the backend at least once in
@@ -65670,16 +66237,44 @@ var init_channel = __esm2({
65670
66237
  const pendingWsSends = [];
65671
66238
  const sentSharedGroupIds = /* @__PURE__ */ new Set();
65672
66239
  const addressedMlsGroupIds = [];
66240
+ if (!(this._persisted?.mlsGroups && this._state === "ready" && this._ws)) {
66241
+ console.warn(
66242
+ `[SecureChannel] send(): shared-MLS block SKIPPED \u2014 mlsGroups=${this._persisted?.mlsGroups ? Object.keys(this._persisted.mlsGroups).length : "none"} state=${this._state} ws=${this._ws ? "open" : "null"}`
66243
+ );
66244
+ }
65673
66245
  if (this._persisted?.mlsGroups && this._state === "ready" && this._ws) {
65674
66246
  const targetSharedGid = targetConvId ? this._sessionGroupIds?.get(targetConvId) : void 0;
66247
+ console.log(
66248
+ `[SecureChannel] send(): considering ${Object.keys(this._persisted.mlsGroups).length} shared group(s); targetConvId=${targetConvId ? targetConvId.slice(0, 8) : "(none \u2014 broadcast)"} targetSharedGid=${targetSharedGid ? targetSharedGid.slice(0, 8) : "(none)"}`
66249
+ );
66250
+ const skipped = [];
65675
66251
  for (const [gid, entry] of Object.entries(this._persisted.mlsGroups)) {
65676
- if (targetConvId && gid !== targetSharedGid) continue;
65677
- if (!entry.mlsGroupId) continue;
66252
+ if (targetConvId && gid !== targetSharedGid) {
66253
+ skipped.push(`${gid.slice(0, 8)}:not-target`);
66254
+ continue;
66255
+ }
66256
+ if (!entry.mlsGroupId) {
66257
+ skipped.push(`${gid.slice(0, 8)}:no-mlsGroupId`);
66258
+ continue;
66259
+ }
65678
66260
  const mlsGroup = this._mlsGroups.get(`1to1-group:${gid}`);
65679
- if (!mlsGroup?.isInitialized || Number(mlsGroup.epoch) <= 0) continue;
66261
+ if (!mlsGroup?.isInitialized) {
66262
+ skipped.push(`${gid.slice(0, 8)}:not-in-memory`);
66263
+ continue;
66264
+ }
66265
+ if (Number(mlsGroup.epoch) <= 0) {
66266
+ skipped.push(`${gid.slice(0, 8)}:epoch<=0`);
66267
+ continue;
66268
+ }
65680
66269
  try {
65681
66270
  const plaintextBytes = new TextEncoder().encode(plaintext);
65682
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66271
+ const cipherBytes = await this._encryptWithCatchUp(
66272
+ mlsGroup,
66273
+ `1to1-group:${gid}`,
66274
+ entry.mlsGroupId,
66275
+ `1:1 group ${gid.slice(0, 8)}`,
66276
+ plaintextBytes
66277
+ );
65683
66278
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
65684
66279
  const payload = {
65685
66280
  group_id: entry.mlsGroupId,
@@ -65701,8 +66296,12 @@ var init_channel = __esm2({
65701
66296
  console.log(`[SecureChannel] Shared MLS group send for group ${gid.slice(0, 8)} (${entry.mlsGroupId.slice(0, 8)})`);
65702
66297
  } catch (err) {
65703
66298
  console.error(`[SecureChannel] Shared MLS group send failed for ${gid.slice(0, 8)}:`, err);
66299
+ skipped.push(`${gid.slice(0, 8)}:encrypt-threw`);
65704
66300
  }
65705
66301
  }
66302
+ if (skipped.length > 0) {
66303
+ console.log(`[SecureChannel] send(): skipped ${skipped.length} group(s) \u2014 ${skipped.join(", ")}`);
66304
+ }
65706
66305
  }
65707
66306
  for (const [convId, session] of this._sessions) {
65708
66307
  if (!session.activated) continue;
@@ -65722,7 +66321,14 @@ var init_channel = __esm2({
65722
66321
  }
65723
66322
  if (mlsGroup?.isInitialized && mlsGroupId && Number(mlsGroup.epoch) > 0 && this._state === "ready" && this._ws) {
65724
66323
  const plaintextBytes = new TextEncoder().encode(plaintext);
65725
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66324
+ const sessionGroupKey = convGroupId && this._mlsGroups.has(`1to1-group:${convGroupId}`) ? `1to1-group:${convGroupId}` : `conv:${convId}`;
66325
+ const cipherBytes = await this._encryptWithCatchUp(
66326
+ mlsGroup,
66327
+ sessionGroupKey,
66328
+ mlsGroupId,
66329
+ `conversation ${convId.slice(0, 8)}`,
66330
+ plaintextBytes
66331
+ );
65726
66332
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
65727
66333
  const payload = {
65728
66334
  conversation_id: convId,
@@ -65817,7 +66423,14 @@ var init_channel = __esm2({
65817
66423
  }
65818
66424
  try {
65819
66425
  const plaintextBytes = new TextEncoder().encode(plaintext);
65820
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66426
+ const mlsOnlyGroupKey = mlsOnlyConvGroupId && this._mlsGroups.has(`1to1-group:${mlsOnlyConvGroupId}`) ? `1to1-group:${mlsOnlyConvGroupId}` : `conv:${mlsConvId}`;
66427
+ const cipherBytes = await this._encryptWithCatchUp(
66428
+ mlsGroup,
66429
+ mlsOnlyGroupKey,
66430
+ resolvedMlsGroupId,
66431
+ `conversation ${mlsConvId.slice(0, 8)}`,
66432
+ plaintextBytes
66433
+ );
65821
66434
  await saveMlsState(this.config.dataDir, resolvedMlsGroupId, JSON.stringify(mlsGroup.exportState()));
65822
66435
  const payload = {
65823
66436
  conversation_id: mlsConvId,
@@ -65843,13 +66456,26 @@ var init_channel = __esm2({
65843
66456
  }
65844
66457
  }
65845
66458
  }
65846
- if (sentCount === 0 && this._sessions.size > 0) {
65847
- console.warn("[SecureChannel] send() delivered to 0 sessions (all skipped or failed)");
66459
+ if (sentCount === 0) {
66460
+ console.warn(
66461
+ `[SecureChannel] send() delivered to 0 destinations \u2014 nothing was encrypted or queued (sessions=${this._sessions.size}, sharedGroups=${this._persisted?.mlsGroups ? Object.keys(this._persisted.mlsGroups).length : 0})`
66462
+ );
65848
66463
  }
65849
66464
  await this._persistState();
66465
+ const wsReady = this._ws?.readyState === 1;
66466
+ if (pendingWsSends.length > 0 && !wsReady) {
66467
+ console.warn(
66468
+ `[SecureChannel] send(): ${pendingWsSends.length} frame(s) NOT written \u2014 socket readyState=${this._ws?.readyState ?? "null"} (1=OPEN)`
66469
+ );
66470
+ }
65850
66471
  for (const frame of pendingWsSends) {
65851
66472
  this._ws.send(frame);
65852
66473
  }
66474
+ if (pendingWsSends.length > 0) {
66475
+ console.log(
66476
+ `[SecureChannel] send(): wrote ${pendingWsSends.length} frame(s) to the socket (sentCount=${sentCount}, readyState=${this._ws?.readyState ?? "null"})`
66477
+ );
66478
+ }
65853
66479
  if (!options?.isResend) {
65854
66480
  for (const mlsGroupId of addressedMlsGroupIds) {
65855
66481
  this._rememberRetryCandidate(mlsGroupId, plaintext, options);
@@ -65899,7 +66525,7 @@ var init_channel = __esm2({
65899
66525
  */
65900
66526
  sendActivitySpan(spanData) {
65901
66527
  if (!this._ws || this._ws.readyState !== WebSocket2.OPEN) return;
65902
- const pluginVersion = true ? "0.23.23" : "0.0.0-dev";
66528
+ const pluginVersion = true ? "0.23.27" : "0.0.0-dev";
65903
66529
  const agentName = this.config.agentName ?? "Agent";
65904
66530
  const resource = {
65905
66531
  "service.name": "agentvault-agent",
@@ -66109,7 +66735,13 @@ var init_channel = __esm2({
66109
66735
  if (mlsGroup?.isInitialized) {
66110
66736
  try {
66111
66737
  const plaintextBytes = new TextEncoder().encode(plaintext);
66112
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
66738
+ const ciphertext = await this._encryptWithCatchUp(
66739
+ mlsGroup,
66740
+ roomId,
66741
+ room.mlsGroupId,
66742
+ `room ${roomId.slice(0, 8)}`,
66743
+ plaintextBytes
66744
+ );
66113
66745
  await saveMlsState(this.config.dataDir, room.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66114
66746
  if (this._state === "ready" && this._ws) {
66115
66747
  this._ws.send(JSON.stringify({
@@ -66360,11 +66992,17 @@ var init_channel = __esm2({
66360
66992
  const before = this._persisted.groupId;
66361
66993
  this._sessionGroupIds = new Map(mine.map((r22) => [r22.id, r22.group_id]));
66362
66994
  this._persisted.conversationGroupIds = Object.fromEntries(this._sessionGroupIds);
66363
- const primary = mine.find((r22) => r22.id === this._persisted.primaryConversationId) ?? mine[0];
66995
+ const joined = mine.find((r22) => this._persisted.mlsGroups?.[r22.group_id]);
66996
+ const primary = mine.find((r22) => r22.id === this._persisted.primaryConversationId) ?? joined ?? mine[0];
66997
+ if (!mine.some((r22) => r22.id === this._persisted.primaryConversationId)) {
66998
+ console.log(
66999
+ `[SecureChannel] Adopting new primary conversation ${primary.id.slice(0, 8)} (group ${primary.group_id.slice(0, 8)}) \u2014 ${joined ? "we hold MLS state for it" : "no joined conversation found, first active row"}`
67000
+ );
67001
+ }
66364
67002
  this._persisted.groupId = primary.group_id;
66365
67003
  this._persisted.primaryConversationId = primary.id;
66366
67004
  const liveGroupIds = new Set(mine.map((r22) => r22.group_id));
66367
- if (!liveGroupIds.has(staleGid)) {
67005
+ if (staleGid && staleMlsGroupId && !liveGroupIds.has(staleGid)) {
66368
67006
  try {
66369
67007
  await deleteMlsState(this.config.dataDir, staleMlsGroupId);
66370
67008
  } catch {
@@ -66378,9 +67016,9 @@ var init_channel = __esm2({
66378
67016
  }
66379
67017
  await this._persistState();
66380
67018
  console.log(
66381
- `[SecureChannel] Re-resolved conversation groups after 'unknown group' ${staleMlsGroupId.slice(0, 8)}: primary group ${String(before).slice(0, 8)} \u2192 ${this._persisted.groupId.slice(0, 8)} (${mine.length} active conversation(s))`
67019
+ `[SecureChannel] Re-resolved conversation groups ${staleMlsGroupId ? `after 'unknown group' ${staleMlsGroupId.slice(0, 8)}` : "on connect (#1034)"}: primary group ${String(before).slice(0, 8)} \u2192 ${this._persisted.groupId.slice(0, 8)} (${mine.length} active conversation(s))`
66382
67020
  );
66383
- return this._persisted.groupId !== before && before === staleGid;
67021
+ return staleGid !== void 0 && this._persisted.groupId !== before && before === staleGid;
66384
67022
  }
66385
67023
  /**
66386
67024
  * Resend the message that a now-reconciled `unknown group` refusal killed (#732).
@@ -67288,7 +67926,13 @@ var init_channel = __esm2({
67288
67926
  const mlsGroup = this._mlsGroups.get(`a2a:${channelEntry.channelId}`);
67289
67927
  if (mlsGroup?.isInitialized) {
67290
67928
  const plaintextBytes = new TextEncoder().encode(text);
67291
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
67929
+ const ciphertext = await this._encryptWithCatchUp(
67930
+ mlsGroup,
67931
+ `a2a:${channelEntry.channelId}`,
67932
+ channelEntry.mlsGroupId,
67933
+ `A2A ${channelEntry.channelId.slice(0, 8)}`,
67934
+ plaintextBytes
67935
+ );
67292
67936
  await saveMlsState(
67293
67937
  this.config.dataDir,
67294
67938
  channelEntry.mlsGroupId,
@@ -67336,7 +67980,13 @@ var init_channel = __esm2({
67336
67980
  const mlsGroup = this._mlsGroups.get(`a2a:${channelId}`);
67337
67981
  if (mlsGroup?.isInitialized) {
67338
67982
  const plaintextBytes = new TextEncoder().encode(text);
67339
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
67983
+ const ciphertext = await this._encryptWithCatchUp(
67984
+ mlsGroup,
67985
+ `a2a:${channelId}`,
67986
+ entry.mlsGroupId,
67987
+ `A2A ${channelId.slice(0, 8)}`,
67988
+ plaintextBytes
67989
+ );
67340
67990
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
67341
67991
  this._ws.send(JSON.stringify({
67342
67992
  event: "a2a_message_mls",
@@ -67782,6 +68432,9 @@ var init_channel = __esm2({
67782
68432
  await this._pullDrDeliveryQueue();
67783
68433
  await this._flushOutboundQueue();
67784
68434
  this._setState("ready");
68435
+ void this._reconcileConversationGroups().catch((err) => {
68436
+ console.warn("[SecureChannel] Conversation-group reconcile failed:", err);
68437
+ });
67785
68438
  void this._reconcileRoomsWithServer().catch(
67786
68439
  (err) => console.warn(`[SecureChannel] room reconcile failed (ignored): ${err instanceof Error ? err.message : String(err)}`)
67787
68440
  ).then(() => this._quarantineOrphanedMlsGroups()).catch(
@@ -67809,7 +68462,7 @@ var init_channel = __esm2({
67809
68462
  agentVersion: this.config.agentVersion ?? "0.0.0",
67810
68463
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67811
68464
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67812
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68465
+ pluginVersion: true ? "0.23.27" : "0.0.0-dev"
67813
68466
  });
67814
68467
  this._telemetryReporter.startAutoFlush(3e4);
67815
68468
  }
@@ -68133,7 +68786,7 @@ var init_channel = __esm2({
68133
68786
  agentVersion: this.config.agentVersion ?? "0.0.0",
68134
68787
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68135
68788
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68136
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68789
+ pluginVersion: true ? "0.23.27" : "0.0.0-dev"
68137
68790
  });
68138
68791
  this._telemetryReporter.startAutoFlush(3e4);
68139
68792
  }
@@ -68499,6 +69152,7 @@ var init_channel = __esm2({
68499
69152
  const convGroupId = data.conversation_group_id;
68500
69153
  const groupId = data.group_id;
68501
69154
  const senderDeviceId = data.sender_device_id;
69155
+ this._noteGroupEpoch(groupId, data.epoch);
68502
69156
  if (senderDeviceId === this._deviceId) return;
68503
69157
  let mgr;
68504
69158
  let mgrKey;
@@ -68576,9 +69230,15 @@ var init_channel = __esm2({
68576
69230
  })),
68577
69231
  no_history: filtered.length === 0
68578
69232
  });
68579
- if (mgr && mlsGroupId) {
69233
+ if (mgr && mgrKey && mlsGroupId) {
68580
69234
  const responseBytes = new TextEncoder().encode(responsePayload);
68581
- const cipher = await mgr.encrypt(responseBytes);
69235
+ const cipher = await this._encryptWithCatchUp(
69236
+ mgr,
69237
+ mgrKey,
69238
+ mlsGroupId,
69239
+ `history catch-up ${mlsGroupId.slice(0, 8)}`,
69240
+ responseBytes
69241
+ );
68582
69242
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mgr.exportState()));
68583
69243
  if (this._ws) {
68584
69244
  this._ws.send(JSON.stringify({
@@ -69321,9 +69981,36 @@ ${messageText}`;
69321
69981
  // ---------------------------------------------------------------------------
69322
69982
  // MLS room message handlers
69323
69983
  // ---------------------------------------------------------------------------
69984
+ /**
69985
+ * #1014: hand a room-framed frame to the handler that owns its group family.
69986
+ *
69987
+ * Returns true when the frame was routed. Routing means "give it to the right
69988
+ * handler" — NOT "decrypt it as a room message". Decrypting under the wrong
69989
+ * group is how MLS state gets corrupted, so an unplaceable group is left for
69990
+ * the caller to self-heal rather than guessed at.
69991
+ */
69992
+ async _routeNonRoomGroupMessage(data, groupId) {
69993
+ const target = this._resolveGroupFamily(groupId);
69994
+ if (!target || target.family === "room") return false;
69995
+ console.log(
69996
+ `[SecureChannel] Room-framed message belongs to ${target.label} \u2014 routing to the handler that owns it (#1014)`
69997
+ );
69998
+ switch (target.family) {
69999
+ case "shared1to1":
70000
+ await this._handleMessageMLS({ ...data, conversation_group_id: target.id });
70001
+ return true;
70002
+ case "conversation":
70003
+ await this._handleMessageMLS({ ...data, conversation_id: target.id });
70004
+ return true;
70005
+ case "a2a":
70006
+ await this._handleA2AMessageMLS({ ...data, a2a_channel_id: target.id });
70007
+ return true;
70008
+ }
70009
+ }
69324
70010
  async _handleRoomMessageMLS(data) {
69325
70011
  const groupId = data.group_id;
69326
70012
  const senderDeviceId = data.sender_device_id;
70013
+ this._noteGroupEpoch(groupId, data.epoch);
69327
70014
  if (senderDeviceId === this._deviceId) return;
69328
70015
  let roomId;
69329
70016
  for (const [rid, room] of Object.entries(this._persisted?.rooms ?? {})) {
@@ -69337,7 +70024,9 @@ ${messageText}`;
69337
70024
  roomId = data.room_id;
69338
70025
  console.log(`[SecureChannel] Room ${roomId.slice(0, 8)} matched by room_id (group_id ${groupId?.slice(0, 8)} mismatch)`);
69339
70026
  } else {
70027
+ if (await this._routeNonRoomGroupMessage(data, groupId)) return;
69340
70028
  console.warn(`[SecureChannel] No room found for MLS group ${groupId?.slice(0, 8)}`);
70029
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
69341
70030
  return;
69342
70031
  }
69343
70032
  }
@@ -69525,63 +70214,63 @@ ${messageText}`;
69525
70214
  console.warn(`[SecureChannel] Failed to send history catchup response:`, sendErr);
69526
70215
  }
69527
70216
  }
70217
+ /**
70218
+ * Apply an inbound MLS commit to whichever group it addresses.
70219
+ *
70220
+ * Returns TRUE when the commit was applied, buffered, or handed to the
70221
+ * failure ladder — i.e. when this agent has taken responsibility for it and
70222
+ * the delivery-queue row may safely be acked. Returns FALSE when the commit
70223
+ * names a group we hold no state for at all, so the caller must NACK and let
70224
+ * the row survive.
70225
+ *
70226
+ * #1012: this used to loop `rooms` then `a2aChannels` and then END. A shared
70227
+ * 1:1 commit (`mlsGroups`) and a legacy per-conversation commit
70228
+ * (`mlsConversations`) matched neither. The call fell off the bottom with no
70229
+ * log, no buffer and no error — and because it did not THROW,
70230
+ * `_pullDeliveryQueue` acked the row and the server deleted it. An MLS commit
70231
+ * cannot be regenerated, so the agent stayed at epoch N while the group moved
70232
+ * to N+1 and every later send() encrypted at an epoch nobody could decrypt.
70233
+ *
70234
+ * The families are enumerated in ONE list resolved by ONE loop, because two
70235
+ * hand-copied loops are exactly how the 1:1 path fell two families behind.
70236
+ */
69528
70237
  async _handleMlsCommit(data) {
69529
70238
  const groupId = data.group_id;
69530
70239
  const epoch = typeof data.epoch === "number" ? data.epoch : 0;
69531
- for (const [roomId, room] of Object.entries(this._persisted?.rooms ?? {})) {
69532
- if (room.mlsGroupId === groupId) {
69533
- const mlsGroup = this._mlsGroups.get(roomId);
69534
- if (mlsGroup?.isInitialized) {
69535
- try {
69536
- const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
69537
- await mlsGroup.processCommit(commitBytes);
69538
- await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
69539
- console.log(`[SecureChannel] MLS commit processed for room ${roomId.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
69540
- this._mlsCommitFailCounts.delete(roomId);
69541
- } catch (err) {
69542
- await this._onCommitFailure(
69543
- roomId,
69544
- groupId,
69545
- err,
69546
- `room ${roomId.slice(0, 8)}`,
69547
- data.epoch,
69548
- mlsGroup.epoch
69549
- );
69550
- }
69551
- } else {
69552
- this._bufferMlsCommit(groupId, epoch, data);
69553
- console.log(`[SecureChannel] Buffered MLS commit for room ${roomId.slice(0, 8)} (epoch=${epoch}, group not initialized)`);
69554
- }
69555
- return;
70240
+ this._noteGroupEpoch(groupId, data.epoch);
70241
+ const target = this._resolveGroupFamily(groupId);
70242
+ if (target) {
70243
+ const { managerKey: groupKey, label } = target;
70244
+ const mlsGroup = this._mlsGroups.get(groupKey);
70245
+ if (!mlsGroup?.isInitialized) {
70246
+ this._bufferMlsCommit(groupId, epoch, data);
70247
+ console.log(`[SecureChannel] Buffered MLS commit for ${label} (epoch=${epoch}, group not initialized)`);
70248
+ return true;
69556
70249
  }
69557
- }
69558
- for (const [chId, chState] of Object.entries(this._persisted?.a2aChannels ?? {})) {
69559
- if (chState.mlsGroupId === groupId) {
69560
- const mlsGroup = this._mlsGroups.get(`a2a:${chId}`);
69561
- if (mlsGroup?.isInitialized) {
69562
- try {
69563
- const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
69564
- await mlsGroup.processCommit(commitBytes);
69565
- await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
69566
- console.log(`[SecureChannel] MLS commit processed for A2A ${chId.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
69567
- this._mlsCommitFailCounts.delete(`a2a:${chId}`);
69568
- } catch (err) {
69569
- await this._onCommitFailure(
69570
- `a2a:${chId}`,
69571
- groupId,
69572
- err,
69573
- `A2A ${chId.slice(0, 8)}`,
69574
- data.epoch,
69575
- mlsGroup.epoch
69576
- );
69577
- }
69578
- } else {
69579
- this._bufferMlsCommit(groupId, epoch, data);
69580
- console.log(`[SecureChannel] Buffered MLS commit for A2A ${chId.slice(0, 8)} (epoch=${epoch}, group not initialized)`);
69581
- }
69582
- return;
70250
+ try {
70251
+ const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
70252
+ await mlsGroup.processCommit(commitBytes);
70253
+ await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
70254
+ console.log(`[SecureChannel] MLS commit processed for ${label} (epoch=${mlsGroup.epoch})`);
70255
+ this._mlsCommitFailCounts.delete(groupKey);
70256
+ } catch (err) {
70257
+ await this._onCommitFailure(
70258
+ groupKey,
70259
+ groupId,
70260
+ err,
70261
+ label,
70262
+ data.epoch,
70263
+ mlsGroup.epoch
70264
+ );
69583
70265
  }
70266
+ return true;
69584
70267
  }
70268
+ this._bufferMlsCommit(groupId, epoch, data);
70269
+ console.warn(
70270
+ `[SecureChannel] MLS commit for UNKNOWN group ${String(groupId).slice(0, 8)} (epoch=${epoch}) \u2014 buffered, requesting a Welcome, NOT acked`
70271
+ );
70272
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
70273
+ return false;
69585
70274
  }
69586
70275
  /** Buffer an MLS commit for replay after Welcome join. Max 50 per group. */
69587
70276
  _bufferMlsCommit(groupId, epoch, data) {
@@ -69771,6 +70460,229 @@ ${messageText}`;
69771
70460
  * `1to1-group:<gid>`, `a2a:<channelId>`, …) — also the decrypt-fail-count key.
69772
70461
  * @param groupId the server-side MLS group id used in the /sync URL.
69773
70462
  */
70463
+ /**
70464
+ * #1015 — the ONLY place this package fulfils a Welcome request.
70465
+ *
70466
+ * Add the members, transmit, and record `fulfilled` **only for the targets
70467
+ * whose frames actually went out**. Every one of the five sites that used to
70468
+ * do this by hand followed the same sequence and had the same three holes:
70469
+ * `this._ws` null (both sends skipped by an `if`), a CLOSING socket (`send`
70470
+ * returns without throwing), and a fulfil POST wrapped in `.catch(() => {})`.
70471
+ *
70472
+ * A fulfilled request is never re-requested. So a Welcome that evaporated
70473
+ * left the requester locked out, the fulfiller at epoch N+1, everyone else at
70474
+ * N, and the bridging commit untransmitted and impossible to regenerate — a
70475
+ * permanently wedged group in which every signal read success.
70476
+ *
70477
+ * Returns the number of requests actually fulfilled.
70478
+ */
70479
+ async _fulfilWelcomeRequests(opts) {
70480
+ const { mlsGroup, mlsGroupId, label, welcomeFields, targets } = opts;
70481
+ if (targets.length === 0) return 0;
70482
+ if (!this._wsIsOpen()) {
70483
+ console.warn(
70484
+ `[SecureChannel] ${label}: socket is not OPEN \u2014 NOT advancing the group; ${targets.length} request(s) stay pending for the next pull`
70485
+ );
70486
+ return 0;
70487
+ }
70488
+ const snapshot = mlsGroup.exportState();
70489
+ const { commit, welcome } = await mlsGroup.addMembers(targets.map((t22) => t22.keyPackage));
70490
+ const commitSent = await this._sendFrameConfirmed(
70491
+ {
70492
+ event: "mls_commit",
70493
+ data: {
70494
+ group_id: mlsGroupId,
70495
+ epoch: Number(mlsGroup.epoch),
70496
+ payload: Buffer.from(commit).toString("hex")
70497
+ }
70498
+ },
70499
+ `${label} commit`
70500
+ );
70501
+ if (!commitSent) {
70502
+ mlsGroup.importState(snapshot);
70503
+ console.warn(
70504
+ `[SecureChannel] ${label}: commit was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing fulfilled, requests stay pending`
70505
+ );
70506
+ return 0;
70507
+ }
70508
+ await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70509
+ if (!welcome) {
70510
+ console.warn(
70511
+ `[SecureChannel] ${label}: addMembers produced NO Welcome \u2014 ${targets.length} request(s) left pending rather than marked fulfilled`
70512
+ );
70513
+ return 0;
70514
+ }
70515
+ let fulfilled = 0;
70516
+ for (const t22 of targets) {
70517
+ const sent = await this._sendFrameConfirmed(
70518
+ {
70519
+ event: "mls_welcome",
70520
+ data: {
70521
+ target_device_id: t22.deviceId,
70522
+ group_id: mlsGroupId,
70523
+ ...welcomeFields,
70524
+ payload: Buffer.from(welcome).toString("hex")
70525
+ }
70526
+ },
70527
+ `${label} welcome\u2192${t22.deviceId.slice(0, 8)}`
70528
+ );
70529
+ if (!sent) {
70530
+ console.warn(
70531
+ `[SecureChannel] ${label}: Welcome for ${t22.deviceId.slice(0, 8)} was NOT written \u2014 leaving the request PENDING so the next pull retries it`
70532
+ );
70533
+ continue;
70534
+ }
70535
+ if (await this._postFulfilWelcome(mlsGroupId, t22.requestId, `${label} ${t22.deviceId.slice(0, 8)}`)) {
70536
+ fulfilled++;
70537
+ }
70538
+ }
70539
+ return fulfilled;
70540
+ }
70541
+ /**
70542
+ * Resolve an MLS group id to the family that owns it.
70543
+ *
70544
+ * There are four: rooms, A2A channels, shared 1:1 groups, and the legacy
70545
+ * per-conversation groups. They all store the MLS group id under
70546
+ * `mlsGroupId`; they differ only in the key their live `MLSGroupManager` sits
70547
+ * under in `_mlsGroups`.
70548
+ *
70549
+ * ONE resolver, because hand-copying this enumeration is what produced
70550
+ * #1012 (`_handleMlsCommit` handled two families of four), #1014 (a
70551
+ * room-framed 1:1 replay matched nothing) and #1016 (a rejoin dropped only
70552
+ * the room manager). Three bugs, one missing abstraction.
70553
+ */
70554
+ _resolveGroupFamily(mlsGroupId) {
70555
+ if (!mlsGroupId) return null;
70556
+ const families = [
70557
+ { family: "room", entries: this._persisted?.rooms, managerKey: (id) => id, label: "room" },
70558
+ { family: "a2a", entries: this._persisted?.a2aChannels, managerKey: (id) => `a2a:${id}`, label: "A2A" },
70559
+ { family: "shared1to1", entries: this._persisted?.mlsGroups, managerKey: (id) => `1to1-group:${id}`, label: "1:1 group" },
70560
+ { family: "conversation", entries: this._persisted?.mlsConversations, managerKey: (id) => `conv:${id}`, label: "conversation" }
70561
+ ];
70562
+ for (const f22 of families) {
70563
+ for (const [id, entry] of Object.entries(f22.entries ?? {})) {
70564
+ if (entry?.mlsGroupId !== mlsGroupId) continue;
70565
+ return {
70566
+ family: f22.family,
70567
+ id,
70568
+ managerKey: f22.managerKey(id),
70569
+ label: `${f22.label} ${id.slice(0, 8)}`
70570
+ };
70571
+ }
70572
+ }
70573
+ return null;
70574
+ }
70575
+ /** #1015: is the socket in a state that can actually take a write? */
70576
+ _wsIsOpen() {
70577
+ return this._ws?.readyState === 1;
70578
+ }
70579
+ /**
70580
+ * #1015: write one frame and report whether the socket took it.
70581
+ *
70582
+ * `ws.send()` on a CLOSING or CLOSED socket returns WITHOUT throwing — the
70583
+ * error surfaces only through the callback, and every fulfilment site passed
70584
+ * none. So a Welcome could be "sent" into a dead socket and the requester
70585
+ * still marked `fulfilled`, which it never re-requests.
70586
+ */
70587
+ _sendFrameConfirmed(frame, label) {
70588
+ const ws = this._ws;
70589
+ if (!ws || ws.readyState !== 1) {
70590
+ console.warn(
70591
+ `[SecureChannel] ${label}: socket is not OPEN (readyState=${ws?.readyState ?? "null"}) \u2014 frame NOT written`
70592
+ );
70593
+ return Promise.resolve(false);
70594
+ }
70595
+ return new Promise((resolve32) => {
70596
+ try {
70597
+ ws.send(JSON.stringify(frame), (err) => {
70598
+ if (err) {
70599
+ console.error(`[SecureChannel] ${label}: socket refused the frame:`, err);
70600
+ resolve32(false);
70601
+ } else {
70602
+ resolve32(true);
70603
+ }
70604
+ });
70605
+ } catch (err) {
70606
+ console.error(`[SecureChannel] ${label}: send threw:`, err);
70607
+ resolve32(false);
70608
+ }
70609
+ });
70610
+ }
70611
+ /**
70612
+ * #1015: record a Welcome as fulfilled — and say so when it fails.
70613
+ *
70614
+ * The batched shared-1:1 site posted this with `.catch(() => {})`, so an HTTP
70615
+ * failure here was invisible on top of the sends being invisible.
70616
+ */
70617
+ async _postFulfilWelcome(mlsGroupId, requestId, label) {
70618
+ try {
70619
+ const res = await fetch(
70620
+ `${this.config.apiUrl}/api/v1/mls/groups/${mlsGroupId}/fulfill-welcome`,
70621
+ {
70622
+ method: "POST",
70623
+ headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70624
+ body: JSON.stringify({ request_id: requestId })
70625
+ }
70626
+ );
70627
+ if (!res.ok) {
70628
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome returned ${res.status}`);
70629
+ return false;
70630
+ }
70631
+ return true;
70632
+ } catch (err) {
70633
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome failed:`, err);
70634
+ return false;
70635
+ }
70636
+ }
70637
+ /**
70638
+ * #1013: fold an epoch seen on an inbound frame into the running hint.
70639
+ *
70640
+ * Anything unusable (missing, non-numeric, negative, fractional) is ignored
70641
+ * rather than defaulted. A defaulted epoch here would be a guess, and a guess
70642
+ * that reads LOW silently disables the check it exists to drive.
70643
+ */
70644
+ _noteGroupEpoch(mlsGroupId, epoch) {
70645
+ if (typeof mlsGroupId !== "string" || !mlsGroupId) return;
70646
+ const current = this._observedGroupEpochs.get(mlsGroupId) ?? 0;
70647
+ const next = noteObservedEpoch(current, epoch);
70648
+ if (next > current) this._observedGroupEpochs.set(mlsGroupId, next);
70649
+ }
70650
+ /**
70651
+ * #1013 — the ONLY place this package encrypts an MLS application message.
70652
+ *
70653
+ * Catch up first when the group is known to have moved past us, then encrypt.
70654
+ * Hermes has done this since #440 and the browser since #996; the plugin —
70655
+ * the implementation that actually ships to customers — did not, so an agent
70656
+ * that missed one commit encrypted every later message at an epoch nobody
70657
+ * could read, while the server stored and relayed it and every signal read
70658
+ * 200.
70659
+ *
70660
+ * Funnelling every sender through one method is the point. A guard applied at
70661
+ * seven call sites decays to a guard applied at six: that is exactly how
70662
+ * `_handleMlsCommit` ended up handling two group families out of four (#1012).
70663
+ *
70664
+ * Best-effort by design. A catch-up that cannot replay — or that throws — must
70665
+ * NOT block the send; we fall through and encrypt at the local epoch, which is
70666
+ * precisely today's behaviour, so the worst case is no regression.
70667
+ */
70668
+ async _encryptWithCatchUp(mlsGroup, groupKey, mlsGroupId, label, plaintextBytes) {
70669
+ const observed = this._observedGroupEpochs.get(mlsGroupId);
70670
+ if (observed !== void 0 && needsPreSendResync(Number(mlsGroup.epoch), observed)) {
70671
+ console.warn(
70672
+ `[SecureChannel] ${label}: local epoch ${Number(mlsGroup.epoch)} is behind the observed group epoch ${observed} \u2014 catching up before encrypt (#1013)`
70673
+ );
70674
+ try {
70675
+ if (!await this._resyncOnDivergence(groupKey, mlsGroupId)) {
70676
+ console.warn(
70677
+ `[SecureChannel] ${label}: catch-up could not replay \u2014 sending at epoch ${Number(mlsGroup.epoch)}. Peers past this epoch will not read it.`
70678
+ );
70679
+ }
70680
+ } catch (err) {
70681
+ console.warn(`[SecureChannel] ${label}: catch-up failed:`, err);
70682
+ }
70683
+ }
70684
+ return await mlsGroup.encrypt(plaintextBytes);
70685
+ }
69774
70686
  async _resyncOnDivergence(groupKey, groupId) {
69775
70687
  const mgr = this._mlsGroups.get(groupKey);
69776
70688
  if (!mgr?.isInitialized) return false;
@@ -69797,11 +70709,24 @@ ${messageText}`;
69797
70709
  applied++;
69798
70710
  }
69799
70711
  if (applied === 0) {
69800
- console.log(`[SecureChannel] Resync for ${groupId.slice(0, 8)}: no new commits to apply (epoch=${mgr.epoch})`);
70712
+ const strikes = (this._resyncNoOpStrikes.get(groupKey) ?? 0) + 1;
70713
+ this._resyncNoOpStrikes.set(groupKey, strikes);
70714
+ if (strikes < _SecureChannel.MAX_RESYNC_NOOPS) {
70715
+ this._mlsDecryptFailCounts.delete(groupKey);
70716
+ console.log(
70717
+ `[SecureChannel] Resync for ${groupId.slice(0, 8)}: already current at epoch=${mgr.epoch} (strike ${strikes}/${_SecureChannel.MAX_RESYNC_NOOPS}) \u2014 NOT re-keying (#1084)`
70718
+ );
70719
+ return true;
70720
+ }
70721
+ this._resyncNoOpStrikes.delete(groupKey);
70722
+ console.warn(
70723
+ `[SecureChannel] Resync for ${groupId.slice(0, 8)}: still failing at epoch=${mgr.epoch} after ${_SecureChannel.MAX_RESYNC_NOOPS} no-op resyncs \u2014 escalating to re-key`
70724
+ );
69801
70725
  return false;
69802
70726
  }
69803
70727
  await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mgr.exportState()));
69804
70728
  this._mlsDecryptFailCounts.delete(groupKey);
70729
+ this._resyncNoOpStrikes.delete(groupKey);
69805
70730
  console.log(
69806
70731
  `[SecureChannel] Resync-on-divergence: applied ${applied} missed commit(s) for ${groupId.slice(0, 8)}, now epoch=${mgr.epoch}`
69807
70732
  );
@@ -70188,6 +71113,7 @@ ${messageText}`;
70188
71113
  const ackedIds = [];
70189
71114
  const nackedIds = [];
70190
71115
  for (const msg of messages) {
71116
+ let handled = true;
70191
71117
  try {
70192
71118
  if (msg.sender_device_id === this._deviceId) {
70193
71119
  ackedIds.push(msg.queue_id);
@@ -70204,7 +71130,7 @@ ${messageText}`;
70204
71130
  a2a_channel_id: msg.a2a_channel_id
70205
71131
  });
70206
71132
  } else if (msg.message_type === "commit") {
70207
- await this._handleMlsCommit({
71133
+ handled = await this._handleMlsCommit({
70208
71134
  group_id: msg.group_id,
70209
71135
  sender_device_id: msg.sender_device_id,
70210
71136
  epoch: msg.epoch,
@@ -70242,9 +71168,18 @@ ${messageText}`;
70242
71168
  conversation_group_id: msg.conversation_group_id,
70243
71169
  created_at: msg.created_at
70244
71170
  });
71171
+ } else {
71172
+ handled = false;
71173
+ console.warn(
71174
+ `[SecureChannel] Delivery application row ${String(msg.message_id).slice(0, 8)} names no room, A2A channel or conversation \u2014 NOT acked`
71175
+ );
70245
71176
  }
70246
71177
  }
70247
- ackedIds.push(msg.queue_id);
71178
+ if (handled) {
71179
+ ackedIds.push(msg.queue_id);
71180
+ } else {
71181
+ nackedIds.push(msg.queue_id);
71182
+ }
70248
71183
  } catch (err) {
70249
71184
  console.warn(`[SecureChannel] Delivery ${msg.message_type} processing failed:`, err);
70250
71185
  nackedIds.push(msg.queue_id);
@@ -70313,38 +71248,20 @@ ${messageText}`;
70313
71248
  if (!kpHex) continue;
70314
71249
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
70315
71250
  const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
70316
- const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
70317
- if (this._ws) {
70318
- this._ws.send(JSON.stringify({
70319
- event: "mls_commit",
70320
- data: {
70321
- group_id: roomState.mlsGroupId,
70322
- epoch: Number(mlsGroup.epoch),
70323
- payload: Buffer.from(commit).toString("hex")
70324
- }
70325
- }));
70326
- }
70327
- if (welcome && this._ws) {
70328
- this._ws.send(JSON.stringify({
70329
- event: "mls_welcome",
70330
- data: {
70331
- target_device_id: req.requesting_device_id,
70332
- group_id: roomState.mlsGroupId,
70333
- room_id: roomId,
70334
- payload: Buffer.from(welcome).toString("hex")
70335
- }
70336
- }));
71251
+ const done = await this._fulfilWelcomeRequests({
71252
+ mlsGroup,
71253
+ mlsGroupId: roomState.mlsGroupId,
71254
+ label: `room ${roomId.slice(0, 8)}`,
71255
+ welcomeFields: { room_id: roomId },
71256
+ targets: [{
71257
+ requestId: req.id,
71258
+ deviceId: req.requesting_device_id,
71259
+ keyPackage: memberKp
71260
+ }]
71261
+ });
71262
+ if (done > 0) {
71263
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in room ${roomId.slice(0, 8)}`);
70337
71264
  }
70338
- await fetch(
70339
- `${this.config.apiUrl}/api/v1/mls/groups/${roomState.mlsGroupId}/fulfill-welcome`,
70340
- {
70341
- method: "POST",
70342
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70343
- body: JSON.stringify({ request_id: req.id })
70344
- }
70345
- );
70346
- await saveMlsState(this.config.dataDir, roomState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70347
- console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in room ${roomId.slice(0, 8)}`);
70348
71265
  } catch (fulfillErr) {
70349
71266
  console.warn(`[SecureChannel] Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
70350
71267
  }
@@ -70383,65 +71300,38 @@ ${messageText}`;
70383
71300
  validReqs.push({ req, kp: MLSGroupManager.deserializeKeyPackage(kpBytes) });
70384
71301
  }
70385
71302
  if (validReqs.length === 0) continue;
71303
+ const sharedTargets = validReqs.map((v22) => ({
71304
+ requestId: v22.req.id,
71305
+ deviceId: v22.req.requesting_device_id,
71306
+ keyPackage: v22.kp
71307
+ }));
70386
71308
  try {
70387
- const { commit, welcome } = await mlsGroup.addMembers(validReqs.map((v22) => v22.kp));
70388
- if (this._ws) {
70389
- this._ws.send(JSON.stringify({
70390
- event: "mls_commit",
70391
- data: {
70392
- group_id: entry.mlsGroupId,
70393
- epoch: Number(mlsGroup.epoch),
70394
- payload: Buffer.from(commit).toString("hex")
70395
- }
70396
- }));
70397
- }
70398
- if (welcome && this._ws) {
70399
- for (const { req } of validReqs) {
70400
- this._ws.send(JSON.stringify({
70401
- event: "mls_welcome",
70402
- data: {
70403
- target_device_id: req.requesting_device_id,
70404
- group_id: entry.mlsGroupId,
70405
- conversation_group_id: gid,
70406
- payload: Buffer.from(welcome).toString("hex")
70407
- }
70408
- }));
70409
- }
70410
- }
70411
- for (const { req } of validReqs) {
70412
- await fetch(
70413
- `${this.config.apiUrl}/api/v1/mls/groups/${entry.mlsGroupId}/fulfill-welcome`,
70414
- {
70415
- method: "POST",
70416
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70417
- body: JSON.stringify({ request_id: req.id })
70418
- }
70419
- ).catch(() => {
70420
- });
71309
+ const done = await this._fulfilWelcomeRequests({
71310
+ mlsGroup,
71311
+ mlsGroupId: entry.mlsGroupId,
71312
+ label: `shared group ${gid.slice(0, 8)}`,
71313
+ welcomeFields: { conversation_group_id: gid },
71314
+ targets: sharedTargets
71315
+ });
71316
+ if (done > 0) {
71317
+ console.log(`[SecureChannel] Batched Welcome for ${done}/${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70421
71318
  }
70422
- await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70423
- console.log(`[SecureChannel] Batched Welcome for ${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70424
71319
  } catch (batchErr) {
70425
71320
  console.warn(`[SecureChannel] Batched Welcome failed for shared group ${gid.slice(0, 8)}, falling back to individual:`, batchErr);
70426
- for (const { req, kp } of validReqs) {
71321
+ for (const target of sharedTargets) {
70427
71322
  try {
70428
- const { commit, welcome } = await mlsGroup.addMembers([kp]);
70429
- if (this._ws) {
70430
- this._ws.send(JSON.stringify({ event: "mls_commit", data: { group_id: entry.mlsGroupId, epoch: Number(mlsGroup.epoch), payload: Buffer.from(commit).toString("hex") } }));
70431
- }
70432
- if (welcome && this._ws) {
70433
- this._ws.send(JSON.stringify({ event: "mls_welcome", data: { target_device_id: req.requesting_device_id, group_id: entry.mlsGroupId, conversation_group_id: gid, payload: Buffer.from(welcome).toString("hex") } }));
70434
- }
70435
- await fetch(`${this.config.apiUrl}/api/v1/mls/groups/${entry.mlsGroupId}/fulfill-welcome`, {
70436
- method: "POST",
70437
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70438
- body: JSON.stringify({ request_id: req.id })
70439
- }).catch(() => {
71323
+ const done = await this._fulfilWelcomeRequests({
71324
+ mlsGroup,
71325
+ mlsGroupId: entry.mlsGroupId,
71326
+ label: `shared group ${gid.slice(0, 8)}`,
71327
+ welcomeFields: { conversation_group_id: gid },
71328
+ targets: [target]
70440
71329
  });
70441
- await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70442
- console.log(`[SecureChannel] Individual Welcome for ${req.requesting_device_id.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
71330
+ if (done > 0) {
71331
+ console.log(`[SecureChannel] Individual Welcome for ${target.deviceId.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
71332
+ }
70443
71333
  } catch (indivErr) {
70444
- console.warn(`[SecureChannel] Individual Welcome failed for ${req.requesting_device_id.slice(0, 8)}:`, indivErr);
71334
+ console.warn(`[SecureChannel] Individual Welcome failed for ${target.deviceId.slice(0, 8)}:`, indivErr);
70445
71335
  }
70446
71336
  }
70447
71337
  }
@@ -70473,38 +71363,20 @@ ${messageText}`;
70473
71363
  if (!kpHex) continue;
70474
71364
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
70475
71365
  const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
70476
- const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
70477
- if (this._ws) {
70478
- this._ws.send(JSON.stringify({
70479
- event: "mls_commit",
70480
- data: {
70481
- group_id: convEntry.mlsGroupId,
70482
- epoch: Number(mlsGroup.epoch),
70483
- payload: Buffer.from(commit).toString("hex")
70484
- }
70485
- }));
70486
- }
70487
- if (welcome && this._ws) {
70488
- this._ws.send(JSON.stringify({
70489
- event: "mls_welcome",
70490
- data: {
70491
- target_device_id: req.requesting_device_id,
70492
- group_id: convEntry.mlsGroupId,
70493
- conversation_id: convId,
70494
- payload: Buffer.from(welcome).toString("hex")
70495
- }
70496
- }));
71366
+ const done = await this._fulfilWelcomeRequests({
71367
+ mlsGroup,
71368
+ mlsGroupId: convEntry.mlsGroupId,
71369
+ label: `conv ${convId.slice(0, 8)}`,
71370
+ welcomeFields: { conversation_id: convId },
71371
+ targets: [{
71372
+ requestId: req.id,
71373
+ deviceId: req.requesting_device_id,
71374
+ keyPackage: memberKp
71375
+ }]
71376
+ });
71377
+ if (done > 0) {
71378
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
70497
71379
  }
70498
- await fetch(
70499
- `${this.config.apiUrl}/api/v1/mls/groups/${convEntry.mlsGroupId}/fulfill-welcome`,
70500
- {
70501
- method: "POST",
70502
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70503
- body: JSON.stringify({ request_id: req.id })
70504
- }
70505
- );
70506
- await saveMlsState(this.config.dataDir, convEntry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70507
- console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
70508
71380
  } catch (fulfillErr) {
70509
71381
  console.warn(`[SecureChannel] Conv Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
70510
71382
  }
@@ -70685,12 +71557,13 @@ ${messageText}`;
70685
71557
  } catch (distErr) {
70686
71558
  console.warn(`[SecureChannel] A2A distribute network error:`, distErr);
70687
71559
  }
70688
- if (!distributed && this._ws) {
70689
- this._ws.send(JSON.stringify({
71560
+ if (!distributed && this._wsIsOpen()) {
71561
+ const a2aLabel = `A2A ${chId.slice(0, 8)}`;
71562
+ const commitSent = await this._sendFrameConfirmed({
70690
71563
  event: "mls_commit",
70691
71564
  data: { group_id: chState.mlsGroupId, epoch: Number(mlsGroup.epoch), payload: commitHex }
70692
- }));
70693
- this._ws.send(JSON.stringify({
71565
+ }, `${a2aLabel} commit`);
71566
+ const welcomeSent = commitSent && await this._sendFrameConfirmed({
70694
71567
  event: "mls_welcome",
70695
71568
  data: {
70696
71569
  target_device_id: req.requesting_device_id,
@@ -70698,7 +71571,11 @@ ${messageText}`;
70698
71571
  a2a_channel_id: chId,
70699
71572
  payload: welcomeHex
70700
71573
  }
70701
- }));
71574
+ }, `${a2aLabel} welcome`);
71575
+ if (!welcomeSent) {
71576
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome frames were NOT written \u2014 leaving the request pending`);
71577
+ continue;
71578
+ }
70702
71579
  try {
70703
71580
  await fetch(
70704
71581
  `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
@@ -70721,16 +71598,15 @@ ${messageText}`;
70721
71598
  console.warn(`[SecureChannel] Cannot deliver Welcome \u2014 HTTP distribute and WS both unavailable`);
70722
71599
  continue;
70723
71600
  }
70724
- await fetch(
70725
- `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/fulfill-welcome`,
70726
- {
70727
- method: "POST",
70728
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70729
- body: JSON.stringify({ request_id: req.id })
70730
- }
71601
+ const a2aFulfilled = await this._postFulfilWelcome(
71602
+ chState.mlsGroupId,
71603
+ req.id,
71604
+ `A2A ${chId.slice(0, 8)}`
70731
71605
  );
70732
71606
  await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70733
- console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
71607
+ if (a2aFulfilled) {
71608
+ console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
71609
+ }
70734
71610
  } catch (fulfillErr) {
70735
71611
  console.log(`[SecureChannel] A2A Welcome fulfill failed: ${fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr)}`);
70736
71612
  }
@@ -70904,12 +71780,12 @@ ${messageText}`;
70904
71780
  console.log(`[SecureChannel] MLS sync: re-join needed for group ${groupId?.slice(0, 8)}`);
70905
71781
  if (groupId) {
70906
71782
  await deleteMlsState(this.config.dataDir, groupId);
70907
- for (const [roomId, room] of Object.entries(this._persisted?.rooms ?? {})) {
70908
- if (room.mlsGroupId === groupId) {
70909
- this._mlsGroups.delete(roomId);
70910
- break;
70911
- }
71783
+ const target = this._resolveGroupFamily(groupId);
71784
+ if (target) {
71785
+ this._mlsGroups.delete(target.managerKey);
71786
+ console.log(`[SecureChannel] MLS sync rejoin: dropped local state for ${target.label}`);
70912
71787
  }
71788
+ void this._requestWelcomeSelfHeal(groupId);
70913
71789
  }
70914
71790
  } else if (action === "replay_complete") {
70915
71791
  console.log(`[SecureChannel] MLS sync complete for group ${groupId?.slice(0, 8)} (${data.count} messages replayed)`);
@@ -70921,6 +71797,7 @@ ${messageText}`;
70921
71797
  */
70922
71798
  async _handleA2AMessageMLS(data) {
70923
71799
  const groupId = data.group_id;
71800
+ this._noteGroupEpoch(groupId, data.epoch);
70924
71801
  const channelId = data.channel_id ?? data.a2a_channel_id;
70925
71802
  let a2aChannelId = channelId;
70926
71803
  if (!a2aChannelId) {
@@ -98144,13 +99021,14 @@ var init_index = __esm2({
98144
99021
  init_skill_invoker();
98145
99022
  await init_skill_telemetry();
98146
99023
  await init_policy_enforcer();
98147
- VERSION = true ? "0.23.23" : "0.0.0-dev";
99024
+ VERSION = true ? "0.23.27" : "0.0.0-dev";
98148
99025
  }
98149
99026
  });
98150
99027
  await init_index();
98151
99028
 
98152
99029
  // src/index.ts
98153
99030
  init_config();
99031
+ init_pending_invite();
98154
99032
 
98155
99033
  // ../../node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs
98156
99034
  import { createRequire as $S } from "node:module";
@@ -119451,18 +120329,18 @@ import { realpathSync as realpathSync2 } from "node:fs";
119451
120329
  import { resolve as resolve3, dirname as dirname2, basename, sep as sep2, isAbsolute } from "node:path";
119452
120330
 
119453
120331
  // src/host-trust.ts
119454
- import { mkdirSync as mkdirSync3, writeFileSync, rmSync as rmSync2, readdirSync as readdirSync2, lstatSync as lstatSync2, readFileSync as readFileSync4, chmodSync as chmodSync2 } from "node:fs";
119455
- import { join as join6 } from "node:path";
120332
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync2, rmSync as rmSync3, readdirSync as readdirSync2, lstatSync as lstatSync2, readFileSync as readFileSync5, chmodSync as chmodSync3 } from "node:fs";
120333
+ import { join as join7 } from "node:path";
119456
120334
  import { homedir, hostname as hostname3 } from "node:os";
119457
120335
  var TRUST_SUBDIR = "host-trust";
119458
- var DIR_MODE2 = 448;
119459
- var FILE_MODE2 = 384;
120336
+ var DIR_MODE3 = 448;
120337
+ var FILE_MODE3 = 384;
119460
120338
  var ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
119461
120339
  var IDENTITY_FILES = ["agentvault.json", "secure-channel.json", "agentvault.json.bak"];
119462
120340
  var HostTrustError = class extends Error {
119463
120341
  };
119464
120342
  function trustDir(root3) {
119465
- return join6(root3 ?? join6(homedir(), ".agentvault"), TRUST_SUBDIR);
120343
+ return join7(root3 ?? join7(homedir(), ".agentvault"), TRUST_SUBDIR);
119466
120344
  }
119467
120345
  function sanitize(deviceId) {
119468
120346
  if (typeof deviceId !== "string" || !ID_RE.test(deviceId)) {
@@ -119473,7 +120351,7 @@ function sanitize(deviceId) {
119473
120351
  function isTrusted(deviceId, root3) {
119474
120352
  try {
119475
120353
  if (typeof deviceId !== "string" || !ID_RE.test(deviceId)) return false;
119476
- const p2 = join6(trustDir(root3), deviceId);
120354
+ const p2 = join7(trustDir(root3), deviceId);
119477
120355
  return lstatSync2(p2).isFile();
119478
120356
  } catch {
119479
120357
  return false;
@@ -119482,26 +120360,26 @@ function isTrusted(deviceId, root3) {
119482
120360
  function grant(deviceId, root3) {
119483
120361
  const id = sanitize(deviceId);
119484
120362
  const dir = trustDir(root3);
119485
- const p2 = join6(dir, id);
119486
- mkdirSync3(dir, { recursive: true, mode: DIR_MODE2 });
119487
- writeFileSync(
120363
+ const p2 = join7(dir, id);
120364
+ mkdirSync4(dir, { recursive: true, mode: DIR_MODE3 });
120365
+ writeFileSync2(
119488
120366
  p2,
119489
120367
  `granted_at=${(/* @__PURE__ */ new Date()).toISOString()} host=${hostname3()}
119490
120368
  `,
119491
- { mode: FILE_MODE2 }
120369
+ { mode: FILE_MODE3 }
119492
120370
  );
119493
- chmodSync2(dir, DIR_MODE2);
119494
- chmodSync2(p2, FILE_MODE2);
120371
+ chmodSync3(dir, DIR_MODE3);
120372
+ chmodSync3(p2, FILE_MODE3);
119495
120373
  }
119496
120374
  function revoke(deviceId, root3) {
119497
120375
  const id = sanitize(deviceId);
119498
- const p2 = join6(trustDir(root3), id);
120376
+ const p2 = join7(trustDir(root3), id);
119499
120377
  try {
119500
120378
  if (!lstatSync2(p2).isFile()) return false;
119501
120379
  } catch {
119502
120380
  return false;
119503
120381
  }
119504
- rmSync2(p2, { force: true });
120382
+ rmSync3(p2, { force: true });
119505
120383
  return true;
119506
120384
  }
119507
120385
  function listTrusted(root3) {
@@ -119516,7 +120394,7 @@ function listTrusted(root3) {
119516
120394
  function readDeviceId(dataDir) {
119517
120395
  for (const f7 of IDENTITY_FILES) {
119518
120396
  try {
119519
- const parsed = JSON.parse(readFileSync4(join6(dataDir, f7), "utf-8"));
120397
+ const parsed = JSON.parse(readFileSync5(join7(dataDir, f7), "utf-8"));
119520
120398
  if (parsed?.deviceId && ID_RE.test(parsed.deviceId)) return parsed.deviceId;
119521
120399
  } catch {
119522
120400
  }
@@ -119526,17 +120404,8 @@ function readDeviceId(dataDir) {
119526
120404
  );
119527
120405
  }
119528
120406
 
119529
- // src/log.ts
119530
- import { format } from "node:util";
119531
- function stamp(message) {
119532
- const ts2 = (/* @__PURE__ */ new Date()).toISOString();
119533
- return message.split("\n").map((line) => `${ts2} ${line}`).join("\n");
119534
- }
119535
- function logLine(...args) {
119536
- console.error(stamp(format(...args)));
119537
- }
119538
-
119539
120407
  // src/worker-permission.ts
120408
+ init_log();
119540
120409
  var PATH_FIELDS = ["file_path", "path", "notebook_path"];
119541
120410
  function canonical(p2) {
119542
120411
  const abs = resolve3(p2);
@@ -133462,6 +134331,7 @@ function date7(params) {
133462
134331
  config2(en_default3());
133463
134332
 
133464
134333
  // src/session.ts
134334
+ init_log();
133465
134335
  function makeRoomSayTool(onSay) {
133466
134336
  return bs(
133467
134337
  "say",
@@ -134072,9 +134942,36 @@ var ArmingState = class {
134072
134942
  }
134073
134943
  };
134074
134944
 
134945
+ // src/service/self-uninstall.ts
134946
+ init_subcommand();
134947
+ init_backend();
134948
+ init_log();
134949
+ var DEVICE_IS_GONE = "device_revoked";
134950
+ function selfUninstallOnTerminal(reason, env, deps = {}) {
134951
+ if (reason !== DEVICE_IS_GONE) return false;
134952
+ const log = deps.log ?? ((m6) => logLine("[bridge] " + m6));
134953
+ let label = "";
134954
+ try {
134955
+ if (!deps.backend && process.platform !== "darwin" && process.platform !== "linux") return false;
134956
+ const backend = deps.backend ?? selectBackend(process.platform);
134957
+ label = resolveLabel(backend, env);
134958
+ if (!backend.status(label).loaded) return false;
134959
+ backend.uninstall(label);
134960
+ log(
134961
+ `device was revoked \u2014 removed the supervised service ${label} so it does not sit installed on this machine pointing at an agent that no longer exists. Your credentials are still on disk and were NOT touched; re-install with a fresh invite token from AgentVault to reconnect.`
134962
+ );
134963
+ return true;
134964
+ } catch (e7) {
134965
+ log(
134966
+ `device was revoked, but the supervised service ${label || "for this agent"} could not be removed: ${e7.message}. Remove it by hand with: AV_AGENT_NAME=<agent-name> agentvault-claude-bridge uninstall`
134967
+ );
134968
+ return false;
134969
+ }
134970
+ }
134971
+
134075
134972
  // src/approve-cli.ts
134076
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync2, readFileSync as readFileSync5, readdirSync as readdirSync3, rmSync as rmSync3, existsSync as existsSync3 } from "node:fs";
134077
- import { join as join7 } from "node:path";
134973
+ import { mkdirSync as mkdirSync6, writeFileSync as writeFileSync4, readFileSync as readFileSync7, readdirSync as readdirSync4, rmSync as rmSync5, existsSync as existsSync5 } from "node:fs";
134974
+ import { join as join11 } from "node:path";
134078
134975
  var APPROVALS_SUBDIR = "arm-approvals";
134079
134976
  var ID_RE2 = /^[A-Za-z0-9_-]{1,128}$/;
134080
134977
  var ApproveArmError = class extends Error {
@@ -134094,24 +134991,24 @@ function sanitizeRoomId(roomId) {
134094
134991
  function writeApproval(dataDir, requestId, roomId) {
134095
134992
  const id = sanitizeRequestId(requestId);
134096
134993
  const room = sanitizeRoomId(roomId);
134097
- const dir = join7(dataDir, APPROVALS_SUBDIR);
134098
- mkdirSync4(dir, { recursive: true });
134099
- writeFileSync2(join7(dir, id), room);
134994
+ const dir = join11(dataDir, APPROVALS_SUBDIR);
134995
+ mkdirSync6(dir, { recursive: true });
134996
+ writeFileSync4(join11(dir, id), room);
134100
134997
  }
134101
134998
  function drainApprovals(dataDir) {
134102
- const dir = join7(dataDir, APPROVALS_SUBDIR);
134103
- if (!existsSync3(dir)) return [];
134999
+ const dir = join11(dataDir, APPROVALS_SUBDIR);
135000
+ if (!existsSync5(dir)) return [];
134104
135001
  const out = [];
134105
- for (const name of readdirSync3(dir)) {
135002
+ for (const name of readdirSync4(dir)) {
134106
135003
  if (!ID_RE2.test(name)) continue;
134107
135004
  let roomId = "";
134108
135005
  try {
134109
- roomId = readFileSync5(join7(dir, name), "utf8").trim();
135006
+ roomId = readFileSync7(join11(dir, name), "utf8").trim();
134110
135007
  } catch {
134111
135008
  }
134112
135009
  out.push({ requestId: name, roomId: ID_RE2.test(roomId) ? roomId : "" });
134113
135010
  try {
134114
- rmSync3(join7(dir, name), { force: true, recursive: true });
135011
+ rmSync5(join11(dir, name), { force: true, recursive: true });
134115
135012
  } catch {
134116
135013
  }
134117
135014
  }
@@ -134252,11 +135149,13 @@ var ActiveTarget = class {
134252
135149
  function attachLifecycle2(channel, opts = {}) {
134253
135150
  const log = opts.log ?? (() => {
134254
135151
  });
135152
+ const selfUninstall = opts.selfUninstall ?? selfUninstallOnTerminal;
134255
135153
  const onTerminal = opts.onTerminal ?? ((_reason, o10) => process.exit(o10.restart ? 1 : 0));
134256
135154
  const terminal = (reason, restart) => {
134257
135155
  log(
134258
135156
  `terminal condition (${reason}) \u2014 ${restart ? "gave up reconnecting; supervisor may respawn" : "this device must re-enroll or another session has taken over"}. Re-run/re-install with a fresh invite token from AgentVault if this persists.`
134259
135157
  );
135158
+ selfUninstall(reason, process.env);
134260
135159
  onTerminal(reason, { restart });
134261
135160
  };
134262
135161
  channel.on("auth_failed", (e7) => terminal(e7.reason, false));
@@ -134479,7 +135378,12 @@ function wireBridge(channel, session, target, opts = {}) {
134479
135378
  }
134480
135379
 
134481
135380
  // src/index.ts
135381
+ init_log();
134482
135382
  async function main() {
135383
+ const { maybeRunDoctorSubcommand: maybeRunDoctorSubcommand2 } = await Promise.resolve().then(() => (init_doctor_cli(), doctor_cli_exports));
135384
+ if (await maybeRunDoctorSubcommand2(process.argv)) {
135385
+ process.exit(process.exitCode ?? 0);
135386
+ }
134483
135387
  const { dataDir: dataDirForSubcommand } = resolveDataDir(process.env);
134484
135388
  if (maybeRunApproveArmSubcommand(process.argv, dataDirForSubcommand)) {
134485
135389
  process.exit(process.exitCode ?? 0);
@@ -134498,7 +135402,7 @@ async function main() {
134498
135402
  "[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"
134499
135403
  );
134500
135404
  }
134501
- logLine(`[bridge] version: ${true ? "0.7.16" : "dev"}`);
135405
+ logLine(`[bridge] version: ${true ? "0.8.1" : "dev"}`);
134502
135406
  logLine(`[bridge] data dir: ${cfg.dataDir} (${cfg.dataDirSource})`);
134503
135407
  logLine(`[bridge] workspace: ${cfg.workspaceDir} \xB7 permissions: ${cfg.permissionMode} \xB7 enclave dir fenced`);
134504
135408
  if (cfg.armRoom) {
@@ -134530,7 +135434,7 @@ async function main() {
134530
135434
  // its default would render "@agentvault/agentvault@0.7.x" — the wrong
134531
135435
  // package name attached to the bridge's version number, which is worse
134532
135436
  // than either alone.
134533
- clientVersion: `@agentvault/claude-bridge@${true ? "0.7.16" : "dev"}`
135437
+ clientVersion: `@agentvault/claude-bridge@${true ? "0.8.1" : "dev"}`
134534
135438
  });
134535
135439
  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.`;
134536
135440
  const deviceJwt = () => {
@@ -134594,9 +135498,9 @@ async function main() {
134594
135498
  `[worker-trap] ${rec.at} ${rec.outcome} after ${rec.ranMs}ms (waited ${rec.waitedMs}ms behind ${rec.queueDepthAtEnqueue}, ${rec.queueDepthAtStart} still queued) replyExpected=${rec.replyExpected}` + (rec.session ? ` composed=${rec.session.composedChars} result=${rec.session.sawResult} said=${rec.session.said}` : "")
134595
135499
  );
134596
135500
  try {
134597
- const dir = join11(cfg.dataDir, "logs");
134598
- mkdirSync6(dir, { recursive: true });
134599
- appendFileSync2(join11(dir, "worker-incidents.jsonl"), JSON.stringify(rec) + "\n");
135501
+ const dir = join13(cfg.dataDir, "logs");
135502
+ mkdirSync7(dir, { recursive: true });
135503
+ appendFileSync2(join13(dir, "worker-incidents.jsonl"), JSON.stringify(rec) + "\n");
134600
135504
  } catch (err) {
134601
135505
  logLine(`[worker-trap] could not persist incident: ${err.message}`);
134602
135506
  }
@@ -134628,6 +135532,11 @@ async function main() {
134628
135532
  attachLifecycle2(channel, {
134629
135533
  log: (m6) => logLine("[bridge] " + m6)
134630
135534
  });
135535
+ channel.on("ready", () => {
135536
+ if (clearPendingInviteIfConsumed(cfg.dataDir, hasPersistedCreds)) {
135537
+ logLine("[bridge] enrolled \u2014 pending invite consumed and removed");
135538
+ }
135539
+ });
134631
135540
  channel.on("state", (s10) => logLine(`[bridge] channel state: ${JSON.stringify(s10)}`));
134632
135541
  channel.on(
134633
135542
  "room_joined",