@agentvault/grok-bridge 0.3.2 → 0.4.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
@@ -54,6 +54,24 @@ var init_pending_invite = __esm({
54
54
  }
55
55
  });
56
56
 
57
+ // src/log.ts
58
+ import { format } from "node:util";
59
+ function stamp(message) {
60
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
61
+ return message.split("\n").map((line) => `${ts} ${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
+
57
75
  // src/config.ts
58
76
  import { existsSync as existsSync2, readFileSync as readFileSync3, mkdirSync as mkdirSync2, chmodSync as chmodSync2 } from "node:fs";
59
77
  import { join as join6, resolve as resolve2, sep, dirname } from "node:path";
@@ -71,7 +89,7 @@ function assertInstallableEntrypoint(entrypoint) {
71
89
  if (isEphemeralEntrypoint(entrypoint)) {
72
90
  throw new Error(
73
91
  `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:
74
- npm i -g @agentvault/grok-bridge && agentvault-grok-bridge ` + INSTALL_FLAG
92
+ npm i -g @agentvault/grok-bridge && agentvault-grok-bridge install`
75
93
  );
76
94
  }
77
95
  }
@@ -83,6 +101,17 @@ function hasRecoverableBackup(dataDir) {
83
101
  return false;
84
102
  }
85
103
  }
