@securecoms/agent-bridge 0.1.0 → 0.3.0

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/bin.js CHANGED
@@ -502,6 +502,144 @@ var payloadsSchema = {
502
502
  "type": "object"
503
503
  }
504
504
  }
505
+ },
506
+ "agent_action": {
507
+ "description": 'The agent_action E2E message body (agent action tree). NOT a message_type \u2014 it rides envelope message_type "system_event"; this "type":"agent_action" field is a body-level discriminator. Two phases: partial (throttled, incremental upsert-by-id while an agent works) and settled (one authoritative full-tree frame, terminal for its runId). Note: this def is compiled standalone (see validate.ts validateAgentActionPayload), so its node sub-schema is nested under a LOCAL $defs (resolved relative to this def, not the sibling top-level $defs) rather than cross-referenced.',
508
+ "$defs": {
509
+ "node": {
510
+ "type": "object",
511
+ "required": [
512
+ "id",
513
+ "type",
514
+ "title",
515
+ "status",
516
+ "ts"
517
+ ],
518
+ "properties": {
519
+ "id": {
520
+ "type": "string"
521
+ },
522
+ "parentId": {
523
+ "type": "string"
524
+ },
525
+ "type": {
526
+ "type": "string",
527
+ "enum": [
528
+ "step",
529
+ "tool_call",
530
+ "sub_task",
531
+ "result"
532
+ ]
533
+ },
534
+ "title": {
535
+ "type": "string"
536
+ },
537
+ "status": {
538
+ "type": "string",
539
+ "enum": [
540
+ "pending",
541
+ "running",
542
+ "done",
543
+ "error"
544
+ ]
545
+ },
546
+ "detail": {
547
+ "type": "object",
548
+ "properties": {
549
+ "args": {},
550
+ "output": {},
551
+ "error": {
552
+ "type": "string"
553
+ }
554
+ }
555
+ },
556
+ "ts": {
557
+ "type": "number"
558
+ }
559
+ }
560
+ }
561
+ },
562
+ "oneOf": [
563
+ {
564
+ "type": "object",
565
+ "required": [
566
+ "v",
567
+ "type",
568
+ "phase",
569
+ "agentActionVersion",
570
+ "runId",
571
+ "nodes"
572
+ ],
573
+ "properties": {
574
+ "v": {
575
+ "const": 1
576
+ },
577
+ "type": {
578
+ "const": "agent_action"
579
+ },
580
+ "phase": {
581
+ "const": "partial"
582
+ },
583
+ "agentActionVersion": {
584
+ "const": "1"
585
+ },
586
+ "runId": {
587
+ "type": "string"
588
+ },
589
+ "nodes": {
590
+ "type": "array",
591
+ "items": {
592
+ "$ref": "#/$defs/node"
593
+ }
594
+ }
595
+ }
596
+ },
597
+ {
598
+ "type": "object",
599
+ "required": [
600
+ "v",
601
+ "type",
602
+ "phase",
603
+ "agentActionVersion",
604
+ "runId",
605
+ "startedAt",
606
+ "endedAt",
607
+ "nodes"
608
+ ],
609
+ "properties": {
610
+ "v": {
611
+ "const": 1
612
+ },
613
+ "type": {
614
+ "const": "agent_action"
615
+ },
616
+ "phase": {
617
+ "const": "settled"
618
+ },
619
+ "agentActionVersion": {
620
+ "const": "1"
621
+ },
622
+ "runId": {
623
+ "type": "string"
624
+ },
625
+ "startedAt": {
626
+ "type": "number"
627
+ },
628
+ "endedAt": {
629
+ "type": "number"
630
+ },
631
+ "resultMessageId": {
632
+ "type": "string"
633
+ },
634
+ "nodes": {
635
+ "type": "array",
636
+ "items": {
637
+ "$ref": "#/$defs/node"
638
+ }
639
+ }
640
+ }
641
+ }
642
+ ]
505
643
  }
506
644
  }
507
645
  };
@@ -521,6 +659,11 @@ for (const t of MESSAGE_TYPES) {
521
659
  }
522
660
  payloadValidators.set(t, ajv.compile(def));
523
661
  }
662
+ var agentActionDef = payloadDefs["agent_action"];
663
+ if (!agentActionDef) {
664
+ throw new Error(`payloads.schema.json is missing the "agent_action" $defs schema`);
665
+ }
666
+ var agentActionValidator = ajv.compile(agentActionDef);
524
667
 
525
668
  // ../connect-core/dist/envelope/sign.js
526
669
  import { canonicalize } from "@agentvault/crypto/canonical";
@@ -853,6 +996,24 @@ async function staleEpochErrorFromResponse(res) {
853
996
  return null;
854
997
  return new StaleEpochError(Number(d.client_epoch ?? 0), Number(d.server_epoch ?? 0));
855
998
  }
999
+ var AgentPausedError = class extends ConnectSdkError {
1000
+ constructor() {
1001
+ super("agent_paused: sending is paused for this agent in this room");
1002
+ }
1003
+ };
1004
+ async function agentPausedErrorFromResponse(res) {
1005
+ if (res.status !== 403)
1006
+ return null;
1007
+ let body;
1008
+ try {
1009
+ body = await res.json();
1010
+ } catch {
1011
+ return null;
1012
+ }
1013
+ const detail = body?.detail;
1014
+ const err = detail?.error;
1015
+ return err === "agent_paused" ? new AgentPausedError() : null;
1016
+ }
856
1017
 
857
1018
  // ../connect-sdk-ts/dist/envelope-builder.js