104
+ function readPersistedDeviceId(dataDir) {
105
+ for (const f2 of [...CRED_FILES, BACKUP_FILE]) {
106
+ try {
107
+ const parsed = JSON.parse(readFileSync3(join6(dataDir, f2), "utf-8"));
108
+ const id = parsed?.deviceId;
109
+ if (typeof id === "string" && id) return id;
110
+ } catch {
111
+ }
112
+ }
113
+ return null;
114
+ }
86
115
  function hasPersistedCreds(dataDir) {
87
116
  if (CRED_FILES.some((f2) => existsSync2(join6(dataDir, f2)))) return true;
88
117
  return hasRecoverableBackup(dataDir);
@@ -91,20 +120,41 @@ function slugify2(name) {
91
120
  return name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
92
121
  }
93
122
  function resolveDataDir(env) {
94
- if (env.AV_DATA_DIR) return { dataDir: env.AV_DATA_DIR, source: "explicit" };
123
+ const supplied = env.AV_AGENT_NAME?.trim();
124
+ const agentName = supplied || LEGACY_DEFAULT_AGENT_NAME;
125
+ const agentNameSource = supplied ? "supplied" : "defaulted";
126
+ if (env.AV_DATA_DIR)
127
+ return { dataDir: env.AV_DATA_DIR, source: "explicit", agentName, agentNameSource };
95
128
  const base = join6(env.HOME ?? "", ".agentvault", "grok-room-bridge");
96
- return { dataDir: join6(base, slugify2(env.AV_AGENT_NAME ?? "grok")), source: "per-agent" };
129
+ const perAgent = join6(base, slugify2(agentName));
130
+ if (!supplied && !hasPersistedCreds(perAgent)) {
131
+ throw new Error(
132
+ `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}, so there is no existing install to infer a name from. Re-run with AV_AGENT_NAME=<agent-name> set.`
133
+ );
134
+ }
135
+ return { dataDir: perAgent, source: "per-agent", agentName, agentNameSource };
97
136
  }
98
137
  function loadConfig(env, argv = []) {
99
- const { dataDir, source: dataDirSource } = resolveDataDir(env);
138
+ const { dataDir, source: dataDirSource, agentName, agentNameSource } = resolveDataDir(env);
139
+ if (agentNameSource === "defaulted") {
140
+ logLine(
141
+ `[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.`
142
+ );
143
+ }
100
144
  const inviteToken = (argv[0] && !argv[0].startsWith("-") ? argv[0] : "") || env.AV_INVITE_TOKEN || readPendingInvite(dataDir) || "";
145
+ if (argv.includes(REMOVED_INSTALL_FLAG)) {
146
+ throw new Error(
147
+ `${REMOVED_INSTALL_FLAG} was removed \u2014 it handed the agent over from a foreground process that could be reaped before the encrypted group finished forming, leaving an agent that was connected and mute (#909). Use the subcommand instead, which lets the service enrol itself:
148
+ npm i -g @agentvault/grok-bridge && AV_AGENT_NAME=<agent-name> AV_INVITE_TOKEN=<token> agentvault-grok-bridge install`
149
+ );
150
+ }
101
151
  const unknownFlags = argv.filter((a2) => a2.startsWith("-") && !KNOWN_FLAGS.has(a2));
102
152
  if (unknownFlags.length) {
103
153
  throw new Error(
104
154
  `unknown option(s): ${unknownFlags.join(", ")} \u2014 supported flags are ${[...KNOWN_FLAGS].join(", ")}`
105
155
  );
106
156
  }
107
- const workspaceDir = env.AV_WORKSPACE_DIR || join6(dirname(dataDir), "workspaces", slugify2(env.AV_AGENT_NAME ?? "grok"));
157
+ const workspaceDir = env.AV_WORKSPACE_DIR || join6(dirname(dataDir), "workspaces", slugify2(agentName));
108
158
  const wsReal = resolve2(workspaceDir);
109
159
  const ddReal = resolve2(dataDir);
110
160
  if (wsReal === ddReal || wsReal.startsWith(ddReal + sep) || ddReal.startsWith(wsReal + sep)) {
@@ -129,17 +179,14 @@ function loadConfig(env, argv = []) {
129
179
  inviteToken,
130
180
  dataDir,
131
181
  dataDirSource,
132
- // One-paste install (--install): enrol, hand off to the supervisor, exit.
133
- // Default false keeps a bare `npx @agentvault/grok-bridge` run identical.
134
- installAfterEnroll: argv.includes(INSTALL_FLAG),
135
182
  ...parseInfoFlags(argv),
136
183
  apiUrl: env.AV_API_URL ?? "https://api.agentvault.chat",
137
184
  // Identity shown to the agent's own session. Set this to the agent's
138
185
  // AgentVault name via AV_AGENT_NAME (the native connect command passes it).
139
- // Defaults to "Grok" — this package IS the Grok bridge, so an unset
140
- // AV_AGENT_NAME still names the actual model running the session, not a
141
- // placeholder impersonating some other agent.
142
- agentName: env.AV_AGENT_NAME ?? "Grok",
186
+ // Resolved in resolveDataDir so the name, the data dir and the workspace dir
187
+ // can never disagree about who this agent is (#921/#927).
188
+ agentName,
189
+ agentNameSource,
143
190
  roomFilter: env.AV_ROOM_ID || void 0,
144
191
  workspaceDir,
145
192
  grokPath: env.AV_GROK_PATH || void 0,
@@ -149,35 +196,19 @@ function loadConfig(env, argv = []) {
149
196
  systemPrompt: env.AV_SYSTEM_PROMPT || void 0
150
197
  };
151
198
  }
152
- var INSTALL_FLAG, VERSION_FLAGS, HELP_FLAGS, KNOWN_FLAGS, CRED_FILES, BACKUP_FILE;
199
+ var REMOVED_INSTALL_FLAG, VERSION_FLAGS, HELP_FLAGS, KNOWN_FLAGS, CRED_FILES, BACKUP_FILE, LEGACY_DEFAULT_AGENT_NAME;
153
200
  var init_config = __esm({
154
201
  "src/config.ts"() {
155
202
  "use strict";
156
203
  init_pending_invite();
157
- INSTALL_FLAG = "--install";
204
+ init_log();
205
+ REMOVED_INSTALL_FLAG = "--install";
158
206
  VERSION_FLAGS = ["--version", "-v"];
159
207
  HELP_FLAGS = ["--help", "-h"];
160
- KNOWN_FLAGS = /* @__PURE__ */ new Set([INSTALL_FLAG, ...VERSION_FLAGS, ...HELP_FLAGS]);
208
+ KNOWN_FLAGS = /* @__PURE__ */ new Set([...VERSION_FLAGS, ...HELP_FLAGS]);
161
209
  CRED_FILES = ["agentvault.json", "secure-channel.json"];
162
210
  BACKUP_FILE = "agentvault.json.bak";
163
- }
164
- });
165
-
166
- // src/log.ts
167
- import { format } from "node:util";
168
- function stamp(message) {
169
- const ts = (/* @__PURE__ */ new Date()).toISOString();
170
- return message.split("\n").map((line) => `${ts} ${line}`).join("\n");
171
- }
172
- function logLine(...args) {
173
- console.error(stamp(format(...args)));
174
- }
175
- function printLine(text) {
176
- console.log(text);
177
- }
178
- var init_log = __esm({
179
- "src/log.ts"() {
180
- "use strict";
211
+ LEGACY_DEFAULT_AGENT_NAME = "Grok";
181
212
  }
182
213
  });
183
214
 
@@ -211,16 +242,6 @@ function writeLauncher(deps, spec) {
211
242
  deps.writeFile(spec.launcherPath, spec.launcherScript);
212
243
  deps.chmod(spec.launcherPath, 493);
213
244
  }
214
- function parseLauncherEntrypoint(script) {
215
- if (!script) return void 0;
216
- const m2 = /^if n=\$\(command -v .+\); then exec "\$n" (.+) "\$@"; fi$/m.exec(script);
217
- if (!m2) return void 0;
218
- return unSq(m2[1]);
219
- }
220
- function unSq(quoted) {
221
- if (quoted.length < 2 || !quoted.startsWith("'") || !quoted.endsWith("'")) return void 0;
222
- return quoted.slice(1, -1).replace(/'\\''/g, "'");
223
- }
224
245
  var FALLBACK_NODES;
225
246
  var init_launcher = __esm({
226
247
  "src/service/launcher.ts"() {
@@ -314,6 +335,37 @@ ${envEntries}
314
335
  </plist>
315
336
  `;
316
337
  }
338
+ function parsePlistEnv(xml) {
339
+ const out = {};
340
+ const keyIdx = xml.indexOf("<key>EnvironmentVariables</key>");
341
+ if (keyIdx < 0) return out;
342
+ const open = xml.indexOf("<dict>", keyIdx);
343
+ if (open < 0) return out;
344
+ let depth = 0;
345
+ let i2 = open;
346
+ let close = -1;
347
+ const tag = /<(\/?)dict>/g;
348
+ tag.lastIndex = open;
349
+ let m2;
350
+ while (m2 = tag.exec(xml)) {
351
+ depth += m2[1] ? -1 : 1;
352
+ if (depth === 0) {
353
+ close = m2.index;
354
+ break;
355
+ }
356
+ i2 = m2.index;
357
+ }
358
+ void i2;
359
+ if (close < 0) return out;
360
+ const body = xml.slice(open, close);
361
+ const pair = /<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g;
362
+ let p2;
363
+ while (p2 = pair.exec(body)) out[unesc(p2[1])] = unesc(p2[2]);
364
+ return out;
365
+ }
366
+ function unesc(s2) {
367
+ return s2.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
368
+ }
317
369
  var LaunchdBackend;
318
370
  var init_launchd = __esm({
319
371
  "src/service/launchd.ts"() {
@@ -368,6 +420,26 @@ var init_launchd = __esm({
368
420
  const r2 = this.deps.exec("launchctl", ["print", `${this.domain()}/${label}`]);
369
421
  return { loaded: r2.code === 0, detail: r2.stdout };
370
422
  }
423
+ /** #913 item 3 — every installed service whose label starts with `prefix`.
424
+ *
425
+ * Reads the plist directory rather than `launchctl list`, because an ORPHAN
426
+ * is typically installed-but-not-loaded: the revoked device exits 0 and
427
+ * KeepAlive leaves it down. `launchctl list` would not show the very thing
428
+ * doctor exists to find. */
429
+ list(prefix) {
430
+ const dir = join11(this.deps.home, "Library", "LaunchAgents");
431
+ const out = [];
432
+ for (const name of this.deps.readdir(dir)) {
433
+ if (!name.startsWith(prefix) || !name.endsWith(".plist")) continue;
434
+ const path2 = join11(dir, name);
435
+ out.push({
436
+ label: name.slice(0, -".plist".length),
437
+ path: path2,
438
+ env: parsePlistEnv(this.deps.readFile(path2))
439
+ });
440
+ }
441
+ return out.sort((a2, b2) => a2.label.localeCompare(b2.label));
442
+ }
371
443
  };
372
444
  }
373
445
  });
@@ -399,6 +471,24 @@ StandardError=append:${spec.logErr}
399
471
  WantedBy=default.target
400
472
  `;
401
473
  }
474
+ function parseUnitEnv(text) {
475
+ const out = {};
476
+ for (const line of text.split("\n")) {
477
+ const m2 = /^Environment="([\s\S]*)"\s*$/.exec(line.trim());
478
+ if (!m2) continue;
479
+ const eq = unq(m2[1]);
480
+ const at = eq.indexOf("=");
481
+ if (at <= 0) continue;
482
+ out[eq.slice(0, at)] = eq.slice(at + 1);
483
+ }
484
+ return out;
485
+ }
486
+ function unq(s2) {
487
+ return s2.replace(/%%/g, "%").replace(
488
+ /\\([\s\S])/g,
489
+ (_2, c2) => c2 === "n" ? "\n" : c2 === "r" ? "\r" : c2 === "t" ? " " : c2
490
+ );
491
+ }
402
492
  var SystemdBackend;
403
493
  var init_systemd = __esm({
404
494
  "src/service/systemd.ts"() {
@@ -439,18 +529,33 @@ var init_systemd = __esm({
439
529
  const active = this.deps.exec("systemctl", ["--user", "is-active", `${label}.service`]);
440
530
  return { loaded: enabled.code === 0, detail: `${enabled.stdout.trim() || "?"} / ${active.stdout.trim() || "?"}` };
441
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 = join12(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 = join12(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, b2) => a2.label.localeCompare(b2.label));
551
+ }
442
552
  };
443
553
  }
444
554
  });
445
555
 
446
556
  // src/service/backend.ts
447
- var backend_exports = {};
448
- __export(backend_exports, {
449
- defaultDeps: () => defaultDeps,
450
- selectBackend: () => selectBackend
451
- });
452
557
  import { spawnSync } from "node:child_process";
453
- import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3, rmSync as rmSync2, existsSync as existsSync4, chmodSync as chmodSync5 } from "node:fs";
558
+ import { mkdirSync as mkdirSync5, writeFileSync as writeFileSync3, rmSync as rmSync2, existsSync as existsSync4, chmodSync as chmodSync5, readdirSync, readFileSync as readFileSync4 } from "node:fs";
454
559
  import { userInfo } from "node:os";
455
560
  function defaultDeps() {
456
561
  return {
@@ -465,7 +570,21 @@ function defaultDeps() {
465
570
  mkdir: (p2) => mkdirSync5(p2, { recursive: true }),
466
571
  chmod: (p2, m2) => chmodSync5(p2, m2),
467
572
  rm: (p2) => rmSync2(p2, { force: true }),
468
- exists: (p2) => existsSync4(p2)
573
+ exists: (p2) => existsSync4(p2),
574
+ readdir: (p2) => {
575
+ try {
576
+ return readdirSync(p2);
577
+ } catch {
578
+ return [];
579
+ }
580
+ },
581
+ readFile: (p2) => {
582
+ try {
583
+ return readFileSync4(p2, "utf8");
584
+ } catch {
585
+ return "";
586
+ }
587
+ }
469
588
  };
470
589
  }
471
590
  function selectBackend(platform, deps) {
@@ -488,6 +607,7 @@ var init_backend = __esm({
488
607
  // src/service/subcommand.ts
489
608
  var subcommand_exports = {};
490
609
  __export(subcommand_exports, {
610
+ labelFor: () => labelFor,
491
611
  maybeRunServiceSubcommand: () => maybeRunServiceSubcommand
492
612
  });
493
613
  function requireAgentName(env) {
@@ -510,7 +630,9 @@ function maybeRunServiceSubcommand(argv, env, deps) {
510
630
  const looksLikeFlag = cmd.startsWith("-");
511
631
  if (!looksLikeToken && !looksLikeFlag) {
512
632
  throw new Error(
513
- `unknown subcommand: ${cmd} \u2014 expected one of ${[...SERVICE_CMDS].join(", ")}, an invite token, or no argument at all`
633
+ // "doctor" is dispatched earlier, in index.ts, so it never reaches here.
634
+ // Named anyway: a customer who typed `doctr` should see it offered.
635
+ `unknown subcommand: ${cmd} \u2014 expected one of ${[...SERVICE_CMDS, "doctor"].join(", ")}, an invite token, or no argument at all`
514
636
  );
515
637
  }
516
638
  return false;
@@ -521,6 +643,7 @@ function maybeRunServiceSubcommand(argv, env, deps) {
521
643
  try {
522
644
  if (cmd === "install") {
523
645
  requireAgentName(env);
646
+ assertInstallableEntrypoint(entrypoint);
524
647
  const cfg = loadConfig(env, []);
525
648
  const firstBoot = !hasPersistedCreds(cfg.dataDir) && !!cfg.inviteToken;
526
649
  if (firstBoot) {
@@ -578,96 +701,313 @@ var init_subcommand = __esm({
578
701
  }
579
702
  });
580
703
 
581
- // src/service/install-on-ready.ts
582
- var install_on_ready_exports = {};
583
- __export(install_on_ready_exports, {
584
- installOnReady: () => installOnReady
704
+ // src/service/doctor.ts
705
+ import { basename } from "node:path";
706
+ async function buildDoctorReport(d2) {
707
+ const installed = d2.labelPrefixes.flatMap((p2) => d2.backend.list(p2));
708
+ const services = [];
709
+ for (const s2 of installed) services.push(await inspect(s2, d2));
710
+ const claimed = new Set(installed.map((s2) => s2.env.AV_DATA_DIR).filter(Boolean));
711
+ const unsupervised = [];
712
+ for (const dataDir of d2.credDirs()) {
713
+ if (claimed.has(dataDir)) continue;
714
+ unsupervised.push(await inspectUnsupervised(dataDir, d2));
715
+ }
716
+ return {
717
+ labelPrefixes: d2.labelPrefixes,
718
+ services,
719
+ unsupervised,
720
+ notChecked: d2.otherLabels()
721
+ };
722
+ }
723
+ async function inspect(s2, d2) {
724
+ const agentName = s2.env.AV_AGENT_NAME ?? "";
725
+ const dataDir = s2.env.AV_DATA_DIR ?? "";
726
+ let loaded = false;
727
+ try {
728
+ loaded = d2.backend.status(s2.label).loaded;
729
+ } catch {
730
+ }
731
+ const base = { label: s2.label, agentName, dataDir, loaded, deviceId: "" };
732
+ if (!dataDir) {
733
+ return {
734
+ ...base,
735
+ verdict: "WARN",
736
+ detail: "the service definition bakes no AV_DATA_DIR, so doctor cannot find its credentials and cannot check its device"
737
+ };
738
+ }
739
+ const creds = d2.readCreds(dataDir);
740
+ if (!creds?.deviceId) {
741
+ return {
742
+ ...base,
743
+ verdict: "WARN",
744
+ detail: `no credentials at ${dataDir} \u2014 installed, but this agent never enrolled`
745
+ };
746
+ }
747
+ const deviceId = creds.deviceId;
748
+ let probe;
749
+ try {
750
+ probe = await d2.fetchStatus(d2.apiUrl, deviceId);
751
+ } catch (e) {
752
+ return { ...base, deviceId, verdict: "SKIP", detail: `could not check the device: ${e.message}` };
753
+ }
754
+ if (probe.inconclusive) {
755
+ return { ...base, deviceId, verdict: "SKIP", detail: `could not check the device: ${probe.inconclusive}` };
756
+ }
757
+ if (probe.gone) {
758
+ return {
759
+ ...base,
760
+ deviceId,
761
+ verdict: "FAIL",
762
+ detail: "the device no longer exists \u2014 this service is an orphan",
763
+ fix: d2.uninstallCommand(agentName)
764
+ };
765
+ }
766
+ switch (probe.status) {
767
+ case "ACTIVE":
768
+ return { ...base, deviceId, verdict: "PASS", detail: "device ACTIVE" };
769
+ case "PENDING":
770
+ return {
771
+ ...base,
772
+ deviceId,
773
+ verdict: "WARN",
774
+ detail: "device PENDING \u2014 enrolled, but never approved in AgentVault"
775
+ };
776
+ case "REVOKED":
777
+ return {
778
+ ...base,
779
+ deviceId,
780
+ verdict: "FAIL",
781
+ detail: "the device was REVOKED \u2014 this service is an orphan",
782
+ fix: d2.uninstallCommand(agentName)
783
+ };
784
+ default:
785
+ return {
786
+ ...base,
787
+ deviceId,
788
+ verdict: "SKIP",
789
+ detail: `the server reported a status this build does not recognise (${probe.status || "none"})`
790
+ };
791
+ }
792
+ }
793
+ async function inspectUnsupervised(dataDir, d2) {
794
+ const agentName = basename(dataDir);
795
+ const base = { dataDir, agentName, deviceId: "" };
796
+ const deviceId = d2.readCreds(dataDir)?.deviceId;
797
+ if (!deviceId) {
798
+ return { ...base, verdict: "WARN", detail: "credentials present but they hold no device id" };
799
+ }
800
+ let probe;
801
+ try {
802
+ probe = await d2.fetchStatus(d2.apiUrl, deviceId);
803
+ } catch (e) {
804
+ return { ...base, deviceId, verdict: "SKIP", detail: `could not check the device: ${e.message}` };
805
+ }
806
+ if (probe.inconclusive) {
807
+ return { ...base, deviceId, verdict: "SKIP", detail: `could not check the device: ${probe.inconclusive}` };
808
+ }
809
+ if (probe.gone) {
810
+ return {
811
+ ...base,
812
+ deviceId,
813
+ verdict: "WARN",
814
+ detail: "the device no longer exists \u2014 these are leftover credentials for a deleted agent"
815
+ };
816
+ }
817
+ if (probe.status === "ACTIVE") {
818
+ return {
819
+ ...base,
820
+ deviceId,
821
+ verdict: "WARN",
822
+ detail: "the device is ACTIVE but nothing supervises it \u2014 this agent is not running",
823
+ fix: d2.installCommand(agentName)
824
+ };
825
+ }
826
+ if (probe.status === "PENDING") {
827
+ return {
828
+ ...base,
829
+ deviceId,
830
+ verdict: "WARN",
831
+ detail: "device PENDING \u2014 enrolled, but never approved in AgentVault"
832
+ };
833
+ }
834
+ if (probe.status === "REVOKED") {
835
+ return {
836
+ ...base,
837
+ deviceId,
838
+ verdict: "WARN",
839
+ detail: "the device was REVOKED \u2014 these are leftover credentials"
840
+ };
841
+ }
842
+ return {
843
+ ...base,
844
+ deviceId,
845
+ verdict: "SKIP",
846
+ detail: `the server reported a status this build does not recognise (${probe.status || "none"})`
847
+ };
848
+ }
849
+ function formatDoctorReport(r2) {
850
+ const out = [];
851
+ out.push(`Services installed for this bridge (${r2.labelPrefixes.map((p2) => p2 + "*").join(", ")})`);
852
+ if (r2.services.length === 0) {
853
+ out.push(` No services installed for this bridge on this machine.`);
854
+ }
855
+ for (const s2 of r2.services) {
856
+ out.push(
857
+ ` ${s2.verdict.padEnd(PAD)}${s2.label} [${s2.agentName || "unnamed"}] ${s2.loaded ? "loaded" : "not loaded"} \u2014 ${s2.detail}`
858
+ );
859
+ if (s2.fix) out.push(` ${"".padEnd(PAD)}fix: ${s2.fix}`);
860
+ }
861
+ if (r2.unsupervised.length) {
862
+ out.push("");
863
+ out.push("Credentials on disk that no installed service points at");
864
+ for (const u2 of r2.unsupervised) {
865
+ out.push(` ${u2.verdict.padEnd(PAD)}${u2.agentName} \u2014 ${u2.detail}`);
866
+ out.push(` ${"".padEnd(PAD)}${u2.dataDir}`);
867
+ if (u2.fix) out.push(` ${"".padEnd(PAD)}fix: ${u2.fix}`);
868
+ }
869
+ }
870
+ if (r2.notChecked.length) {
871
+ out.push("");
872
+ out.push("Found on this machine, NOT checked by this command");
873
+ for (const l2 of r2.notChecked) out.push(` ${l2}`);
874
+ out.push(
875
+ " These belong to other AgentVault agent families. This command can only read the credentials of its own, so it says nothing about their health."
876
+ );
877
+ }
878
+ const counts = { PASS: 0, WARN: 0, FAIL: 0, SKIP: 0 };
879
+ for (const s2 of r2.services) counts[s2.verdict]++;
880
+ for (const u2 of r2.unsupervised) counts[u2.verdict]++;
881
+ out.push("");
882
+ out.push(
883
+ `Summary: ${counts.PASS} PASS, ${counts.WARN} WARN, ${counts.FAIL} FAIL, ${counts.SKIP} SKIP` + (counts.SKIP ? " (SKIP = could not check, NOT a problem found)" : "")
884
+ );
885
+ if (counts.FAIL) {
886
+ out.push(
887
+ "`uninstall` removes the SERVICE only \u2014 your credentials stay on disk, so a mistake here costs a re-install, not an identity."
888
+ );
889
+ }
890
+ return out;
891
+ }
892
+ var PAD;
893
+ var init_doctor = __esm({
894
+ "src/service/doctor.ts"() {
895
+ "use strict";
896
+ PAD = 6;
897
+ }
898
+ });
899
+
900
+ // src/service/doctor-cli.ts
901
+ var doctor_cli_exports = {};
902
+ __export(doctor_cli_exports, {
903
+ fetchDeviceStatus: () => fetchDeviceStatus,
904
+ maybeRunDoctorSubcommand: () => maybeRunDoctorSubcommand,
905
+ realDoctorDeps: () => realDoctorDeps
585
906
  });
586
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "node:fs";
587
907
  import { join as join13 } from "node:path";
588
- function installOnReady(channel, cfg, deps) {
589
- const hasCreds = deps.hasCreds ?? hasPersistedCreds;
590
- const readLauncher = deps.readLauncher ?? ((p2) => existsSync5(p2) ? readFileSync4(p2, "utf8") : void 0);
591
- const fenceAuthExists = deps.fenceAuthExists ?? ((home) => existsSync5(join13(home, ".grok", "auth.json")));
592
- let fired = false;
593
- channel.on("ready", () => {
594
- if (fired) return;
595
- fired = true;
596
- void (async () => {
597
- try {
598
- if (!hasCreds(cfg.dataDir)) {
599
- throw new Error(
600
- `enrolment did not persist credentials to ${cfg.dataDir} \u2014 refusing to install a service that would crash-loop`
601
- );
602
- }
603
- if (!fenceAuthExists(cfg.fenceHome)) {
604
- throw new Error(
605
- "no grok credential in the fence \u2014 run `grok login`, then paste the same command again (your enrolment is already saved, so the token is not needed). Note that XAI_API_KEY from your shell is NOT inherited by the background service."
606
- );
607
- }
608
- if (deps.waitForIdle) {
609
- const timeoutMs = deps.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
610
- let timer;
611
- const timedOut = await Promise.race([
612
- deps.waitForIdle().then(() => false),
613
- new Promise((r2) => {
614
- timer = setTimeout(() => r2(true), timeoutMs);
615
- })
616
- ]);
617
- if (timer) clearTimeout(timer);
618
- if (timedOut) {
619
- deps.log(
620
- `agent still busy after ${timeoutMs}ms \u2014 handing off anyway; that reply may be lost`
621
- );
622
- }
623
- }
624
- const reaped = await deps.reap();
625
- if (!reaped.ok) {
626
- throw new Error(
627
- `teardown did not complete (${reaped.failures.join("; ")}) \u2014 refusing to install over a session that may still be open`
628
- );
629
- }
630
- const backend = deps.backend ?? selectBackend(process.platform);
631
- const spec = buildServiceSpec(cfg, {
632
- entrypoint: deps.entrypoint,
633
- home: deps.env.HOME ?? "",
634
- path: deps.env.PATH ?? ""
635
- });
636
- const installedEntrypoint = parseLauncherEntrypoint(readLauncher(spec.launcherPath));
637
- const entrypointIsCurrent = installedEntrypoint !== void 0 && !isEphemeralEntrypoint(installedEntrypoint) && installedEntrypoint === deps.entrypoint;
638
- if (backend.status(spec.label).loaded && entrypointIsCurrent) {
639
- backend.restart(spec.label);
640
- deps.log(`service ${spec.label} already installed and current \u2014 restarted`);
641
- } else {
642
- backend.install(spec);
643
- deps.log(
644
- installedEntrypoint && !entrypointIsCurrent ? `service ${spec.label} pointed at a stale entrypoint (${installedEntrypoint}) \u2014 re-installed` : `installed service ${spec.label}`
645
- );
646
- }
647
- const after = backend.status(spec.label);
648
- if (!after.loaded) {
649
- throw new Error(`service did not load: ${after.detail}`);
650
- }
651
- deps.log(`it is running now. logs: ${spec.logErr}`);
652
- deps.log(`check it: agentvault-grok-bridge status`);
653
- deps.log(`restart it: agentvault-grok-bridge restart (never 'install')`);
654
- deps.exit(0);
655
- } catch (e) {
656
- deps.log(`--install failed: ${e instanceof Error ? e.message : String(e)}`);
657
- deps.exit(1);
658
- }
659
- })();
908
+ import { readdirSync as readdirSync2 } from "node:fs";
909
+ async function fetchDeviceStatus(apiUrl, deviceId, doFetch = fetch) {
910
+ const res = await doFetch(`${apiUrl}/api/v1/devices/${deviceId}/status`, {
911
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS)
660
912
  });
913
+ if (res.status === 404) {
914
+ try {
915
+ await res.json();
916
+ } catch {
917
+ return {
918
+ inconclusive: `${apiUrl} answered 404 with a non-JSON body \u2014 check AV_API_URL points at the API`
919
+ };
920
+ }
921
+ return { gone: true };
922
+ }
923
+ if (res.status === 429) return { inconclusive: "rate limited by the API (429)" };
924
+ if (!res.ok) return { inconclusive: `the API answered ${res.status}` };
925
+ let body;
926
+ try {
927
+ body = await res.json();
928
+ } catch {
929
+ return { inconclusive: `${apiUrl} answered 200 with a non-JSON body \u2014 check AV_API_URL` };
930
+ }
931
+ return { status: body?.status };
932
+ }
933
+ function listCredDirs(base) {
934
+ const out = [];
935
+ if (hasPersistedCreds(base)) out.push(base);
936
+ let names;
937
+ try {
938
+ names = readdirSync2(base);
939
+ } catch {
940
+ return out;
941
+ }
942
+ for (const name of names) {
943
+ const dir = join13(base, name);
944
+ if (hasPersistedCreds(dir)) out.push(dir);
945
+ }
946
+ return out;
947
+ }
948
+ function otherFamilyLabels(backend) {
949
+ const out = [];
950
+ for (const p2 of OTHER_FAMILY_PREFIXES) {
951
+ try {
952
+ for (const s2 of backend.list(p2)) out.push(s2.label);
953
+ } catch {
954
+ }
955
+ }
956
+ return out.sort();
957
+ }
958
+ function realDoctorDeps(env) {
959
+ const backend = selectBackend(process.platform);
960
+ const base = join13(env.HOME ?? "", ".agentvault", "grok-room-bridge");
961
+ return {
962
+ backend,
963
+ labelPrefixes: [LABEL_PREFIX],
964
+ base,
965
+ // Same default as config.ts. Read from the env directly rather than through
966
+ // loadConfig, which now REFUSES without AV_AGENT_NAME (#921) — and doctor's
967
+ // whole job is to run when you do not know which agents exist.
968
+ apiUrl: env.AV_API_URL ?? "https://api.agentvault.chat",
969
+ uninstallCommand: (n2) => `AV_AGENT_NAME=${n2 || "<agent-name>"} agentvault-grok-bridge uninstall`,
970
+ installCommand: (n2) => `AV_AGENT_NAME=${n2 || "<agent-name>"} agentvault-grok-bridge install`,
971
+ readCreds: (dir) => {
972
+ const deviceId = readPersistedDeviceId(dir);
973
+ return deviceId ? { deviceId } : null;
974
+ },
975
+ credDirs: () => listCredDirs(base),
976
+ fetchStatus: (apiUrl, deviceId) => fetchDeviceStatus(apiUrl, deviceId),
977
+ otherLabels: () => otherFamilyLabels(backend)
978
+ };
661
979
  }
662
- var DEFAULT_IDLE_TIMEOUT_MS;
663
- var init_install_on_ready = __esm({
664
- "src/service/install-on-ready.ts"() {
980
+ async function maybeRunDoctorSubcommand(argv, opts = {}) {
981
+ if (argv[2] !== "doctor") return false;
982
+ const log = opts.log ?? printLine;
983
+ try {
984
+ const report = await buildDoctorReport(opts.deps ?? realDoctorDeps(opts.env ?? process.env));
985
+ for (const line of formatDoctorReport(report)) log(line);
986
+ if (report.services.some((s2) => s2.verdict === "FAIL")) process.exitCode = 1;
987
+ } catch (e) {
988
+ log(`doctor could not run: ${e.message}`);
989
+ process.exitCode = 1;
990
+ }
991
+ return true;
992
+ }
993
+ var LABEL_PREFIX, OTHER_FAMILY_PREFIXES, PROBE_TIMEOUT_MS;
994
+ var init_doctor_cli = __esm({
995
+ "src/service/doctor-cli.ts"() {
665
996
  "use strict";
666
- init_config();
667
- init_spec();
668
- init_launcher();
669
997
  init_backend();
670
- DEFAULT_IDLE_TIMEOUT_MS = 3e4;
998
+ init_config();
999
+ init_log();
1000
+ init_doctor();
1001
+ LABEL_PREFIX = "dev.agentvault.grok-";
1002
+ OTHER_FAMILY_PREFIXES = [
1003
+ "dev.agentvault.bridge-",
1004
+ // claude bridge, current label
1005
+ "com.agentvault.claude-bridge.",
1006
+ // claude bridge, pre-rename label (#837)
1007
+ "ai.hermes.gateway"
1008
+ // hermes, one service per profile
1009
+ ];
1010
+ PROBE_TIMEOUT_MS = 8e3;
671
1011
  }
672
1012
  });
673
1013
 
@@ -45559,6 +45899,11 @@ var init_ratchet = __esm2({
45559
45899
  return { header, headerSignature, ciphertext, nonce };
45560
45900
  }
45561
45901
  decrypt(message) {
45902
+ if (this.state.peerIdentityPublicKey) {
45903
+ if (!verifyHeaderSignature(message.header, message.headerSignature, this.state.peerIdentityPublicKey)) {
45904
+ throw new Error("Header signature verification failed");
45905
+ }
45906
+ }
45562
45907
  const isV2 = message.envelopeVersion === "2.0.0" && message.encryptedHeader != null && message.headerNonce != null;
45563
45908
  const skippedResult = this.trySkippedKeys(message, isV2);
45564
45909
  if (skippedResult !== null) {
@@ -45572,14 +45917,14 @@ var init_ratchet = __esm2({
45572
45917
  if (message.header.messageNumber === 0) {
45573
45918
  try {
45574
45919
  const { messageKey: testKey, headerKey: testHeaderKey, nextChainKey: nextChainKey2 } = kdfChainKey(this.state.rootKey);
45575
- let ad2;
45920
+ let ad;
45576
45921
  if (isV2) {
45577
45922
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, testHeaderKey);
45578
- ad2 = message.encryptedHeader;
45923
+ ad = message.encryptedHeader;
45579
45924
  } else {
45580
- ad2 = serializeHeader(message.header);
45925
+ ad = serializeHeader(message.header);
45581
45926
  }
45582
- const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad2, message.nonce, testKey);
45927
+ const ptBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, testKey);
45583
45928
  this.state.dhReceivingPublicKey = message.header.dhPublicKey;
45584
45929
  this.state.receivingChain = {
45585
45930
  chainKey: nextChainKey2,
@@ -45599,13 +45944,7 @@ var init_ratchet = __esm2({
45599
45944
  this.skipMessages(this.state.receivingChain, message.header.messageNumber, message.header.dhPublicKey, isV2);
45600
45945
  const chain = this.state.receivingChain;
45601
45946
  const { nextChainKey, messageKey, headerKey } = kdfChainKey(chain.chainKey);
45602
- chain.chainKey = nextChainKey;
45603
- chain.messageNumber++;
45604
- if (this.state.peerIdentityPublicKey) {
45605
- if (!verifyHeaderSignature(message.header, message.headerSignature, this.state.peerIdentityPublicKey)) {
45606
- throw new Error("Header signature verification failed");
45607
- }
45608
- }
45947
+ let plaintextBytes;
45609
45948
  if (isV2) {
45610
45949
  try {
45611
45950
  libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.encryptedHeader, null, message.headerNonce, headerKey);
@@ -45613,19 +45952,21 @@ var init_ratchet = __esm2({
45613
45952
  throw new Error("V2 header decryption failed");
45614
45953
  }
45615
45954
  try {
45616
- const plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, message.encryptedHeader, message.nonce, messageKey);
45617
- return libsodium_wrappers_default.to_string(plaintextBytes);
45955
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, message.encryptedHeader, message.nonce, messageKey);
45618
45956
  } catch {
45619
45957
  throw new Error("V2 decryption failed: ciphertext tampered or wrong key");
45620
45958
  }
45959
+ } else {
45960
+ const ad = serializeHeader(message.header);
45961
+ try {
45962
+ plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, messageKey);
45963
+ } catch {
45964
+ throw new Error("Decryption failed: ciphertext tampered or wrong key");
45965
+ }
45621
45966
  }
45622
- const ad = serializeHeader(message.header);
45623
- try {
45624
- const plaintextBytes = libsodium_wrappers_default.crypto_aead_xchacha20poly1305_ietf_decrypt(null, message.ciphertext, ad, message.nonce, messageKey);
45625
- return libsodium_wrappers_default.to_string(plaintextBytes);
45626
- } catch {
45627
- throw new Error("Decryption failed: ciphertext tampered or wrong key");
45628
- }
45967
+ chain.chainKey = nextChainKey;
45968
+ chain.messageNumber++;
45969
+ return libsodium_wrappers_default.to_string(plaintextBytes);
45629
45970
  }
45630
45971
  dhRatchetReceive(theirDhPublic) {
45631
45972
  this.state.previousSendingChainLength = this.state.sendingChain?.messageNumber ?? 0;
@@ -63799,6 +64140,23 @@ var init_mls_delivery_order = __esm2({
63799
64140
  "use strict";
63800
64141
  }
63801
64142
  });
64143
+ function noteObservedEpoch(current, seen) {
64144
+ const n2 = Number(seen);
64145
+ if (!Number.isInteger(n2) || n2 < 0)
64146
+ return current;
64147
+ return n2 > current ? n2 : current;
64148
+ }
64149
+ function needsPreSendResync(localEpoch, observedEpoch) {
64150
+ if (!Number.isInteger(localEpoch) || !Number.isInteger(observedEpoch)) {
64151
+ return false;
64152
+ }
64153
+ return observedEpoch > localEpoch;
64154
+ }
64155
+ var init_mls_presend_epoch = __esm2({
64156
+ "../crypto/dist/mls-presend-epoch.js"() {
64157
+ "use strict";
64158
+ }
64159
+ });
63802
64160
  var dist_exports = {};
63803
64161
  __export2(dist_exports, {
63804
64162
  AV_CREDENTIAL_CONTEXT: () => AV_CREDENTIAL_CONTEXT,
@@ -63868,7 +64226,9 @@ __export2(dist_exports, {
63868
64226
  hexTransportToEncryptedMessage: () => hexTransportToEncryptedMessage,
63869
64227
  issueCredential: () => issueCredential,
63870
64228
  multibaseToPublicKey: () => multibaseToPublicKey,
64229
+ needsPreSendResync: () => needsPreSendResync,
63871
64230
  normalizeBackupCode: () => normalizeBackupCode,
64231
+ noteObservedEpoch: () => noteObservedEpoch,
63872
64232
  orderDeliveryBatch: () => orderDeliveryBatch,
63873
64233
  parseTraceparent: () => parseTraceparent,
63874
64234
  performX3DH: () => performX3DH,
@@ -63908,6 +64268,7 @@ var init_dist = __esm2({
63908
64268
  init_mls_group();
63909
64269
  await init_owner_sync();
63910
64270
  init_mls_delivery_order();
64271
+ init_mls_presend_epoch();
63911
64272
  }
63912
64273
  });
63913
64274
  async function ensureSecureDir(dir) {
@@ -64100,7 +64461,7 @@ var init_mls_kp_pool = __esm2({
64100
64461
  }
64101
64462
  });
64102
64463
  function ownIdentity() {
64103
- const v2 = true ? "0.23.23" : FALLBACK;
64464
+ const v2 = true ? "0.23.27" : FALLBACK;
64104
64465
  return `${PACKAGE}@${v2}`;
64105
64466
  }
64106
64467
  function buildClientVersion(override) {
@@ -65016,6 +65377,7 @@ var init_channel = __esm2({
65016
65377
  await init_libsodium_wrappers();
65017
65378
  await init_dist();
65018
65379
  await init_dist();
65380
+ init_mls_presend_epoch();
65019
65381
  init_mls_state();
65020
65382
  await init_mls_kp_pool();
65021
65383
  init_client_version();
@@ -65129,6 +65491,17 @@ var init_channel = __esm2({
65129
65491
  * self-heal rather than be logged and dropped. */
65130
65492
  _mlsCommitFailCounts = /* @__PURE__ */ new Map();
65131
65493
  static MAX_MLS_DECRYPT_FAILS = 3;
65494
+ /**
65495
+ * #1084: consecutive resyncs that found NOTHING to apply, per group.
65496
+ *
65497
+ * "No new commits" means the server holds no commit past our epoch — we are
65498
+ * NOT BEHIND. Treating that as corruption and re-keying is what drove the
65499
+ * epoch churn (see `_resyncOnDivergence`). But a state that is genuinely
65500
+ * broken while its epoch counter happens to match must still recover, so the
65501
+ * suppression is bounded rather than absolute.
65502
+ */
65503
+ _resyncNoOpStrikes = /* @__PURE__ */ new Map();
65504
+ static MAX_RESYNC_NOOPS = 3;
65132
65505
  /** Cached MLS KeyPackage bundle for this device (regenerated on each connect). */
65133
65506
  _mlsKeyPackage = null;
65134
65507
  /** Pending KeyPackage bundle from request-Welcome flow (used by _handleMlsWelcome). */
@@ -65166,6 +65539,13 @@ var init_channel = __esm2({
65166
65539
  _kpPoolFilling = false;
65167
65540
  /** Buffer for MLS commits received before Welcome (keyed by groupId, sorted by epoch). */
65168
65541
  _pendingMlsCommits = /* @__PURE__ */ new Map();
65542
+ /**
65543
+ * #1013: the highest group epoch any inbound frame has reported, per MLS
65544
+ * group id. Every inbound frame carries the epoch the group was at when it
65545
+ * was produced, so each is a LOWER BOUND on the group's current epoch — the
65546
+ * hint is monotonic and only ever rises.
65547
+ */
65548
+ _observedGroupEpochs = /* @__PURE__ */ new Map();
65169
65549
  /** In-memory credential store for renter-provided credentials (never persisted). */
65170
65550
  _credentialStore = new CredentialStore();
65171
65551
  /** Rooms whose roster has been refreshed from the backend at least once in
@@ -65850,16 +66230,44 @@ var init_channel = __esm2({
65850
66230
  const pendingWsSends = [];
65851
66231
  const sentSharedGroupIds = /* @__PURE__ */ new Set();
65852
66232
  const addressedMlsGroupIds = [];
66233
+ if (!(this._persisted?.mlsGroups && this._state === "ready" && this._ws)) {
66234
+ console.warn(
66235
+ `[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"}`
66236
+ );
66237
+ }
65853
66238
  if (this._persisted?.mlsGroups && this._state === "ready" && this._ws) {
65854
66239
  const targetSharedGid = targetConvId ? this._sessionGroupIds?.get(targetConvId) : void 0;
66240
+ console.log(
66241
+ `[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)"}`
66242
+ );
66243
+ const skipped = [];
65855
66244
  for (const [gid, entry] of Object.entries(this._persisted.mlsGroups)) {
65856
- if (targetConvId && gid !== targetSharedGid) continue;
65857
- if (!entry.mlsGroupId) continue;
66245
+ if (targetConvId && gid !== targetSharedGid) {
66246
+ skipped.push(`${gid.slice(0, 8)}:not-target`);
66247
+ continue;
66248
+ }
66249
+ if (!entry.mlsGroupId) {
66250
+ skipped.push(`${gid.slice(0, 8)}:no-mlsGroupId`);
66251
+ continue;
66252
+ }
65858
66253
  const mlsGroup = this._mlsGroups.get(`1to1-group:${gid}`);
65859
- if (!mlsGroup?.isInitialized || Number(mlsGroup.epoch) <= 0) continue;
66254
+ if (!mlsGroup?.isInitialized) {
66255
+ skipped.push(`${gid.slice(0, 8)}:not-in-memory`);
66256
+ continue;
66257
+ }
66258
+ if (Number(mlsGroup.epoch) <= 0) {
66259
+ skipped.push(`${gid.slice(0, 8)}:epoch<=0`);
66260
+ continue;
66261
+ }
65860
66262
  try {
65861
66263
  const plaintextBytes = new TextEncoder().encode(plaintext);
65862
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66264
+ const cipherBytes = await this._encryptWithCatchUp(
66265
+ mlsGroup,
66266
+ `1to1-group:${gid}`,
66267
+ entry.mlsGroupId,
66268
+ `1:1 group ${gid.slice(0, 8)}`,
66269
+ plaintextBytes
66270
+ );
65863
66271
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
65864
66272
  const payload = {
65865
66273
  group_id: entry.mlsGroupId,
@@ -65881,8 +66289,12 @@ var init_channel = __esm2({
65881
66289
  console.log(`[SecureChannel] Shared MLS group send for group ${gid.slice(0, 8)} (${entry.mlsGroupId.slice(0, 8)})`);
65882
66290
  } catch (err) {
65883
66291
  console.error(`[SecureChannel] Shared MLS group send failed for ${gid.slice(0, 8)}:`, err);
66292
+ skipped.push(`${gid.slice(0, 8)}:encrypt-threw`);
65884
66293
  }
65885
66294
  }
66295
+ if (skipped.length > 0) {
66296
+ console.log(`[SecureChannel] send(): skipped ${skipped.length} group(s) \u2014 ${skipped.join(", ")}`);
66297
+ }
65886
66298
  }
65887
66299
  for (const [convId, session] of this._sessions) {
65888
66300
  if (!session.activated) continue;
@@ -65902,7 +66314,14 @@ var init_channel = __esm2({
65902
66314
  }
65903
66315
  if (mlsGroup?.isInitialized && mlsGroupId && Number(mlsGroup.epoch) > 0 && this._state === "ready" && this._ws) {
65904
66316
  const plaintextBytes = new TextEncoder().encode(plaintext);
65905
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66317
+ const sessionGroupKey = convGroupId && this._mlsGroups.has(`1to1-group:${convGroupId}`) ? `1to1-group:${convGroupId}` : `conv:${convId}`;
66318
+ const cipherBytes = await this._encryptWithCatchUp(
66319
+ mlsGroup,
66320
+ sessionGroupKey,
66321
+ mlsGroupId,
66322
+ `conversation ${convId.slice(0, 8)}`,
66323
+ plaintextBytes
66324
+ );
65906
66325
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
65907
66326
  const payload = {
65908
66327
  conversation_id: convId,
@@ -65997,7 +66416,14 @@ var init_channel = __esm2({
65997
66416
  }
65998
66417
  try {
65999
66418
  const plaintextBytes = new TextEncoder().encode(plaintext);
66000
- const cipherBytes = await mlsGroup.encrypt(plaintextBytes);
66419
+ const mlsOnlyGroupKey = mlsOnlyConvGroupId && this._mlsGroups.has(`1to1-group:${mlsOnlyConvGroupId}`) ? `1to1-group:${mlsOnlyConvGroupId}` : `conv:${mlsConvId}`;
66420
+ const cipherBytes = await this._encryptWithCatchUp(
66421
+ mlsGroup,
66422
+ mlsOnlyGroupKey,
66423
+ resolvedMlsGroupId,
66424
+ `conversation ${mlsConvId.slice(0, 8)}`,
66425
+ plaintextBytes
66426
+ );
66001
66427
  await saveMlsState(this.config.dataDir, resolvedMlsGroupId, JSON.stringify(mlsGroup.exportState()));
66002
66428
  const payload = {
66003
66429
  conversation_id: mlsConvId,
@@ -66023,13 +66449,26 @@ var init_channel = __esm2({
66023
66449
  }
66024
66450
  }
66025
66451
  }
66026
- if (sentCount === 0 && this._sessions.size > 0) {
66027
- console.warn("[SecureChannel] send() delivered to 0 sessions (all skipped or failed)");
66452
+ if (sentCount === 0) {
66453
+ console.warn(
66454
+ `[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})`
66455
+ );
66028
66456
  }
66029
66457
  await this._persistState();
66458
+ const wsReady = this._ws?.readyState === 1;
66459
+ if (pendingWsSends.length > 0 && !wsReady) {
66460
+ console.warn(
66461
+ `[SecureChannel] send(): ${pendingWsSends.length} frame(s) NOT written \u2014 socket readyState=${this._ws?.readyState ?? "null"} (1=OPEN)`
66462
+ );
66463
+ }
66030
66464
  for (const frame of pendingWsSends) {
66031
66465
  this._ws.send(frame);
66032
66466
  }
66467
+ if (pendingWsSends.length > 0) {
66468
+ console.log(
66469
+ `[SecureChannel] send(): wrote ${pendingWsSends.length} frame(s) to the socket (sentCount=${sentCount}, readyState=${this._ws?.readyState ?? "null"})`
66470
+ );
66471
+ }
66033
66472
  if (!options?.isResend) {
66034
66473
  for (const mlsGroupId of addressedMlsGroupIds) {
66035
66474
  this._rememberRetryCandidate(mlsGroupId, plaintext, options);
@@ -66079,7 +66518,7 @@ var init_channel = __esm2({
66079
66518
  */
66080
66519
  sendActivitySpan(spanData) {
66081
66520
  if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
66082
- const pluginVersion = true ? "0.23.23" : "0.0.0-dev";
66521
+ const pluginVersion = true ? "0.23.27" : "0.0.0-dev";
66083
66522
  const agentName = this.config.agentName ?? "Agent";
66084
66523
  const resource = {
66085
66524
  "service.name": "agentvault-agent",
@@ -66289,7 +66728,13 @@ var init_channel = __esm2({
66289
66728
  if (mlsGroup?.isInitialized) {
66290
66729
  try {
66291
66730
  const plaintextBytes = new TextEncoder().encode(plaintext);
66292
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
66731
+ const ciphertext = await this._encryptWithCatchUp(
66732
+ mlsGroup,
66733
+ roomId,
66734
+ room.mlsGroupId,
66735
+ `room ${roomId.slice(0, 8)}`,
66736
+ plaintextBytes
66737
+ );
66293
66738
  await saveMlsState(this.config.dataDir, room.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
66294
66739
  if (this._state === "ready" && this._ws) {
66295
66740
  this._ws.send(JSON.stringify({
@@ -66540,11 +66985,17 @@ var init_channel = __esm2({
66540
66985
  const before = this._persisted.groupId;
66541
66986
  this._sessionGroupIds = new Map(mine.map((r2) => [r2.id, r2.group_id]));
66542
66987
  this._persisted.conversationGroupIds = Object.fromEntries(this._sessionGroupIds);
66543
- const primary = mine.find((r2) => r2.id === this._persisted.primaryConversationId) ?? mine[0];
66988
+ const joined = mine.find((r2) => this._persisted.mlsGroups?.[r2.group_id]);
66989
+ const primary = mine.find((r2) => r2.id === this._persisted.primaryConversationId) ?? joined ?? mine[0];
66990
+ if (!mine.some((r2) => r2.id === this._persisted.primaryConversationId)) {
66991
+ console.log(
66992
+ `[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"}`
66993
+ );
66994
+ }
66544
66995
  this._persisted.groupId = primary.group_id;
66545
66996
  this._persisted.primaryConversationId = primary.id;
66546
66997
  const liveGroupIds = new Set(mine.map((r2) => r2.group_id));
66547
- if (!liveGroupIds.has(staleGid)) {
66998
+ if (staleGid && staleMlsGroupId && !liveGroupIds.has(staleGid)) {
66548
66999
  try {
66549
67000
  await deleteMlsState(this.config.dataDir, staleMlsGroupId);
66550
67001
  } catch {
@@ -66558,9 +67009,9 @@ var init_channel = __esm2({
66558
67009
  }
66559
67010
  await this._persistState();
66560
67011
  console.log(
66561
- `[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))`
67012
+ `[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))`
66562
67013
  );
66563
- return this._persisted.groupId !== before && before === staleGid;
67014
+ return staleGid !== void 0 && this._persisted.groupId !== before && before === staleGid;
66564
67015
  }
66565
67016
  /**
66566
67017
  * Resend the message that a now-reconciled `unknown group` refusal killed (#732).
@@ -67468,7 +67919,13 @@ var init_channel = __esm2({
67468
67919
  const mlsGroup = this._mlsGroups.get(`a2a:${channelEntry.channelId}`);
67469
67920
  if (mlsGroup?.isInitialized) {
67470
67921
  const plaintextBytes = new TextEncoder().encode(text);
67471
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
67922
+ const ciphertext = await this._encryptWithCatchUp(
67923
+ mlsGroup,
67924
+ `a2a:${channelEntry.channelId}`,
67925
+ channelEntry.mlsGroupId,
67926
+ `A2A ${channelEntry.channelId.slice(0, 8)}`,
67927
+ plaintextBytes
67928
+ );
67472
67929
  await saveMlsState(
67473
67930
  this.config.dataDir,
67474
67931
  channelEntry.mlsGroupId,
@@ -67516,7 +67973,13 @@ var init_channel = __esm2({
67516
67973
  const mlsGroup = this._mlsGroups.get(`a2a:${channelId}`);
67517
67974
  if (mlsGroup?.isInitialized) {
67518
67975
  const plaintextBytes = new TextEncoder().encode(text);
67519
- const ciphertext = await mlsGroup.encrypt(plaintextBytes);
67976
+ const ciphertext = await this._encryptWithCatchUp(
67977
+ mlsGroup,
67978
+ `a2a:${channelId}`,
67979
+ entry.mlsGroupId,
67980
+ `A2A ${channelId.slice(0, 8)}`,
67981
+ plaintextBytes
67982
+ );
67520
67983
  await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
67521
67984
  this._ws.send(JSON.stringify({
67522
67985
  event: "a2a_message_mls",
@@ -67962,6 +68425,9 @@ var init_channel = __esm2({
67962
68425
  await this._pullDrDeliveryQueue();
67963
68426
  await this._flushOutboundQueue();
67964
68427
  this._setState("ready");
68428
+ void this._reconcileConversationGroups().catch((err) => {
68429
+ console.warn("[SecureChannel] Conversation-group reconcile failed:", err);
68430
+ });
67965
68431
  void this._reconcileRoomsWithServer().catch(
67966
68432
  (err) => console.warn(`[SecureChannel] room reconcile failed (ignored): ${err instanceof Error ? err.message : String(err)}`)
67967
68433
  ).then(() => this._quarantineOrphanedMlsGroups()).catch(
@@ -67989,7 +68455,7 @@ var init_channel = __esm2({
67989
68455
  agentVersion: this.config.agentVersion ?? "0.0.0",
67990
68456
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
67991
68457
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
67992
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68458
+ pluginVersion: true ? "0.23.27" : "0.0.0-dev"
67993
68459
  });
67994
68460
  this._telemetryReporter.startAutoFlush(3e4);
67995
68461
  }
@@ -68313,7 +68779,7 @@ var init_channel = __esm2({
68313
68779
  agentVersion: this.config.agentVersion ?? "0.0.0",
68314
68780
  // __AV_VERSION__ is injected by esbuild from package.json at build time.
68315
68781
  // Falls back to "0.0.0-dev" in non-bundled contexts (tests).
68316
- pluginVersion: true ? "0.23.23" : "0.0.0-dev"
68782
+ pluginVersion: true ? "0.23.27" : "0.0.0-dev"
68317
68783
  });
68318
68784
  this._telemetryReporter.startAutoFlush(3e4);
68319
68785
  }
@@ -68679,6 +69145,7 @@ var init_channel = __esm2({
68679
69145
  const convGroupId = data.conversation_group_id;
68680
69146
  const groupId = data.group_id;
68681
69147
  const senderDeviceId = data.sender_device_id;
69148
+ this._noteGroupEpoch(groupId, data.epoch);
68682
69149
  if (senderDeviceId === this._deviceId) return;
68683
69150
  let mgr;
68684
69151
  let mgrKey;
@@ -68756,9 +69223,15 @@ var init_channel = __esm2({
68756
69223
  })),
68757
69224
  no_history: filtered.length === 0
68758
69225
  });
68759
- if (mgr && mlsGroupId) {
69226
+ if (mgr && mgrKey && mlsGroupId) {
68760
69227
  const responseBytes = new TextEncoder().encode(responsePayload);
68761
- const cipher = await mgr.encrypt(responseBytes);
69228
+ const cipher = await this._encryptWithCatchUp(
69229
+ mgr,
69230
+ mgrKey,
69231
+ mlsGroupId,
69232
+ `history catch-up ${mlsGroupId.slice(0, 8)}`,
69233
+ responseBytes
69234
+ );
68762
69235
  await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mgr.exportState()));
68763
69236
  if (this._ws) {
68764
69237
  this._ws.send(JSON.stringify({
@@ -69501,9 +69974,36 @@ ${messageText}`;
69501
69974
  // ---------------------------------------------------------------------------
69502
69975
  // MLS room message handlers
69503
69976
  // ---------------------------------------------------------------------------
69977
+ /**
69978
+ * #1014: hand a room-framed frame to the handler that owns its group family.
69979
+ *
69980
+ * Returns true when the frame was routed. Routing means "give it to the right
69981
+ * handler" — NOT "decrypt it as a room message". Decrypting under the wrong
69982
+ * group is how MLS state gets corrupted, so an unplaceable group is left for
69983
+ * the caller to self-heal rather than guessed at.
69984
+ */
69985
+ async _routeNonRoomGroupMessage(data, groupId) {
69986
+ const target = this._resolveGroupFamily(groupId);
69987
+ if (!target || target.family === "room") return false;
69988
+ console.log(
69989
+ `[SecureChannel] Room-framed message belongs to ${target.label} \u2014 routing to the handler that owns it (#1014)`
69990
+ );
69991
+ switch (target.family) {
69992
+ case "shared1to1":
69993
+ await this._handleMessageMLS({ ...data, conversation_group_id: target.id });
69994
+ return true;
69995
+ case "conversation":
69996
+ await this._handleMessageMLS({ ...data, conversation_id: target.id });
69997
+ return true;
69998
+ case "a2a":
69999
+ await this._handleA2AMessageMLS({ ...data, a2a_channel_id: target.id });
70000
+ return true;
70001
+ }
70002
+ }
69504
70003
  async _handleRoomMessageMLS(data) {
69505
70004
  const groupId = data.group_id;
69506
70005
  const senderDeviceId = data.sender_device_id;
70006
+ this._noteGroupEpoch(groupId, data.epoch);
69507
70007
  if (senderDeviceId === this._deviceId) return;
69508
70008
  let roomId;
69509
70009
  for (const [rid, room] of Object.entries(this._persisted?.rooms ?? {})) {
@@ -69517,7 +70017,9 @@ ${messageText}`;
69517
70017
  roomId = data.room_id;
69518
70018
  console.log(`[SecureChannel] Room ${roomId.slice(0, 8)} matched by room_id (group_id ${groupId?.slice(0, 8)} mismatch)`);
69519
70019
  } else {
70020
+ if (await this._routeNonRoomGroupMessage(data, groupId)) return;
69520
70021
  console.warn(`[SecureChannel] No room found for MLS group ${groupId?.slice(0, 8)}`);
70022
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
69521
70023
  return;
69522
70024
  }
69523
70025
  }
@@ -69705,63 +70207,63 @@ ${messageText}`;
69705
70207
  console.warn(`[SecureChannel] Failed to send history catchup response:`, sendErr);
69706
70208
  }
69707
70209
  }
70210
+ /**
70211
+ * Apply an inbound MLS commit to whichever group it addresses.
70212
+ *
70213
+ * Returns TRUE when the commit was applied, buffered, or handed to the
70214
+ * failure ladder — i.e. when this agent has taken responsibility for it and
70215
+ * the delivery-queue row may safely be acked. Returns FALSE when the commit
70216
+ * names a group we hold no state for at all, so the caller must NACK and let
70217
+ * the row survive.
70218
+ *
70219
+ * #1012: this used to loop `rooms` then `a2aChannels` and then END. A shared
70220
+ * 1:1 commit (`mlsGroups`) and a legacy per-conversation commit
70221
+ * (`mlsConversations`) matched neither. The call fell off the bottom with no
70222
+ * log, no buffer and no error — and because it did not THROW,
70223
+ * `_pullDeliveryQueue` acked the row and the server deleted it. An MLS commit
70224
+ * cannot be regenerated, so the agent stayed at epoch N while the group moved
70225
+ * to N+1 and every later send() encrypted at an epoch nobody could decrypt.
70226
+ *
70227
+ * The families are enumerated in ONE list resolved by ONE loop, because two
70228
+ * hand-copied loops are exactly how the 1:1 path fell two families behind.
70229
+ */
69708
70230
  async _handleMlsCommit(data) {
69709
70231
  const groupId = data.group_id;
69710
70232
  const epoch = typeof data.epoch === "number" ? data.epoch : 0;
69711
- for (const [roomId, room] of Object.entries(this._persisted?.rooms ?? {})) {
69712
- if (room.mlsGroupId === groupId) {
69713
- const mlsGroup = this._mlsGroups.get(roomId);
69714
- if (mlsGroup?.isInitialized) {
69715
- try {
69716
- const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
69717
- await mlsGroup.processCommit(commitBytes);
69718
- await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
69719
- console.log(`[SecureChannel] MLS commit processed for room ${roomId.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
69720
- this._mlsCommitFailCounts.delete(roomId);
69721
- } catch (err) {
69722
- await this._onCommitFailure(
69723
- roomId,
69724
- groupId,
69725
- err,
69726
- `room ${roomId.slice(0, 8)}`,
69727
- data.epoch,
69728
- mlsGroup.epoch
69729
- );
69730
- }
69731
- } else {
69732
- this._bufferMlsCommit(groupId, epoch, data);
69733
- console.log(`[SecureChannel] Buffered MLS commit for room ${roomId.slice(0, 8)} (epoch=${epoch}, group not initialized)`);
69734
- }
69735
- return;
70233
+ this._noteGroupEpoch(groupId, data.epoch);
70234
+ const target = this._resolveGroupFamily(groupId);
70235
+ if (target) {
70236
+ const { managerKey: groupKey, label } = target;
70237
+ const mlsGroup = this._mlsGroups.get(groupKey);
70238
+ if (!mlsGroup?.isInitialized) {
70239
+ this._bufferMlsCommit(groupId, epoch, data);
70240
+ console.log(`[SecureChannel] Buffered MLS commit for ${label} (epoch=${epoch}, group not initialized)`);
70241
+ return true;
69736
70242
  }
69737
- }
69738
- for (const [chId, chState] of Object.entries(this._persisted?.a2aChannels ?? {})) {
69739
- if (chState.mlsGroupId === groupId) {
69740
- const mlsGroup = this._mlsGroups.get(`a2a:${chId}`);
69741
- if (mlsGroup?.isInitialized) {
69742
- try {
69743
- const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
69744
- await mlsGroup.processCommit(commitBytes);
69745
- await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
69746
- console.log(`[SecureChannel] MLS commit processed for A2A ${chId.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
69747
- this._mlsCommitFailCounts.delete(`a2a:${chId}`);
69748
- } catch (err) {
69749
- await this._onCommitFailure(
69750
- `a2a:${chId}`,
69751
- groupId,
69752
- err,
69753
- `A2A ${chId.slice(0, 8)}`,
69754
- data.epoch,
69755
- mlsGroup.epoch
69756
- );
69757
- }
69758
- } else {
69759
- this._bufferMlsCommit(groupId, epoch, data);
69760
- console.log(`[SecureChannel] Buffered MLS commit for A2A ${chId.slice(0, 8)} (epoch=${epoch}, group not initialized)`);
69761
- }
69762
- return;
70243
+ try {
70244
+ const commitBytes = new Uint8Array(Buffer.from(data.payload, "hex"));
70245
+ await mlsGroup.processCommit(commitBytes);
70246
+ await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mlsGroup.exportState()));
70247
+ console.log(`[SecureChannel] MLS commit processed for ${label} (epoch=${mlsGroup.epoch})`);
70248
+ this._mlsCommitFailCounts.delete(groupKey);
70249
+ } catch (err) {
70250
+ await this._onCommitFailure(
70251
+ groupKey,
70252
+ groupId,
70253
+ err,
70254
+ label,
70255
+ data.epoch,
70256
+ mlsGroup.epoch
70257
+ );
69763
70258
  }
70259
+ return true;
69764
70260
  }
70261
+ this._bufferMlsCommit(groupId, epoch, data);
70262
+ console.warn(
70263
+ `[SecureChannel] MLS commit for UNKNOWN group ${String(groupId).slice(0, 8)} (epoch=${epoch}) \u2014 buffered, requesting a Welcome, NOT acked`
70264
+ );
70265
+ if (groupId) void this._requestWelcomeSelfHeal(groupId);
70266
+ return false;
69765
70267
  }
69766
70268
  /** Buffer an MLS commit for replay after Welcome join. Max 50 per group. */
69767
70269
  _bufferMlsCommit(groupId, epoch, data) {
@@ -69951,6 +70453,229 @@ ${messageText}`;
69951
70453
  * `1to1-group:<gid>`, `a2a:<channelId>`, …) — also the decrypt-fail-count key.
69952
70454
  * @param groupId the server-side MLS group id used in the /sync URL.
69953
70455
  */
70456
+ /**
70457
+ * #1015 — the ONLY place this package fulfils a Welcome request.
70458
+ *
70459
+ * Add the members, transmit, and record `fulfilled` **only for the targets
70460
+ * whose frames actually went out**. Every one of the five sites that used to
70461
+ * do this by hand followed the same sequence and had the same three holes:
70462
+ * `this._ws` null (both sends skipped by an `if`), a CLOSING socket (`send`
70463
+ * returns without throwing), and a fulfil POST wrapped in `.catch(() => {})`.
70464
+ *
70465
+ * A fulfilled request is never re-requested. So a Welcome that evaporated
70466
+ * left the requester locked out, the fulfiller at epoch N+1, everyone else at
70467
+ * N, and the bridging commit untransmitted and impossible to regenerate — a
70468
+ * permanently wedged group in which every signal read success.
70469
+ *
70470
+ * Returns the number of requests actually fulfilled.
70471
+ */
70472
+ async _fulfilWelcomeRequests(opts) {
70473
+ const { mlsGroup, mlsGroupId, label, welcomeFields, targets } = opts;
70474
+ if (targets.length === 0) return 0;
70475
+ if (!this._wsIsOpen()) {
70476
+ console.warn(
70477
+ `[SecureChannel] ${label}: socket is not OPEN \u2014 NOT advancing the group; ${targets.length} request(s) stay pending for the next pull`
70478
+ );
70479
+ return 0;
70480
+ }
70481
+ const snapshot = mlsGroup.exportState();
70482
+ const { commit, welcome } = await mlsGroup.addMembers(targets.map((t2) => t2.keyPackage));
70483
+ const commitSent = await this._sendFrameConfirmed(
70484
+ {
70485
+ event: "mls_commit",
70486
+ data: {
70487
+ group_id: mlsGroupId,
70488
+ epoch: Number(mlsGroup.epoch),
70489
+ payload: Buffer.from(commit).toString("hex")
70490
+ }
70491
+ },
70492
+ `${label} commit`
70493
+ );
70494
+ if (!commitSent) {
70495
+ mlsGroup.importState(snapshot);
70496
+ console.warn(
70497
+ `[SecureChannel] ${label}: commit was NOT written \u2014 rolled back to epoch ${Number(mlsGroup.epoch)}, nothing fulfilled, requests stay pending`
70498
+ );
70499
+ return 0;
70500
+ }
70501
+ await saveMlsState(this.config.dataDir, mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70502
+ if (!welcome) {
70503
+ console.warn(
70504
+ `[SecureChannel] ${label}: addMembers produced NO Welcome \u2014 ${targets.length} request(s) left pending rather than marked fulfilled`
70505
+ );
70506
+ return 0;
70507
+ }
70508
+ let fulfilled = 0;
70509
+ for (const t2 of targets) {
70510
+ const sent = await this._sendFrameConfirmed(
70511
+ {
70512
+ event: "mls_welcome",
70513
+ data: {
70514
+ target_device_id: t2.deviceId,
70515
+ group_id: mlsGroupId,
70516
+ ...welcomeFields,
70517
+ payload: Buffer.from(welcome).toString("hex")
70518
+ }
70519
+ },
70520
+ `${label} welcome\u2192${t2.deviceId.slice(0, 8)}`
70521
+ );
70522
+ if (!sent) {
70523
+ console.warn(
70524
+ `[SecureChannel] ${label}: Welcome for ${t2.deviceId.slice(0, 8)} was NOT written \u2014 leaving the request PENDING so the next pull retries it`
70525
+ );
70526
+ continue;
70527
+ }
70528
+ if (await this._postFulfilWelcome(mlsGroupId, t2.requestId, `${label} ${t2.deviceId.slice(0, 8)}`)) {
70529
+ fulfilled++;
70530
+ }
70531
+ }
70532
+ return fulfilled;
70533
+ }
70534
+ /**
70535
+ * Resolve an MLS group id to the family that owns it.
70536
+ *
70537
+ * There are four: rooms, A2A channels, shared 1:1 groups, and the legacy
70538
+ * per-conversation groups. They all store the MLS group id under
70539
+ * `mlsGroupId`; they differ only in the key their live `MLSGroupManager` sits
70540
+ * under in `_mlsGroups`.
70541
+ *
70542
+ * ONE resolver, because hand-copying this enumeration is what produced
70543
+ * #1012 (`_handleMlsCommit` handled two families of four), #1014 (a
70544
+ * room-framed 1:1 replay matched nothing) and #1016 (a rejoin dropped only
70545
+ * the room manager). Three bugs, one missing abstraction.
70546
+ */
70547
+ _resolveGroupFamily(mlsGroupId) {
70548
+ if (!mlsGroupId) return null;
70549
+ const families = [
70550
+ { family: "room", entries: this._persisted?.rooms, managerKey: (id) => id, label: "room" },
70551
+ { family: "a2a", entries: this._persisted?.a2aChannels, managerKey: (id) => `a2a:${id}`, label: "A2A" },
70552
+ { family: "shared1to1", entries: this._persisted?.mlsGroups, managerKey: (id) => `1to1-group:${id}`, label: "1:1 group" },
70553
+ { family: "conversation", entries: this._persisted?.mlsConversations, managerKey: (id) => `conv:${id}`, label: "conversation" }
70554
+ ];
70555
+ for (const f2 of families) {
70556
+ for (const [id, entry] of Object.entries(f2.entries ?? {})) {
70557
+ if (entry?.mlsGroupId !== mlsGroupId) continue;
70558
+ return {
70559
+ family: f2.family,
70560
+ id,
70561
+ managerKey: f2.managerKey(id),
70562
+ label: `${f2.label} ${id.slice(0, 8)}`
70563
+ };
70564
+ }
70565
+ }
70566
+ return null;
70567
+ }
70568
+ /** #1015: is the socket in a state that can actually take a write? */
70569
+ _wsIsOpen() {
70570
+ return this._ws?.readyState === 1;
70571
+ }
70572
+ /**
70573
+ * #1015: write one frame and report whether the socket took it.
70574
+ *
70575
+ * `ws.send()` on a CLOSING or CLOSED socket returns WITHOUT throwing — the
70576
+ * error surfaces only through the callback, and every fulfilment site passed
70577
+ * none. So a Welcome could be "sent" into a dead socket and the requester
70578
+ * still marked `fulfilled`, which it never re-requests.
70579
+ */
70580
+ _sendFrameConfirmed(frame, label) {
70581
+ const ws = this._ws;
70582
+ if (!ws || ws.readyState !== 1) {
70583
+ console.warn(
70584
+ `[SecureChannel] ${label}: socket is not OPEN (readyState=${ws?.readyState ?? "null"}) \u2014 frame NOT written`
70585
+ );
70586
+ return Promise.resolve(false);
70587
+ }
70588
+ return new Promise((resolve3) => {
70589
+ try {
70590
+ ws.send(JSON.stringify(frame), (err) => {
70591
+ if (err) {
70592
+ console.error(`[SecureChannel] ${label}: socket refused the frame:`, err);
70593
+ resolve3(false);
70594
+ } else {
70595
+ resolve3(true);
70596
+ }
70597
+ });
70598
+ } catch (err) {
70599
+ console.error(`[SecureChannel] ${label}: send threw:`, err);
70600
+ resolve3(false);
70601
+ }
70602
+ });
70603
+ }
70604
+ /**
70605
+ * #1015: record a Welcome as fulfilled — and say so when it fails.
70606
+ *
70607
+ * The batched shared-1:1 site posted this with `.catch(() => {})`, so an HTTP
70608
+ * failure here was invisible on top of the sends being invisible.
70609
+ */
70610
+ async _postFulfilWelcome(mlsGroupId, requestId, label) {
70611
+ try {
70612
+ const res = await fetch(
70613
+ `${this.config.apiUrl}/api/v1/mls/groups/${mlsGroupId}/fulfill-welcome`,
70614
+ {
70615
+ method: "POST",
70616
+ headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70617
+ body: JSON.stringify({ request_id: requestId })
70618
+ }
70619
+ );
70620
+ if (!res.ok) {
70621
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome returned ${res.status}`);
70622
+ return false;
70623
+ }
70624
+ return true;
70625
+ } catch (err) {
70626
+ console.warn(`[SecureChannel] ${label}: fulfill-welcome failed:`, err);
70627
+ return false;
70628
+ }
70629
+ }
70630
+ /**
70631
+ * #1013: fold an epoch seen on an inbound frame into the running hint.
70632
+ *
70633
+ * Anything unusable (missing, non-numeric, negative, fractional) is ignored
70634
+ * rather than defaulted. A defaulted epoch here would be a guess, and a guess
70635
+ * that reads LOW silently disables the check it exists to drive.
70636
+ */
70637
+ _noteGroupEpoch(mlsGroupId, epoch) {
70638
+ if (typeof mlsGroupId !== "string" || !mlsGroupId) return;
70639
+ const current = this._observedGroupEpochs.get(mlsGroupId) ?? 0;
70640
+ const next = noteObservedEpoch(current, epoch);
70641
+ if (next > current) this._observedGroupEpochs.set(mlsGroupId, next);
70642
+ }
70643
+ /**
70644
+ * #1013 — the ONLY place this package encrypts an MLS application message.
70645
+ *
70646
+ * Catch up first when the group is known to have moved past us, then encrypt.
70647
+ * Hermes has done this since #440 and the browser since #996; the plugin —
70648
+ * the implementation that actually ships to customers — did not, so an agent
70649
+ * that missed one commit encrypted every later message at an epoch nobody
70650
+ * could read, while the server stored and relayed it and every signal read
70651
+ * 200.
70652
+ *
70653
+ * Funnelling every sender through one method is the point. A guard applied at
70654
+ * seven call sites decays to a guard applied at six: that is exactly how
70655
+ * `_handleMlsCommit` ended up handling two group families out of four (#1012).
70656
+ *
70657
+ * Best-effort by design. A catch-up that cannot replay — or that throws — must
70658
+ * NOT block the send; we fall through and encrypt at the local epoch, which is
70659
+ * precisely today's behaviour, so the worst case is no regression.
70660
+ */
70661
+ async _encryptWithCatchUp(mlsGroup, groupKey, mlsGroupId, label, plaintextBytes) {
70662
+ const observed = this._observedGroupEpochs.get(mlsGroupId);
70663
+ if (observed !== void 0 && needsPreSendResync(Number(mlsGroup.epoch), observed)) {
70664
+ console.warn(
70665
+ `[SecureChannel] ${label}: local epoch ${Number(mlsGroup.epoch)} is behind the observed group epoch ${observed} \u2014 catching up before encrypt (#1013)`
70666
+ );
70667
+ try {
70668
+ if (!await this._resyncOnDivergence(groupKey, mlsGroupId)) {
70669
+ console.warn(
70670
+ `[SecureChannel] ${label}: catch-up could not replay \u2014 sending at epoch ${Number(mlsGroup.epoch)}. Peers past this epoch will not read it.`
70671
+ );
70672
+ }
70673
+ } catch (err) {
70674
+ console.warn(`[SecureChannel] ${label}: catch-up failed:`, err);
70675
+ }
70676
+ }
70677
+ return await mlsGroup.encrypt(plaintextBytes);
70678
+ }
69954
70679
  async _resyncOnDivergence(groupKey, groupId) {
69955
70680
  const mgr = this._mlsGroups.get(groupKey);
69956
70681
  if (!mgr?.isInitialized) return false;
@@ -69977,11 +70702,24 @@ ${messageText}`;
69977
70702
  applied++;
69978
70703
  }
69979
70704
  if (applied === 0) {
69980
- console.log(`[SecureChannel] Resync for ${groupId.slice(0, 8)}: no new commits to apply (epoch=${mgr.epoch})`);
70705
+ const strikes = (this._resyncNoOpStrikes.get(groupKey) ?? 0) + 1;
70706
+ this._resyncNoOpStrikes.set(groupKey, strikes);
70707
+ if (strikes < _SecureChannel.MAX_RESYNC_NOOPS) {
70708
+ this._mlsDecryptFailCounts.delete(groupKey);
70709
+ console.log(
70710
+ `[SecureChannel] Resync for ${groupId.slice(0, 8)}: already current at epoch=${mgr.epoch} (strike ${strikes}/${_SecureChannel.MAX_RESYNC_NOOPS}) \u2014 NOT re-keying (#1084)`
70711
+ );
70712
+ return true;
70713
+ }
70714
+ this._resyncNoOpStrikes.delete(groupKey);
70715
+ console.warn(
70716
+ `[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`
70717
+ );
69981
70718
  return false;
69982
70719
  }
69983
70720
  await saveMlsState(this.config.dataDir, groupId, JSON.stringify(mgr.exportState()));
69984
70721
  this._mlsDecryptFailCounts.delete(groupKey);
70722
+ this._resyncNoOpStrikes.delete(groupKey);
69985
70723
  console.log(
69986
70724
  `[SecureChannel] Resync-on-divergence: applied ${applied} missed commit(s) for ${groupId.slice(0, 8)}, now epoch=${mgr.epoch}`
69987
70725
  );
@@ -70368,6 +71106,7 @@ ${messageText}`;
70368
71106
  const ackedIds = [];
70369
71107
  const nackedIds = [];
70370
71108
  for (const msg of messages) {
71109
+ let handled = true;
70371
71110
  try {
70372
71111
  if (msg.sender_device_id === this._deviceId) {
70373
71112
  ackedIds.push(msg.queue_id);
@@ -70384,7 +71123,7 @@ ${messageText}`;
70384
71123
  a2a_channel_id: msg.a2a_channel_id
70385
71124
  });
70386
71125
  } else if (msg.message_type === "commit") {
70387
- await this._handleMlsCommit({
71126
+ handled = await this._handleMlsCommit({
70388
71127
  group_id: msg.group_id,
70389
71128
  sender_device_id: msg.sender_device_id,
70390
71129
  epoch: msg.epoch,
@@ -70422,9 +71161,18 @@ ${messageText}`;
70422
71161
  conversation_group_id: msg.conversation_group_id,
70423
71162
  created_at: msg.created_at
70424
71163
  });
71164
+ } else {
71165
+ handled = false;
71166
+ console.warn(
71167
+ `[SecureChannel] Delivery application row ${String(msg.message_id).slice(0, 8)} names no room, A2A channel or conversation \u2014 NOT acked`
71168
+ );
70425
71169
  }
70426
71170
  }
70427
- ackedIds.push(msg.queue_id);
71171
+ if (handled) {
71172
+ ackedIds.push(msg.queue_id);
71173
+ } else {
71174
+ nackedIds.push(msg.queue_id);
71175
+ }
70428
71176
  } catch (err) {
70429
71177
  console.warn(`[SecureChannel] Delivery ${msg.message_type} processing failed:`, err);
70430
71178
  nackedIds.push(msg.queue_id);
@@ -70493,38 +71241,20 @@ ${messageText}`;
70493
71241
  if (!kpHex) continue;
70494
71242
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
70495
71243
  const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
70496
- const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
70497
- if (this._ws) {
70498
- this._ws.send(JSON.stringify({
70499
- event: "mls_commit",
70500
- data: {
70501
- group_id: roomState.mlsGroupId,
70502
- epoch: Number(mlsGroup.epoch),
70503
- payload: Buffer.from(commit).toString("hex")
70504
- }
70505
- }));
70506
- }
70507
- if (welcome && this._ws) {
70508
- this._ws.send(JSON.stringify({
70509
- event: "mls_welcome",
70510
- data: {
70511
- target_device_id: req.requesting_device_id,
70512
- group_id: roomState.mlsGroupId,
70513
- room_id: roomId,
70514
- payload: Buffer.from(welcome).toString("hex")
70515
- }
70516
- }));
71244
+ const done = await this._fulfilWelcomeRequests({
71245
+ mlsGroup,
71246
+ mlsGroupId: roomState.mlsGroupId,
71247
+ label: `room ${roomId.slice(0, 8)}`,
71248
+ welcomeFields: { room_id: roomId },
71249
+ targets: [{
71250
+ requestId: req.id,
71251
+ deviceId: req.requesting_device_id,
71252
+ keyPackage: memberKp
71253
+ }]
71254
+ });
71255
+ if (done > 0) {
71256
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in room ${roomId.slice(0, 8)}`);
70517
71257
  }
70518
- await fetch(
70519
- `${this.config.apiUrl}/api/v1/mls/groups/${roomState.mlsGroupId}/fulfill-welcome`,
70520
- {
70521
- method: "POST",
70522
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70523
- body: JSON.stringify({ request_id: req.id })
70524
- }
70525
- );
70526
- await saveMlsState(this.config.dataDir, roomState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70527
- console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in room ${roomId.slice(0, 8)}`);
70528
71258
  } catch (fulfillErr) {
70529
71259
  console.warn(`[SecureChannel] Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
70530
71260
  }
@@ -70563,65 +71293,38 @@ ${messageText}`;
70563
71293
  validReqs.push({ req, kp: MLSGroupManager.deserializeKeyPackage(kpBytes) });
70564
71294
  }
70565
71295
  if (validReqs.length === 0) continue;
71296
+ const sharedTargets = validReqs.map((v2) => ({
71297
+ requestId: v2.req.id,
71298
+ deviceId: v2.req.requesting_device_id,
71299
+ keyPackage: v2.kp
71300
+ }));
70566
71301
  try {
70567
- const { commit, welcome } = await mlsGroup.addMembers(validReqs.map((v2) => v2.kp));
70568
- if (this._ws) {
70569
- this._ws.send(JSON.stringify({
70570
- event: "mls_commit",
70571
- data: {
70572
- group_id: entry.mlsGroupId,
70573
- epoch: Number(mlsGroup.epoch),
70574
- payload: Buffer.from(commit).toString("hex")
70575
- }
70576
- }));
70577
- }
70578
- if (welcome && this._ws) {
70579
- for (const { req } of validReqs) {
70580
- this._ws.send(JSON.stringify({
70581
- event: "mls_welcome",
70582
- data: {
70583
- target_device_id: req.requesting_device_id,
70584
- group_id: entry.mlsGroupId,
70585
- conversation_group_id: gid,
70586
- payload: Buffer.from(welcome).toString("hex")
70587
- }
70588
- }));
70589
- }
70590
- }
70591
- for (const { req } of validReqs) {
70592
- await fetch(
70593
- `${this.config.apiUrl}/api/v1/mls/groups/${entry.mlsGroupId}/fulfill-welcome`,
70594
- {
70595
- method: "POST",
70596
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70597
- body: JSON.stringify({ request_id: req.id })
70598
- }
70599
- ).catch(() => {
70600
- });
71302
+ const done = await this._fulfilWelcomeRequests({
71303
+ mlsGroup,
71304
+ mlsGroupId: entry.mlsGroupId,
71305
+ label: `shared group ${gid.slice(0, 8)}`,
71306
+ welcomeFields: { conversation_group_id: gid },
71307
+ targets: sharedTargets
71308
+ });
71309
+ if (done > 0) {
71310
+ console.log(`[SecureChannel] Batched Welcome for ${done}/${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70601
71311
  }
70602
- await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70603
- console.log(`[SecureChannel] Batched Welcome for ${validReqs.length} devices in shared group ${gid.slice(0, 8)} (epoch=${mlsGroup.epoch})`);
70604
71312
  } catch (batchErr) {
70605
71313
  console.warn(`[SecureChannel] Batched Welcome failed for shared group ${gid.slice(0, 8)}, falling back to individual:`, batchErr);
70606
- for (const { req, kp } of validReqs) {
71314
+ for (const target of sharedTargets) {
70607
71315
  try {
70608
- const { commit, welcome } = await mlsGroup.addMembers([kp]);
70609
- if (this._ws) {
70610
- this._ws.send(JSON.stringify({ event: "mls_commit", data: { group_id: entry.mlsGroupId, epoch: Number(mlsGroup.epoch), payload: Buffer.from(commit).toString("hex") } }));
70611
- }
70612
- if (welcome && this._ws) {
70613
- 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") } }));
70614
- }
70615
- await fetch(`${this.config.apiUrl}/api/v1/mls/groups/${entry.mlsGroupId}/fulfill-welcome`, {
70616
- method: "POST",
70617
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70618
- body: JSON.stringify({ request_id: req.id })
70619
- }).catch(() => {
71316
+ const done = await this._fulfilWelcomeRequests({
71317
+ mlsGroup,
71318
+ mlsGroupId: entry.mlsGroupId,
71319
+ label: `shared group ${gid.slice(0, 8)}`,
71320
+ welcomeFields: { conversation_group_id: gid },
71321
+ targets: [target]
70620
71322
  });
70621
- await saveMlsState(this.config.dataDir, entry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70622
- console.log(`[SecureChannel] Individual Welcome for ${req.requesting_device_id.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
71323
+ if (done > 0) {
71324
+ console.log(`[SecureChannel] Individual Welcome for ${target.deviceId.slice(0, 8)} in shared group ${gid.slice(0, 8)}`);
71325
+ }
70623
71326
  } catch (indivErr) {
70624
- console.warn(`[SecureChannel] Individual Welcome failed for ${req.requesting_device_id.slice(0, 8)}:`, indivErr);
71327
+ console.warn(`[SecureChannel] Individual Welcome failed for ${target.deviceId.slice(0, 8)}:`, indivErr);
70625
71328
  }
70626
71329
  }
70627
71330
  }
@@ -70653,38 +71356,20 @@ ${messageText}`;
70653
71356
  if (!kpHex) continue;
70654
71357
  const kpBytes = new Uint8Array(Buffer.from(kpHex, "hex"));
70655
71358
  const memberKp = MLSGroupManager.deserializeKeyPackage(kpBytes);
70656
- const { commit, welcome } = await mlsGroup.addMembers([memberKp]);
70657
- if (this._ws) {
70658
- this._ws.send(JSON.stringify({
70659
- event: "mls_commit",
70660
- data: {
70661
- group_id: convEntry.mlsGroupId,
70662
- epoch: Number(mlsGroup.epoch),
70663
- payload: Buffer.from(commit).toString("hex")
70664
- }
70665
- }));
70666
- }
70667
- if (welcome && this._ws) {
70668
- this._ws.send(JSON.stringify({
70669
- event: "mls_welcome",
70670
- data: {
70671
- target_device_id: req.requesting_device_id,
70672
- group_id: convEntry.mlsGroupId,
70673
- conversation_id: convId,
70674
- payload: Buffer.from(welcome).toString("hex")
70675
- }
70676
- }));
71359
+ const done = await this._fulfilWelcomeRequests({
71360
+ mlsGroup,
71361
+ mlsGroupId: convEntry.mlsGroupId,
71362
+ label: `conv ${convId.slice(0, 8)}`,
71363
+ welcomeFields: { conversation_id: convId },
71364
+ targets: [{
71365
+ requestId: req.id,
71366
+ deviceId: req.requesting_device_id,
71367
+ keyPackage: memberKp
71368
+ }]
71369
+ });
71370
+ if (done > 0) {
71371
+ console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
70677
71372
  }
70678
- await fetch(
70679
- `${this.config.apiUrl}/api/v1/mls/groups/${convEntry.mlsGroupId}/fulfill-welcome`,
70680
- {
70681
- method: "POST",
70682
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70683
- body: JSON.stringify({ request_id: req.id })
70684
- }
70685
- );
70686
- await saveMlsState(this.config.dataDir, convEntry.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70687
- console.log(`[SecureChannel] Fulfilled Welcome for ${req.requesting_device_id.slice(0, 8)} in conv ${convId.slice(0, 8)}`);
70688
71373
  } catch (fulfillErr) {
70689
71374
  console.warn(`[SecureChannel] Conv Welcome fulfill failed for ${req.requesting_device_id.slice(0, 8)}:`, fulfillErr);
70690
71375
  }
@@ -70865,12 +71550,13 @@ ${messageText}`;
70865
71550
  } catch (distErr) {
70866
71551
  console.warn(`[SecureChannel] A2A distribute network error:`, distErr);
70867
71552
  }
70868
- if (!distributed && this._ws) {
70869
- this._ws.send(JSON.stringify({
71553
+ if (!distributed && this._wsIsOpen()) {
71554
+ const a2aLabel = `A2A ${chId.slice(0, 8)}`;
71555
+ const commitSent = await this._sendFrameConfirmed({
70870
71556
  event: "mls_commit",
70871
71557
  data: { group_id: chState.mlsGroupId, epoch: Number(mlsGroup.epoch), payload: commitHex }
70872
- }));
70873
- this._ws.send(JSON.stringify({
71558
+ }, `${a2aLabel} commit`);
71559
+ const welcomeSent = commitSent && await this._sendFrameConfirmed({
70874
71560
  event: "mls_welcome",
70875
71561
  data: {
70876
71562
  target_device_id: req.requesting_device_id,
@@ -70878,7 +71564,11 @@ ${messageText}`;
70878
71564
  a2a_channel_id: chId,
70879
71565
  payload: welcomeHex
70880
71566
  }
70881
- }));
71567
+ }, `${a2aLabel} welcome`);
71568
+ if (!welcomeSent) {
71569
+ console.warn(`[SecureChannel] ${a2aLabel}: Welcome frames were NOT written \u2014 leaving the request pending`);
71570
+ continue;
71571
+ }
70882
71572
  try {
70883
71573
  await fetch(
70884
71574
  `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/distribute`,
@@ -70901,16 +71591,15 @@ ${messageText}`;
70901
71591
  console.warn(`[SecureChannel] Cannot deliver Welcome \u2014 HTTP distribute and WS both unavailable`);
70902
71592
  continue;
70903
71593
  }
70904
- await fetch(
70905
- `${this.config.apiUrl}/api/v1/mls/groups/${chState.mlsGroupId}/fulfill-welcome`,
70906
- {
70907
- method: "POST",
70908
- headers: { Authorization: `Bearer ${this._deviceJwt}`, "Content-Type": "application/json" },
70909
- body: JSON.stringify({ request_id: req.id })
70910
- }
71594
+ const a2aFulfilled = await this._postFulfilWelcome(
71595
+ chState.mlsGroupId,
71596
+ req.id,
71597
+ `A2A ${chId.slice(0, 8)}`
70911
71598
  );
70912
71599
  await saveMlsState(this.config.dataDir, chState.mlsGroupId, JSON.stringify(mlsGroup.exportState()));
70913
- console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
71600
+ if (a2aFulfilled) {
71601
+ console.log(`[SecureChannel] Fulfilled A2A Welcome for ${req.requesting_device_id.slice(0, 8)} in channel ${chId.slice(0, 8)}`);
71602
+ }
70914
71603
  } catch (fulfillErr) {
70915
71604
  console.log(`[SecureChannel] A2A Welcome fulfill failed: ${fulfillErr instanceof Error ? fulfillErr.message : String(fulfillErr)}`);
70916
71605
  }
@@ -71084,12 +71773,12 @@ ${messageText}`;
71084
71773
  console.log(`[SecureChannel] MLS sync: re-join needed for group ${groupId?.slice(0, 8)}`);
71085
71774
  if (groupId) {
71086
71775
  await deleteMlsState(this.config.dataDir, groupId);
71087
- for (const [roomId, room] of Object.entries(this._persisted?.rooms ?? {})) {
71088
- if (room.mlsGroupId === groupId) {
71089
- this._mlsGroups.delete(roomId);
71090
- break;
71091
- }
71776
+ const target = this._resolveGroupFamily(groupId);
71777
+ if (target) {
71778
+ this._mlsGroups.delete(target.managerKey);
71779
+ console.log(`[SecureChannel] MLS sync rejoin: dropped local state for ${target.label}`);
71092
71780
  }
71781
+ void this._requestWelcomeSelfHeal(groupId);
71093
71782
  }
71094
71783
  } else if (action === "replay_complete") {
71095
71784
  console.log(`[SecureChannel] MLS sync complete for group ${groupId?.slice(0, 8)} (${data.count} messages replayed)`);
@@ -71101,6 +71790,7 @@ ${messageText}`;
71101
71790
  */
71102
71791
  async _handleA2AMessageMLS(data) {
71103
71792
  const groupId = data.group_id;
71793
+ this._noteGroupEpoch(groupId, data.epoch);
71104
71794
  const channelId = data.channel_id ?? data.a2a_channel_id;
71105
71795
  let a2aChannelId = channelId;
71106
71796
  if (!a2aChannelId) {
@@ -98324,7 +99014,7 @@ var init_index = __esm2({
98324
99014
  init_skill_invoker();
98325
99015
  await init_skill_telemetry();
98326
99016
  await init_policy_enforcer();
98327
- VERSION = true ? "0.23.23" : "0.0.0-dev";
99017
+ VERSION = true ? "0.23.27" : "0.0.0-dev";
98328
99018
  }
98329
99019
  });
98330
99020
  await init_index();
@@ -98965,6 +99655,40 @@ async function startRoomMcpServer(opts) {
98965
99655
  };
98966
99656
  }
98967
99657
 
99658
+ // src/service/self-uninstall.ts
99659
+ init_subcommand();
99660
+ init_backend();
99661
+ init_log();
99662
+ var DEVICE_IS_GONE = "device_revoked";
99663
+ function selfUninstallOnTerminal(reason, env, deps = {}) {
99664
+ if (reason !== DEVICE_IS_GONE) return false;
99665
+ const log = deps.log ?? ((m2) => logLine("[grok-bridge] " + m2));
99666
+ let label;
99667
+ try {
99668
+ label = labelFor(env);
99669
+ } catch (e) {
99670
+ log(
99671
+ `device was revoked, but ${e.message} \u2014 cannot tell which service to remove. Remove it by hand with: AV_AGENT_NAME=<agent-name> agentvault-grok-bridge uninstall`
99672
+ );
99673
+ return false;
99674
+ }
99675
+ try {
99676
+ if (!deps.backend && process.platform !== "darwin" && process.platform !== "linux") return false;
99677
+ const backend = deps.backend ?? selectBackend(process.platform);
99678
+ if (!backend.status(label).loaded) return false;
99679
+ backend.uninstall(label);
99680
+ log(
99681
+ `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.`
99682
+ );
99683
+ return true;
99684
+ } catch (e) {
99685
+ log(
99686
+ `device was revoked, but removing the service ${label} failed: ${e.message}. Remove it by hand with: AV_AGENT_NAME=<agent-name> agentvault-grok-bridge uninstall`
99687
+ );
99688
+ return false;
99689
+ }
99690
+ }
99691
+
98968
99692
  // src/bridge.ts
98969
99693
  var RoomHushState = class {
98970
99694
  until = /* @__PURE__ */ new Map();
@@ -99020,11 +99744,13 @@ var ActiveTarget = class {
99020
99744
  function attachLifecycle2(channel, opts = {}) {
99021
99745
  const log = opts.log ?? (() => {
99022
99746
  });
99747
+ const selfUninstall = opts.selfUninstall ?? selfUninstallOnTerminal;
99023
99748
  const onTerminal = opts.onTerminal ?? ((_reason, o2) => process.exit(o2.restart ? 1 : 0));
99024
99749
  const terminal = (reason, restart) => {
99025
99750
  log(
99026
99751
  `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.`
99027
99752
  );
99753
+ selfUninstall(reason, process.env);
99028
99754
  onTerminal(reason, { restart });
99029
99755
  };
99030
99756
  channel.on("auth_failed", (e) => terminal(e.reason, false));
@@ -99178,6 +99904,10 @@ var shutdown = async (code = 0) => {
99178
99904
  }
99179
99905
  };
99180
99906
  async function main() {
99907
+ const { maybeRunDoctorSubcommand: maybeRunDoctorSubcommand2 } = await Promise.resolve().then(() => (init_doctor_cli(), doctor_cli_exports));
99908
+ if (await maybeRunDoctorSubcommand2(process.argv)) {
99909
+ process.exit(process.exitCode ?? 0);
99910
+ }
99181
99911
  const { maybeRunServiceSubcommand: maybeRunServiceSubcommand2 } = await Promise.resolve().then(() => (init_subcommand(), subcommand_exports));
99182
99912
  if (maybeRunServiceSubcommand2(process.argv, process.env, {
99183
99913
  log: (m2) => logLine("[grok-bridge] " + m2)
@@ -99187,7 +99917,7 @@ async function main() {
99187
99917
  const info = parseInfoFlags(process.argv.slice(2));
99188
99918
  if (info.showVersion || info.showHelp) {
99189
99919
  const version2 = buildClientVersion2(
99190
- true ? "0.3.2" : void 0
99920
+ true ? "0.4.1" : void 0
99191
99921
  );
99192
99922
  if (info.showVersion) printLine(version2);
99193
99923
  if (info.showHelp) {
@@ -99226,11 +99956,6 @@ async function main() {
99226
99956
  process.exit(0);
99227
99957
  }
99228
99958
  const cfg = loadConfig(process.env, process.argv.slice(2));
99229
- if (cfg.installAfterEnroll) {
99230
- assertInstallableEntrypoint(process.argv[1]);
99231
- const { selectBackend: selectBackend2 } = await Promise.resolve().then(() => (init_backend(), backend_exports));
99232
- selectBackend2(process.platform);
99233
- }
99234
99959
  mkdirSync6(cfg.workspaceDir, { recursive: true });
99235
99960
  mkdirSync6(cfg.fenceHome, { recursive: true });
99236
99961
  seedFenceCredential({
@@ -99245,7 +99970,7 @@ async function main() {
99245
99970
  agentName: cfg.agentName,
99246
99971
  platform: "node",
99247
99972
  clientVersion: buildClientVersion2(
99248
- true ? "0.3.2" : void 0
99973
+ true ? "0.4.1" : void 0
99249
99974
  )
99250
99975
  });
99251
99976
  channelRef = channel;
@@ -99336,23 +100061,6 @@ async function main() {
99336
100061
  logLine("[grok-bridge] enrolled \u2014 pending invite consumed and removed");
99337
100062
  }
99338
100063
  });
99339
- if (cfg.installAfterEnroll) {
99340
- const { installOnReady: installOnReady2 } = await Promise.resolve().then(() => (init_install_on_ready(), install_on_ready_exports));
99341
- installOnReady2(channel, cfg, {
99342
- entrypoint: process.argv[1],
99343
- env: process.env,
99344
- // FOUND LIVE 2026-08-16: the owner's activation greeting is usually already
99345
- // being answered when `ready` fires, and reaping mid-turn killed the reply
99346
- // for good — the message was acked, so the service never saw it again.
99347
- // GrokSession.idle() resolves when the queue drains.
99348
- waitForIdle: () => session.idle(),
99349
- // The one shared teardown. `exiting` is deliberately NOT set here: Ctrl-C
99350
- // must keep working for the whole multi-second launchctl window.
99351
- reap: reapAll,
99352
- exit: (code) => process.exit(code),
99353
- log: (m2) => logLine("[grok-bridge] " + m2)
99354
- });
99355
- }
99356
100064
  await channel.start();
99357
100065
  }
99358
100066
  main().catch(async (err) => {