858
1019
  async function buildOutboundEnvelope(p) {
@@ -957,7 +1118,7 @@ var ALWAYS_SAMPLE = /* @__PURE__ */ new Set([
957
1118
  var KNOWN = new Set(Object.values(SPAN));
958
1119
 
959
1120
  // ../connect-sdk-ts/dist/groupkey/mls.js
960
- import { MLSGroupManager, bytesToHex, hexToBytes } from "@agentvault/crypto";
1121
+ import { MLSGroupManager, bytesToHex, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
961
1122
 
962
1123
  // ../connect-sdk-ts/dist/groupkey/operation-queue.js
963
1124
  var MlsOperationQueue = class {
@@ -986,11 +1147,27 @@ var MlsOperationQueue = class {
986
1147
  chainSize(channelId) {
987
1148
  return this.inFlight.get(channelId) ?? 0;
988
1149
  }
1150
+ /** Number of channels with an in-flight or queued op. 0 == fully idle. */
1151
+ get activeChannels() {
1152
+ return this.chains.size;
1153
+ }
1154
+ /**
1155
+ * H1 graceful-drain barrier: resolve once every op currently chained (in-flight
1156
+ * + already queued) has SETTLED. Ops enqueued AFTER this call are not awaited —
1157
+ * the caller must stop accepting new work first (e.g. close the ping stream).
1158
+ * Uses allSettled so a rejected op can't make the drain reject. Lets a graceful
1159
+ * shutdown wait for an in-flight commit/join/ack to finish before the DB handle
1160
+ * is released, so a SIGTERM/`kickstart -k` can't tear MLS state mid-operation.
1161
+ */
1162
+ async idle() {
1163
+ await Promise.allSettled([...this.chains.values()]);
1164
+ }
989
1165
  };
990
1166
 
991
1167
  // ../connect-sdk-ts/dist/groupkey/mls.js
992
1168
  var LABEL = "agentvault-connect-v1";
993
1169
  var DEFAULT_STUCK_THRESHOLD = 2;
1170
+ var DEFAULT_QUARANTINE_THRESHOLD = 8;
994
1171
  var BootstrapRaceLostError = class extends Error {
995
1172
  constructor() {
996
1173
  super("bootstrap race lost: another device created the group; join instead");
@@ -1010,10 +1187,24 @@ var HealAddAfterRemoveError = class extends Error {
1010
1187
  this.cause = cause;
1011
1188
  }
1012
1189
  };
1190
+ var GuestHealStaleKpError = class extends Error {
1191
+ constructor(kpId) {
1192
+ super(`healDivergedGuest: fresh key package ${kpId} is gone/consumed \u2014 refusing to seal an un-openable Welcome`);
1193
+ this.name = "GuestHealStaleKpError";
1194
+ }
1195
+ };
1196
+ var ForgedCredentialError = class extends Error {
1197
+ constructor(expected, actual) {
1198
+ super(`heal: KeyPackage credential identity ${actual} != intended ${expected} \u2014 refusing to Add a forged leaf`);
1199
+ this.name = "ForgedCredentialError";
1200
+ }
1201
+ };
1013
1202
  var MlsGroupKeyProvider = class {
1014
1203
  o;
1015
1204
  suite;
1016
1205
  managers = /* @__PURE__ */ new Map();
1206
+ /** One-time libsodium (WASM) warm-up guard — see ensureCryptoReady(). */
1207
+ cryptoWarmed = false;
1017
1208
  /**
1018
1209
  * Per-CHANNEL Promise-chain mutex (gen2's MlsOperationQueue, ported). Every
1019
1210
  * manager-mutating operation on a channel takes this lock keyed by channelId,
@@ -1034,6 +1225,12 @@ var MlsGroupKeyProvider = class {
1034
1225
  * holds that channel's lock).
1035
1226
  */
1036
1227
  locks = new MlsOperationQueue();
1228
+ /** Per-DEVICE publish mutex (B1). Serializes publishKeyPackage's check-then-act
1229
+ * guard so two callers (ingestOnce divergence branch vs selfHealSweep) cannot
1230
+ * both pass "no pending bundle", both POST, and orphan the first bundle when the
1231
+ * server delete-and-replaces. Keyed by deviceId (a provider has one), SEPARATE
1232
+ * from `locks` (channel-keyed) so it never deadlocks against a held channel lock. */
1233
+ publishLock = new MlsOperationQueue();
1037
1234
  /**
1038
1235
  * Per-channel decrypt-error / stuck tracker (ADR-0002). Bounded by the number
1039
1236
  * of channels this device touches (channels are bounded; an entry is created
@@ -1043,14 +1240,22 @@ var MlsGroupKeyProvider = class {
1043
1240
  */
1044
1241
  errors = /* @__PURE__ */ new Map();
1045
1242
  stuckThreshold;
1243
+ /** Consecutive-error threshold at which a wedged channel's row is quarantined
1244
+ * (ack-drained). See {@link MlsGroupKeyProviderOpts.quarantineThreshold}. */
1245
+ quarantineThreshold;
1046
1246
  /** Cumulative count of past-epoch application rows permanently skipped (acked
1047
1247
  * and drained) because forward-secrecy has deleted their epoch's secrets. Read
1048
1248
  * via {@link permanentlySkippedCount}. */
1049
1249
  permanentlySkipped = 0;
1250
+ /** Cumulative count of rows ack-drained via QUARANTINE (P0a), as distinct from
1251
+ * the past-epoch {@link permanentlySkipped} drain. Read via
1252
+ * {@link quarantineDrainedCount}. */
1253
+ quarantineDrained = 0;
1050
1254
  constructor(o) {
1051
1255
  this.o = o;
1052
1256
  this.suite = o.suite ?? "xchacha20poly1305";
1053
1257
  this.stuckThreshold = Math.max(1, o.stuckThreshold ?? DEFAULT_STUCK_THRESHOLD);
1258
+ this.quarantineThreshold = Math.max(1, o.quarantineThreshold ?? DEFAULT_QUARANTINE_THRESHOLD);
1054
1259
  }
1055
1260
  get deviceId() {
1056
1261
  return this.o.deviceId;
@@ -1068,7 +1273,14 @@ var MlsGroupKeyProvider = class {
1068
1273
  const cid = row.connect_channel_id;
1069
1274
  if (!cid)
1070
1275
  return;
1071
- const e = this.errors.get(cid) ?? { decryptErrors: 0, consecutiveErrors: 0, lastErrorAt: null };
1276
+ const e = this.errors.get(cid) ?? {
1277
+ decryptErrors: 0,
1278
+ consecutiveErrors: 0,
1279
+ lastErrorAt: null,
1280
+ quarantined: false,
1281
+ wedgedRun: 0,
1282
+ wedgedSignalled: false
1283
+ };
1072
1284
  e.decryptErrors += 1;
1073
1285
  e.consecutiveErrors += 1;
1074
1286
  e.lastErrorAt = Date.now();
@@ -1078,12 +1290,42 @@ var MlsGroupKeyProvider = class {
1078
1290
  * A commit/application row for the channel decrypted cleanly: the channel is
1079
1291
  * advancing, so clear the consecutive-error run (un-sticks it). Leaves the
1080
1292
  * cumulative `decryptErrors` total intact for diagnostics. No-op for a channel
1081
- * that never errored.
1293
+ * that never errored. Also clears QUARANTINE state (P0a): a healed/rejoined
1294
+ * channel resumes normal processing and can quarantine again later if it
1295
+ * re-wedges (the onWedged guard is reset so the hook can fire again).
1082
1296
  */
1083
1297
  recordDecryptSuccess(channelId) {
1084
1298
  const e = this.errors.get(channelId);
1085
- if (e && e.consecutiveErrors !== 0)
1299
+ if (!e)
1300
+ return;
1301
+ if (e.consecutiveErrors !== 0)
1086
1302
  e.consecutiveErrors = 0;
1303
+ e.wedgedRun = 0;
1304
+ e.quarantined = false;
1305
+ e.wedgedSignalled = false;
1306
+ }
1307
+ /**
1308
+ * Called from the decrypt-failure catch in {@link receiveOnce} when the
1309
+ * channel's consecutive-error run has just reached {@link quarantineThreshold}
1310
+ * and the row was NOT already drained via the past-epoch `permanentlyDead`
1311
+ * check (P0a, daemon-stability). Marks the channel quarantined and fires
1312
+ * {@link MlsGroupKeyProviderOpts.onWedged} exactly once per episode. Does NOT
1313
+ * itself ack the row — the caller pushes `row.queue_id` onto the same acked
1314
+ * list the `permanentlyDead` drain uses, flushed via `ackDelivery` at the end
1315
+ * of `receiveOnce`. Deliberately independent of `liveEpoch`: it must work even
1316
+ * when there is no local state for the channel at all (the no-local-state /
1317
+ * Hermes storm case), because quarantine is a pure consecutive-failure-count
1318
+ * decision, not an epoch comparison.
1319
+ */
1320
+ quarantineChannel(channelId) {
1321
+ const e = this.errors.get(channelId);
1322
+ if (!e)
1323
+ return;
1324
+ e.quarantined = true;
1325
+ if (!e.wedgedSignalled) {
1326
+ e.wedgedSignalled = true;
1327
+ this.o.onWedged?.(channelId);
1328
+ }
1087
1329
  }
1088
1330
  /**
1089
1331
  * Snapshot of per-channel decrypt-error / stuck state (ADR-0002 observability).
@@ -1100,11 +1342,42 @@ var MlsGroupKeyProvider = class {
1100
1342
  decryptErrors: e.decryptErrors,
1101
1343
  consecutiveErrors: e.consecutiveErrors,
1102
1344
  lastErrorAt: e.lastErrorAt,
1103
- stuck: e.consecutiveErrors >= this.stuckThreshold
1345
+ stuck: e.consecutiveErrors >= this.stuckThreshold,
1346
+ quarantined: e.quarantined
1104
1347
  };
1105
1348
  }
1106
1349
  return out;
1107
1350
  }
1351
+ /**
1352
+ * H1 graceful shutdown: resolve once every in-flight per-channel op AND the
1353
+ * publish lock have settled, or `deadlineMs` elapses (bounded so a hung network
1354
+ * op can't block process exit). The caller MUST have already stopped feeding new
1355
+ * work (closed the ping stream) so the queues can actually drain. Loops because a
1356
+ * settling op can chain a follow-on (e.g. a commit that then drains a pending
1357
+ * commit). Lets close() wait for an in-flight commit/join/ack to finish before
1358
+ * the DB handle is released — a SIGTERM / `kickstart -k` can no longer tear MLS
1359
+ * state mid-operation (a half-applied commit, or a join whose Welcome was acked
1360
+ * but whose state was not yet saved).
1361
+ */
1362
+ async drainInFlight(deadlineMs = 5e3) {
1363
+ let timer;
1364
+ const deadline = new Promise((res) => {
1365
+ timer = setTimeout(res, deadlineMs);
1366
+ timer.unref?.();
1367
+ });
1368
+ const drain = (async () => {
1369
+ while (this.locks.activeChannels > 0 || this.publishLock.activeChannels > 0) {
1370
+ await this.locks.idle();
1371
+ await this.publishLock.idle();
1372
+ }
1373
+ })();
1374
+ try {
1375
+ await Promise.race([drain, deadline]);
1376
+ } finally {
1377
+ if (timer)
1378
+ clearTimeout(timer);
1379
+ }
1380
+ }
1108
1381
  /** Total past-epoch rows permanently skipped (acked without decrypting) since
1109
1382
  * this provider was instantiated. A permanently-undecryptable row is one whose
1110
1383
  * `row.epoch` is strictly less than the channel's live epoch: forward-secrecy
@@ -1112,6 +1385,12 @@ var MlsGroupKeyProvider = class {
1112
1385
  permanentlySkippedCount() {
1113
1386
  return this.permanentlySkipped;
1114
1387
  }
1388
+ /** Total rows ack-drained via QUARANTINE (P0a) since this provider was
1389
+ * instantiated — a wedged channel's undecryptable backlog, distinct from the
1390
+ * past-epoch {@link permanentlySkippedCount} drain. */
1391
+ quarantineDrainedCount() {
1392
+ return this.quarantineDrained;
1393
+ }
1115
1394
  /** Evict the live in-memory MLS group manager for a channel. Used when the
1116
1395
  * device leaves or deletes a channel: dropping the persisted store record is
1117
1396
  * not enough on its own, because a warm manager keeps decrypting until the
@@ -1280,16 +1559,99 @@ var MlsGroupKeyProvider = class {
1280
1559
  const w = rows.find((r) => r.message_type === "welcome" && r.connect_channel_id != null);
1281
1560
  return w?.connect_channel_id ?? null;
1282
1561
  }
1562
+ /**
1563
+ * Initialize the ts-mls libsodium (WASM) backend ONCE before any store
1564
+ * operation that deserializes a persisted KeyPackage. generateKeyPackage
1565
+ * awaits sodium.ready and is pure in-memory (it neither publishes to the
1566
+ * backend nor writes the store), so the throwaway bundle is a side-effect-free
1567
+ * warm-up. Without it, the FIRST loadPendingBundle at boot (the republish
1568
+ * guard, and the join path) runs while WASM is still cold, throws deep in the
1569
+ * crypto ("Cannot read properties of undefined (reading '_malloc')"), is caught
1570
+ * and misread as "no held bundle", and the daemon mints + republishes a FRESH
1571
+ * KeyPackage on every restart — orphaning every Welcome sealed to the prior
1572
+ * published KP (the root cause of "fresh DM never opens" across restarts).
1573
+ * Idempotent and cheap (~ms); the flag makes repeat calls no-ops.
1574
+ */
1575
+ async ensureCryptoReady() {
1576
+ if (this.cryptoWarmed)
1577
+ return;
1578
+ await new MLSGroupManager().generateKeyPackage(this.o.identity);
1579
+ this.cryptoWarmed = true;
1580
+ }
1283
1581
  async publishKeyPackage() {
1582
+ await this.ensureCryptoReady();
1583
+ return this.publishLock.run(this.o.deviceId, () => this.publishKeyPackageUnlocked());
1584
+ }
1585
+ /**
1586
+ * Force a FRESH published KeyPackage even when a pending bundle is already held.
1587
+ *
1588
+ * `publishKeyPackage`'s republish guard deliberately no-ops while a bundle is
1589
+ * held (so a normal re-publish can't orphan a leaf a queued Welcome needs). But
1590
+ * the reset self-heal reaches here only AFTER a fully-verified supersede Welcome
1591
+ * FAILED to open ("No matching secret found") — proving the currently-published
1592
+ * KP has diverged from our held bundle, so that bundle is already useless for
1593
+ * opening any Welcome sealed to the published KP. Delete it (dropping the guard)
1594
+ * and publish anew under the same device publish-lock, so the server's published
1595
+ * KP now matches a bundle we hold and the owner's re-Welcome is openable. Returns
1596
+ * the new KP id. Only the reset-unopenable self-heal calls this.
1597
+ */
1598
+ async forceRepublishKeyPackage() {
1599
+ return this.publishLock.run(this.o.deviceId, async () => {
1600
+ await this.o.store.deletePendingBundle(this.o.deviceId);
1601
+ return this.publishKeyPackageUnlocked();
1602
+ });
1603
+ }
1604
+ /**
1605
+ * True iff we hold a pending bundle AND the server's currently-advertised
1606
+ * KeyPackage for this device is that SAME bundle's public KeyPackage.
1607
+ *
1608
+ * The reset self-heal uses this to avoid a needless — and harmful —
1609
+ * force-republish. A failed `joinFromWelcome` on a supersede proves only that
1610
+ * THIS Welcome's sealed KP != our held bundle; it does NOT prove the CURRENTLY
1611
+ * published KP diverged. In the common case the reset committer simply sealed to
1612
+ * an OLDER KP snapshot while the current published KP still equals our held
1613
+ * bundle — a plain re-Welcome (re-sealed to the current published KP) then opens,
1614
+ * and deleting the shared per-device bundle would ORPHAN other channels' openable
1615
+ * Welcomes sealed to it (the multichannel-join case). Only when this returns
1616
+ * false (published KP genuinely diverged from held) is a force-republish warranted:
1617
+ * in the normal single-daemon case every currently-sealed Welcome uses the published
1618
+ * KP, so deleting the held bundle orphans nothing; a Welcome sealed to the held
1619
+ * bundle can only exist in a rare multi-instance / out-of-band-republish window, and
1620
+ * even then a supersede Welcome self-heals next cycle (held == published again).
1621
+ *
1622
+ * Best-effort: any error resolving either side returns false, so the caller
1623
+ * force-republishes (the conservative recovery). */
1624
+ async heldBundleMatchesPublishedKp() {
1625
+ try {
1626
+ const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1627
+ if (!bundle)
1628
+ return false;
1629
+ const heldHex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
1630
+ const published = await this.o.backend.fetchKeyPackages([this.o.deviceId]);
1631
+ const pubHex = published[this.o.deviceId];
1632
+ return !!pubHex && pubHex === heldHex;
1633
+ } catch {
1634
+ return false;
1635
+ }
1636
+ }
1637
+ async publishKeyPackageUnlocked() {
1284
1638
  if (await this.o.store.loadPendingBundle(this.o.deviceId)) {
1285
1639
  console.warn("publishKeyPackage: pending bundle exists; skipping republish");
1286
- return;
1640
+ return await this.o.store.loadLastKpId(this.o.deviceId) ?? "";
1287
1641
  }
1642
+ console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
1288
1643
  const mgr = new MLSGroupManager();
1289
1644
  const bundle = await mgr.generateKeyPackage(this.o.identity);
1290
1645
  const hex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
1291
- await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
1292
- await this.o.store.savePendingBundle(this.o.deviceId, bundle);
1646
+ const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
1647
+ await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
1648
+ return id;
1649
+ }
1650
+ /** The server-assigned id of the last KeyPackage this device published,
1651
+ * or null if none has been published yet. Lets a diverged guest's self-heal
1652
+ * signal name its outstanding KP (P2-A). */
1653
+ async getLastPublishedKpId() {
1654
+ return this.o.store.loadLastKpId(this.o.deviceId);
1293
1655
  }
1294
1656
  async ensureSession(channelId, opts) {
1295
1657
  return this.locks.run(channelId, () => this.ensureSessionUnlocked(channelId, opts));
@@ -1297,6 +1659,7 @@ var MlsGroupKeyProvider = class {
1297
1659
  async ensureSessionUnlocked(channelId, opts) {
1298
1660
  if (!opts.forceReinit && await this.o.store.load(channelId))
1299
1661
  return;
1662
+ await this.ensureCryptoReady();
1300
1663
  let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1301
1664
  if (!bundle && (opts.bootstrap || opts.forceReinit)) {
1302
1665
  await this.publishKeyPackage();
@@ -1327,7 +1690,6 @@ var MlsGroupKeyProvider = class {
1327
1690
  });
1328
1691
  await this.o.store.save(channelId, mgr.exportState());
1329
1692
  this.managers.set(channelId, mgr);
1330
- await this.o.store.deletePendingBundle(this.o.deviceId);
1331
1693
  return;
1332
1694
  }
1333
1695
  if (opts.bootstrap) {
@@ -1355,30 +1717,23 @@ var MlsGroupKeyProvider = class {
1355
1717
  });
1356
1718
  this.managers.set(channelId, mgr);
1357
1719
  await this.o.store.save(channelId, mgr.exportState());
1358
- await this.o.store.deletePendingBundle(this.o.deviceId);
1359
1720
  } else {
1360
1721
  const rows = await this.o.backend.pullDelivery(this.o.deviceId);
1361
1722
  const w = rows.find((r) => r.message_type === "welcome" && (r.connect_channel_id === channelId || r.connect_channel_id == null));
1362
1723
  if (!w)
1363
1724
  throw new Error("ensureSession(join): no Welcome available yet for this channel");
1364
1725
  const mgr = new MLSGroupManager();
1365
- await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
1726
+ try {
1727
+ await mgr.joinFromWelcome(hexToBytes(w.payload), bundle);
1728
+ } catch (err) {
1729
+ this.recordDecryptError(w, err);
1730
+ console.warn(`ensureSession(join): discarding un-openable Welcome for channel ${channelId} (queue_id=${w.queue_id}); private KeyPackage key no longer held`);
1731
+ await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
1732
+ return;
1733
+ }
1366
1734
  this.managers.set(channelId, mgr);
1367
1735
  await this.o.store.save(channelId, mgr.exportState());
1368
1736
  await this.o.backend.ackDelivery(this.o.deviceId, [w.queue_id]);
1369
- const remaining = await this.o.backend.pullDelivery(this.o.deviceId);
1370
- let burstPending = false;
1371
- for (const r of remaining) {
1372
- if (r.message_type !== "welcome")
1373
- continue;
1374
- const cid = r.connect_channel_id;
1375
- if (!cid || !await this.o.store.load(cid)) {
1376
- burstPending = true;
1377
- break;
1378
- }
1379
- }
1380
- if (!burstPending)
1381
- await this.o.store.deletePendingBundle(this.o.deviceId);
1382
1737
  }
1383
1738
  }
1384
1739
  /**
@@ -1397,7 +1752,7 @@ var MlsGroupKeyProvider = class {
1397
1752
  async addMemberToLiveGroup(channelId, newDeviceId) {
1398
1753
  return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0)));
1399
1754
  }
1400
- async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt) {
1755
+ async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
1401
1756
  const mgr = await this.manager(channelId);
1402
1757
  const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
1403
1758
  const kps = await this.o.backend.fetchKeyPackages([newDeviceId]);
@@ -1405,6 +1760,13 @@ var MlsGroupKeyProvider = class {
1405
1760
  if (!hex)
1406
1761
  throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
1407
1762
  const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
1763
+ if (opts?.expectedIdentityId) {
1764
+ const cred = pkg.leafNode.credential;
1765
+ const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
1766
+ if (claimed !== opts.expectedIdentityId) {
1767
+ throw new ForgedCredentialError(opts.expectedIdentityId, claimed);
1768
+ }
1769
+ }
1408
1770
  const priorEpoch = Number(mgr.epoch);
1409
1771
  const { commit, welcome } = await mgr.addMembers([pkg]);
1410
1772
  const members = [.../* @__PURE__ */ new Set([...init.member_device_ids ?? [this.o.deviceId], newDeviceId])];
@@ -1426,7 +1788,13 @@ var MlsGroupKeyProvider = class {
1426
1788
  await this.drainChannelCommitsUnlocked(this.o.deviceId, channelId);
1427
1789
  } catch {
1428
1790
  }
1429
- return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1);
1791
+ try {
1792
+ const work = await this.o.backend.getCommitterWork(channelId);
1793
+ if (!work.adds.includes(newDeviceId))
1794
+ return;
1795
+ } catch {
1796
+ }
1797
+ return this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt + 1, opts);
1430
1798
  }
1431
1799
  await this.o.store.save(channelId, mgr.exportState());
1432
1800
  }
@@ -1618,7 +1986,7 @@ var MlsGroupKeyProvider = class {
1618
1986
  * Unlocked committer ops this composes (removeDeviceFromLiveGroupUnlocked /
1619
1987
  * addMemberToLiveGroupUnlocked) — no weaker path is introduced.
1620
1988
  */
1621
- async healDivergedMember(channelId, memberDeviceId) {
1989
+ async healDivergedMember(channelId, memberDeviceId, opts) {
1622
1990
  return this.locks.run(channelId, () => this.evictOnThrow(channelId, async () => {
1623
1991
  const kps = await this.o.backend.fetchKeyPackages([memberDeviceId]);
1624
1992
  const hex = kps[memberDeviceId];
@@ -1629,6 +1997,9 @@ var MlsGroupKeyProvider = class {
1629
1997
  throw new Error(`healDivergedMember: unexpected credential type ${cred.credentialType} (expected basic)`);
1630
1998
  }
1631
1999
  const identity = new TextDecoder().decode(cred.identity);
2000
+ if (opts?.expectedIdentityId && identity !== opts.expectedIdentityId) {
2001
+ throw new ForgedCredentialError(opts.expectedIdentityId, identity);
2002
+ }
1632
2003
  const mgr = await this.manager(channelId);
1633
2004
  const leaves = mgr.leafIndicesForIdentity(identity);
1634
2005
  if (leaves.length > 1) {
@@ -1645,7 +2016,7 @@ var MlsGroupKeyProvider = class {
1645
2016
  }
1646
2017
  }
1647
2018
  try {
1648
- await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0);
2019
+ await this.addMemberToLiveGroupUnlocked(channelId, memberDeviceId, 0, opts);
1649
2020
  } catch (e) {
1650
2021
  if (removeLanded)
1651
2022
  throw new HealAddAfterRemoveError(e);
@@ -1653,6 +2024,48 @@ var MlsGroupKeyProvider = class {
1653
2024
  }
1654
2025
  }));
1655
2026
  }
2027
+ /** Re-Welcome a fallen-behind GUEST (same device, stale leaf) back to the live
2028
+ * epoch. Identical crypto to healDivergedMember (Remove-first same-device, then
2029
+ * re-Add at the live epoch); named separately for the Phase 2 guest dispatch/
2030
+ * sweep call sites and for the cross-tenant authz that differs only server-side.
2031
+ * Caller contract (like the member path): the guest republishes a fresh
2032
+ * KeyPackage first, then resetChannelForRejoin + joinAndReconcile after.
2033
+ *
2034
+ * Phase 2 fail-closed gate: `expectedKpId` is the id of the guest's fresh,
2035
+ * retained KeyPackage (the one the caller confirmed the guest republished).
2036
+ * Before doing anything else we fetch it BY ID and refuse
2037
+ * ({@link GuestHealStaleKpError}) if it's gone — never Remove+re-Add on the
2038
+ * strength of "some KeyPackage is published for this device", because that
2039
+ * could be stale, consumed, or absent and would seal an un-openable Welcome,
2040
+ * bricking the guest instead of healing it. Once the gate passes we delegate to
2041
+ * the proven {@link healDivergedMember}: given `publish_key_package`'s
2042
+ * delete-and-replace semantics and the guest's pending-bundle republish guard,
2043
+ * the by-device fetch inside healDivergedMember resolves to exactly this same
2044
+ * KeyPackage — the id-fetch here is the fail-closed contract, the member heal
2045
+ * is the mechanism. */
2046
+ async healDivergedGuest(channelId, guestDeviceId, expectedKpId) {
2047
+ const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
2048
+ if (!hex)
2049
+ throw new GuestHealStaleKpError(expectedKpId);
2050
+ return this.healDivergedMember(channelId, guestDeviceId);
2051
+ }
2052
+ /** Re-Welcome a diverged agent/member with BOTH the fail-closed fresh-KP gate
2053
+ * AND the TOCTOU-safe anti-forgery check. Mirrors {@link healDivergedGuest}'s
2054
+ * id-by-value gate (refuse if `expectedKpId` is gone/consumed →
2055
+ * {@link GuestHealStaleKpError}, never sealing an un-openable Welcome), then
2056
+ * delegates to {@link healDivergedMember} with `expectedIdentityId` so the
2057
+ * committed-bytes assertion binds the re-Added leaf to the intended identity
2058
+ * (throws {@link ForgedCredentialError} on a credential-identity mismatch,
2059
+ * including on the 409-retry path). This is the entry point the owner's
2060
+ * agent-diverged heal sweep uses: `expectedKpId`/`expectedIdentityId` come from
2061
+ * the welcome-request row the agent published, so a compromised agent cannot
2062
+ * substitute a KeyPackage that forges a different identity. */
2063
+ async healDivergedMemberFresh(channelId, memberDeviceId, expectedKpId, expectedIdentityId) {
2064
+ const hex = await this.o.backend.fetchKeyPackageById(expectedKpId);
2065
+ if (!hex)
2066
+ throw new GuestHealStaleKpError(expectedKpId);
2067
+ return this.healDivergedMember(channelId, memberDeviceId, { expectedIdentityId });
2068
+ }
1656
2069
  /**
1657
2070
  * Member-side: drop the diverged local state for a channel so a subsequent
1658
2071
  * {@link joinAndReconcile} consumes the committer's fresh Welcome (from
@@ -1687,6 +2100,56 @@ var MlsGroupKeyProvider = class {
1687
2100
  this.errors.delete(channelId);
1688
2101
  });
1689
2102
  }
2103
+ /** Owner-attested reset supersede (owner/web side, Task 9). Mirrors the
2104
+ * ensureSession forceReinit branch but posts a client-chosen new_group_id +
2105
+ * signed attestation to the reset-mode endpoint, then builds + distributes
2106
+ * the new group. Runs under the channel lock (serialized against
2107
+ * receiveOnce / committer ops).
2108
+ *
2109
+ * `opts.otherDeviceIds` MUST be the CHANNEL-WIDE union of every active
2110
+ * member device holding an unconsumed KeyPackage (H2) -- the caller (the web
2111
+ * client's members-endpoint enumeration) is responsible for that union;
2112
+ * this method only handles the mechanical THIS-device exclusion: this
2113
+ * device is the committer that creates the group, so it must not be in the
2114
+ * MLS addMembers set (adding your own new leaf via addMembers is
2115
+ * nonsensical / unsupported). The wire member_device_ids below re-includes
2116
+ * it as [deviceId, ...others] (deduped), so the Task-2 completeness guard
2117
+ * (required subset of supplied) is still satisfied even though it never
2118
+ * goes through addMembers. */
2119
+ async resetSessionSupersede(channelId, opts) {
2120
+ return this.locks.run(channelId, async () => {
2121
+ let bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2122
+ if (!bundle) {
2123
+ await this.publishKeyPackage();
2124
+ bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2125
+ }
2126
+ if (!bundle)
2127
+ throw new Error("resetSessionSupersede: call publishKeyPackage() first");
2128
+ const others = [...new Set(opts.otherDeviceIds)].filter((d) => d !== this.o.deviceId);
2129
+ const reinit = await this.o.backend.resetReinitConnectChannelGroup(channelId, [this.o.deviceId, ...others], { newGroupId: opts.newGroupId, signatureHex: opts.signatureHex, nonceHex: opts.nonceHex, signedContentHex: opts.signedContentHex });
2130
+ const mgr = new MLSGroupManager();
2131
+ await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
2132
+ const kps = await this.o.backend.fetchKeyPackages(others);
2133
+ const pkgs = others.map((d) => {
2134
+ const hex = kps[d];
2135
+ if (!hex)
2136
+ throw new Error(`resetSessionSupersede: no published KeyPackage for device ${d}`);
2137
+ return MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2138
+ });
2139
+ const { commit, welcome } = await mgr.addMembers(pkgs);
2140
+ await this.o.backend.distribute(reinit.group_id, {
2141
+ device_id: this.o.deviceId,
2142
+ commit: bytesToHex(commit),
2143
+ commit_epoch: Number(mgr.epoch),
2144
+ expected_epoch: reinit.current_epoch,
2145
+ member_device_ids: [this.o.deviceId, ...others],
2146
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
2147
+ });
2148
+ await this.o.store.save(channelId, mgr.exportState());
2149
+ this.managers.set(channelId, mgr);
2150
+ await this.o.store.deletePendingBundle(this.o.deviceId);
2151
+ });
2152
+ }
1690
2153
  /** Returns true when an in-memory MLS manager is live for the channel.
1691
2154
  * Cheap synchronous check; does NOT consult the store. */
1692
2155
  hasChannelState(channelId) {
@@ -1700,6 +2163,19 @@ var MlsGroupKeyProvider = class {
1700
2163
  return true;
1701
2164
  return await this.o.store.load(channelId) !== null;
1702
2165
  }
2166
+ /**
2167
+ * Return the number of leaves the given identity holds in the live tree for
2168
+ * `channelId`. Loads the manager from the store if not already cached.
2169
+ *
2170
+ * Primarily used by the gate (S20) to assert that a peer-committer 409-retry
2171
+ * does NOT create a duplicate leaf for an already-added device. A return value
2172
+ * of 1 is the expected healthy state; 0 means not yet a member; > 1 means a
2173
+ * duplicate-leaf bug fired (the S3 guard failed to suppress the re-Add).
2174
+ */
2175
+ async leafCountForIdentity(channelId, identityId) {
2176
+ const mgr = await this.manager(channelId);
2177
+ return mgr.leafIndicesForIdentity(identityId).length;
2178
+ }
1703
2179
  /** Returns true when the live in-memory MLS tree for `channelId` contains at
1704
2180
  * least one leaf bearing `identityId` as its credential. Used by the agent
1705
2181
  * admission watcher (liveness §4.3) to decide whether an Add+Welcome is still
@@ -1719,18 +2195,130 @@ var MlsGroupKeyProvider = class {
1719
2195
  const r = await this.o.backend.getConnectChannelGroup(channelId);
1720
2196
  return { exists: r.exists, groupId: r.group_id, epoch: r.current_epoch };
1721
2197
  }
2198
+ /**
2199
+ * Verified group supersede for the agent's receive pump (owner-attested reset).
2200
+ * Returns true when the channel's state was swapped to the new group and the
2201
+ * Welcome row acked; false when the supersede was REFUSED (row left un-acked).
2202
+ *
2203
+ * FAIL-CLOSED order (spec §3): step-0 pin-presence guard FIRST (no pinned owner
2204
+ * ⇒ refuse, never call verify), then structural signer check, then attestation
2205
+ * verify against the NON-EMPTY pinned key, then post-join group-id assertion.
2206
+ */
2207
+ async trySupersede(channelId, row, heldGroupId) {
2208
+ const owner = this.o.onResolvePinnedOwner ? await this.o.onResolvePinnedOwner(channelId) : null;
2209
+ if (!owner || !owner.publicKeyHex) {
2210
+ this.recordDecryptError(row, new Error("supersede refused: no pinned owner key (step-0 guard)"));
2211
+ return false;
2212
+ }
2213
+ const retired = await this.o.store.loadRetiredGroupIds(channelId);
2214
+ if (retired.includes(row.group_id)) {
2215
+ this.recordDecryptError(row, new Error(`supersede refused: group ${row.group_id} is retired (rollback/replay)`));
2216
+ return false;
2217
+ }
2218
+ const att = await this.o.backend.fetchResetAttestation(row.group_id);
2219
+ if (!att) {
2220
+ this.recordDecryptError(row, new Error(`supersede refused: no attestation for group ${row.group_id}`));
2221
+ return false;
2222
+ }
2223
+ if (att.signerIdentityId !== owner.identityId) {
2224
+ this.recordDecryptError(row, new Error("supersede refused: signer is not the pinned owner"));
2225
+ return false;
2226
+ }
2227
+ let content;
2228
+ try {
2229
+ content = JSON.parse(att.signedContent);
2230
+ } catch {
2231
+ this.recordDecryptError(row, new Error("supersede refused: unparseable signed_content"));
2232
+ return false;
2233
+ }
2234
+ if (content.channel_id !== row.connect_channel_id || content.new_group_id !== row.group_id) {
2235
+ this.recordDecryptError(row, new Error("supersede refused: attestation channel/group mismatch"));
2236
+ return false;
2237
+ }
2238
+ const ok = await verifyApprovalArtifact({ content, signature: att.signatureHex, signerPublicKey: owner.publicKeyHex, createdAt: "", version: 1 }, owner.publicKeyHex);
2239
+ if (!ok) {
2240
+ this.recordDecryptError(row, new Error("supersede refused: signature does not verify against pinned owner key"));
2241
+ return false;
2242
+ }
2243
+ const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
2244
+ if (!bundle) {
2245
+ this.recordDecryptError(row, new Error("supersede refused: no pending KeyPackage bundle to open the new Welcome"));
2246
+ return false;
2247
+ }
2248
+ return await this.locks.run(channelId, async () => {
2249
+ const mgr = new MLSGroupManager();
2250
+ try {
2251
+ await mgr.joinFromWelcome(hexToBytes(row.payload), bundle);
2252
+ } catch (err) {
2253
+ this.recordDecryptError(row, err);
2254
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2255
+ console.warn(`[trace] supersede joinFromWelcome FAILED ch=${channelId.slice(0, 8)} g=${row.group_id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)} -> self-heal`);
2256
+ }
2257
+ let requested = false;
2258
+ try {
2259
+ requested = await this.o.onSupersedeUnopenable?.(channelId, row.group_id) ?? false;
2260
+ } catch {
2261
+ }
2262
+ if (requested) {
2263
+ try {
2264
+ await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
2265
+ } catch {
2266
+ }
2267
+ }
2268
+ return false;
2269
+ }
2270
+ if (new TextDecoder().decode(mgr.groupId) !== row.group_id) {
2271
+ this.recordDecryptError(row, new Error("supersede refused: post-join group id != attested new_group_id"));
2272
+ return false;
2273
+ }
2274
+ this.evictChannelState(channelId);
2275
+ await this.o.store.delete(channelId);
2276
+ await this.o.store.discardStaging(channelId);
2277
+ await this.o.store.addRetiredGroupId(channelId, heldGroupId);
2278
+ this.errors.delete(channelId);
2279
+ this.managers.set(channelId, mgr);
2280
+ await this.o.store.save(channelId, mgr.exportState());
2281
+ await this.o.backend.ackDelivery(this.o.deviceId, [row.queue_id]);
2282
+ return true;
2283
+ });
2284
+ }
1722
2285
  async receiveOnce(deviceId) {
1723
2286
  const rows = await this.o.backend.pullDelivery(deviceId);
2287
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY && rows.length) {
2288
+ console.warn(`[trace] receiveOnce pulled ${rows.length}: ` + rows.map((r) => `${r.message_type}@e${r.epoch}/ch${(r.connect_channel_id || "-").slice(0, 8)}/g${(r.group_id || "-").slice(0, 8)}`).join(", "));
2289
+ }
1724
2290
  const appEntries = [];
1725
2291
  const handshakeAcked = [];
2292
+ const supersededChannelIds = [];
1726
2293
  const order = { welcome: 0, commit: 1, application: 2 };
1727
2294
  rows.sort((a, b) => order[a.message_type] - order[b.message_type]);
1728
2295
  for (const row of rows) {
1729
2296
  try {
1730
2297
  if (row.message_type === "welcome") {
1731
2298
  const wcid = row.connect_channel_id;
1732
- if (wcid && await this.o.store.load(wcid)) {
1733
- handshakeAcked.push(row.queue_id);
2299
+ if (wcid) {
2300
+ const existing = await this.o.store.load(wcid);
2301
+ if (existing) {
2302
+ let heldGroupId = null;
2303
+ try {
2304
+ const mgr = await this.manager(wcid);
2305
+ heldGroupId = new TextDecoder().decode(mgr.groupId);
2306
+ } catch {
2307
+ heldGroupId = null;
2308
+ }
2309
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2310
+ console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} heldGroup=${heldGroupId ? heldGroupId.slice(0, 8) : "NULL"} -> ${heldGroupId === null ? "SKIP(manager-unloadable)" : heldGroupId === row.group_id ? "stray-ack" : "trySupersede"}`);
2311
+ }
2312
+ if (heldGroupId !== null && heldGroupId === row.group_id) {
2313
+ handshakeAcked.push(row.queue_id);
2314
+ } else if (heldGroupId !== null && heldGroupId !== row.group_id) {
2315
+ const ok = await this.trySupersede(wcid, row, heldGroupId);
2316
+ if (ok)
2317
+ supersededChannelIds.push(wcid);
2318
+ }
2319
+ } else if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
2320
+ console.warn(`[trace] welcome ch=${wcid.slice(0, 8)} rowGroup=${(row.group_id || "-").slice(0, 8)} -> NO-LOCAL-STATE (leave un-acked for join path)`);
2321
+ }
1734
2322
  }
1735
2323
  } else if (row.message_type === "commit") {
1736
2324
  const cid = row.connect_channel_id;
@@ -1764,12 +2352,14 @@ var MlsGroupKeyProvider = class {
1764
2352
  plaintext,
1765
2353
  envelope,
1766
2354
  receivedAt: Date.now(),
1767
- retryCount: 0
2355
+ retryCount: 0,
2356
+ expires_at: row.expires_at
1768
2357
  });
1769
2358
  }
1770
2359
  } catch (err) {
1771
2360
  this.recordDecryptError(row, err);
1772
2361
  const cid = row.connect_channel_id;
2362
+ let drained = false;
1773
2363
  if (cid && typeof row.epoch === "number") {
1774
2364
  let liveEpoch;
1775
2365
  try {
@@ -1781,12 +2371,27 @@ var MlsGroupKeyProvider = class {
1781
2371
  if (permanentlyDead) {
1782
2372
  handshakeAcked.push(row.queue_id);
1783
2373
  this.permanentlySkipped++;
2374
+ drained = true;
2375
+ }
2376
+ }
2377
+ if (!drained && cid) {
2378
+ const e = this.errors.get(cid);
2379
+ if (e) {
2380
+ e.wedgedRun += 1;
2381
+ if (e.wedgedRun >= this.quarantineThreshold) {
2382
+ handshakeAcked.push(row.queue_id);
2383
+ this.quarantineDrained++;
2384
+ this.quarantineChannel(cid);
2385
+ }
1784
2386
  }
1785
2387
  }
1786
2388
  }
1787
2389
  }
1788
2390
  if (handshakeAcked.length)
1789
2391
  await this.o.backend.ackDelivery(deviceId, handshakeAcked);
2392
+ if (supersededChannelIds.length) {
2393
+ Object.defineProperty(appEntries, "supersededChannelIds", { value: supersededChannelIds, enumerable: false });
2394
+ }
1790
2395
  return appEntries;
1791
2396
  }
1792
2397
  /**
@@ -1867,11 +2472,38 @@ var FRAME_PARSERS = {
1867
2472
  roomId: d?.room_id ? str(d.room_id) : void 0,
1868
2473
  requestingDeviceId: str(d?.requesting_device_id),
1869
2474
  requestingIdentityId: d?.requesting_identity_id ? str(d.requesting_identity_id) : void 0,
2475
+ freshKpId: d?.fresh_kp_id ? String(d.fresh_kp_id) : void 0,
1870
2476
  requestId: d?.request_id ? str(d.request_id) : void 0
1871
2477
  }
2478
+ }),
2479
+ // Diverged-guest heal Phase 2 (P2-A): the server wakes surviving devices to
2480
+ // heal a guest whose ratchet tree diverged. Durable state is the
2481
+ // mls_guest_heal_request row; this frame is just the wakeup hint.
2482
+ mls_guest_heal_requested: (d) => ({
2483
+ guestHealRequest: {
2484
+ groupId: str(d?.group_id),
2485
+ channelId: str(d?.channel_id),
2486
+ enclaveId: str(d?.enclave_id),
2487
+ guestIdentityId: str(d?.guest_identity_id),
2488
+ guestDeviceId: str(d?.guest_device_id),
2489
+ freshKpId: str(d?.fresh_kp_id),
2490
+ requestId: str(d?.request_id)
2491
+ }
1872
2492
  })
1873
2493
  };
1874
2494
  var S2C_FRAME_NAMES = new Set(Object.keys(FRAME_PARSERS));
2495
+ var TERMINAL_FRAME_EVENTS = /* @__PURE__ */ new Set([
2496
+ "device_revoked",
2497
+ "identity.revoked"
2498
+ ]);
2499
+ function terminalFrameReason(frame) {
2500
+ if (!frame || typeof frame !== "object")
2501
+ return void 0;
2502
+ const f = frame;
2503
+ if (typeof f.event !== "string" || !TERMINAL_FRAME_EVENTS.has(f.event))
2504
+ return void 0;
2505
+ return typeof f.data?.reason === "string" && f.data.reason ? f.data.reason : f.event;
2506
+ }
1875
2507
  function parseServerFrame(frame) {
1876
2508
  if (!frame || typeof frame !== "object")
1877
2509
  return void 0;
@@ -1887,8 +2519,22 @@ function openDeliveryPings(o) {
1887
2519
  const rng = o.rng ?? Math.random;
1888
2520
  const maxQueue = o.maxQueue ?? 8;
1889
2521
  const stableResetMs = o.stableResetMs ?? 1e4;
2522
+ const hardened = o.hardened ?? false;
2523
+ const now = o.now ?? (() => Date.now());
2524
+ const silenceMs = o.silenceMs ?? 9e4;
2525
+ const watchdogMs = o.watchdogMs ?? 3e4;
2526
+ const wakeGapMs = o.wakeGapMs ?? 12e4;
2527
+ const authWarCap = o.authWarCap ?? 3;
2528
+ const authWarWindowMs = o.authWarWindowMs ?? 12e4;
2529
+ const lifecycle = (event, detail) => {
2530
+ try {
2531
+ o.onLifecycle?.(event, detail);
2532
+ } catch {
2533
+ }
2534
+ };
1890
2535
  let closed = false;
1891
2536
  let attempt = 0;
2537
+ let connectGen = 0;
1892
2538
  let sock;
1893
2539
  let backoffTimer;
1894
2540
  let stableTimer;
@@ -1900,6 +2546,17 @@ function openDeliveryPings(o) {
1900
2546
  pongTimer = void 0;
1901
2547
  }
1902
2548
  };
2549
+ let terminalReason;
2550
+ let lastInboundAt = 0;
2551
+ let lastTickAt;
2552
+ let watchdogTimer;
2553
+ const authCloses = [];
2554
+ const stopWatchdog = () => {
2555
+ if (watchdogTimer) {
2556
+ clearInterval(watchdogTimer);
2557
+ watchdogTimer = void 0;
2558
+ }
2559
+ };
1903
2560
  const queue = [];
1904
2561
  let waiter;
1905
2562
  let iterStarted = false;
@@ -1919,9 +2576,18 @@ function openDeliveryPings(o) {
1919
2576
  if (stableTimer)
1920
2577
  clearTimeout(stableTimer);
1921
2578
  stopPong();
1922
- try {
1923
- sock?.close();
1924
- } catch {
2579
+ stopWatchdog();
2580
+ const ws = sock;
2581
+ sock = void 0;
2582
+ if (ws) {
2583
+ ws.onopen = null;
2584
+ ws.onmessage = null;
2585
+ ws.onclose = null;
2586
+ ws.onerror = null;
2587
+ try {
2588
+ ws.close();
2589
+ } catch {
2590
+ }
1925
2591
  }
1926
2592
  if (waiter) {
1927
2593
  const w = waiter;
@@ -1932,13 +2598,60 @@ function openDeliveryPings(o) {
1932
2598
  const scheduleReconnect = () => {
1933
2599
  if (closed)
1934
2600
  return;
2601
+ if (backoffTimer)
2602
+ clearTimeout(backoffTimer);
2603
+ connectGen += 1;
1935
2604
  attempt += 1;
1936
2605
  const ceil = Math.min(cap, base * 2 ** (attempt - 1));
1937
2606
  backoffTimer = setTimeout(connect, Math.floor(rng() * ceil));
1938
2607
  };
2608
+ const forceCycle = (event, detail) => {
2609
+ if (closed)
2610
+ return;
2611
+ lifecycle(event, detail);
2612
+ const ws = sock;
2613
+ sock = void 0;
2614
+ if (stableTimer)
2615
+ clearTimeout(stableTimer);
2616
+ stopPong();
2617
+ if (ws) {
2618
+ ws.onopen = null;
2619
+ ws.onmessage = null;
2620
+ ws.onclose = null;
2621
+ ws.onerror = null;
2622
+ try {
2623
+ ws.close();
2624
+ } catch {
2625
+ }
2626
+ }
2627
+ scheduleReconnect();
2628
+ };
2629
+ const startWatchdog = () => {
2630
+ if (!hardened || watchdogTimer)
2631
+ return;
2632
+ lastTickAt = now();
2633
+ watchdogTimer = setInterval(() => {
2634
+ if (closed)
2635
+ return;
2636
+ const t = now();
2637
+ const gap = lastTickAt !== void 0 ? t - lastTickAt : 0;
2638
+ if (lastTickAt !== void 0 && gap > wakeGapMs) {
2639
+ lastTickAt = t;
2640
+ attempt = 0;
2641
+ forceCycle("wake", `${gap}ms gap`);
2642
+ return;
2643
+ }
2644
+ lastTickAt = t;
2645
+ if (sock && sock.readyState === 1 && t - lastInboundAt > silenceMs) {
2646
+ forceCycle("halfopen", `${t - lastInboundAt}ms silent`);
2647
+ }
2648
+ }, watchdogMs);
2649
+ };
1939
2650
  async function connect() {
1940
2651
  if (closed)
1941
2652
  return;
2653
+ const gen = connectGen;
2654
+ terminalReason = void 0;
1942
2655
  let token;
1943
2656
  try {
1944
2657
  token = await o.tokenProvider();
@@ -1946,15 +2659,17 @@ function openDeliveryPings(o) {
1946
2659
  scheduleReconnect();
1947
2660
  return;
1948
2661
  }
1949
- if (closed)
2662
+ if (closed || gen !== connectGen)
1950
2663
  return;
1951
2664
  const sep = o.url.includes("?") ? "&" : "?";
1952
2665
  const ws = new o.wsImpl(`${o.url}${sep}token=${encodeURIComponent(token)}`);
1953
2666
  sock = ws;
1954
2667
  ws.onopen = () => {
1955
2668
  push({ reconnected: true });
2669
+ lastInboundAt = now();
1956
2670
  stableTimer = setTimeout(() => {
1957
2671
  attempt = 0;
2672
+ authCloses.length = 0;
1958
2673
  }, stableResetMs);
1959
2674
  stopPong();
1960
2675
  pongTimer = setInterval(() => {
@@ -1966,8 +2681,26 @@ function openDeliveryPings(o) {
1966
2681
  }, PRESENCE_PONG_MS);
1967
2682
  };
1968
2683
  ws.onmessage = (e) => {
2684
+ lastInboundAt = now();
2685
+ let f;
2686
+ try {
2687
+ f = JSON.parse(String(e.data));
2688
+ } catch {
2689
+ return;
2690
+ }
2691
+ if (hardened) {
2692
+ const tr = terminalFrameReason(f);
2693
+ if (tr) {
2694
+ terminalReason = tr;
2695
+ } else if (f?.event === "ping") {
2696
+ try {
2697
+ if (ws.readyState === 1)
2698
+ ws.send(JSON.stringify({ event: "pong" }));
2699
+ } catch {
2700
+ }
2701
+ }
2702
+ }
1969
2703
  try {
1970
- const f = JSON.parse(String(e.data));
1971
2704
  const ping = parseServerFrame(f);
1972
2705
  if (ping)
1973
2706
  push(ping);
@@ -1983,14 +2716,41 @@ function openDeliveryPings(o) {
1983
2716
  stopPong();
1984
2717
  if (closed)
1985
2718
  return;
1986
- if (e.code === 4001) {
2719
+ const code = e.code;
2720
+ if (!hardened) {
2721
+ if (code === 4001) {
2722
+ end();
2723
+ return;
2724
+ }
2725
+ scheduleReconnect();
2726
+ return;
2727
+ }
2728
+ const tr = terminalReason;
2729
+ terminalReason = void 0;
2730
+ if (code === 4006 || tr && code === 4001) {
2731
+ lifecycle("terminal", tr ?? `code ${code}`);
1987
2732
  end();
1988
2733
  return;
1989
2734
  }
2735
+ if (code === 4001) {
2736
+ const t = now();
2737
+ authCloses.push(t);
2738
+ while (authCloses.length && t - authCloses[0] > authWarWindowMs)
2739
+ authCloses.shift();
2740
+ if (authCloses.length >= authWarCap) {
2741
+ lifecycle("terminal", `auth war-cap ${authCloses.length}/${authWarWindowMs}ms`);
2742
+ end();
2743
+ return;
2744
+ }
2745
+ lifecycle("reconnect", "auth-4001");
2746
+ scheduleReconnect();
2747
+ return;
2748
+ }
1990
2749
  scheduleReconnect();
1991
2750
  };
1992
2751
  }
1993
2752
  void connect();
2753
+ startWatchdog();
1994
2754
  const pings = {
1995
2755
  [Symbol.asyncIterator]() {
1996
2756
  if (iterStarted)
@@ -2105,6 +2865,11 @@ var RestTransport = class {
2105
2865
  if (stale)
2106
2866
  throw stale;
2107
2867
  }
2868
+ if (res.status === 403) {
2869
+ const paused = await agentPausedErrorFromResponse(res.clone ? res.clone() : res);
2870
+ if (paused)
2871
+ throw paused;
2872
+ }
2108
2873
  if (!res.ok)
2109
2874
  throw new Error(`publish failed: ${res.status}`);
2110
2875
  const j = await res.json();
@@ -2120,7 +2885,13 @@ var RestTransport = class {
2120
2885
  if (this.opts.connectIdentityId) {
2121
2886
  wsUrl += `?connect_identity_id=${encodeURIComponent(this.opts.connectIdentityId)}`;
2122
2887
  }
2123
- const handle = openDeliveryPings({ url: wsUrl, tokenProvider: this.tokenProvider, wsImpl: this.ws });
2888
+ const handle = openDeliveryPings({
2889
+ url: wsUrl,
2890
+ tokenProvider: this.tokenProvider,
2891
+ wsImpl: this.ws,
2892
+ hardened: this.opts.hardenedReconnect ?? false,
2893
+ onLifecycle: this.opts.onWsLifecycle
2894
+ });
2124
2895
  this._controlSend = handle.send;
2125
2896
  const close = () => {
2126
2897
  if (this._controlSend === handle.send)
@@ -2195,10 +2966,11 @@ var RestMlsBackend = class {
2195
2966
  return { device_id: String(j.device_id) };
2196
2967
  }
2197
2968
  async publishKeyPackage(deviceId, keyPackageHex) {
2198
- await this.post("/api/v1/mls/key-packages", {
2969
+ const j = await this.post("/api/v1/mls/key-packages", {
2199
2970
  key_package: keyPackageHex,
2200
2971
  device_id: deviceId
2201
2972
  });
2973
+ return String(j.id);
2202
2974
  }
2203
2975
  async getConnectChannelGroup(channelId) {
2204
2976
  const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/group`);
@@ -2235,10 +3007,37 @@ var RestMlsBackend = class {
2235
3007
  member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
2236
3008
  };
2237
3009
  }
2238
- async fetchKeyPackages(deviceIds) {
2239
- const j = await this.get(`/api/v1/mls/key-packages/batch?device_ids=${deviceIds.join(",")}`);
3010
+ async resetReinitConnectChannelGroup(channelId, memberDeviceIds, att) {
3011
+ const committer = memberDeviceIds[0];
3012
+ if (!committer)
3013
+ throw new Error("resetReinitConnectChannelGroup: empty member set");
3014
+ const j = await this.post(`/api/v1/mls/connect-channels/${channelId}/reinit`, {
3015
+ member_device_ids: memberDeviceIds,
3016
+ committer_device_id: committer,
3017
+ mode: "reset",
3018
+ new_group_id: att.newGroupId,
3019
+ reset_attestation_signature: att.signatureHex,
3020
+ reset_attestation_nonce: att.nonceHex,
3021
+ reset_attestation_content: att.signedContentHex
3022
+ });
3023
+ return {
3024
+ group_id: String(j.group_id),
3025
+ current_epoch: Number(j.current_epoch),
3026
+ member_device_ids: Array.isArray(j.member_device_ids) ? j.member_device_ids.map(String) : []
3027
+ };
3028
+ }
3029
+ async fetchKeyPackages(deviceIds) {
3030
+ const j = await this.get(`/api/v1/mls/key-packages/batch?device_ids=${deviceIds.join(",")}`);
2240
3031
  return Object.fromEntries(Object.entries(j).map(([k, v]) => [k, String(v)]));
2241
3032
  }
3033
+ async fetchKeyPackageById(kpId) {
3034
+ try {
3035
+ const j = await this.get(`/api/v1/mls/key-packages/by-id/${kpId}`);
3036
+ return String(j.key_package);
3037
+ } catch {
3038
+ return null;
3039
+ }
3040
+ }
2242
3041
  async distribute(groupId, req) {
2243
3042
  await this.post(`/api/v1/mls/groups/${groupId}/distribute`, {
2244
3043
  ...req,
@@ -2255,13 +3054,48 @@ var RestMlsBackend = class {
2255
3054
  queue_ids: queueIds
2256
3055
  });
2257
3056
  }
2258
- async requestWelcome(groupId, deviceId) {
2259
- await this.post(`/api/v1/mls/groups/${groupId}/request-welcome`, { device_id: deviceId });
3057
+ async requestWelcome(groupId, deviceId, freshKpId) {
3058
+ const body = { device_id: deviceId };
3059
+ if (freshKpId)
3060
+ body.fresh_kp_id = freshKpId;
3061
+ await this.post(`/api/v1/mls/groups/${groupId}/request-welcome`, body);
3062
+ }
3063
+ async getCommitterWork(channelId) {
3064
+ const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/committer-work`);
3065
+ return {
3066
+ adds: Array.isArray(j.adds) ? j.adds.map(String) : [],
3067
+ removes: Array.isArray(j.removes) ? j.removes.map(String) : []
3068
+ };
3069
+ }
3070
+ async acquireCommitLease(channelId) {
3071
+ const res = await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease`, { method: "POST", headers: this.headers(), body: JSON.stringify({}) });
3072
+ if (res.status === 409 || res.status === 503)
3073
+ return null;
3074
+ if (!res.ok)
3075
+ throw new Error(`commit-lease ${res.status}`);
3076
+ const j = await res.json();
3077
+ return String(j.token);
3078
+ }
3079
+ async releaseCommitLease(channelId, token) {
3080
+ await this.f(this.opts.baseUrl + `/api/v1/mls/connect-channels/${channelId}/commit-lease/release`, { method: "POST", headers: this.headers(), body: JSON.stringify({ token }) }).catch(() => {
3081
+ });
3082
+ }
3083
+ async fetchResetAttestation(groupId) {
3084
+ try {
3085
+ const j = await this.get(`/api/v1/mls/groups/${groupId}/reset-attestation`);
3086
+ return {
3087
+ signedContent: String(j.signed_content),
3088
+ signatureHex: String(j.signature_hex),
3089
+ signerIdentityId: String(j.signer_identity_id)
3090
+ };
3091
+ } catch {
3092
+ return null;
3093
+ }
2260
3094
  }
2261
3095
  };
2262
3096
 
2263
3097
  // ../connect-sdk-ts/dist/mls/fake-backend.js
2264
- import { bytesToHex as bytesToHex2 } from "@agentvault/crypto";
3098
+ import { bytesToHex as bytesToHex2, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
2265
3099
 
2266
3100
  // ../connect-sdk-ts/dist/receive/inbox-cipher.js
2267
3101
  import { hkdf } from "@noble/hashes/hkdf";
@@ -2311,9 +3145,10 @@ var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
2311
3145
 
2312
3146
  // ../connect-mls-store/dist/sqlite-store.js
2313
3147
  import { DatabaseSync } from "node:sqlite";
3148
+ import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex3, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
2314
3149
 
2315
3150
  // ../connect-mls-store/dist/schema.js
2316
- var SCHEMA_VERSION = 1;
3151
+ var SCHEMA_VERSION = 4;
2317
3152
  function openSchema(db) {
2318
3153
  db.exec("PRAGMA journal_mode=WAL");
2319
3154
  const row = db.prepare("PRAGMA user_version").get();
@@ -2328,8 +3163,39 @@ function openSchema(db) {
2328
3163
  PRIMARY KEY (identity_id, channel_id)
2329
3164
  );
2330
3165
  `);
2331
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
2332
3166
  }
3167
+ if (row.user_version < 2) {
3168
+ db.exec(`
3169
+ CREATE TABLE IF NOT EXISTS mls_identity_pin (
3170
+ owner_identity_id TEXT PRIMARY KEY,
3171
+ public_key_hex TEXT NOT NULL,
3172
+ key_algo TEXT NOT NULL,
3173
+ pinned_at INTEGER NOT NULL
3174
+ );
3175
+ `);
3176
+ }
3177
+ if (row.user_version < 3) {
3178
+ db.exec(`
3179
+ CREATE TABLE IF NOT EXISTS mls_retired_group (
3180
+ channel_id TEXT NOT NULL,
3181
+ group_id TEXT NOT NULL,
3182
+ PRIMARY KEY (channel_id, group_id)
3183
+ );
3184
+ `);
3185
+ }
3186
+ if (row.user_version < 4) {
3187
+ db.exec(`
3188
+ CREATE TABLE IF NOT EXISTS mls_pending_bundle (
3189
+ identity_id TEXT NOT NULL,
3190
+ ref TEXT NOT NULL,
3191
+ kp_id TEXT,
3192
+ sealed_bundle BLOB,
3193
+ updated_at INTEGER NOT NULL,
3194
+ PRIMARY KEY (identity_id, ref)
3195
+ );
3196
+ `);
3197
+ }
3198
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
2333
3199
  }
2334
3200
 
2335
3201
  // ../connect-mls-store/dist/errors.js
@@ -2347,13 +3213,39 @@ var MlsStoreSealError = class extends Error {
2347
3213
  };
2348
3214
 
2349
3215
  // ../connect-mls-store/dist/sqlite-store.js
3216
+ function encodeBundle(bundle) {
3217
+ const pp = bundle.privatePackage;
3218
+ const obj = {
3219
+ pub: bytesToHex3(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage)),
3220
+ initPriv: bytesToHex3(pp.initPrivateKey),
3221
+ hpkePriv: bytesToHex3(pp.hpkePrivateKey),
3222
+ sigPriv: bytesToHex3(pp.signaturePrivateKey)
3223
+ };
3224
+ return new TextEncoder().encode(JSON.stringify(obj));
3225
+ }
3226
+ function decodeBundle(bytes) {
3227
+ const obj = JSON.parse(new TextDecoder().decode(bytes));
3228
+ return {
3229
+ publicPackage: MLSGroupManager2.deserializeKeyPackage(hexToBytes3(obj.pub)),
3230
+ privatePackage: {
3231
+ initPrivateKey: hexToBytes3(obj.initPriv),
3232
+ hpkePrivateKey: hexToBytes3(obj.hpkePriv),
3233
+ signaturePrivateKey: hexToBytes3(obj.sigPriv)
3234
+ }
3235
+ };
3236
+ }
2350
3237
  var SqliteMlsStateStore = class {
2351
3238
  db;
2352
3239
  id;
2353
3240
  cipher;
2354
- // Bundles stay in-memory (spec §4): private half not durably serializable.
2355
- // Per-instance map → per-identity store instances are naturally isolated (§6.1).
3241
+ // In-memory write-through cache over the durable mls_pending_bundle table.
3242
+ // Persisting the bundle (schema v4) is the root-cause fix for restart-induced
3243
+ // KeyPackage rotation: with the bundle in-memory ONLY, every restart lost it,
3244
+ // re-minted+re-published a fresh KP, and orphaned every Welcome sealed to the
3245
+ // prior published KP. The cache avoids re-unsealing on every access; sqlite is
3246
+ // the source of truth across process restarts.
2356
3247
  bundles = /* @__PURE__ */ new Map();
3248
+ lastKpIds = /* @__PURE__ */ new Map();
2357
3249
  constructor(opts) {
2358
3250
  this.id = opts.identityId;
2359
3251
  this.cipher = opts.cipherFor(opts.identityId);
@@ -2422,12 +3314,74 @@ var SqliteMlsStateStore = class {
2422
3314
  }
2423
3315
  async savePendingBundle(ref, bundle) {
2424
3316
  this.bundles.set(ref, bundle);
3317
+ const sealed = await this.cipher.seal(encodeBundle(bundle));
3318
+ this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, sealed_bundle, updated_at)
3319
+ VALUES (?,?,?,?)
3320
+ ON CONFLICT(identity_id, ref) DO UPDATE SET sealed_bundle=excluded.sealed_bundle, updated_at=excluded.updated_at`).run(this.id, ref, sealed, Date.now());
2425
3321
  }
2426
3322
  async loadPendingBundle(ref) {
2427
- return this.bundles.get(ref) ?? null;
3323
+ const cached = this.bundles.get(ref);
3324
+ if (cached)
3325
+ return cached;
3326
+ const row = this.db.prepare("SELECT sealed_bundle FROM mls_pending_bundle WHERE identity_id=? AND ref=?").get(this.id, ref);
3327
+ if (!row?.sealed_bundle)
3328
+ return null;
3329
+ let bundle;
3330
+ try {
3331
+ bundle = decodeBundle(await this.cipher.open(row.sealed_bundle));
3332
+ } catch (e) {
3333
+ if (typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY) {
3334
+ console.warn(`[trace] loadPendingBundle: unseal/decode FAILED for ref ${ref.slice(0, 8)}: ${e?.message}`);
3335
+ }
3336
+ return null;
3337
+ }
3338
+ this.bundles.set(ref, bundle);
3339
+ return bundle;
2428
3340
  }
2429
3341
  async deletePendingBundle(ref) {
2430
3342
  this.bundles.delete(ref);
3343
+ this.db.prepare("UPDATE mls_pending_bundle SET sealed_bundle=NULL, updated_at=? WHERE identity_id=? AND ref=?").run(Date.now(), this.id, ref);
3344
+ }
3345
+ async saveLastKpId(deviceId, id) {
3346
+ this.lastKpIds.set(deviceId, id);
3347
+ this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, kp_id, updated_at)
3348
+ VALUES (?,?,?,?)
3349
+ ON CONFLICT(identity_id, ref) DO UPDATE SET kp_id=excluded.kp_id, updated_at=excluded.updated_at`).run(this.id, deviceId, id, Date.now());
3350
+ }
3351
+ async loadLastKpId(deviceId) {
3352
+ const cached = this.lastKpIds.get(deviceId);
3353
+ if (cached)
3354
+ return cached;
3355
+ const row = this.db.prepare("SELECT kp_id FROM mls_pending_bundle WHERE identity_id=? AND ref=?").get(this.id, deviceId);
3356
+ if (!row?.kp_id)
3357
+ return null;
3358
+ this.lastKpIds.set(deviceId, row.kp_id);
3359
+ return row.kp_id;
3360
+ }
3361
+ async saveLastKpAndBundle(deviceId, id, bundle) {
3362
+ this.lastKpIds.set(deviceId, id);
3363
+ this.bundles.set(deviceId, bundle);
3364
+ const sealed = await this.cipher.seal(encodeBundle(bundle));
3365
+ this.db.prepare(`INSERT INTO mls_pending_bundle (identity_id, ref, kp_id, sealed_bundle, updated_at)
3366
+ VALUES (?,?,?,?,?)
3367
+ ON CONFLICT(identity_id, ref) DO UPDATE SET kp_id=excluded.kp_id, sealed_bundle=excluded.sealed_bundle, updated_at=excluded.updated_at`).run(this.id, deviceId, id, sealed, Date.now());
3368
+ }
3369
+ async savePinnedIdentityKey(identityId, key) {
3370
+ this.db.prepare(`INSERT INTO mls_identity_pin (owner_identity_id, public_key_hex, key_algo, pinned_at)
3371
+ VALUES (?,?,?,?)
3372
+ ON CONFLICT(owner_identity_id) DO UPDATE SET
3373
+ public_key_hex=excluded.public_key_hex, key_algo=excluded.key_algo, pinned_at=excluded.pinned_at`).run(identityId, key.publicKeyHex, key.keyAlgo, Date.now());
3374
+ }
3375
+ async loadPinnedIdentityKey(identityId) {
3376
+ const row = this.db.prepare("SELECT public_key_hex, key_algo FROM mls_identity_pin WHERE owner_identity_id=?").get(identityId);
3377
+ return row ? { publicKeyHex: row.public_key_hex, keyAlgo: row.key_algo } : null;
3378
+ }
3379
+ async addRetiredGroupId(channelId, groupId) {
3380
+ this.db.prepare("INSERT OR IGNORE INTO mls_retired_group (channel_id, group_id) VALUES (?,?)").run(channelId, groupId);
3381
+ }
3382
+ async loadRetiredGroupIds(channelId) {
3383
+ const rows = this.db.prepare("SELECT group_id FROM mls_retired_group WHERE channel_id=?").all(channelId);
3384
+ return rows.map((r) => r.group_id);
2431
3385
  }
2432
3386
  /** @internal test-only */
2433
3387
  _rawMainForTest(identityId, channelId) {
@@ -2439,6 +3393,11 @@ var SqliteMlsStateStore = class {
2439
3393
  }
2440
3394
  };
2441
3395
 
3396
+ // src/bridge-agent.ts
3397
+ import { decryptFile, encryptFile, computeFileDigest, base64ToBytes as base64ToBytes2, bytesToBase64 as bytesToBase642 } from "@agentvault/crypto";
3398
+ import { promises as fsp } from "node:fs";
3399
+ import { join as join2, dirname as dirname2 } from "node:path";
3400
+
2442
3401
  // src/latency.ts
2443
3402
  var DEFAULT_CAPACITY = 1024;
2444
3403
  function round(n) {
@@ -2586,6 +3545,148 @@ var TokenBucketFloor = class {
2586
3545
  }
2587
3546
  };
2588
3547
 
3548
+ // src/decrypt-error-log-limiter.ts
3549
+ var DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS = 6e4;
3550
+ var MAX_DISTINCT_PER_WINDOW = 4;
3551
+ function normalizeMessage(m) {
3552
+ return m.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "<id>").replace(/[0-9a-f]{16,}/gi, "<hex>");
3553
+ }
3554
+ var DecryptErrorLogLimiter = class {
3555
+ windowMs;
3556
+ now;
3557
+ log;
3558
+ windows = /* @__PURE__ */ new Map();
3559
+ constructor(opts = {}) {
3560
+ this.windowMs = opts.windowMs ?? DEFAULT_DECRYPT_ERROR_LOG_WINDOW_MS;
3561
+ this.now = opts.now ?? Date.now;
3562
+ this.log = opts.log ?? ((line) => console.warn(line));
3563
+ }
3564
+ /**
3565
+ * Record one decrypt error for `channelId`. Logs immediately if this is
3566
+ * the channel's first error since its window opened (or its prior window
3567
+ * had already elapsed, in which case a summary for that just-closed
3568
+ * window is flushed first); otherwise the error is counted silently
3569
+ * toward the window's eventual summary line.
3570
+ */
3571
+ record(channelId, info) {
3572
+ const t = this.now();
3573
+ const w = this.windows.get(channelId);
3574
+ if (!w) {
3575
+ this.logFirst(channelId, info);
3576
+ this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
3577
+ return;
3578
+ }
3579
+ if (t - w.windowStart >= this.windowMs) {
3580
+ this.flushSummary(channelId, w);
3581
+ this.logFirst(channelId, info);
3582
+ this.windows.set(channelId, { windowStart: t, count: 1, lastEpoch: info.epoch, lastMessage: info.message, loggedKeys: /* @__PURE__ */ new Set([normalizeMessage(info.message)]) });
3583
+ return;
3584
+ }
3585
+ w.count += 1;
3586
+ w.lastEpoch = info.epoch;
3587
+ w.lastMessage = info.message;
3588
+ const key = normalizeMessage(info.message);
3589
+ if (!w.loggedKeys.has(key) && w.loggedKeys.size < MAX_DISTINCT_PER_WINDOW) {
3590
+ this.logFirst(channelId, info);
3591
+ w.loggedKeys.add(key);
3592
+ }
3593
+ }
3594
+ /**
3595
+ * Clear a channel's window (e.g. once the caller learns the channel
3596
+ * cleared/healed) so its NEXT error logs immediately as a fresh "first"
3597
+ * rather than being folded into a stale window's summary. There is
3598
+ * currently no onDecryptSuccess-style hook from the SDK to wire this to
3599
+ * automatically (see bridge-agent.ts's onDecryptError wiring comment) —
3600
+ * exposed here so a future success signal (or a caller with its own
3601
+ * channel-cleared signal) can reset explicitly. In its absence, the
3602
+ * window simply rolls over on interval elapse, which is an accepted
3603
+ * simplification (see report).
3604
+ */
3605
+ reset(channelId) {
3606
+ this.windows.delete(channelId);
3607
+ }
3608
+ logFirst(channelId, info) {
3609
+ this.log(
3610
+ `[bridge] decrypt error on channel ${channelId} (queue ${info.queueId ?? "?"}, epoch ${info.epoch ?? "?"}): ${info.message}`
3611
+ );
3612
+ }
3613
+ flushSummary(channelId, w) {
3614
+ if (w.count <= 1) return;
3615
+ const seconds = Math.round(this.windowMs / 1e3);
3616
+ this.log(
3617
+ `[bridge] decrypt errors on channel ${channelId}: ${w.count} in last ${seconds}s (epoch ${w.lastEpoch ?? "?"}, ${w.lastMessage})`
3618
+ );
3619
+ }
3620
+ };
3621
+
3622
+ // src/attachments.ts
3623
+ import { basename } from "node:path";
3624
+ function sanitizeFilename(name, fallback) {
3625
+ const base = basename(name ?? "");
3626
+ const cleaned = base.replace(/[/\\]/g, "").replace(/^\.+$/, "");
3627
+ return cleaned.length > 0 ? cleaned : fallback;
3628
+ }
3629
+ var MIME_BY_EXT = {
3630
+ png: "image/png",
3631
+ jpg: "image/jpeg",
3632
+ jpeg: "image/jpeg",
3633
+ gif: "image/gif",
3634
+ webp: "image/webp",
3635
+ svg: "image/svg+xml",
3636
+ pdf: "application/pdf",
3637
+ txt: "text/plain",
3638
+ md: "text/markdown",
3639
+ json: "application/json",
3640
+ csv: "text/csv",
3641
+ html: "text/html",
3642
+ mp4: "video/mp4",
3643
+ mp3: "audio/mpeg",
3644
+ zip: "application/zip",
3645
+ wav: "audio/wav"
3646
+ };
3647
+ function mimeForName(name) {
3648
+ const dot = name.lastIndexOf(".");
3649
+ if (dot < 0 || dot === name.length - 1) return "application/octet-stream";
3650
+ return MIME_BY_EXT[name.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
3651
+ }
3652
+ async function prepareAttachment(filePath, caption, channelId, deps) {
3653
+ const bytes = await deps.readFile(filePath);
3654
+ const name = basename(filePath);
3655
+ const { encryptedData, fileKey, fileNonce, digest } = deps.encryptFile(bytes);
3656
+ const blobId = await deps.uploadBlob(encryptedData, channelId);
3657
+ const body = {
3658
+ v: 1,
3659
+ type: "artifact_share",
3660
+ name,
3661
+ mime: mimeForName(name),
3662
+ size: bytes.length,
3663
+ blobId,
3664
+ fileKey: deps.bytesToBase64(fileKey),
3665
+ fileNonce: deps.bytesToBase64(fileNonce),
3666
+ digest
3667
+ };
3668
+ if (caption) body.text = caption;
3669
+ return body;
3670
+ }
3671
+ async function materializeAttachment(body, ctx, deps) {
3672
+ const ciphertext = await deps.downloadBlob(body.blobId);
3673
+ const sha2565 = deps.computeFileDigest(ciphertext);
3674
+ if (sha2565 !== body.digest) {
3675
+ throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
3676
+ }
3677
+ const plain = deps.decryptFile(
3678
+ ciphertext,
3679
+ deps.base64ToBytes(body.fileKey),
3680
+ deps.base64ToBytes(body.fileNonce)
3681
+ );
3682
+ const safeName = sanitizeFilename(body.name, ctx.messageId);
3683
+ const dir = `${ctx.attachmentsDir}/${ctx.channelId}`;
3684
+ const path = `${dir}/${ctx.messageId}-${safeName}`;
3685
+ await deps.mkdirp(dir);
3686
+ await deps.writeFile(path, plain);
3687
+ return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2565 };
3688
+ }
3689
+
2589
3690
  // src/bridge-agent.ts
2590
3691
  var DEFAULT_MAX_BUFFER = 1e3;
2591
3692
  var CHANNEL_META_TTL_MS = 5 * 6e4;
@@ -2606,16 +3707,48 @@ var RateLimitedError = class extends Error {
2606
3707
  this.name = "RateLimitedError";
2607
3708
  }
2608
3709
  };
3710
+ var InvalidPathError = class extends Error {
3711
+ constructor(filePath, cause) {
3712
+ super(`cannot read attachment file: ${filePath}`);
3713
+ this.filePath = filePath;
3714
+ this.name = "InvalidPathError";
3715
+ if (cause !== void 0) this.cause = cause;
3716
+ }
3717
+ };
3718
+ var AgentPausedSendError = class extends Error {
3719
+ constructor(channelId) {
3720
+ super(`agent is paused in channel ${channelId}; send suppressed (no self-heal, no retry)`);
3721
+ this.channelId = channelId;
3722
+ this.name = "AgentPausedSendError";
3723
+ }
3724
+ };
3725
+ var UnknownActionRunError = class extends Error {
3726
+ constructor(runId) {
3727
+ super(`unknown or already-settled agent action run: ${runId}`);
3728
+ this.runId = runId;
3729
+ this.name = "UnknownActionRunError";
3730
+ }
3731
+ };
2609
3732
  var BRIDGE_VERSION = "0.0.0";
2610
- function hexToBytes2(h) {
3733
+ function hexToBytes4(h) {
2611
3734
  return new Uint8Array(Buffer.from(h, "hex"));
2612
3735
  }
2613
3736
  var BridgeAgent = class _BridgeAgent {
2614
3737
  constructor(cfg, deps = {}) {
2615
3738
  this.cfg = cfg;
2616
- const seed = hexToBytes2(cfg.signingPrivateKey);
3739
+ const seed = hexToBytes4(cfg.signingPrivateKey);
2617
3740
  this.custody = SoftwareKeyCustody.fromPrivateKey("ed25519", seed);
2618
- this.transport = deps.transport ?? new RestTransport({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey, connectIdentityId: cfg.identityId });
3741
+ this.transport = deps.transport ?? new RestTransport({
3742
+ baseUrl: cfg.apiBase,
3743
+ bearerToken: cfg.apiKey,
3744
+ connectIdentityId: cfg.identityId,
3745
+ // P1b: hardened delivery-ping reconnect (verify-before-terminal +
3746
+ // half-open + wake + auth war-cap). On by default for the daemon;
3747
+ // SECURECOMS_WS_HARDENED=false reverts to the legacy 4001-terminal path.
3748
+ hardenedReconnect: process.env.SECURECOMS_WS_HARDENED !== "false",
3749
+ onWsLifecycle: (event, detail) => process.stderr.write(`[bridge] delivery-ping ${event}${detail ? ` (${detail})` : ""}
3750
+ `)
3751
+ });
2619
3752
  this.backend = deps.backend ?? new RestMlsBackend({ baseUrl: cfg.apiBase, bearerToken: cfg.apiKey });
2620
3753
  this.store = new SqliteMlsStateStore({
2621
3754
  dbPath: cfg.storePath,
@@ -2630,12 +3763,47 @@ var BridgeAgent = class _BridgeAgent {
2630
3763
  // ADR-0002: count + log agent-side decrypt failures. Without this hook the
2631
3764
  // daemon was 100% blind to a wedged channel (it "looked idle"); the
2632
3765
  // provider's errorStats() snapshot is surfaced on GET /v1/metrics below.
3766
+ // P0b (daemon-stability): logging is routed through decryptErrorLogLimiter
3767
+ // rather than a raw console.warn per failure — a wedged channel used to
3768
+ // redeliver the same undecryptable row on every pullDelivery cycle and
3769
+ // produced a 114 MB / 765,000-line daemon log in a single day. The
3770
+ // limiter still logs the first failure immediately and collapses every
3771
+ // repeat within 60s into one summary line; errorStats() below is fed
3772
+ // directly by the provider and is completely unaffected.
2633
3773
  onDecryptError: (row, err) => {
2634
3774
  const cid = row.connect_channel_id ?? "(unmapped)";
2635
- console.warn(
2636
- `[bridge] decrypt error on channel ${cid} (queue ${row.queue_id}, epoch ${row.epoch}): ${err instanceof Error ? err.message : String(err)}`
2637
- );
2638
- }
3775
+ this.decryptErrorLogLimiter.record(cid, {
3776
+ epoch: row.epoch,
3777
+ queueId: row.queue_id,
3778
+ message: err instanceof Error ? err.message : String(err)
3779
+ });
3780
+ },
3781
+ // P0b (daemon-stability): fired EXACTLY ONCE by the SDK (P0a) when a
3782
+ // channel's decrypt-failure run crosses quarantineThreshold and its
3783
+ // stuck row has been ack-drained. Log one quarantine line, then
3784
+ // best-effort trigger the SAME held-KP re-Welcome heal used by
3785
+ // ingestOnce's Task-11 branch and selfHealSweep (see
3786
+ // healWedgedChannel below) — never throws back into the SDK caller.
3787
+ onWedged: (channelId) => {
3788
+ try {
3789
+ console.warn(
3790
+ `[bridge] channel ${channelId} quarantined after persistent decrypt failures \u2014 signalling re-Welcome`
3791
+ );
3792
+ void this.healWedgedChannel(channelId);
3793
+ } catch {
3794
+ }
3795
+ },
3796
+ // Owner-attested Reset Secure Session (Task 8): the SDK's step-0 guard
3797
+ // calls this before honoring an owner-authored supersede. It must return
3798
+ // a value ONLY when the structural owner is unambiguous AND a pin exists
3799
+ // for it — never trust a bare in-memory cache without reconfirming
3800
+ // against the persisted pin (see resolvePinnedOwnerHook below).
3801
+ onResolvePinnedOwner: (channelId) => this.resolvePinnedOwnerHook(channelId),
3802
+ // P1a self-heal: a verified reset supersede whose Welcome we cannot open
3803
+ // (sealed to a stale published KeyPackage) — ask the committer to re-Welcome
3804
+ // us to the reset group with our CURRENT KeyPackage. Cooldown-guarded, never
3805
+ // throws (mirrors the divergence-heal signal path).
3806
+ onSupersedeUnopenable: (channelId, groupId) => this.healSupersedeUnopenable(channelId, groupId)
2639
3807
  });
2640
3808
  this.fetchImpl = deps.fetchImpl ?? fetch;
2641
3809
  this.latency = deps.latency ?? new LatencyRecorder();
@@ -2644,6 +3812,37 @@ var BridgeAgent = class _BridgeAgent {
2644
3812
  capacity: cfg.rateLimitCapacity ?? 20,
2645
3813
  refillPerMinute: cfg.rateLimitRefillPerMinute ?? 20
2646
3814
  });
3815
+ this.actionClock = deps.actionThrottleNow ?? Date.now;
3816
+ this.rng = deps.rng ?? Math.random;
3817
+ this.actionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
3818
+ this.globalActionRateFloor = new TokenBucketFloor({ capacity: 1, refillPerMinute: 30, now: this.actionClock });
3819
+ const self = this;
3820
+ this.downloadBlobImpl = deps.downloadBlob ?? async function(blobId) {
3821
+ const url = `${cfg.apiBase}/api/v1/attachments/${encodeURIComponent(blobId)}?caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
3822
+ const res = await self.fetchImpl(url, { headers: { authorization: `Bearer ${cfg.apiKey}` } });
3823
+ if (!res.ok) throw new Error(`download blob failed: ${res.status}`);
3824
+ return new Uint8Array(await res.arrayBuffer());
3825
+ };
3826
+ this.uploadBlobImpl = deps.uploadBlob ?? async function(ciphertext, channelId) {
3827
+ const url = `${cfg.apiBase}/api/v1/attachments?channel_id=${encodeURIComponent(channelId)}&caller_identity_id=${encodeURIComponent(cfg.identityId)}`;
3828
+ const res = await self.fetchImpl(url, {
3829
+ method: "POST",
3830
+ headers: { "content-type": "application/octet-stream", authorization: `Bearer ${cfg.apiKey}` },
3831
+ body: ciphertext
3832
+ });
3833
+ if (!res.ok) throw new Error(`upload blob failed: ${res.status}`);
3834
+ const j = await res.json();
3835
+ return String(j.attachment_id);
3836
+ };
3837
+ this.attachmentsDirImpl = deps.attachmentsDir ?? join2(dirname2(cfg.storePath), "attachments");
3838
+ this.action = {
3839
+ begin: (channelId) => this.actionBegin(channelId),
3840
+ start: (opts) => this.actionStart(opts),
3841
+ update: (runId, nodeId, patch) => this.actionUpdate(runId, nodeId, patch),
3842
+ finish: (runId, nodeId, patch) => this.actionFinish(runId, nodeId, patch),
3843
+ settle: (runId, opts) => this.actionSettle(runId, opts),
3844
+ wrap: (runId, fn, opts) => this.actionWrap(runId, fn, opts)
3845
+ };
2647
3846
  }
2648
3847
  transport;
2649
3848
  provider;
@@ -2673,6 +3872,15 @@ var BridgeAgent = class _BridgeAgent {
2673
3872
  * per channel on a miss.
2674
3873
  */
2675
3874
  channelRoster = /* @__PURE__ */ new Map();
3875
+ /**
3876
+ * Task 8 (owner-attested Reset Secure Session): per-channel STRUCTURAL owner
3877
+ * cache, fed by resolveAndPinOwner() on every roster refresh and consumed by
3878
+ * the onResolvePinnedOwner hook passed to MlsGroupKeyProvider. "anomalous"
3879
+ * means the DM roster shape was not exactly one non-self, non-agent
3880
+ * counterpart, OR the freshly-fetched key no longer matches an existing pin —
3881
+ * either way the hook must fail closed (return null) for that channel.
3882
+ */
3883
+ channelOwner = /* @__PURE__ */ new Map();
2676
3884
  buffer = [];
2677
3885
  maxBuffer;
2678
3886
  /** Cumulative count of messages shed by the buffer cap (surfaced on health()/metrics()). */
@@ -2687,11 +3895,111 @@ var BridgeAgent = class _BridgeAgent {
2687
3895
  rateLimitedSends = 0;
2688
3896
  /** Cumulative count of requestWelcome calls fired for no-local-state deliveries (self-heal signal). */
2689
3897
  welcomeRequestsSent = 0;
3898
+ /** 2026-07-03 latency triage: cumulative delivery pings received on the WS
3899
+ * stream. 0 while messages still flow = the stream is dead and inbound is
3900
+ * riding the 25s safety floor (surfaced on health()/metrics). */
3901
+ pingsReceived = 0;
3902
+ /** True once close() begins — gates the ping-stream-death stderr warning so
3903
+ * a normal shutdown doesn't cry wolf. */
3904
+ closingDown = false;
3905
+ /**
3906
+ * Track 5: cumulative count of artifact_share messages where download/decrypt/digest
3907
+ * failed. Fail-soft — failures are acked (no redelivery), logged to stderr, and this
3908
+ * counter is incremented. Surfaced on health()/metrics().
3909
+ */
3910
+ failedAttachments = 0;
2690
3911
  /** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
2691
3912
  lastWelcomeRequest = /* @__PURE__ */ new Map();
2692
3913
  /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
2693
3914
  static REQUEST_WELCOME_COOLDOWN_MS = 12e4;
3915
+ // H1: bound the graceful-shutdown drain so a hung in-flight op can't block exit.
3916
+ static GRACEFUL_DRAIN_MS = 5e3;
3917
+ /**
3918
+ * P0b (daemon-stability): collapses the onDecryptError console.warn from
3919
+ * "one line per failure" (the 114 MB/765k-line-in-a-day log source) to at
3920
+ * most one line immediately + one summary line per 60s per channel. LOG
3921
+ * OUTPUT ONLY — does not touch errorStats()/quarantine counters, which
3922
+ * live entirely in the SDK provider and are unaffected by this class.
3923
+ */
3924
+ decryptErrorLogLimiter = new DecryptErrorLogLimiter();
3925
+ /**
3926
+ * Task 12: proactive self-heal sweep kill-switch. Task 11's held-KP signal
3927
+ * only fires reactively, from ingestOnce's Phase-0 pass over PULLED delivery
3928
+ * rows — an idle-but-wiped channel therefore loses its NEXT message (the
3929
+ * very message that would have proven it diverged). selfHealSweep() closes
3930
+ * that gap by proactively walking every non-DM member channel at boot and
3931
+ * on an interval. `SECURECOMS_SELF_HEAL_SWEEP=false` disables it, falling
3932
+ * back to the delivery-row-driven Task 11 behaviour only.
3933
+ */
3934
+ selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
3935
+ /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
3936
+ static SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
3937
+ // C3: add up to this much random jitter to BOTH the boot sweep delay and each
3938
+ // periodic interval, so a fleet of daemons restarting/sweeping together does not
3939
+ // fire re-Welcome signals at the SAME instant and thundering-herd the single
3940
+ // committer (the observed 409 commit-lease saturation). Decorrelation only —
3941
+ // C1 already removed the restart KP-rotation storm that caused the herd.
3942
+ static SELF_HEAL_SWEEP_JITTER_MS = 3e4;
3943
+ selfHealTimer;
3944
+ bootSweepTimer;
3945
+ rng;
2694
3946
  rateFloor;
3947
+ /**
3948
+ * agent.action.* run state, keyed by runId.
3949
+ *
3950
+ * Review M3 (bug fix): the ORIGINAL comment here said "deliberately
3951
+ * unbounded" — but nothing ever bounded it either. Only actionSettle()
3952
+ * deleted an entry; a begun-but-never-settled run (agent crash, bug,
3953
+ * dropped/never-sent reply) leaked for the daemon's entire lifetime. Now
3954
+ * bounded by MAX_UNSETTLED_ACTION_RUNS (LRU eviction by startedAt) plus a
3955
+ * lazy ACTION_RUN_STALE_MS sweep, both applied on every actionBegin() —
3956
+ * mirrors the send-side inbound-buffer bound (capBuffer/maxBuffer).
3957
+ */
3958
+ actionRuns = /* @__PURE__ */ new Map();
3959
+ /** Review M3: hard cap on concurrently-tracked UNSETTLED runs. Exceeding it
3960
+ * evicts the oldest (LRU by startedAt) to make room — never grows past
3961
+ * this regardless of how many runs are begun and abandoned. */
3962
+ static MAX_UNSETTLED_ACTION_RUNS = 50;
3963
+ /** Review M3: an unsettled run older than this (by startedAt, relative to
3964
+ * actionClock()) is swept away on the next actionBegin(), independent of
3965
+ * the count-based cap — bounds memory even when begin() calls are rare. */
3966
+ static ACTION_RUN_STALE_MS = 60 * 6e4;
3967
+ // 60 minutes
3968
+ /** Review I3/M3: injected clock (defaults Date.now) for the action-partial
3969
+ * token buckets AND the stale-run sweep — a single seam so tests can
3970
+ * simulate elapsed time deterministically. */
3971
+ actionClock;
3972
+ /**
3973
+ * Per-run token bucket for `agent_action` PARTIAL frames (Global
3974
+ * Constraint: do NOT model on the unthrottled reportStatus; do NOT reuse
3975
+ * `rateFloor`/`enforceRateFloor`, whose semantics are "throw past
3976
+ * capacity" — partials instead COALESCE: a throttled call just leaves its
3977
+ * change in `dirty` for the next allowed send to pick up).
3978
+ *
3979
+ * Review I3 (bug fix): capacity 1 / refill 60-per-minute was EXACTLY the
3980
+ * backend's MESSAGING_LIMIT (60/60s per sender) — no headroom. Partials +
3981
+ * the settled frame + the agent's real reply + reportStatus all compete
3982
+ * for that one shared per-sender budget, so a long run's settled frame or
3983
+ * reply could 429 right at the end. Lowered to 30/min (~1 per 2s) so
3984
+ * partials alone use at most ~half the identity's send budget, leaving
3985
+ * headroom for the settled frame / reply / status traffic riding the SAME
3986
+ * cap. Independent of `cfg.rateLimitCapacity` (room/a2a/attachment floor).
3987
+ * `settled` frames bypass this bucket entirely — they always send, exactly
3988
+ * once, per run.
3989
+ */
3990
+ actionRateFloor;
3991
+ /**
3992
+ * Review I3 (bug fix): a GLOBAL bucket shared across every run, on top of
3993
+ * the per-run bucket above. Without this, N concurrent runs each get their
3994
+ * OWN ~30/min budget, so the partial send rate multiplies with agent
3995
+ * concurrency and can still blow past the shared 60/min identity edge cap
3996
+ * (each run individually "has headroom" but the SUM doesn't). Same
3997
+ * capacity/refill as the per-run bucket — it caps the aggregate, not any
3998
+ * single run.
3999
+ */
4000
+ globalActionRateFloor;
4001
+ /** Public `agent.action.*` surface — assigned once at the end of the constructor. */
4002
+ action;
2695
4003
  waiters = [];
2696
4004
  pingHandle;
2697
4005
  safetyTimer;
@@ -2699,6 +4007,12 @@ var BridgeAgent = class _BridgeAgent {
2699
4007
  startedAt = 0;
2700
4008
  store;
2701
4009
  latency;
4010
+ /** Track 5: injectable blob download fn (default: fetch-based). */
4011
+ downloadBlobImpl;
4012
+ /** Track 5: injectable blob upload fn (default: fetch-based). */
4013
+ uploadBlobImpl;
4014
+ /** Track 5: directory where received attachments are saved. */
4015
+ attachmentsDirImpl;
2702
4016
  /**
2703
4017
  * Enforce the inbound-buffer cap with a DROP-OLDEST policy.
2704
4018
  *
@@ -2722,6 +4036,14 @@ var BridgeAgent = class _BridgeAgent {
2722
4036
  this.startedAt = Date.now();
2723
4037
  await this.provider.publishKeyPackage();
2724
4038
  this.startReceivePump();
4039
+ if (this.selfHealSweepEnabled) {
4040
+ this.bootSweepTimer = setTimeout(() => void this.selfHealSweep(), this.sweepJitter());
4041
+ this.bootSweepTimer.unref?.();
4042
+ }
4043
+ }
4044
+ /** C3: a random 0..SELF_HEAL_SWEEP_JITTER_MS decorrelation offset. */
4045
+ sweepJitter() {
4046
+ return Math.floor(this.rng() * _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS);
2725
4047
  }
2726
4048
  async reconcileJoin(channelId) {
2727
4049
  if (this.joinState.get(channelId) === "JOINED") return true;
@@ -2734,6 +4056,145 @@ var BridgeAgent = class _BridgeAgent {
2734
4056
  return false;
2735
4057
  }
2736
4058
  }
4059
+ /**
4060
+ * The held-KP re-Welcome signal (Task 11), shared by EVERY heal path: the
4061
+ * reactive no-local-state branch in ingestOnce(), the proactive
4062
+ * selfHealSweep() (Task 12), the onWedged forked-state recovery
4063
+ * (healWedgedChannel), and the reset supersede (healSupersedeUnopenable).
4064
+ *
4065
+ * Before asking the committer to (re-)Welcome us, it RECONCILES the server's
4066
+ * published KeyPackage to our held bundle. This is the fix for the observed
4067
+ * "fresh DM never replies" AND "reset doesn't work": the committer seals every
4068
+ * Welcome to our SERVER-PUBLISHED KP, but that KP had diverged from our
4069
+ * locally-HELD bundle, so joinFromWelcome throws "No matching secret found" /
4070
+ * "private KeyPackage key no longer held" and the Welcome is discarded. A bare
4071
+ * re-Welcome re-seals to the SAME diverged published KP — un-openable again —
4072
+ * looping until the channel quarantines. So:
4073
+ * - published == held → publishKeyPackage() (no-op while held; mints only if
4074
+ * somehow none is held), then re-Welcome re-seals to the current KP we hold.
4075
+ * - published != held → forceRepublishKeyPackage() re-syncs the server to a
4076
+ * bundle we hold, so the owner's re-Welcome is openable. Orphan-safe: a
4077
+ * diverged published KP means every other pending Welcome sealed via the
4078
+ * server is un-openable too, so replacing the shared per-device bundle
4079
+ * strands nothing that was openable (see heldBundleMatchesPublishedKp).
4080
+ * getLastPublishedKpId then names the reconciled KP so the owner re-seals to it.
4081
+ * Callers own their cooldown/rate-limit gate and try/catch — this does neither.
4082
+ */
4083
+ async signalHeldKpWelcome(groupId) {
4084
+ if (await this.provider.heldBundleMatchesPublishedKp()) {
4085
+ await this.provider.publishKeyPackage();
4086
+ } else {
4087
+ await this.provider.forceRepublishKeyPackage();
4088
+ }
4089
+ const freshKpId = await this.provider.getLastPublishedKpId() ?? void 0;
4090
+ await this.backend.requestWelcome(groupId, this.cfg.deviceId, freshKpId);
4091
+ this.welcomeRequestsSent++;
4092
+ }
4093
+ /**
4094
+ * P0b (daemon-stability): the onWedged→heal path. onWedged only hands us a
4095
+ * `connect_channel_id` — resolve it to an MLS groupId the SAME way
4096
+ * selfHealSweep does, via the read-only getConnectChannelGroup probe
4097
+ * (never initConnectChannelGroup, which side-effects a member_device_ids
4098
+ * union + commit), then fire the existing held-KP re-Welcome signal.
4099
+ * Respects the SAME per-channel cooldown (lastWelcomeRequest /
4100
+ * REQUEST_WELCOME_COOLDOWN_MS) used by ingestOnce's Task-11 branch and
4101
+ * selfHealSweep — this must never hammer requestWelcome on its own.
4102
+ *
4103
+ * Best-effort in every sense: if the channel has no resolvable group (no
4104
+ * local state to probe from), this logs nothing further and just returns —
4105
+ * the channel is left to the next selfHealSweep pass or reactive Task-11
4106
+ * branch, mirroring how those two paths already treat an unresolvable
4107
+ * group (`continue`, no signal). ANY failure along the way (probe throws,
4108
+ * signal throws) is swallowed: a wedge heal must never throw back into the
4109
+ * SDK's onWedged caller. Single-shot per wedge episode — full rejoin
4110
+ * reliability is P1, not this hook's job.
4111
+ */
4112
+ async healWedgedChannel(channelId) {
4113
+ try {
4114
+ const now = performance.now();
4115
+ const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
4116
+ if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return;
4117
+ const g = await this.backend.getConnectChannelGroup(channelId);
4118
+ if (!g?.group_id) return;
4119
+ this.lastWelcomeRequest.set(channelId, now);
4120
+ if (await this.provider.hasPersistedChannelState(channelId)) {
4121
+ await this.provider.resetChannelForRejoin(channelId);
4122
+ this.joinState.delete(channelId);
4123
+ }
4124
+ await this.signalHeldKpWelcome(g.group_id);
4125
+ } catch {
4126
+ }
4127
+ }
4128
+ /**
4129
+ * P1a self-heal (onSupersedeUnopenable): a fully-verified reset supersede whose
4130
+ * Welcome we cannot open — it was sealed to a stale published KeyPackage our
4131
+ * held bundle no longer matches ("No matching secret found"). trySupersede has
4132
+ * verified owner + attestation + signature, so this is a real reset we WANT; we
4133
+ * just can't open THIS Welcome. Ask the committer to re-Welcome us to the reset
4134
+ * `groupId` with our CURRENT KeyPackage (signalHeldKpWelcome republishes/ensures
4135
+ * a held KP, then requestWelcome), so the owner re-seals a Welcome we can open
4136
+ * and the reset converges. The groupId is known (handed in), so no probe needed.
4137
+ *
4138
+ * Returns true ONLY when a re-Welcome was actually requested — trySupersede then
4139
+ * discards the un-openable row. Under the shared per-channel cooldown (or on any
4140
+ * failure) returns false, so trySupersede LEAVES the row for a bounded retry
4141
+ * rather than dropping it with no replacement in flight. Never throws.
4142
+ */
4143
+ async healSupersedeUnopenable(channelId, groupId) {
4144
+ try {
4145
+ const now = performance.now();
4146
+ const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
4147
+ if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return false;
4148
+ this.lastWelcomeRequest.set(channelId, now);
4149
+ await this.signalHeldKpWelcome(groupId);
4150
+ return true;
4151
+ } catch {
4152
+ return false;
4153
+ }
4154
+ }
4155
+ /**
4156
+ * Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
4157
+ * branch only fires reactively — off a delivery ROW that already arrived
4158
+ * for a diverged channel. A wiped-but-IDLE channel (no traffic since the
4159
+ * wipe) never produces that row, so its next real message would be the
4160
+ * first thing lost. This sweep walks every member channel — rooms AND DMs
4161
+ * (H2: a wiped, idle DM with no traffic since the wipe otherwise never
4162
+ * proactively re-Welcomes, so it stays dark until the owner happens to send)
4163
+ * — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS, and, for any with no
4164
+ * persisted state even after a reconcile attempt, resolves group_id via
4165
+ * the READ-ONLY getConnectChannelGroup probe (never initConnectChannelGroup,
4166
+ * which would side-effect a member_device_ids union + commit) and applies
4167
+ * the same held-KP signal — respecting the existing per-channel cooldown.
4168
+ *
4169
+ * Each channel is wrapped in its own try/catch: `listChannels` filters at
4170
+ * the identity level while `requestWelcome` (via request_welcome) gates at
4171
+ * the device level, so a channel where this identity is a member but this
4172
+ * DEVICE never held a leaf can 403 on the signal call. That's harmless and
4173
+ * must not abort the sweep for every other channel.
4174
+ */
4175
+ async selfHealSweep() {
4176
+ let chans;
4177
+ try {
4178
+ chans = await this.listChannels();
4179
+ } catch {
4180
+ return;
4181
+ }
4182
+ for (const c of chans) {
4183
+ try {
4184
+ if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
4185
+ await this.reconcileJoin(c.channel_id);
4186
+ if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
4187
+ const g = await this.backend.getConnectChannelGroup(c.channel_id);
4188
+ if (!g?.group_id) continue;
4189
+ const now = performance.now();
4190
+ const last = this.lastWelcomeRequest.get(c.channel_id) ?? -Infinity;
4191
+ if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
4192
+ this.lastWelcomeRequest.set(c.channel_id, now);
4193
+ await this.signalHeldKpWelcome(g.group_id);
4194
+ } catch {
4195
+ }
4196
+ }
4197
+ }
2737
4198
  /**
2738
4199
  * Per-channel send-floor check. No-op on allow; throws RateLimitedError on a
2739
4200
  * trip (incrementing the observable counter). `kind` namespaces the bucket so
@@ -2746,6 +4207,40 @@ var BridgeAgent = class _BridgeAgent {
2746
4207
  throw new RateLimitedError(channelId, this.rateFloor.msUntilNextToken(key));
2747
4208
  }
2748
4209
  }
4210
+ /**
4211
+ * The ONE seam every application-send path (send/sendA2A/sendAttachment)
4212
+ * calls to publish, wrapping the SDK's {@link sendWithSelfHeal} with the
4213
+ * daemon's per-room-pause terminal classification (Task 9).
4214
+ *
4215
+ * `AgentPausedError` (Task 8's SDK classification of the backend's 403
4216
+ * `agent_paused`) means an owner/admin has deliberately muted this agent in
4217
+ * this room — NOT a transient/network failure. That distinction MUST be
4218
+ * made here, before control returns to any caller, so:
4219
+ * - it never reaches a heal/self-heal trigger (healWedgedChannel /
4220
+ * onWedged re-Welcome are wired off the RECEIVE path via SDK provider
4221
+ * callbacks, not off a send failure — this seam guarantees a send
4222
+ * failure never grows one either, now or as the code evolves), and
4223
+ * - it never gets treated as a generic/retryable failure by a caller —
4224
+ * rethrown as the daemon's OWN typed `AgentPausedSendError` (mirroring
4225
+ * ChannelPendingError/RateLimitedError, both also bridge-agent.ts
4226
+ * locals) so http-server.ts/ws-server.ts can `instanceof`-check it the
4227
+ * same way they already do for those two, instead of it falling into
4228
+ * their generic "internal error" bucket.
4229
+ * Any other error (including StaleEpochError retries, which sendWithSelfHeal
4230
+ * already resolves internally) propagates unchanged.
4231
+ */
4232
+ async sendWithSelfHealTerminal(...args2) {
4233
+ const [, channelId] = args2;
4234
+ try {
4235
+ return await sendWithSelfHeal(...args2);
4236
+ } catch (e) {
4237
+ if (e instanceof AgentPausedError) {
4238
+ console.warn(`[bridge] send suppressed: agent paused in channel ${channelId}`);
4239
+ throw new AgentPausedSendError(channelId);
4240
+ }
4241
+ throw e;
4242
+ }
4243
+ }
2749
4244
  async send(channelId, text, messageId) {
2750
4245
  const meta = this.freshMeta(channelId);
2751
4246
  if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
@@ -2753,7 +4248,7 @@ var BridgeAgent = class _BridgeAgent {
2753
4248
  if (!joined) throw new ChannelPendingError(channelId);
2754
4249
  const id = messageId ?? crypto.randomUUID();
2755
4250
  const sendStart = performance.now();
2756
- await sendWithSelfHeal(
4251
+ await this.sendWithSelfHealTerminal(
2757
4252
  this.provider,
2758
4253
  channelId,
2759
4254
  (epoch) => this.latency.time(
@@ -2789,7 +4284,7 @@ var BridgeAgent = class _BridgeAgent {
2789
4284
  if (!joined) throw new ChannelPendingError(channelId);
2790
4285
  const id = messageId ?? crypto.randomUUID();
2791
4286
  const sendStart = performance.now();
2792
- await sendWithSelfHeal(
4287
+ await this.sendWithSelfHealTerminal(
2793
4288
  this.provider,
2794
4289
  channelId,
2795
4290
  (epoch) => this.latency.time(
@@ -2819,6 +4314,59 @@ var BridgeAgent = class _BridgeAgent {
2819
4314
  this.latency.record("send.total", performance.now() - sendStart);
2820
4315
  return { message_id: id, status: "sent" };
2821
4316
  }
4317
+ sendDeps() {
4318
+ return {
4319
+ uploadBlob: (ciphertext, channelId) => this.uploadBlobImpl(ciphertext, channelId),
4320
+ encryptFile,
4321
+ bytesToBase64: bytesToBase642,
4322
+ readFile: (p) => fsp.readFile(p).then((b) => new Uint8Array(b))
4323
+ };
4324
+ }
4325
+ async sendAttachment(channelId, filePath, caption) {
4326
+ this.enforceRateFloor("attachment", channelId);
4327
+ const joined = await this.reconcileJoin(channelId);
4328
+ if (!joined) throw new ChannelPendingError(channelId);
4329
+ let body;
4330
+ try {
4331
+ body = await prepareAttachment(filePath, caption, channelId, this.sendDeps());
4332
+ } catch (e) {
4333
+ if (e.code === "ENOENT" || e.code === "EACCES" || e.code === "ENOTDIR" || e.code === "EISDIR") {
4334
+ throw new InvalidPathError(filePath, e);
4335
+ }
4336
+ throw e;
4337
+ }
4338
+ const id = crypto.randomUUID();
4339
+ const sendStart = performance.now();
4340
+ await this.sendWithSelfHealTerminal(
4341
+ this.provider,
4342
+ channelId,
4343
+ (epoch) => this.latency.time(
4344
+ "send.encrypt",
4345
+ () => buildOutboundEnvelope({
4346
+ custody: this.custody,
4347
+ groupKey: this.provider,
4348
+ messageId: id,
4349
+ channelId,
4350
+ enclaveId: this.cfg.enclaveId,
4351
+ senderId: this.cfg.identityId,
4352
+ senderType: "agent",
4353
+ type: "artifact_share",
4354
+ bodyPlaintext: JSON.stringify(body),
4355
+ bindingId: "",
4356
+ bindingVersion: "0.0.0",
4357
+ agentSkillId: null,
4358
+ epoch,
4359
+ traceparent: ZERO_TRACEPARENT,
4360
+ references: [],
4361
+ stage5Outcome: "allow",
4362
+ stage5Evaluators: []
4363
+ })
4364
+ ),
4365
+ (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
4366
+ );
4367
+ this.latency.record("send.total", performance.now() - sendStart);
4368
+ return { message_id: id, status: "sent" };
4369
+ }
2822
4370
  async listChannels() {
2823
4371
  const url = `${this.cfg.apiBase}/api/v1/enclaves/${this.cfg.enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
2824
4372
  const res = await this.fetchImpl(url, {
@@ -2886,9 +4434,77 @@ var BridgeAgent = class _BridgeAgent {
2886
4434
  members.set(r.identity_id, { name: r.display_name, is_agent: r.is_agent });
2887
4435
  }
2888
4436
  this.channelRoster.set(channelId, { members, at: Date.now() });
4437
+ await this.resolveAndPinOwner(channelId, rows);
2889
4438
  } catch {
2890
4439
  }
2891
4440
  }
4441
+ /**
4442
+ * Task 8 (owner-attested Reset Secure Session): STRUCTURAL owner selection —
4443
+ * NEVER read the server's `role` field to pick the owner. In a DM the owner
4444
+ * is the SINGLE non-self, non-agent counterpart; any other roster shape
4445
+ * (zero or 2+ non-agent counterparts) is anomalous and REFUSES (no pin, no
4446
+ * supersede eligibility). On the first clean sighting of an unambiguous
4447
+ * owner with no existing pin, TOFU-pin its public key. On a later sighting
4448
+ * where a pin already exists and the freshly-fetched key differs, this is an
4449
+ * anomaly: surface it (log) and refuse — NEVER silently re-pin. Fail-soft:
4450
+ * this must never throw into the roster refresh path.
4451
+ */
4452
+ async resolveAndPinOwner(channelId, rows) {
4453
+ try {
4454
+ const counterparts = rows.filter((r) => r.identity_id !== this.cfg.identityId && !r.is_agent);
4455
+ if (counterparts.length !== 1) {
4456
+ this.channelOwner.set(channelId, "anomalous");
4457
+ return;
4458
+ }
4459
+ const owner = counterparts[0];
4460
+ const publicKeyHex = owner.public_key ?? "";
4461
+ if (!publicKeyHex) {
4462
+ this.channelOwner.set(channelId, "anomalous");
4463
+ return;
4464
+ }
4465
+ this.channelOwner.set(channelId, { identityId: owner.identity_id, publicKeyHex });
4466
+ const existing = await this.store.loadPinnedIdentityKey(owner.identity_id);
4467
+ if (!existing) {
4468
+ await this.store.savePinnedIdentityKey(owner.identity_id, {
4469
+ publicKeyHex,
4470
+ keyAlgo: owner.key_algo ?? "ed25519"
4471
+ });
4472
+ } else if (existing.publicKeyHex !== publicKeyHex) {
4473
+ console.warn(
4474
+ `[bridge] pin mismatch for owner ${owner.identity_id} on channel ${channelId}: pinned != roster`
4475
+ );
4476
+ this.channelOwner.set(channelId, "anomalous");
4477
+ }
4478
+ } catch {
4479
+ }
4480
+ }
4481
+ /**
4482
+ * Task 8: the `onResolvePinnedOwner` hook handed to MlsGroupKeyProvider. The
4483
+ * SDK's step-0 guard calls this before honoring an owner-authored supersede;
4484
+ * returning null makes it fail closed. Returns a value ONLY when (a) the
4485
+ * structural owner cache has an unambiguous entry for this channel AND (b) a
4486
+ * persisted pin still exists and matches it — a bare in-memory cache hit is
4487
+ * never trusted on its own, it is always reconfirmed against the store.
4488
+ */
4489
+ async resolvePinnedOwnerHook(channelId) {
4490
+ const cached = this.channelOwner.get(channelId);
4491
+ const TRACE = typeof process !== "undefined" && process.env?.SECURECOMS_TRACE_DELIVERY;
4492
+ if (!cached || cached === "anomalous") {
4493
+ if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: cache ${!cached ? "MISSING (cold)" : "anomalous"}`);
4494
+ return null;
4495
+ }
4496
+ const pin = await this.store.loadPinnedIdentityKey(cached.identityId);
4497
+ if (!pin) {
4498
+ if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: NO PERSISTED PIN for owner ${cached.identityId.slice(0, 8)} (cache had ${cached.publicKeyHex.slice(0, 8)})`);
4499
+ return null;
4500
+ }
4501
+ if (pin.publicKeyHex !== cached.publicKeyHex) {
4502
+ if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> NULL: PIN MISMATCH pin=${pin.publicKeyHex.slice(0, 8)} cache=${cached.publicKeyHex.slice(0, 8)}`);
4503
+ return null;
4504
+ }
4505
+ if (TRACE) console.warn(`[trace] resolvePinnedOwner ch=${channelId.slice(0, 8)} -> OK owner=${cached.identityId.slice(0, 8)}`);
4506
+ return { identityId: cached.identityId, publicKeyHex: pin.publicKeyHex };
4507
+ }
2892
4508
  async reportStatus(channelId, state, detail) {
2893
4509
  const joined = await this.reconcileJoin(channelId);
2894
4510
  if (!joined) throw new ChannelPendingError(channelId);
@@ -2923,20 +4539,213 @@ var BridgeAgent = class _BridgeAgent {
2923
4539
  (envelope) => this.transport.publish(envelope)
2924
4540
  );
2925
4541
  }
4542
+ /** `runId` must exist and not have settled yet — settle() is terminal. */
4543
+ requireActionRun(runId) {
4544
+ const run = this.actionRuns.get(runId);
4545
+ if (!run || run.settled) throw new UnknownActionRunError(runId);
4546
+ return run;
4547
+ }
4548
+ /**
4549
+ * Build + send one `agent_action` E2E body over `system_event` (mirrors
4550
+ * sendA2A/reportStatus's envelope construction — same self-heal seam). Used
4551
+ * for BOTH partial and settled frames; the caller decides whether a send is
4552
+ * throttle-gated (partials, via flushActionPartial) or unconditional
4553
+ * (settled, always exactly once).
4554
+ */
4555
+ async sendActionFrame(channelId, body) {
4556
+ const joined = await this.reconcileJoin(channelId);
4557
+ if (!joined) throw new ChannelPendingError(channelId);
4558
+ await sendWithSelfHeal(
4559
+ this.provider,
4560
+ channelId,
4561
+ (epoch) => buildOutboundEnvelope({
4562
+ custody: this.custody,
4563
+ groupKey: this.provider,
4564
+ messageId: crypto.randomUUID(),
4565
+ channelId,
4566
+ enclaveId: this.cfg.enclaveId,
4567
+ senderId: this.cfg.identityId,
4568
+ senderType: "agent",
4569
+ type: "system_event",
4570
+ bodyPlaintext: JSON.stringify(body),
4571
+ bindingId: "",
4572
+ bindingVersion: "0.0.0",
4573
+ agentSkillId: null,
4574
+ epoch,
4575
+ traceparent: ZERO_TRACEPARENT,
4576
+ references: [],
4577
+ stage5Outcome: "allow",
4578
+ stage5Evaluators: []
4579
+ }),
4580
+ (envelope) => this.transport.publish(envelope)
4581
+ );
4582
+ }
4583
+ /**
4584
+ * The coalescing throttle (review H4 / Global Constraint: ~1/sec per run).
4585
+ * If the run has no dirty nodes, no-op. If the per-run bucket has no token,
4586
+ * no-op too — the change stays in `dirty` and rides along with whatever the
4587
+ * NEXT allowed send captures (a later start/update/finish call, or settle(),
4588
+ * which always flushes regardless of the bucket). This is the coalescing:
4589
+ * a burst of rapid calls collapses into however many sends the ~1/sec floor
4590
+ * actually allows, each carrying every node changed since the last send.
4591
+ */
4592
+ async flushActionPartial(runId, run) {
4593
+ if (run.dirty.size === 0) return;
4594
+ if (!this.actionRateFloor.tryConsume(`action:${runId}`)) return;
4595
+ if (!this.globalActionRateFloor.tryConsume("action:__global__")) return;
4596
+ const nodes = [...run.dirty].map((id) => run.nodes.get(id)).filter((n) => n !== void 0);
4597
+ run.dirty.clear();
4598
+ await this.sendActionFrame(run.channelId, {
4599
+ v: 1,
4600
+ type: "agent_action",
4601
+ agentActionVersion: "1",
4602
+ phase: "partial",
4603
+ runId,
4604
+ nodes
4605
+ });
4606
+ }
4607
+ /** Review M3: drop any unsettled run older than ACTION_RUN_STALE_MS
4608
+ * (relative to `now`). Lazy — invoked on every actionBegin() rather than
4609
+ * a background timer, mirroring the client store's orphan-TTL pattern. */
4610
+ sweepStaleActionRuns(now) {
4611
+ for (const [runId, run] of this.actionRuns) {
4612
+ if (now - run.startedAt >= _BridgeAgent.ACTION_RUN_STALE_MS) this.actionRuns.delete(runId);
4613
+ }
4614
+ }
4615
+ actionBegin(channelId) {
4616
+ const now = this.actionClock();
4617
+ this.sweepStaleActionRuns(now);
4618
+ if (this.actionRuns.size >= _BridgeAgent.MAX_UNSETTLED_ACTION_RUNS) {
4619
+ let oldestId;
4620
+ let oldestAt = Infinity;
4621
+ for (const [id, r] of this.actionRuns) {
4622
+ if (r.startedAt < oldestAt) {
4623
+ oldestAt = r.startedAt;
4624
+ oldestId = id;
4625
+ }
4626
+ }
4627
+ if (oldestId !== void 0) this.actionRuns.delete(oldestId);
4628
+ }
4629
+ const runId = crypto.randomUUID();
4630
+ this.actionRuns.set(runId, {
4631
+ channelId,
4632
+ nodes: /* @__PURE__ */ new Map(),
4633
+ dirty: /* @__PURE__ */ new Set(),
4634
+ startedAt: now,
4635
+ settled: false
4636
+ });
4637
+ return runId;
4638
+ }
4639
+ async actionStart(opts) {
4640
+ const run = this.requireActionRun(opts.runId);
4641
+ const nodeId = crypto.randomUUID();
4642
+ const node = {
4643
+ id: nodeId,
4644
+ type: opts.type,
4645
+ title: opts.title,
4646
+ status: "running",
4647
+ ts: Date.now(),
4648
+ ...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
4649
+ };
4650
+ run.nodes.set(nodeId, node);
4651
+ run.dirty.add(nodeId);
4652
+ await this.flushActionPartial(opts.runId, run);
4653
+ return nodeId;
4654
+ }
4655
+ async actionUpdate(runId, nodeId, patch) {
4656
+ const run = this.requireActionRun(runId);
4657
+ const existing = run.nodes.get(nodeId);
4658
+ if (!existing) throw new UnknownActionRunError(`${runId}/${nodeId}`);
4659
+ const updated = {
4660
+ ...existing,
4661
+ ...patch.status !== void 0 ? { status: patch.status } : {},
4662
+ ...patch.detail !== void 0 ? { detail: patch.detail } : {},
4663
+ ts: Date.now()
4664
+ };
4665
+ run.nodes.set(nodeId, updated);
4666
+ run.dirty.add(nodeId);
4667
+ await this.flushActionPartial(runId, run);
4668
+ }
4669
+ async actionFinish(runId, nodeId, patch) {
4670
+ await this.actionUpdate(runId, nodeId, patch);
4671
+ }
4672
+ async actionSettle(runId, opts = {}) {
4673
+ const run = this.requireActionRun(runId);
4674
+ run.settled = true;
4675
+ run.dirty.clear();
4676
+ const nodes = [...run.nodes.values()];
4677
+ await this.sendActionFrame(run.channelId, {
4678
+ v: 1,
4679
+ type: "agent_action",
4680
+ agentActionVersion: "1",
4681
+ phase: "settled",
4682
+ runId,
4683
+ startedAt: run.startedAt,
4684
+ endedAt: Date.now(),
4685
+ ...opts.resultMessageId !== void 0 ? { resultMessageId: opts.resultMessageId } : {},
4686
+ nodes
4687
+ });
4688
+ this.actionRuns.delete(runId);
4689
+ }
4690
+ /**
4691
+ * Auto-capture seam (Task 4 "auto-capture"): wraps a tool-call / sub-agent
4692
+ * invocation so a bare agent gets a start→finish node pair without hand-
4693
+ * rolling the begin/start/update/finish calls itself. Success → status
4694
+ * "done"; a thrown error → status "error" with the message captured in
4695
+ * `detail.error`, then the original error is rethrown (wrap never swallows).
4696
+ */
4697
+ async actionWrap(runId, fn, opts) {
4698
+ const nodeId = await this.actionStart({
4699
+ runId,
4700
+ type: opts.type,
4701
+ title: opts.title,
4702
+ ...opts.parentId !== void 0 ? { parentId: opts.parentId } : {}
4703
+ });
4704
+ try {
4705
+ const result = await fn();
4706
+ await this.actionFinish(runId, nodeId, { status: "done" });
4707
+ return result;
4708
+ } catch (err) {
4709
+ await this.actionFinish(runId, nodeId, {
4710
+ status: "error",
4711
+ detail: { error: err instanceof Error ? err.message : String(err) }
4712
+ });
4713
+ throw err;
4714
+ }
4715
+ }
2926
4716
  startReceivePump() {
2927
4717
  const sub = this.transport.subscribeDeliveryPings("");
2928
4718
  this.pingHandle = { close: sub.close };
2929
4719
  void (async () => {
2930
4720
  try {
2931
- for await (const _ping of sub.pings) {
4721
+ for await (const ping of sub.pings) {
4722
+ this.pingsReceived++;
4723
+ if (ping?.reconnected) {
4724
+ process.stderr.write("[bridge] delivery-ping WS (re)connected \u2014 push inbound active\n");
4725
+ }
2932
4726
  await this.ingestOnce();
2933
4727
  }
2934
4728
  } catch {
2935
4729
  }
4730
+ if (!this.closingDown) {
4731
+ process.stderr.write(
4732
+ "[bridge] delivery-ping stream ENDED (terminal WS close, e.g. 4001) \u2014 inbound reduced to the 25s safety-floor drain until the daemon restarts\n"
4733
+ );
4734
+ }
2936
4735
  })();
2937
4736
  this.safetyTimer = setInterval(() => {
2938
4737
  void this.ingestOnce();
2939
4738
  }, 25e3);
4739
+ if (this.selfHealSweepEnabled) {
4740
+ const scheduleSweep = () => {
4741
+ this.selfHealTimer = setTimeout(() => {
4742
+ void this.selfHealSweep();
4743
+ scheduleSweep();
4744
+ }, _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + this.sweepJitter());
4745
+ this.selfHealTimer.unref?.();
4746
+ };
4747
+ scheduleSweep();
4748
+ }
2940
4749
  }
2941
4750
  async ingestOnce() {
2942
4751
  const cycleStart = performance.now();
@@ -2951,6 +4760,11 @@ var BridgeAgent = class _BridgeAgent {
2951
4760
  await this.reconcileJoin(cid);
2952
4761
  }
2953
4762
  }
4763
+ for (const cid of channelIds) {
4764
+ if (this.freshRoster(cid) === void 0) {
4765
+ await this.refreshChannelRoster(cid);
4766
+ }
4767
+ }
2954
4768
  for (const row of rows) {
2955
4769
  const cid = row.connect_channel_id;
2956
4770
  const gid = row.group_id;
@@ -2961,8 +4775,7 @@ var BridgeAgent = class _BridgeAgent {
2961
4775
  if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
2962
4776
  this.lastWelcomeRequest.set(cid, now);
2963
4777
  try {
2964
- await this.backend.requestWelcome(gid, this.cfg.deviceId);
2965
- this.welcomeRequestsSent++;
4778
+ await this.signalHeldKpWelcome(gid);
2966
4779
  } catch {
2967
4780
  }
2968
4781
  }
@@ -2974,6 +4787,9 @@ var BridgeAgent = class _BridgeAgent {
2974
4787
  } catch {
2975
4788
  return;
2976
4789
  }
4790
+ for (const cid of entries.supersededChannelIds ?? []) {
4791
+ this.joinState.set(cid, "JOINED");
4792
+ }
2977
4793
  if (!entries.length) return;
2978
4794
  let metaRefreshed = false;
2979
4795
  if (entries.some((m) => this.freshMeta(m.channelId) === void 0)) {
@@ -2995,6 +4811,42 @@ var BridgeAgent = class _BridgeAgent {
2995
4811
  new TextDecoder().decode(m.plaintext)
2996
4812
  );
2997
4813
  if (parsed.type && parsed.type !== "text") {
4814
+ if (parsed.type === "artifact_share") {
4815
+ try {
4816
+ const senderName = this.freshRoster(m.channelId)?.get(m.envelope.sender_id)?.name;
4817
+ const senderType = m.envelope.sender_type;
4818
+ const senderIsAgent = senderType === "agent" ? true : senderType === "human" ? false : void 0;
4819
+ const meta = await materializeAttachment(
4820
+ parsed,
4821
+ { attachmentsDir: this.attachmentsDirImpl, channelId: m.channelId, messageId: m.messageId },
4822
+ {
4823
+ downloadBlob: this.downloadBlobImpl,
4824
+ decryptFile,
4825
+ computeFileDigest,
4826
+ base64ToBytes: base64ToBytes2,
4827
+ mkdirp: (d) => fsp.mkdir(d, { recursive: true }).then(() => void 0),
4828
+ writeFile: (p, data) => fsp.writeFile(p, data)
4829
+ }
4830
+ );
4831
+ this.buffer.push({
4832
+ channel_id: m.channelId,
4833
+ message_id: m.messageId,
4834
+ sender_id: m.envelope.sender_id,
4835
+ text: typeof parsed.text === "string" ? parsed.text : "",
4836
+ ts: m.receivedAt,
4837
+ sender_name: senderName,
4838
+ sender_is_agent: senderIsAgent,
4839
+ attachments: [meta]
4840
+ });
4841
+ this.capBuffer();
4842
+ } catch (e) {
4843
+ this.failedAttachments++;
4844
+ process.stderr.write(
4845
+ `[bridge] attachment failed on ${m.messageId}: ${e.message}
4846
+ `
4847
+ );
4848
+ }
4849
+ }
2998
4850
  acked.push(m.queueId);
2999
4851
  continue;
3000
4852
  }
@@ -3119,6 +4971,8 @@ var BridgeAgent = class _BridgeAgent {
3119
4971
  filtered_room: this.filteredRoomMessages,
3120
4972
  rate_limited: this.rateLimitedSends,
3121
4973
  permanently_skipped: this.provider.permanentlySkippedCount(),
4974
+ failed_attachments: this.failedAttachments,
4975
+ delivery_pings: this.pingsReceived,
3122
4976
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
3123
4977
  version: BRIDGE_VERSION
3124
4978
  };
@@ -3159,12 +5013,17 @@ var BridgeAgent = class _BridgeAgent {
3159
5013
  filteredRoomMessages: this.filteredRoomMessages,
3160
5014
  rateLimitedSends: this.rateLimitedSends,
3161
5015
  permanentlySkipped: this.provider.permanentlySkippedCount(),
3162
- welcomeRequestsSent: this.welcomeRequestsSent
5016
+ welcomeRequestsSent: this.welcomeRequestsSent,
5017
+ failedAttachments: this.failedAttachments
3163
5018
  };
3164
5019
  }
3165
5020
  async close() {
5021
+ this.closingDown = true;
3166
5022
  this.pingHandle?.close();
3167
5023
  if (this.safetyTimer) clearInterval(this.safetyTimer);
5024
+ if (this.bootSweepTimer) clearTimeout(this.bootSweepTimer);
5025
+ if (this.selfHealTimer) clearTimeout(this.selfHealTimer);
5026
+ await this.provider.drainInFlight(_BridgeAgent.GRACEFUL_DRAIN_MS);
3168
5027
  this.store.close();
3169
5028
  }
3170
5029
  };
@@ -3226,6 +5085,7 @@ var BRIDGE_ERRORS = {
3226
5085
  body_too_large: { http: 400, message: "request body exceeds the size limit" },
3227
5086
  channel_pending: { http: 503, message: "channel not yet admitted; retry shortly" },
3228
5087
  rate_limited: { http: 429, message: "rate limit exceeded; retry shortly" },
5088
+ unknown_action_run: { http: 404, message: "unknown or already-settled action run" },
3229
5089
  internal: { http: 500, message: "internal error" }
3230
5090
  };
3231
5091
  function errorBody(code, detail) {
@@ -3271,6 +5131,11 @@ var a2aSchema = z.object({
3271
5131
  kind: z.enum(["message", "task", "artifact"]),
3272
5132
  object: z.record(z.unknown())
3273
5133
  });
5134
+ var sendAttachmentSchema = z.object({
5135
+ channel_id: z.string().min(1),
5136
+ path: z.string().min(1),
5137
+ caption: z.string().optional()
5138
+ });
3274
5139
  var sendSchema = z.object({
3275
5140
  channel_id: z.string().min(1),
3276
5141
  text: z.string().optional(),
@@ -3365,6 +5230,12 @@ function createHttpServer(agent, opts) {
3365
5230
  const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a, parsed.data.idempotency_key) : await agent.send(parsed.data.channel_id, parsed.data.text, parsed.data.idempotency_key);
3366
5231
  return json(res, 200, r);
3367
5232
  }
5233
+ if (method === "POST" && path === "/v1/send-attachment") {
5234
+ const parsed = sendAttachmentSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
5235
+ if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
5236
+ const r = await agent.sendAttachment(parsed.data.channel_id, parsed.data.path, parsed.data.caption);
5237
+ return json(res, 200, r);
5238
+ }
3368
5239
  if (method === "POST" && path === "/v1/status") {
3369
5240
  const parsed = statusSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
3370
5241
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
@@ -3414,6 +5285,7 @@ function createHttpServer(agent, opts) {
3414
5285
  }
3415
5286
  if (e instanceof ChannelPendingError) return jsonError(res, "channel_pending");
3416
5287
  if (e instanceof RateLimitedError) return jsonError(res, "rate_limited");
5288
+ if (e instanceof InvalidPathError) return jsonError(res, "invalid_request", e.message);
3417
5289
  if (e instanceof SyntaxError) return jsonError(res, "invalid_json");
3418
5290
  process.stderr.write(`[bridge] request error: ${e?.message ?? e}
3419
5291
  `);
@@ -3623,26 +5495,99 @@ async function handleInbound(sock, agent, data, inflight) {
3623
5495
  }
3624
5496
  return;
3625
5497
  }
5498
+ const NODE_TYPES = ["step", "tool_call", "sub_task", "result"];
5499
+ const NODE_STATUSES = ["pending", "running", "done", "error"];
5500
+ const actionError = (e) => {
5501
+ sendJson(sock, wsErrorFrame(e instanceof UnknownActionRunError ? "unknown_action_run" : "invalid_request"));
5502
+ };
5503
+ if (m.type === "action_begin") {
5504
+ if (typeof m.channel_id !== "string" || !m.channel_id) {
5505
+ sendJson(sock, wsErrorFrame("invalid_request", "channel_id required"));
5506
+ return;
5507
+ }
5508
+ try {
5509
+ sendJson(sock, { type: "action_begun", run_id: agent.action.begin(m.channel_id) });
5510
+ } catch (e) {
5511
+ actionError(e);
5512
+ }
5513
+ return;
5514
+ }
5515
+ if (m.type === "action_start") {
5516
+ if (typeof m.run_id !== "string" || !m.run_id || typeof m.title !== "string" || !m.title || !NODE_TYPES.includes(m.node_type)) {
5517
+ sendJson(sock, wsErrorFrame("invalid_request", "run_id, node_type, title required"));
5518
+ return;
5519
+ }
5520
+ try {
5521
+ const nodeId = await agent.action.start({
5522
+ runId: m.run_id,
5523
+ type: m.node_type,
5524
+ title: m.title,
5525
+ parentId: typeof m.parent_id === "string" && m.parent_id ? m.parent_id : void 0
5526
+ });
5527
+ sendJson(sock, { type: "action_started", node_id: nodeId });
5528
+ } catch (e) {
5529
+ actionError(e);
5530
+ }
5531
+ return;
5532
+ }
5533
+ if (m.type === "action_update" || m.type === "action_finish") {
5534
+ if (typeof m.run_id !== "string" || !m.run_id || typeof m.node_id !== "string" || !m.node_id) {
5535
+ sendJson(sock, wsErrorFrame("invalid_request", "run_id and node_id required"));
5536
+ return;
5537
+ }
5538
+ try {
5539
+ if (m.type === "action_finish") {
5540
+ if (m.status !== "done" && m.status !== "error") {
5541
+ sendJson(sock, wsErrorFrame("invalid_request", "finish status must be done|error"));
5542
+ return;
5543
+ }
5544
+ await agent.action.finish(m.run_id, m.node_id, { status: m.status, detail: m.detail });
5545
+ } else {
5546
+ if (m.status !== void 0 && !NODE_STATUSES.includes(m.status)) {
5547
+ sendJson(sock, wsErrorFrame("invalid_request", "bad status"));
5548
+ return;
5549
+ }
5550
+ await agent.action.update(m.run_id, m.node_id, { status: m.status, detail: m.detail });
5551
+ }
5552
+ sendJson(sock, { type: "ok" });
5553
+ } catch (e) {
5554
+ actionError(e);
5555
+ }
5556
+ return;
5557
+ }
5558
+ if (m.type === "action_settle") {
5559
+ if (typeof m.run_id !== "string" || !m.run_id) {
5560
+ sendJson(sock, wsErrorFrame("invalid_request", "run_id required"));
5561
+ return;
5562
+ }
5563
+ try {
5564
+ await agent.action.settle(m.run_id, { resultMessageId: typeof m.result_message_id === "string" && m.result_message_id ? m.result_message_id : void 0 });
5565
+ sendJson(sock, { type: "action_settled", run_id: m.run_id });
5566
+ } catch (e) {
5567
+ actionError(e);
5568
+ }
5569
+ return;
5570
+ }
3626
5571
  sendJson(sock, wsErrorFrame("invalid_request"));
3627
5572
  }
3628
5573
 
3629
5574
  // src/bin.ts
3630
5575
  import { readFileSync as readFileSync3, mkdirSync, writeFileSync as writeFileSync2, rmSync, existsSync as existsSync2, realpathSync } from "node:fs";
3631
- import { join as join3, dirname as dirname4 } from "node:path";
5576
+ import { join as join4, dirname as dirname5 } from "node:path";
3632
5577
  import { fileURLToPath } from "node:url";
3633
5578
  import { homedir, userInfo } from "node:os";
3634
5579
  import { spawnSync } from "node:child_process";
3635
5580
 
3636
5581
  // src/service/install.ts
3637
- import { dirname as dirname2 } from "node:path";
5582
+ import { dirname as dirname3 } from "node:path";
3638
5583
 
3639
5584
  // src/service/config.ts
3640
- import { basename, extname, isAbsolute, resolve } from "node:path";
5585
+ import { basename as basename2, extname, isAbsolute, resolve } from "node:path";
3641
5586
  function slugify(s) {
3642
5587
  return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
3643
5588
  }
3644
5589
  function deriveServiceConfig(input) {
3645
- const stem = input.label ?? basename(input.credentialPath, extname(input.credentialPath));
5590
+ const stem = input.label ?? basename2(input.credentialPath, extname(input.credentialPath));
3646
5591
  const slug = slugify(stem);
3647
5592
  const cred = isAbsolute(input.credentialPath) ? input.credentialPath : resolve(input.credentialPath);
3648
5593
  const args2 = ["--credential", cred];
@@ -3718,7 +5663,7 @@ WantedBy=default.target
3718
5663
  var ServiceUnsupportedError = class extends Error {
3719
5664
  };
3720
5665
  function tokenHint(input) {
3721
- return `${dirname2(input.credentialPath)}/bridge-token`;
5666
+ return `${dirname3(input.credentialPath)}/bridge-token`;
3722
5667
  }
3723
5668
  function requireOk(r, what) {
3724
5669
  if (r.status !== 0) throw new Error(`${what} failed (exit ${r.status})${r.stderr ? `: ${r.stderr.trim()}` : ""}`);
@@ -3726,8 +5671,8 @@ function requireOk(r, what) {
3726
5671
  function installService(input, deps) {
3727
5672
  const cfg = deriveServiceConfig(input);
3728
5673
  if (deps.platform === "darwin") {
3729
- deps.mkdirp(dirname2(cfg.plistPath));
3730
- deps.mkdirp(dirname2(cfg.logPath));
5674
+ deps.mkdirp(dirname3(cfg.plistPath));
5675
+ deps.mkdirp(dirname3(cfg.logPath));
3731
5676
  deps.writeFile(cfg.plistPath, renderLaunchdPlist(cfg));
3732
5677
  deps.run("launchctl", ["bootout", `gui/${cfg.uid}/${cfg.label}`]);
3733
5678
  requireOk(deps.run("launchctl", ["bootstrap", `gui/${cfg.uid}`, cfg.plistPath]), "launchctl bootstrap");
@@ -3736,7 +5681,7 @@ function installService(input, deps) {
3736
5681
  return { label: cfg.label, filePath: cfg.plistPath, logHint: cfg.logPath, tokenHint: tokenHint(input) };
3737
5682
  }
3738
5683
  if (deps.platform === "linux") {
3739
- deps.mkdirp(dirname2(cfg.unitPath));
5684
+ deps.mkdirp(dirname3(cfg.unitPath));
3740
5685
  deps.writeFile(cfg.unitPath, renderSystemdUnit(cfg));
3741
5686
  deps.run("systemctl", ["--user", "daemon-reload"]);
3742
5687
  deps.run("loginctl", ["enable-linger", deps.user]);
@@ -3764,7 +5709,7 @@ function uninstallService(input, deps) {
3764
5709
  }
3765
5710
 
3766
5711
  // src/service/resolve-bin.ts
3767
- import { join as join2, dirname as dirname3 } from "node:path";
5712
+ import { join as join3, dirname as dirname4 } from "node:path";
3768
5713
  import { createRequire } from "node:module";
3769
5714
  var GlobalInstallPermissionError = class extends Error {
3770
5715
  constructor(message) {
@@ -3789,7 +5734,7 @@ function resolveStableBinScript(input, deps) {
3789
5734
  if (deps.fileExists(input.binScript)) return input.binScript;
3790
5735
  throw new Error(`bin path does not exist and is not an npx cache path: ${input.binScript}`);
3791
5736
  }
3792
- const pkgRoot = dirname3(dirname3(input.binScript));
5737
+ const pkgRoot = dirname4(dirname4(input.binScript));
3793
5738
  const inst = deps.run("npm", ["i", "-g", pkgRoot, "--install-links"]);
3794
5739
  if (inst.status !== 0) {
3795
5740
  if (/EACCES|permission denied/i.test(inst.stderr)) {
@@ -3802,7 +5747,7 @@ function resolveStableBinScript(input, deps) {
3802
5747
  }
3803
5748
  const root = deps.run("npm", ["root", "-g"]);
3804
5749
  if (root.status !== 0) throw new Error(`npm root -g failed (exit ${root.status})`);
3805
- const stable = join2(root.stdout.trim(), input.packageName, "dist", "bin.js");
5750
+ const stable = join3(root.stdout.trim(), input.packageName, "dist", "bin.js");
3806
5751
  if (!deps.fileExists(stable)) throw new Error(`global install did not produce ${stable}`);
3807
5752
  return stable;
3808
5753
  }
@@ -3816,6 +5761,10 @@ function loadPackageMeta(importMetaUrl) {
3816
5761
  function nodeMajorOk(version) {
3817
5762
  return Number(version.split(".")[0]) >= 24;
3818
5763
  }
5764
+ function tokenFingerprint(token) {
5765
+ const prefixLen = Math.min(6, token.length);
5766
+ return `${token.slice(0, prefixLen)}... (${token.length} chars)`;
5767
+ }
3819
5768
  function argOrEnv(flag, env) {
3820
5769
  const i = process.argv.indexOf(flag);
3821
5770
  if (i >= 0 && process.argv[i + 1]) return process.argv[i + 1];
@@ -3921,7 +5870,7 @@ async function main() {
3921
5870
  `);
3922
5871
  process.exit(1);
3923
5872
  }
3924
- const tokenFile = daemon.tokenFile ?? join3(dirname4(credentialPath), "bridge-token");
5873
+ const tokenFile = daemon.tokenFile ?? join4(dirname5(credentialPath), "bridge-token");
3925
5874
  const token = resolveToken({ tokenFile, token: daemon.token });
3926
5875
  const agent = new BridgeAgent(cfg);
3927
5876
  await agent.start();
@@ -3935,7 +5884,12 @@ async function main() {
3935
5884
  agentWs.close();
3936
5885
  server.close();
3937
5886
  closeAllSseStreams();
3938
- void agent.close().finally(() => process.exit(0));
5887
+ const hard = setTimeout(() => process.exit(0), 8e3);
5888
+ hard.unref?.();
5889
+ void agent.close().finally(() => {
5890
+ clearTimeout(hard);
5891
+ process.exit(0);
5892
+ });
3939
5893
  };
3940
5894
  process.on("SIGINT", shutdown);
3941
5895
  process.on("SIGTERM", shutdown);
@@ -3944,7 +5898,7 @@ async function main() {
3944
5898
  const wsScheme = tls ? "wss" : "ws";
3945
5899
  process.stderr.write(`[bridge] listening on ${scheme}://${daemon.bind}:${daemon.port} (agent WS at ${wsScheme}://${daemon.bind}:${daemon.port}/v1/ws)
3946
5900
  `);
3947
- process.stderr.write(`BRIDGE_TOKEN=${token}
5901
+ process.stderr.write(`[bridge] bearer token: ${tokenFingerprint(token)} (token file: ${tokenFile})
3948
5902
  `);
3949
5903
  });
3950
5904
  }
@@ -3959,5 +5913,6 @@ if (isMain) {
3959
5913
  }
3960
5914
  export {
3961
5915
  nodeMajorOk,
3962
- parseVerb
5916
+ parseVerb,
5917
+ tokenFingerprint
3963
5918
  };