@securecoms/agent-bridge 0.5.0 → 0.7.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
@@ -93,6 +93,15 @@ function validateBind(bind, tls) {
93
93
  }
94
94
  }
95
95
 
96
+ // ../connect-core/dist/cadence.js
97
+ var SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
98
+ var SELF_HEAL_SWEEP_JITTER_MS = 3e4;
99
+ var REQUEST_WELCOME_COOLDOWN_MS = 12e4;
100
+ var WORST_CASE_RESIGNAL_MS = Math.ceil(REQUEST_WELCOME_COOLDOWN_MS / SELF_HEAL_SWEEP_INTERVAL_MS) * (SELF_HEAL_SWEEP_INTERVAL_MS + SELF_HEAL_SWEEP_JITTER_MS);
101
+ function isResignalDue(now, lastAt, cooldownMs = REQUEST_WELCOME_COOLDOWN_MS) {
102
+ return now - lastAt >= cooldownMs;
103
+ }
104
+
96
105
  // ../connect-core/dist/envelope/types.js
97
106
  var MESSAGE_TYPES = [
98
107
  "text",
@@ -1421,6 +1430,23 @@ var MlsGroupKeyProvider = class {
1421
1430
  * server delete-and-replaces. Keyed by deviceId (a provider has one), SEPARATE
1422
1431
  * from `locks` (channel-keyed) so it never deadlocks against a held channel lock. */
1423
1432
  publishLock = new MlsOperationQueue();
1433
+ /**
1434
+ * M6 (ADR-0013 Decision H): wall-clock ms of the last SUCCESSFUL decrypt per
1435
+ * channel. This is the only evidence-based liveness signal the member has —
1436
+ * every other membership indicator in the daemon (`joinState`, a persisted
1437
+ * store row, a leaf in the tree) is a structural artifact that a forked
1438
+ * channel keeps asserting long after it stopped being able to read anything.
1439
+ *
1440
+ * ⚠️ Deliberately written OUTSIDE the `errors` map, and written BEFORE
1441
+ * {@link recordDecryptSuccess}'s `if (!e) return` early-exit. That guard is
1442
+ * correct for its own purpose (there is no error run to clear for a channel
1443
+ * that never errored) but it means a healthy channel decrypting normally
1444
+ * never touches `errors` at all. Keying this timestamp off that map would
1445
+ * leave it null for exactly the channels that are working — a field that
1446
+ * reports "no successful decrypt" for a healthy channel is worse than no
1447
+ * field, because it inverts the signal it exists to give.
1448
+ */
1449
+ lastDecryptAt = /* @__PURE__ */ new Map();
1424
1450
  /**
1425
1451
  * Per-channel decrypt-error / stuck tracker (ADR-0002). Bounded by the number
1426
1452
  * of channels this device touches (channels are bounded; an entry is created
@@ -1485,6 +1511,7 @@ var MlsGroupKeyProvider = class {
1485
1511
  * re-wedges (the onWedged guard is reset so the hook can fire again).
1486
1512
  */
1487
1513
  recordDecryptSuccess(channelId) {
1514
+ this.lastDecryptAt.set(channelId, Date.now());
1488
1515
  const e = this.errors.get(channelId);
1489
1516
  if (!e)
1490
1517
  return;
@@ -1632,7 +1659,9 @@ var MlsGroupKeyProvider = class {
1632
1659
  * equals newDeviceId (the same device re-admitted).
1633
1660
  */
1634
1661
  async admitGuestDevice(channelId, g) {
1635
- await this.addMemberToLiveGroup(channelId, g.newDeviceId);
1662
+ await this.addMemberToLiveGroup(channelId, g.newDeviceId, {
1663
+ knownLeafDevices: g.priorDeviceId ? [g.priorDeviceId] : []
1664
+ });
1636
1665
  if (g.priorDeviceId && g.priorDeviceId !== g.newDeviceId) {
1637
1666
  const orderedDeviceIds = g.orderedDeviceIds ?? [g.priorDeviceId, g.newDeviceId];
1638
1667
  await this.removeDeviceFromLiveGroup(channelId, g.priorDeviceId, {
@@ -1657,6 +1686,85 @@ var MlsGroupKeyProvider = class {
1657
1686
  async currentEpoch(channelId) {
1658
1687
  return Number((await this.manager(channelId)).epoch);
1659
1688
  }
1689
+ /**
1690
+ * Converge a BEHIND member in place by replaying the commits it missed,
1691
+ * instead of tearing down its session and being re-admitted.
1692
+ *
1693
+ * Ported from gen2 (#440). This is the primitive SecureComs was missing, and
1694
+ * its absence is why divergence here has been effectively unrecoverable: the
1695
+ * only client-side route back was Remove+Add, which gen2 treats as the
1696
+ * LAST RESORT and rate-limits hard, because they measured it as a divergence
1697
+ * SOURCE (#360 — epoch 1204 in ~6 minutes; "the re-admitter's churn IS the
1698
+ * bug, not the member").
1699
+ *
1700
+ * Replay is strictly cheaper and safer than re-admission:
1701
+ * - no re-key, no roster change, no epoch movement at all
1702
+ * - it cannot storm the group, because it only READS
1703
+ * - it preserves the member's leaf, so it cannot create a duplicate
1704
+ *
1705
+ * The server already implements the whole contract
1706
+ * (`GET /mls/groups/{id}/sync?last_epoch=N` → `{action, messages}`) and has
1707
+ * since before this change. Nothing consumed it. That is the entire defect.
1708
+ *
1709
+ * Returns true when the member converged to the server's epoch. Returns false
1710
+ * when the caller should fall back to re-admission — the gap exceeded the
1711
+ * server's REJOIN_THRESHOLD, there was nothing to apply, or a commit would
1712
+ * not apply (which means the state is genuinely FORKED, not merely behind,
1713
+ * and replay cannot fix a fork).
1714
+ */
1715
+ async resyncByReplay(channelId, groupId) {
1716
+ if (!await this.hasPersistedChannelState(channelId))
1717
+ return false;
1718
+ let from;
1719
+ try {
1720
+ from = await this.currentEpoch(channelId);
1721
+ } catch {
1722
+ return false;
1723
+ }
1724
+ const resp = await this.o.backend.syncGroup(groupId, from, this.o.deviceId);
1725
+ if (resp.action !== "replay")
1726
+ return false;
1727
+ const commits = resp.messages.filter((m) => m.message_type === "commit").sort((a, b) => a.epoch - b.epoch);
1728
+ if (commits.length === 0)
1729
+ return false;
1730
+ let applied = 0;
1731
+ try {
1732
+ await this.locks.run(channelId, async () => {
1733
+ const mgr = await this.manager(channelId);
1734
+ for (const c of commits) {
1735
+ if (c.epoch <= Number(mgr.epoch))
1736
+ continue;
1737
+ await mgr.decrypt(hexToBytes(c.payload));
1738
+ applied++;
1739
+ }
1740
+ if (applied > 0)
1741
+ await this.o.store.save(channelId, mgr.exportState());
1742
+ });
1743
+ } catch (e) {
1744
+ console.warn(`[mls] resyncByReplay ${channelId.slice(0, 8)}: commit failed to apply after ${applied} \u2014 state is forked, not behind:`, e?.message);
1745
+ return false;
1746
+ }
1747
+ if (applied > 0) {
1748
+ this.recordDecryptSuccess(channelId);
1749
+ }
1750
+ return applied > 0;
1751
+ }
1752
+ /**
1753
+ * M6 (ADR-0013 Decision H): wall-clock ms of the last successful decrypt for
1754
+ * `channelId`, or `undefined` if this process has never decrypted anything on
1755
+ * it. Synchronous and allocation-free — it is read from the `/healthz` path,
1756
+ * which must never fan out work, because the conditions it exists to report
1757
+ * are exactly the conditions under which fanned-out work hangs.
1758
+ *
1759
+ * `undefined` is genuinely ambiguous and callers MUST NOT read it as
1760
+ * "broken": it also covers a freshly-restarted daemon and a healthy channel
1761
+ * that has simply been quiet. It is evidence of liveness when present, never
1762
+ * evidence of divergence when absent. Divergence is decided by the held-vs-
1763
+ * server epoch comparison, not by this field.
1764
+ */
1765
+ lastDecryptAtFor(channelId) {
1766
+ return this.lastDecryptAt.get(channelId);
1767
+ }
1660
1768
  /**
1661
1769
  * Run a committer op and, on ANY throw that escapes it, evict the channel's
1662
1770
  * cached MLSGroupManager.
@@ -1880,7 +1988,7 @@ var MlsGroupKeyProvider = class {
1880
1988
  if (!bundle)
1881
1989
  throw new Error("ensureSession: call publishKeyPackage() first");
1882
1990
  if (opts.forceReinit) {
1883
- const others = opts.otherDeviceIds ?? [];
1991
+ const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
1884
1992
  const reinit = await this.o.backend.reinitConnectChannelGroup(channelId, [this.o.deviceId, ...others]);
1885
1993
  const mgr = new MLSGroupManager();
1886
1994
  await mgr.createGroup(new TextEncoder().encode(reinit.group_id), bundle);
@@ -1910,7 +2018,7 @@ var MlsGroupKeyProvider = class {
1910
2018
  throw new BootstrapRaceLostError();
1911
2019
  const mgr = new MLSGroupManager();
1912
2020
  await mgr.createGroup(new TextEncoder().encode(init.group_id), bundle);
1913
- const others = opts.otherDeviceIds ?? [];
2021
+ const others = [...new Set(opts.otherDeviceIds ?? [])].filter((d) => d !== this.o.deviceId);
1914
2022
  const kps = await this.o.backend.fetchKeyPackages(others);
1915
2023
  const pkgs = others.map((d) => {
1916
2024
  const hex = kps[d];
@@ -1961,17 +2069,38 @@ var MlsGroupKeyProvider = class {
1961
2069
  * full member roster (unioning in any device ids we pass). That avoids a
1962
2070
  * parallel group_id-by-channel resolver.
1963
2071
  */
1964
- async addMemberToLiveGroup(channelId, newDeviceId) {
1965
- return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0)));
2072
+ async addMemberToLiveGroup(channelId, newDeviceId, opts) {
2073
+ return this.locks.run(channelId, () => this.evictOnThrow(channelId, () => this.addMemberToLiveGroupUnlocked(channelId, newDeviceId, 0, opts)));
1966
2074
  }
1967
2075
  async addMemberToLiveGroupUnlocked(channelId, newDeviceId, _attempt, opts) {
1968
2076
  const mgr = await this.manager(channelId);
1969
- const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
1970
2077
  const kps = await this.o.backend.fetchKeyPackages([newDeviceId]);
1971
2078
  const hex = kps[newDeviceId];
1972
2079
  if (!hex)
1973
2080
  throw new Error(`addMemberToLiveGroup: no published KeyPackage for device ${newDeviceId}`);
1974
2081
  const pkg = MLSGroupManager.deserializeKeyPackage(hexToBytes(hex));
2082
+ if (_attempt === 0) {
2083
+ let adds;
2084
+ try {
2085
+ adds = (await this.o.backend.getCommitterWork(channelId)).adds;
2086
+ } catch (e) {
2087
+ throw new Error(`addMemberToLiveGroup: committer-work unreachable for channel ${channelId} \u2014 refusing plain Add for device ${newDeviceId} (fail closed): ` + (e instanceof Error ? e.message : String(e)));
2088
+ }
2089
+ if (!adds.includes(newDeviceId)) {
2090
+ const cred = pkg.leafNode.credential;
2091
+ const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : null;
2092
+ const leaves = claimed === null ? null : mgr.leafIndicesForIdentity(claimed);
2093
+ if (leaves === null || leaves.length > 0) {
2094
+ const known = opts?.knownLeafDevices ? [...new Set(opts.knownLeafDevices)] : null;
2095
+ const swapSafe = leaves !== null && known !== null && !known.includes(newDeviceId) && known.length === leaves.length;
2096
+ if (!swapSafe) {
2097
+ console.warn(`addMemberToLiveGroup: device ${newDeviceId.slice(0, 8)} is outside the committer-work add-delta and its identity already holds a leaf \u2014 terminal skip (no commit)`);
2098
+ return;
2099
+ }
2100
+ }
2101
+ }
2102
+ }
2103
+ const init = await this.o.backend.initConnectChannelGroup(channelId, [this.o.deviceId, newDeviceId]);
1975
2104
  if (opts?.expectedIdentityId) {
1976
2105
  const cred = pkg.leafNode.credential;
1977
2106
  const claimed = cred.credentialType === "basic" ? new TextDecoder().decode(cred.identity) : "";
@@ -2310,6 +2439,7 @@ var MlsGroupKeyProvider = class {
2310
2439
  await this.o.store.delete(channelId);
2311
2440
  await this.o.store.discardStaging(channelId);
2312
2441
  this.errors.delete(channelId);
2442
+ this.lastDecryptAt.delete(channelId);
2313
2443
  });
2314
2444
  }
2315
2445
  /** Owner-attested reset supersede (owner/web side, Task 9). Mirrors the
@@ -2375,6 +2505,25 @@ var MlsGroupKeyProvider = class {
2375
2505
  return true;
2376
2506
  return await this.o.store.load(channelId) !== null;
2377
2507
  }
2508
+ /**
2509
+ * Returns the group id our persisted (or live in-memory) MLS state holds for
2510
+ * `channelId`, or null when there is no state to read, or when the state
2511
+ * exists but a loaded manager can't be constructed from it. Read-only —
2512
+ * `manager()` only loads from the store and caches in memory; it makes no
2513
+ * backend call and writes nothing to the store. Mirrors the same
2514
+ * held-vs-row comparison the Welcome receive path already does inline
2515
+ * (see the `heldGroupId` decode in the `message_type === "welcome"` branch
2516
+ * of `receiveOnce`) so callers can ask "is my held state for THIS group?"
2517
+ * without duplicating that decode logic.
2518
+ */
2519
+ async heldGroupIdFor(channelId) {
2520
+ try {
2521
+ const mgr = await this.manager(channelId);
2522
+ return new TextDecoder().decode(mgr.groupId);
2523
+ } catch {
2524
+ return null;
2525
+ }
2526
+ }
2378
2527
  /**
2379
2528
  * Return the number of leaves the given identity holds in the live tree for
2380
2529
  * `channelId`. Loads the manager from the store if not already cached.
@@ -3192,6 +3341,22 @@ var RestMlsBackend = class {
3192
3341
  });
3193
3342
  return String(j.id);
3194
3343
  }
3344
+ async syncGroup(groupId, lastEpoch, deviceId) {
3345
+ const deviceParam = deviceId ? `&device_id=${encodeURIComponent(deviceId)}` : "";
3346
+ const j = await this.get(`/api/v1/mls/groups/${groupId}/sync?last_epoch=${encodeURIComponent(String(lastEpoch))}${deviceParam}`);
3347
+ const action = j?.action === "rejoin" ? "rejoin" : "replay";
3348
+ const messages = Array.isArray(j?.messages) ? j.messages : [];
3349
+ return {
3350
+ action,
3351
+ messages: messages.map((m) => ({
3352
+ id: String(m.id),
3353
+ epoch: Number(m.epoch),
3354
+ message_type: String(m.message_type),
3355
+ sender_device_id: String(m.sender_device_id ?? ""),
3356
+ payload: String(m.payload ?? "")
3357
+ }))
3358
+ };
3359
+ }
3195
3360
  async getConnectChannelGroup(channelId) {
3196
3361
  const j = await this.get(`/api/v1/mls/connect-channels/${channelId}/group`);
3197
3362
  return {
@@ -4422,6 +4587,22 @@ var InvalidPathError = class extends Error {
4422
4587
  if (cause !== void 0) this.cause = cause;
4423
4588
  }
4424
4589
  };
4590
+ var InvalidMessageIdError = class extends Error {
4591
+ constructor(messageId) {
4592
+ super(`message_id must be a UUID, got: ${messageId}`);
4593
+ this.messageId = messageId;
4594
+ this.name = "InvalidMessageIdError";
4595
+ }
4596
+ };
4597
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4598
+ function isUuid(s) {
4599
+ return UUID_RE.test(s);
4600
+ }
4601
+ function resolveMessageId(messageId) {
4602
+ if (messageId === void 0) return crypto.randomUUID();
4603
+ if (!isUuid(messageId)) throw new InvalidMessageIdError(messageId);
4604
+ return messageId;
4605
+ }
4425
4606
  var AgentPausedSendError = class extends Error {
4426
4607
  constructor(channelId) {
4427
4608
  super(`agent is paused in channel ${channelId}; send suppressed (no self-heal, no retry)`);
@@ -4622,10 +4803,75 @@ var BridgeAgent = class _BridgeAgent {
4622
4803
  * counter is incremented. Surfaced on health()/metrics().
4623
4804
  */
4624
4805
  failedAttachments = 0;
4806
+ /**
4807
+ * Task 8: count of live AUTHENTICATED /v1/ws sessions, so a later `status`
4808
+ * verb can tell "bridge process up" (the app's green dot, which only
4809
+ * proves bridge<->backend) apart from "a harness is actually attached"
4810
+ * (a real consumer holding an authenticated session). Only ws-server.ts
4811
+ * mutates this, and only after authedUpgrade() passes — an unauthenticated
4812
+ * socket, or the HTTP /healthz probe, must never move this counter.
4813
+ * Surfaced on health()/metrics().
4814
+ */
4815
+ authSessions = 0;
4816
+ /**
4817
+ * Task 3 (audit M3): count of newest-wins evictions of a prior /v1/ws
4818
+ * session. The daemon's WS is single-consumer newest-wins, so
4819
+ * `agent_sessions` alone can stay >=1 while two connectors flap the same
4820
+ * identity in and out — this counter makes that split-brain visible.
4821
+ * Only ws-server.ts mutates this, via noteSessionEvicted(). Surfaced on
4822
+ * health()/status.
4823
+ */
4824
+ evictions = 0;
4625
4825
  /** Per-channel timestamp of last requestWelcome call, for cooldown enforcement. */
4626
4826
  lastWelcomeRequest = /* @__PURE__ */ new Map();
4627
4827
  /** Minimum gap between requestWelcome calls for the same channel (2 minutes). */
4628
- static REQUEST_WELCOME_COOLDOWN_MS = 12e4;
4828
+ /**
4829
+ * M6 (ADR-0013 Decision H): the sweep's per-channel observation cache, and the
4830
+ * only thing {@link health} reports divergence from.
4831
+ *
4832
+ * Written by `selfHealSweep` in a `finally`, read synchronously by `health()`.
4833
+ * `health()` must NEVER fan out network calls of its own: a health endpoint
4834
+ * that does I/O hangs under exactly the conditions it exists to report, and
4835
+ * the conditions here are "the delivery service is unreachable or lying".
4836
+ */
4837
+ observations = /* @__PURE__ */ new Map();
4838
+ /**
4839
+ * A3 (dr-evil must-fix #3): true when the sweep found the provider missing a
4840
+ * required capability (stale SDK dist under a newer bridge dist) and ABORTED.
4841
+ * Folded into `observations_stale` (and therefore `degraded`) on health():
4842
+ * a bridge that cannot heal must never present trustworthy-looking data.
4843
+ */
4844
+ providerCapabilityMissing = false;
4845
+ /**
4846
+ * ADR-0013 Decision D: the wipe's OWN anti-thrash bound, deliberately
4847
+ * separate from {@link REQUEST_WELCOME_COOLDOWN_MS} and separately keyed.
4848
+ *
4849
+ * Two bounds exist because they protect different things and must never
4850
+ * share a gate. The request cooldown protects the single committer from
4851
+ * churn; deferring an ask costs a little latency. The wipe bound only stops
4852
+ * the member thrashing its own session; skipping a wipe costs the member its
4853
+ * ONLY route back, permanently. Sharing one map is what starved the repair.
4854
+ */
4855
+ static WIPE_ANTI_THRASH_MS = 6e5;
4856
+ /**
4857
+ * ADR-0013 Decision A (wipe-first): the SHORTER re-wipe floor used only when
4858
+ * the fork is PROVEN (the self-heal sweep's resyncByReplay returned false).
4859
+ *
4860
+ * The 600s WIPE_ANTI_THRASH_MS is the right duty cycle for the reactive
4861
+ * onWedged path, but it is a TRAP for a proven-forked member: while the member
4862
+ * holds forked state the owner's re-Welcome is stray-acked (mls.ts, rowGroup ==
4863
+ * heldGroup) and destroyed, so the member never returns to no-state and the
4864
+ * Welcome never lands via the first-join path. Suppressing the re-wipe for a
4865
+ * full 10 minutes means every owner Welcome in that window is destroyed — the
4866
+ * observed hermes flap. This floor lets a proven-forked member re-wipe promptly
4867
+ * (return to no-state so the next Welcome opens) while still bounding the loop:
4868
+ * ~one 25s member ingest poll + margin, which preserves Decision D ("cannot
4869
+ * loop") because two wipes still cannot fire inside a single poll interval.
4870
+ */
4871
+ static PROVEN_FORK_REWIPE_FLOOR_MS = 3e4;
4872
+ /** Per-channel timestamp of the last state repudiation (Decision D). */
4873
+ lastWipe = /* @__PURE__ */ new Map();
4874
+ static REQUEST_WELCOME_COOLDOWN_MS = REQUEST_WELCOME_COOLDOWN_MS;
4629
4875
  // H1: bound the graceful-shutdown drain so a hung in-flight op can't block exit.
4630
4876
  static GRACEFUL_DRAIN_MS = 5e3;
4631
4877
  /**
@@ -4647,13 +4893,13 @@ var BridgeAgent = class _BridgeAgent {
4647
4893
  */
4648
4894
  selfHealSweepEnabled = process.env.SECURECOMS_SELF_HEAL_SWEEP !== "false";
4649
4895
  /** Task 12: proactive sweep cadence (in addition to the once-at-boot fire). */
4650
- static SELF_HEAL_SWEEP_INTERVAL_MS = 9e4;
4896
+ static SELF_HEAL_SWEEP_INTERVAL_MS = SELF_HEAL_SWEEP_INTERVAL_MS;
4651
4897
  // C3: add up to this much random jitter to BOTH the boot sweep delay and each
4652
4898
  // periodic interval, so a fleet of daemons restarting/sweeping together does not
4653
4899
  // fire re-Welcome signals at the SAME instant and thundering-herd the single
4654
4900
  // committer (the observed 409 commit-lease saturation). Decorrelation only —
4655
4901
  // C1 already removed the restart KP-rotation storm that caused the herd.
4656
- static SELF_HEAL_SWEEP_JITTER_MS = 3e4;
4902
+ static SELF_HEAL_SWEEP_JITTER_MS = SELF_HEAL_SWEEP_JITTER_MS;
4657
4903
  selfHealTimer;
4658
4904
  bootSweepTimer;
4659
4905
  rng;
@@ -4792,7 +5038,8 @@ var BridgeAgent = class _BridgeAgent {
4792
5038
  await this.provider.joinAndReconcile(channelId, this.cfg.enclaveId);
4793
5039
  this.joinState.set(channelId, "JOINED");
4794
5040
  return true;
4795
- } catch {
5041
+ } catch (e) {
5042
+ console.warn(`[bridge] reconcileJoin ${channelId} failed:`, e);
4796
5043
  this.joinState.set(channelId, "PENDING");
4797
5044
  return false;
4798
5045
  }
@@ -4850,20 +5097,40 @@ var BridgeAgent = class _BridgeAgent {
4850
5097
  * SDK's onWedged caller. Single-shot per wedge episode — full rejoin
4851
5098
  * reliability is P1, not this hook's job.
4852
5099
  */
4853
- async healWedgedChannel(channelId) {
5100
+ async healWedgedChannel(channelId, provenForked = false) {
4854
5101
  try {
4855
5102
  const now = performance.now();
4856
- const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
4857
- if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return;
4858
5103
  const g = await this.backend.getConnectChannelGroup(channelId);
4859
- if (!g?.group_id) return;
4860
- this.lastWelcomeRequest.set(channelId, now);
4861
- if (await this.provider.hasPersistedChannelState(channelId)) {
4862
- await this.provider.resetChannelForRejoin(channelId);
4863
- this.joinState.delete(channelId);
5104
+ if (!g?.group_id) return false;
5105
+ const lastWipe = this.lastWipe.get(channelId) ?? -Infinity;
5106
+ const floor = provenForked ? _BridgeAgent.PROVEN_FORK_REWIPE_FLOOR_MS : _BridgeAgent.WIPE_ANTI_THRASH_MS;
5107
+ let repudiated = false;
5108
+ if (now - lastWipe >= floor) {
5109
+ if (await this.provider.hasPersistedChannelState(channelId)) {
5110
+ await this.provider.resetChannelForRejoin(channelId);
5111
+ repudiated = true;
5112
+ }
5113
+ this.lastWipe.set(channelId, now);
5114
+ } else {
5115
+ console.warn(
5116
+ `[bridge] healWedgedChannel ${channelId}: wipe suppressed by ${provenForked ? "proven-fork re-wipe floor" : "anti-thrash bound"} (${Math.round((floor - (now - lastWipe)) / 1e3)}s remaining) \u2014 NOT repudiated`
5117
+ );
4864
5118
  }
5119
+ this.joinState.delete(channelId);
5120
+ if (!repudiated && await this.provider.hasPersistedChannelState(channelId)) {
5121
+ console.warn(
5122
+ `[bridge] healWedgedChannel ${channelId}: wipe suppressed and state still held \u2014 NOT requesting (the answer would be stray-acked and would cost the group two commits)`
5123
+ );
5124
+ return false;
5125
+ }
5126
+ const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
5127
+ if (!isResignalDue(now, last, _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS)) return repudiated;
5128
+ this.lastWelcomeRequest.set(channelId, now);
4865
5129
  await this.signalHeldKpWelcome(g.group_id);
4866
- } catch {
5130
+ return repudiated;
5131
+ } catch (e) {
5132
+ console.warn(`[bridge] healWedgedChannel ${channelId} failed:`, e);
5133
+ return false;
4867
5134
  }
4868
5135
  }
4869
5136
  /**
@@ -4885,7 +5152,7 @@ var BridgeAgent = class _BridgeAgent {
4885
5152
  try {
4886
5153
  const now = performance.now();
4887
5154
  const last = this.lastWelcomeRequest.get(channelId) ?? -Infinity;
4888
- if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) return false;
5155
+ if (!isResignalDue(now, last, _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS)) return false;
4889
5156
  this.lastWelcomeRequest.set(channelId, now);
4890
5157
  await this.signalHeldKpWelcome(groupId);
4891
5158
  return true;
@@ -4893,6 +5160,19 @@ var BridgeAgent = class _BridgeAgent {
4893
5160
  return false;
4894
5161
  }
4895
5162
  }
5163
+ /**
5164
+ * Task 2 (MLS convergence slice): true when we hold persisted state for
5165
+ * `channelId` whose group id does NOT match `serverGroupId` — the group
5166
+ * the server currently says is live for that channel. Holding SOME state
5167
+ * is not proof of health; it is proof of health only for the group the
5168
+ * server currently attests. A null held group id (state present but the
5169
+ * manager can't be loaded from it) is treated as diverged too — we can't
5170
+ * confirm health, so we ask rather than silently trusting stale state.
5171
+ */
5172
+ async isChannelDiverged(channelId, serverGroupId) {
5173
+ const held = await this.provider.heldGroupIdFor(channelId);
5174
+ return held !== serverGroupId;
5175
+ }
4896
5176
  /**
4897
5177
  * Task 12: proactive self-heal sweep. ingestOnce's Task-11 divergence
4898
5178
  * branch only fires reactively — off a delivery ROW that already arrived
@@ -4901,11 +5181,22 @@ var BridgeAgent = class _BridgeAgent {
4901
5181
  * first thing lost. This sweep walks every member channel — rooms AND DMs
4902
5182
  * (H2: a wiped, idle DM with no traffic since the wipe otherwise never
4903
5183
  * proactively re-Welcomes, so it stays dark until the owner happens to send)
4904
- * — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS, and, for any with no
4905
- * persisted state even after a reconcile attempt, resolves group_id via
4906
- * the READ-ONLY getConnectChannelGroup probe (never initConnectChannelGroup,
4907
- * which would side-effect a member_device_ids union + commit) and applies
4908
- * the same held-KP signal — respecting the existing per-channel cooldown.
5184
+ * — fired once at boot + on SELF_HEAL_SWEEP_INTERVAL_MS.
5185
+ *
5186
+ * Task 2: holding persisted state is NOT proof of health by itself — it is
5187
+ * proof of health only for the group the server currently says is live for
5188
+ * the channel. The prior rule ("I have persisted state ⇒ healthy ⇒ don't
5189
+ * ask") silently stopped filing welcome-requests for any channel that had
5190
+ * been superseded (e.g. a server-side reset/rotation) while the member
5191
+ * still held state for the OLD group: it could hold state, hold the WRONG
5192
+ * state, and never say anything. So the server's current group_id is
5193
+ * fetched FIRST via the READ-ONLY getConnectChannelGroup probe (never
5194
+ * initConnectChannelGroup, which would side-effect a member_device_ids
5195
+ * union + commit), then the branch is: persisted state whose group id
5196
+ * matches the server's current group ⇒ healthy, skip; persisted state for
5197
+ * a STALE group id ⇒ diverged, signal; no persisted state at all ⇒
5198
+ * reconcile-first (consume an already-queued Welcome) before concluding
5199
+ * divergence. All three paths go through the same per-channel cooldown.
4909
5200
  *
4910
5201
  * Each channel is wrapped in its own try/catch: `listChannels` filters at
4911
5202
  * the identity level while `requestWelcome` (via request_welcome) gates at
@@ -4914,25 +5205,114 @@ var BridgeAgent = class _BridgeAgent {
4914
5205
  * must not abort the sweep for every other channel.
4915
5206
  */
4916
5207
  async selfHealSweep() {
5208
+ if (typeof this.provider.resyncByReplay !== "function" || typeof this.provider.heldGroupIdFor !== "function") {
5209
+ this.providerCapabilityMissing = true;
5210
+ console.error(
5211
+ "[bridge] selfHealSweep ABORTED: provider is missing required capabilities (resyncByReplay/heldGroupIdFor) \u2014 stale SDK dist? Rebuild SDK dist THEN bridge dist. Healing is NOT running; observations are reported STALE."
5212
+ );
5213
+ return;
5214
+ }
5215
+ this.providerCapabilityMissing = false;
4917
5216
  let chans;
4918
5217
  try {
4919
5218
  chans = await this.listChannels();
4920
- } catch {
5219
+ } catch (e) {
5220
+ console.warn("[bridge] selfHealSweep aborted: listChannels failed:", e);
4921
5221
  return;
4922
5222
  }
4923
5223
  for (const c of chans) {
5224
+ const obs = {
5225
+ channel_id: c.channel_id,
5226
+ state: "probe_failed",
5227
+ held_group_id: null,
5228
+ server_group_id: null,
5229
+ held_epoch: null,
5230
+ server_epoch: null,
5231
+ diverged: null,
5232
+ last_decrypt_at: this.provider.lastDecryptAtFor?.(c.channel_id) ?? null,
5233
+ observed_at: Date.now()
5234
+ };
4924
5235
  try {
4925
- if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
4926
- await this.reconcileJoin(c.channel_id);
4927
- if (await this.provider.hasPersistedChannelState(c.channel_id)) continue;
4928
5236
  const g = await this.backend.getConnectChannelGroup(c.channel_id);
4929
- if (!g?.group_id) continue;
5237
+ obs.server_group_id = g?.group_id ?? null;
5238
+ obs.server_epoch = g?.current_epoch ?? null;
5239
+ if (!g?.group_id) {
5240
+ obs.state = "group_unresolvable";
5241
+ continue;
5242
+ }
5243
+ const hasState = await this.provider.hasPersistedChannelState(c.channel_id);
5244
+ if (hasState) {
5245
+ obs.held_group_id = await this.provider.heldGroupIdFor(c.channel_id).catch(() => null);
5246
+ obs.held_epoch = await this.provider.currentEpoch(c.channel_id).catch(() => null);
5247
+ if (!await this.isChannelDiverged(c.channel_id, g.group_id)) {
5248
+ if (obs.held_epoch === null) {
5249
+ obs.state = "probe_failed";
5250
+ obs.diverged = null;
5251
+ continue;
5252
+ }
5253
+ const behind = typeof obs.held_epoch === "number" && typeof obs.server_epoch === "number" && obs.held_epoch < obs.server_epoch;
5254
+ obs.state = behind ? "behind" : "healthy";
5255
+ obs.diverged = behind;
5256
+ if (behind) {
5257
+ const prev = this.observations.get(c.channel_id);
5258
+ const notAdvancing = prev != null && prev.held_epoch === obs.held_epoch;
5259
+ if (notAdvancing) {
5260
+ obs.state = "stuck";
5261
+ if (await this.provider.resyncByReplay(c.channel_id, g.group_id)) {
5262
+ obs.held_epoch = await this.provider.currentEpoch(c.channel_id).catch(() => null);
5263
+ obs.last_decrypt_at = this.provider.lastDecryptAtFor?.(c.channel_id) ?? null;
5264
+ if (obs.held_epoch === null) {
5265
+ obs.state = "probe_failed";
5266
+ obs.diverged = null;
5267
+ continue;
5268
+ }
5269
+ obs.state = "healthy";
5270
+ obs.diverged = false;
5271
+ console.warn(
5272
+ `[bridge] selfHealSweep: ${c.channel_id} caught up by REPLAY to epoch ${obs.held_epoch} (no re-admit, no epoch churn)`
5273
+ );
5274
+ continue;
5275
+ }
5276
+ const repudiated = await this.healWedgedChannel(c.channel_id, true);
5277
+ console.warn(
5278
+ `[bridge] selfHealSweep: ${c.channel_id} STUCK at epoch ${obs.held_epoch} (server=${obs.server_epoch}) across two sweeps \u2014 ${repudiated ? "repudiated and re-requested" : "repair SUPPRESSED (anti-thrash); state unchanged"}`
5279
+ );
5280
+ }
5281
+ continue;
5282
+ }
5283
+ console.debug(
5284
+ `[bridge] selfHealSweep: ${c.channel_id} ${behind ? `BEHIND (held=${obs.held_epoch} server=${obs.server_epoch}) \u2014 reporting only, action predicate is group-id` : "healthy, held group matches server"} \u2014 skipping`
5285
+ );
5286
+ continue;
5287
+ }
5288
+ obs.state = "diverged";
5289
+ obs.diverged = true;
5290
+ console.warn(`[bridge] selfHealSweep: ${c.channel_id} holds STALE group state \u2014 signalling re-Welcome`);
5291
+ } else {
5292
+ obs.state = "no_local_state";
5293
+ obs.diverged = false;
5294
+ await this.reconcileJoin(c.channel_id);
5295
+ if (await this.provider.hasPersistedChannelState(c.channel_id)) {
5296
+ obs.held_group_id = await this.provider.heldGroupIdFor(c.channel_id).catch(() => null);
5297
+ obs.held_epoch = await this.provider.currentEpoch(c.channel_id).catch(() => null);
5298
+ if (obs.held_epoch === null) {
5299
+ obs.state = "probe_failed";
5300
+ obs.diverged = null;
5301
+ continue;
5302
+ }
5303
+ obs.state = "healthy";
5304
+ continue;
5305
+ }
5306
+ }
4930
5307
  const now = performance.now();
4931
5308
  const last = this.lastWelcomeRequest.get(c.channel_id) ?? -Infinity;
4932
- if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
5309
+ if (!isResignalDue(now, last, _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS)) continue;
4933
5310
  this.lastWelcomeRequest.set(c.channel_id, now);
4934
5311
  await this.signalHeldKpWelcome(g.group_id);
4935
- } catch {
5312
+ } catch (e) {
5313
+ console.warn(`[bridge] selfHealSweep: channel ${c.channel_id} failed:`, e);
5314
+ } finally {
5315
+ this.observations.set(c.channel_id, obs);
4936
5316
  }
4937
5317
  }
4938
5318
  }
@@ -4987,7 +5367,7 @@ var BridgeAgent = class _BridgeAgent {
4987
5367
  if (meta && !meta.is_dm) this.enforceRateFloor("room", channelId);
4988
5368
  const joined = await this.reconcileJoin(channelId);
4989
5369
  if (!joined) throw new ChannelPendingError(channelId);
4990
- const id = messageId ?? crypto.randomUUID();
5370
+ const id = resolveMessageId(messageId);
4991
5371
  const sendStart = performance.now();
4992
5372
  await this.sendWithSelfHealTerminal(
4993
5373
  this.provider,
@@ -5024,7 +5404,7 @@ var BridgeAgent = class _BridgeAgent {
5024
5404
  this.enforceRateFloor("a2a", channelId);
5025
5405
  const joined = await this.reconcileJoin(channelId);
5026
5406
  if (!joined) throw new ChannelPendingError(channelId);
5027
- const id = messageId ?? crypto.randomUUID();
5407
+ const id = resolveMessageId(messageId);
5028
5408
  const sendStart = performance.now();
5029
5409
  await this.sendWithSelfHealTerminal(
5030
5410
  this.provider,
@@ -5565,11 +5945,12 @@ var BridgeAgent = class _BridgeAgent {
5565
5945
  if (await this.provider.hasPersistedChannelState(cid)) continue;
5566
5946
  const now = performance.now();
5567
5947
  const last = this.lastWelcomeRequest.get(cid) ?? -Infinity;
5568
- if (now - last < _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS) continue;
5948
+ if (!isResignalDue(now, last, _BridgeAgent.REQUEST_WELCOME_COOLDOWN_MS)) continue;
5569
5949
  this.lastWelcomeRequest.set(cid, now);
5570
5950
  try {
5571
5951
  await this.signalHeldKpWelcome(gid);
5572
- } catch {
5952
+ } catch (e) {
5953
+ console.warn(`[bridge] ingestOnce: signalHeldKpWelcome for ${cid} failed:`, e);
5573
5954
  }
5574
5955
  }
5575
5956
  } catch {
@@ -5758,11 +6139,55 @@ var BridgeAgent = class _BridgeAgent {
5758
6139
  const w = this.waiters.splice(0);
5759
6140
  for (const r of w) r();
5760
6141
  }
6142
+ /**
6143
+ * Task 8: record a newly authenticated /v1/ws session. Called by
6144
+ * ws-server.ts exactly once per connection, AFTER authedUpgrade() passes
6145
+ * (never for a socket closed 1008 for bad auth, and never for the HTTP
6146
+ * /healthz probe, which is not a /v1/ws session at all).
6147
+ */
6148
+ noteSessionOpen() {
6149
+ this.authSessions++;
6150
+ }
6151
+ /**
6152
+ * Task 8: record an authenticated /v1/ws session ending. Clamped at 0 so a
6153
+ * stray extra close (e.g. a double-fired cleanup) can't go negative and
6154
+ * poison the next open/close cycle.
6155
+ */
6156
+ noteSessionClose() {
6157
+ this.authSessions = Math.max(0, this.authSessions - 1);
6158
+ }
6159
+ /**
6160
+ * Task 3 (audit M3): record a prior /v1/ws session being evicted by a
6161
+ * newer connection for the same identity (newest-wins). Called by
6162
+ * ws-server.ts right after prior.cleanup() in the eviction path.
6163
+ */
6164
+ noteSessionEvicted() {
6165
+ this.evictions++;
6166
+ }
5761
6167
  health() {
5762
6168
  let joined = 0;
5763
6169
  for (const s of this.joinState.values()) if (s === "JOINED") joined++;
6170
+ const channels = [...this.observations.values()];
6171
+ const newest = channels.reduce((m, o) => Math.max(m, o.observed_at), 0);
6172
+ const stalePeriodMs = _BridgeAgent.SELF_HEAL_SWEEP_INTERVAL_MS + _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS;
6173
+ const observationsStale = this.providerCapabilityMissing || channels.length === 0 || Date.now() - newest > stalePeriodMs;
6174
+ const divergedChannels = channels.filter((o) => o.diverged === true).length;
6175
+ const liveChannels = channels.map((o) => ({
6176
+ ...o,
6177
+ last_decrypt_at: this.provider.lastDecryptAtFor?.(o.channel_id) ?? o.last_decrypt_at
6178
+ }));
5764
6179
  return {
6180
+ // Deliberately still unconditional. `agent-bridge status` and any external
6181
+ // monitor read this field, and a monitor that restarts non-ok bridges would
6182
+ // loop forever on a diverged agent. The additive fields below land first so
6183
+ // the data can be trusted; flipping this to "degraded" is its own change.
5765
6184
  status: "ok",
6185
+ sweep_enabled: this.selfHealSweepEnabled,
6186
+ observations_stale: observationsStale,
6187
+ diverged_channels: divergedChannels,
6188
+ // A2: the truthful roll-up gates read instead of `status` (D-3).
6189
+ degraded: divergedChannels > 0 || observationsStale,
6190
+ channels: liveChannels,
5766
6191
  identity_id: this.cfg.identityId,
5767
6192
  joined_channels: joined,
5768
6193
  buffered: this.buffer.length,
@@ -5772,6 +6197,8 @@ var BridgeAgent = class _BridgeAgent {
5772
6197
  permanently_skipped: this.provider.permanentlySkippedCount(),
5773
6198
  failed_attachments: this.failedAttachments,
5774
6199
  delivery_pings: this.pingsReceived,
6200
+ agent_sessions: this.authSessions,
6201
+ evictions: this.evictions,
5775
6202
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
5776
6203
  version: BRIDGE_VERSION,
5777
6204
  ...this.memory.stats?.() ?? {}
@@ -6038,7 +6465,7 @@ function createHttpServer(agent, opts) {
6038
6465
  if (method === "POST" && path === "/v1/send") {
6039
6466
  const parsed = sendSchema.safeParse(JSON.parse(await readBody(req, SEND_CAP)));
6040
6467
  if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
6041
- 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);
6468
+ const r = parsed.data.a2a ? await agent.sendA2A(parsed.data.channel_id, parsed.data.a2a) : await agent.send(parsed.data.channel_id, parsed.data.text);
6042
6469
  return json(res, 200, r);
6043
6470
  }
6044
6471
  if (method === "POST" && path === "/v1/send-attachment") {
@@ -6169,12 +6596,14 @@ function attachAgentWebSocket(server, agent, opts) {
6169
6596
  sock.close(1008, "unauthorized");
6170
6597
  return;
6171
6598
  }
6599
+ agent.noteSessionOpen();
6172
6600
  if (current && current.sock !== sock) {
6173
6601
  const prior = current;
6174
6602
  if (prior.sock.readyState === WebSocket.OPEN) {
6175
6603
  prior.sock.close(1e3, "replaced by newer connection");
6176
6604
  }
6177
6605
  prior.cleanup();
6606
+ agent.noteSessionEvicted();
6178
6607
  }
6179
6608
  sock.isAlive = true;
6180
6609
  sock.on("pong", () => {
@@ -6204,6 +6633,7 @@ function attachAgentWebSocket(server, agent, opts) {
6204
6633
  const cleanup = () => {
6205
6634
  if (cleaned) return;
6206
6635
  cleaned = true;
6636
+ agent.noteSessionClose();
6207
6637
  unsubscribe();
6208
6638
  if (inflight.size) {
6209
6639
  agent.requeueFront([...inflight.values()]);
@@ -6290,9 +6720,8 @@ async function handleInbound(sock, agent, data, inflight) {
6290
6720
  sendJson(sock, wsErrorFrame("invalid_request"));
6291
6721
  return;
6292
6722
  }
6293
- const idempotencyKey = typeof m.idempotency_key === "string" ? m.idempotency_key : void 0;
6294
6723
  try {
6295
- const r = await agent.send(m.channel_id, m.text, idempotencyKey);
6724
+ const r = await agent.send(m.channel_id, m.text);
6296
6725
  sendJson(sock, { type: "sent", message_id: r.message_id });
6297
6726
  } catch (e) {
6298
6727
  if (e instanceof ChannelPendingError) {
@@ -6403,9 +6832,9 @@ async function handleInbound(sock, agent, data, inflight) {
6403
6832
  }
6404
6833
 
6405
6834
  // src/bin.ts
6406
- import { readFileSync as readFileSync6, mkdirSync, writeFileSync as writeFileSync2, rmSync, existsSync as existsSync2, realpathSync, readdirSync } from "node:fs";
6407
- import { join as join9, dirname as dirname5, isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
6408
- import { fileURLToPath } from "node:url";
6835
+ import { readFileSync as readFileSync7, mkdirSync as mkdirSync2, writeFileSync as writeFileSync3, rmSync, existsSync as existsSync2, realpathSync, readdirSync, statSync as statSync2 } from "node:fs";
6836
+ import { join as join11, dirname as dirname7, isAbsolute as isAbsolute2, resolve as resolve2 } from "node:path";
6837
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
6409
6838
  import { homedir as homedir2, userInfo } from "node:os";
6410
6839
  import { spawnSync } from "node:child_process";
6411
6840
 
@@ -6414,10 +6843,83 @@ import { dirname as dirname4 } from "node:path";
6414
6843
 
6415
6844
  // src/service/config.ts
6416
6845
  import { basename as basename2, extname, isAbsolute, resolve } from "node:path";
6846
+
6847
+ // src/service/resolve-node.ts
6848
+ var REQUIRED_NODE_MAJOR = 24;
6849
+ var VERSION_EMBEDDED = [
6850
+ /[\\/]Cellar[\\/]/,
6851
+ // Homebrew keg
6852
+ /[\\/]node[\\/]v?\d+\.\d+/,
6853
+ // nvm, generic .../node/v24.4.1/...
6854
+ /[\\/]nodejs[\\/]\d/,
6855
+ // asdf .../installs/nodejs/24.4.1/...
6856
+ /[\\/]node-versions[\\/]/,
6857
+ // fnm
6858
+ /[\\/]\.volta[\\/]/,
6859
+ // volta
6860
+ /[\\/]\.fnm[\\/]/,
6861
+ // fnm (alt layout)
6862
+ /[\\/]\.nvm[\\/]/,
6863
+ // nvm home
6864
+ /[\\/]\.asdf[\\/]/,
6865
+ // asdf home
6866
+ /[\\/]n[\\/]versions[\\/]/
6867
+ // n
6868
+ ];
6869
+ function isVersionEmbeddedNodePath(p) {
6870
+ return VERSION_EMBEDDED.some((re) => re.test(p));
6871
+ }
6872
+ var STABLE_LOCATIONS = [
6873
+ "/usr/local/bin/node",
6874
+ // nodejs.org pkg (mac + linux)
6875
+ "/opt/homebrew/bin/node",
6876
+ // Homebrew symlink (arm mac)
6877
+ "/opt/homebrew/opt/node/bin/node",
6878
+ "/usr/bin/node"
6879
+ // system (linux)
6880
+ ];
6881
+ function usable(path, deps) {
6882
+ if (!deps.fileExists(path)) return false;
6883
+ if (isVersionEmbeddedNodePath(path)) return false;
6884
+ const major = deps.nodeMajorOf(path);
6885
+ if (major == null || major < REQUIRED_NODE_MAJOR) return false;
6886
+ const a = deps.archOf(path);
6887
+ if (a != null && a !== deps.arch()) return false;
6888
+ return true;
6889
+ }
6890
+ function resolveStableNodePath(runningExecPath, deps) {
6891
+ let runningReal;
6892
+ try {
6893
+ runningReal = deps.realpath(runningExecPath);
6894
+ } catch {
6895
+ runningReal = runningExecPath;
6896
+ }
6897
+ for (const loc of STABLE_LOCATIONS) {
6898
+ if (!deps.fileExists(loc)) continue;
6899
+ let locReal;
6900
+ try {
6901
+ locReal = deps.realpath(loc);
6902
+ } catch {
6903
+ continue;
6904
+ }
6905
+ if (locReal === runningReal && usable(loc, deps)) return { path: loc, stable: true };
6906
+ }
6907
+ for (const loc of STABLE_LOCATIONS) {
6908
+ if (usable(loc, deps)) return { path: loc, stable: true };
6909
+ }
6910
+ return { path: runningExecPath, stable: false };
6911
+ }
6912
+
6913
+ // src/service/config.ts
6417
6914
  function slugify(s) {
6418
6915
  return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
6419
6916
  }
6420
6917
  function deriveServiceConfig(input) {
6918
+ if (isVersionEmbeddedNodePath(input.execPath)) {
6919
+ throw new Error(
6920
+ `install: refusing to bake a version-embedded Node path into the service definition: ${input.execPath}. Re-run install under a system/Homebrew Node, or pass --exec-path <stable-path>.`
6921
+ );
6922
+ }
6421
6923
  const stem = input.label ?? basename2(input.credentialPath, extname(input.credentialPath));
6422
6924
  const slug = slugify(stem);
6423
6925
  const cred = isAbsolute(input.credentialPath) ? input.credentialPath : resolve(input.credentialPath);
@@ -6540,7 +7042,139 @@ function uninstallService(input, deps) {
6540
7042
  }
6541
7043
 
6542
7044
  // src/service/install-prep.ts
6543
- import { join as join8 } from "node:path";
7045
+ import { join as join9 } from "node:path";
7046
+
7047
+ // src/service/wire.ts
7048
+ import { join as join8, dirname as dirname5 } from "node:path";
7049
+ import { fileURLToPath } from "node:url";
7050
+ function detectHarness(home, statDir) {
7051
+ if (statDir(`${home}/.openclaw`)) return "openclaw";
7052
+ if (statDir(`${home}/.hermes`)) return "hermes";
7053
+ return null;
7054
+ }
7055
+ function connectorTargetDir(home, harness) {
7056
+ if (harness === "openclaw") return `${home}/.openclaw/local-plugins/securecoms`;
7057
+ return `${home}/.hermes/local-plugins/securecoms`;
7058
+ }
7059
+ function connectorsSrcDir(importMetaUrl) {
7060
+ const pkgRoot = dirname5(dirname5(fileURLToPath(importMetaUrl)));
7061
+ return join8(pkgRoot, "connectors", "openclaw");
7062
+ }
7063
+ function parseArg(argv, flag) {
7064
+ const i = argv.indexOf(flag);
7065
+ return i >= 0 ? argv[i + 1] : void 0;
7066
+ }
7067
+ function resolveEntry(deps) {
7068
+ const identityArg = parseArg(deps.argv, "--identity");
7069
+ if (!identityArg) {
7070
+ deps.log("wire: missing --identity <id|slug>");
7071
+ return null;
7072
+ }
7073
+ const entry = deps.readFleet().find((e) => e.identityId === identityArg || e.slug === identityArg);
7074
+ if (!entry) {
7075
+ deps.log(`wire: unknown identity "${identityArg}" (not found in the local fleet)`);
7076
+ return null;
7077
+ }
7078
+ const harnessArg = parseArg(deps.argv, "--harness");
7079
+ if (harnessArg !== "openclaw" && harnessArg !== "hermes") {
7080
+ deps.log("wire: missing/invalid --harness <openclaw|hermes> (auto-detection did not resolve one)");
7081
+ return null;
7082
+ }
7083
+ return { entry, harness: harnessArg };
7084
+ }
7085
+ function runWire(deps) {
7086
+ const resolved = resolveEntry(deps);
7087
+ if (!resolved) return 1;
7088
+ const { entry, harness } = resolved;
7089
+ if (harness === "hermes") {
7090
+ deps.log(
7091
+ "[securecoms] Hermes connector is not available yet (fast-follow); detection and target dir only. Nothing installed."
7092
+ );
7093
+ return 1;
7094
+ }
7095
+ const targetDir = connectorTargetDir(deps.home, harness);
7096
+ deps.copyConnector(deps.connectorsSrcDir, targetDir);
7097
+ deps.writeBinding(targetDir, entry.identityId);
7098
+ if (typeof entry.port === "number") {
7099
+ const h = deps.getHealth(entry.port);
7100
+ if (h && (h.agent_sessions ?? 0) >= 1) {
7101
+ deps.log(`wire: ${entry.slug} attached (agent_sessions=${h.agent_sessions})`);
7102
+ } else {
7103
+ deps.log(`wire: connector installed but not yet attached \u2014 restart the harness to pick it up.`);
7104
+ }
7105
+ } else {
7106
+ deps.log(`wire: connector installed (no bridge port known yet) \u2014 restart the harness to pick it up.`);
7107
+ }
7108
+ return 0;
7109
+ }
7110
+ function runUnwire(deps) {
7111
+ const resolved = resolveEntry(deps);
7112
+ if (!resolved) return 1;
7113
+ const { harness } = resolved;
7114
+ if (harness === "hermes") {
7115
+ deps.log("[securecoms] Hermes connector is not available yet (fast-follow); nothing to remove.");
7116
+ return 0;
7117
+ }
7118
+ deps.removeBinding(connectorTargetDir(deps.home, harness));
7119
+ return 0;
7120
+ }
7121
+
7122
+ // src/service/harness-wire.ts
7123
+ function customFacts(input) {
7124
+ const ws = `ws://127.0.0.1:${input.port ?? "<PORT>"}/v1/ws`;
7125
+ return [
7126
+ ` bridge WS: ${ws}`,
7127
+ ` token file: ${input.tokenFilePath}`,
7128
+ ` Bring your own connector: open the WS with the bearer token read from that file.`,
7129
+ ` Protocol reference: the "WebSocket" section of the agent-bridge README.`
7130
+ ].join("\n");
7131
+ }
7132
+ function autoWireHarness(input, deps) {
7133
+ const h = input.harness;
7134
+ if (!h || h === "none") return { echo: "", wired: false, error: false };
7135
+ const retry = `agent-bridge wire --harness openclaw --identity ${input.slug}`;
7136
+ if (h === "openclaw") {
7137
+ const parent = `${input.home}/.openclaw`;
7138
+ if (!deps.statDir(parent)) {
7139
+ return {
7140
+ echo: `[securecoms] OpenClaw not found at ~/.openclaw \u2014 the bridge service is installed, but the harness was NOT wired.
7141
+ Install OpenClaw, then re-run install, or run: ${retry}
7142
+ (or re-run install with --harness custom).`,
7143
+ wired: false,
7144
+ error: true
7145
+ };
7146
+ }
7147
+ const targetDir = connectorTargetDir(input.home, "openclaw");
7148
+ try {
7149
+ const prev = deps.readBinding(targetDir);
7150
+ deps.copyConnector(input.connectorsSrcDir, targetDir);
7151
+ deps.writeBinding(targetDir, input.identityId);
7152
+ const rebind = prev && prev !== input.identityId ? `
7153
+ rebind: ${prev} \u2192 ${input.identityId}` : "";
7154
+ return {
7155
+ echo: `[securecoms] OpenClaw connector installed and bound to identity ${input.identityId}.${rebind}
7156
+ Now restart OpenClaw to pick up the connector.`,
7157
+ wired: true,
7158
+ error: false
7159
+ };
7160
+ } catch (e) {
7161
+ return {
7162
+ echo: `[securecoms] the bridge service is installed, but wiring the OpenClaw connector FAILED: ${e.message}
7163
+ Retry after fixing the path: ${retry}`,
7164
+ wired: false,
7165
+ error: true
7166
+ };
7167
+ }
7168
+ }
7169
+ if (h === "hermes") {
7170
+ return { echo: `[securecoms] Hermes turnkey connector is coming (Phase 2). For now drive Hermes via the custom WS path:
7171
+ ${customFacts(input)}`, wired: false, error: false };
7172
+ }
7173
+ return { echo: `[securecoms] Custom harness \u2014 bring your own connector:
7174
+ ${customFacts(input)}`, wired: false, error: false };
7175
+ }
7176
+
7177
+ // src/service/install-prep.ts
6544
7178
  function assertReadableCredential(absPath, deps) {
6545
7179
  let raw;
6546
7180
  try {
@@ -6563,10 +7197,10 @@ function assertReadableCredential(absPath, deps) {
6563
7197
  return { deviceId: json2.device_id, identityId: json2.identity_id, issuedAt };
6564
7198
  }
6565
7199
  function durableDir(home, slug) {
6566
- return join8(home, `securecoms-agent-${slug}`);
7200
+ return join9(home, `securecoms-agent-${slug}`);
6567
7201
  }
6568
7202
  function durableCredentialPath(home, slug) {
6569
- return join8(durableDir(home, slug), `${slug}.json`);
7203
+ return join9(durableDir(home, slug), `${slug}.json`);
6570
7204
  }
6571
7205
  function takeCustody(args2, deps) {
6572
7206
  const dir = durableDir(args2.home, args2.slug);
@@ -6613,7 +7247,7 @@ function readSiblingDefs(dir, prefix, deps) {
6613
7247
  for (const n of names) {
6614
7248
  if (!n.startsWith(prefix)) continue;
6615
7249
  try {
6616
- out.push(deps.readFile(join8(dir, n)));
7250
+ out.push(deps.readFile(join9(dir, n)));
6617
7251
  } catch {
6618
7252
  }
6619
7253
  }
@@ -6645,14 +7279,6 @@ async function verifyDaemonUp(args2, deps) {
6645
7279
  }
6646
7280
  return false;
6647
7281
  }
6648
- function harnessSnippet(args2) {
6649
- const label = args2.harness === "hermes" ? "Hermes" : args2.harness === "openclaw" ? "OpenClaw" : "your harness";
6650
- return [
6651
- `# ${label}: point the agent at this bridge`,
6652
- `SECURECOMS_BRIDGE_URL=ws://127.0.0.1:${args2.port}/v1/ws`,
6653
- `SECURECOMS_BRIDGE_TOKEN="$(cat ${args2.tokenFilePath})"`
6654
- ].join("\n");
6655
- }
6656
7282
  async function orchestrateInstall(ctx) {
6657
7283
  const parsed = assertReadableCredential(ctx.credentialAbsPath, ctx.prepDeps);
6658
7284
  const stemForSlug = ctx.label ?? ctx.credentialAbsPath.replace(/^.*[\\/]/, "").replace(/\.[^.]*$/, "");
@@ -6686,12 +7312,194 @@ async function orchestrateInstall(ctx) {
6686
7312
  ` token: ${result.tokenHint}`,
6687
7313
  ` (Re-running install is the only way to update a live credential \u2014 replacing the download alone does nothing.)`
6688
7314
  ];
6689
- let harnessSnippetShown = false;
6690
- if (ctx.harness && ctx.harness !== "none") {
6691
- lines.push("", harnessSnippet({ slug, port, tokenFilePath: result.tokenHint, harness: ctx.harness }));
6692
- harnessSnippetShown = true;
7315
+ const wire = autoWireHarness(
7316
+ {
7317
+ harness: ctx.harness,
7318
+ identityId: parsed.identityId,
7319
+ slug,
7320
+ port,
7321
+ home: ctx.home,
7322
+ tokenFilePath: result.tokenHint,
7323
+ connectorsSrcDir: ctx.connectorsSrcDir
7324
+ },
7325
+ ctx.autoWireDeps
7326
+ );
7327
+ if (wire.echo) lines.push("", wire.echo);
7328
+ return { echo: lines.join("\n") + "\n", harnessWired: wire.wired, harnessError: wire.error };
7329
+ }
7330
+
7331
+ // src/service/fleet.ts
7332
+ import { dirname as dirname6 } from "node:path";
7333
+ function parseArgFromDef(def, flag) {
7334
+ const esc = flag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7335
+ const re = new RegExp(`${esc}(?:<\\/string>\\s*<string>|\\s+)([^<\\s]+)`);
7336
+ const m = re.exec(def);
7337
+ return m ? m[1] : null;
7338
+ }
7339
+ function parseExecPathFromDef(def) {
7340
+ const plist = /<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]+)<\/string>/.exec(def);
7341
+ if (plist) return plist[1];
7342
+ const unit = /^ExecStart=(\S+)/m.exec(def);
7343
+ return unit ? unit[1] : null;
7344
+ }
7345
+ function envOf(apiBase) {
7346
+ if (!apiBase) return "unknown";
7347
+ if (apiBase.includes("app.securecoms.ai")) return "prod";
7348
+ return "preprod";
7349
+ }
7350
+ var SLUG_FROM_PLIST = /com\.securecoms\.bridge\.(.+)\.plist$/;
7351
+ var SLUG_FROM_UNIT = /securecoms-bridge-(.+)\.service$/;
7352
+ function readFleet(deps) {
7353
+ const sources = [
7354
+ { dir: `${deps.home}/Library/LaunchAgents`, prefix: "com.securecoms.bridge.", slugRe: SLUG_FROM_PLIST },
7355
+ { dir: `${deps.home}/.config/systemd/user`, prefix: "securecoms-bridge-", slugRe: SLUG_FROM_UNIT }
7356
+ ];
7357
+ const byIdentity = /* @__PURE__ */ new Map();
7358
+ for (const src of sources) {
7359
+ for (const name of deps.listDir(src.dir)) {
7360
+ if (!name.startsWith(src.prefix)) continue;
7361
+ const slugMatch = src.slugRe.exec(name);
7362
+ if (!slugMatch) continue;
7363
+ const slug = slugMatch[1];
7364
+ let def;
7365
+ try {
7366
+ def = deps.readFile(`${src.dir}/${name}`);
7367
+ } catch {
7368
+ continue;
7369
+ }
7370
+ const credPath = parseArgFromDef(def, "--credential");
7371
+ const portStr = parseArgFromDef(def, "--port");
7372
+ const interp = parseExecPathFromDef(def);
7373
+ let identityId = `unknown:${slug}`, deviceId = "", label = slug, env = "unknown";
7374
+ let credDir = null;
7375
+ if (credPath) {
7376
+ credDir = dirname6(credPath);
7377
+ try {
7378
+ const c = JSON.parse(deps.readFile(credPath));
7379
+ if (c.identity_id) identityId = c.identity_id;
7380
+ if (c.device_id) deviceId = c.device_id;
7381
+ env = envOf(c.api_base);
7382
+ } catch {
7383
+ }
7384
+ }
7385
+ byIdentity.set(identityId, {
7386
+ identityId,
7387
+ deviceId,
7388
+ slug,
7389
+ label,
7390
+ environment: env,
7391
+ port: portStr ? Number(portStr) : null,
7392
+ interpreterPath: interp,
7393
+ // Fail-loud (Part A posture): a def whose interpreter can't be parsed
7394
+ // is NOT a clean bill of health — it's an unknown we can't vouch for,
7395
+ // so it must surface as a reboot risk for triage, not pass silently.
7396
+ interpreterStable: interp ? !isVersionEmbeddedNodePath(interp) : false,
7397
+ credentialDir: credDir
7398
+ });
7399
+ }
7400
+ }
7401
+ return [...byIdentity.values()];
7402
+ }
7403
+
7404
+ // src/service/status.ts
7405
+ function readFleetStatus(deps) {
7406
+ return deps.readFleet().map((e) => {
7407
+ const up = e.port != null && deps.probeConnect(e.port);
7408
+ let harness = "-";
7409
+ let evictions = 0;
7410
+ if (up && e.port != null) {
7411
+ const h = deps.getHealth(e.port);
7412
+ harness = (h?.agent_sessions ?? 0) >= 1 ? "attached" : "no brain";
7413
+ evictions = h?.evictions ?? 0;
7414
+ }
7415
+ return {
7416
+ slug: e.slug,
7417
+ environment: e.environment,
7418
+ port: e.port,
7419
+ bridge: up ? "up" : "down",
7420
+ harness,
7421
+ interpreter: e.interpreterStable ? "stable" : "REBOOT-RISK",
7422
+ evictions
7423
+ };
7424
+ });
7425
+ }
7426
+ function renderStatusTable(rows) {
7427
+ const head = ["AGENT", "ENV", "PORT", "BRIDGE", "HARNESS", "INTERPRETER", "FLAPS"];
7428
+ const body = rows.map((r) => [
7429
+ r.slug,
7430
+ r.environment,
7431
+ String(r.port ?? "-"),
7432
+ r.bridge,
7433
+ r.harness,
7434
+ r.interpreter,
7435
+ String(r.evictions)
7436
+ ]);
7437
+ const widths = head.map((h, i) => Math.max(h.length, ...body.map((row) => row[i].length)));
7438
+ const fmt = (cols) => cols.map((c, i) => c.padEnd(widths[i])).join(" ").trimEnd();
7439
+ return [fmt(head), ...body.map(fmt)].join("\n") + "\n";
7440
+ }
7441
+
7442
+ // src/service/connection-file.ts
7443
+ import {
7444
+ chmodSync,
7445
+ mkdirSync,
7446
+ readFileSync as readFileSync6,
7447
+ renameSync,
7448
+ unlinkSync,
7449
+ writeFileSync as writeFileSync2
7450
+ } from "node:fs";
7451
+ import { randomUUID } from "node:crypto";
7452
+ import { join as join10 } from "node:path";
7453
+ var REAL_FS = {
7454
+ mkdir: (p, mode) => mkdirSync(p, { recursive: true, mode }),
7455
+ chmod: (p, mode) => chmodSync(p, mode),
7456
+ writeFile: (p, data, mode) => writeFileSync2(p, data, { mode }),
7457
+ rename: (a, b) => renameSync(a, b),
7458
+ readFile: (p) => readFileSync6(p, "utf8"),
7459
+ unlink: (p) => unlinkSync(p)
7460
+ };
7461
+ function securecomsDir(home) {
7462
+ return join10(home, ".securecoms");
7463
+ }
7464
+ function connectionsDir(home) {
7465
+ return join10(securecomsDir(home), "connections");
7466
+ }
7467
+ function connectionFilePath(home, identityId) {
7468
+ return join10(connectionsDir(home), `${identityId}.json`);
7469
+ }
7470
+ function newInstanceId() {
7471
+ return randomUUID();
7472
+ }
7473
+ function buildConnectionFile(args2) {
7474
+ return {
7475
+ schema: "securecoms.connection/1",
7476
+ identity_id: args2.identityId,
7477
+ bridge_url: `ws://127.0.0.1:${args2.port}/v1/ws`,
7478
+ token_file: args2.tokenFile,
7479
+ instance_id: args2.instanceId,
7480
+ slug: args2.slug ?? args2.identityId,
7481
+ label: args2.label ?? args2.slug ?? args2.identityId,
7482
+ written_at: args2.now
7483
+ };
7484
+ }
7485
+ function writeConnectionFile(home, doc, deps = REAL_FS) {
7486
+ deps.mkdir(securecomsDir(home), 448);
7487
+ deps.chmod(securecomsDir(home), 448);
7488
+ deps.mkdir(connectionsDir(home), 448);
7489
+ deps.chmod(connectionsDir(home), 448);
7490
+ const path = connectionFilePath(home, doc.identity_id);
7491
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
7492
+ deps.writeFile(tmp, JSON.stringify(doc, null, 2) + "\n", 384);
7493
+ deps.chmod(tmp, 384);
7494
+ deps.rename(tmp, path);
7495
+ }
7496
+ function removeConnectionFileIfOwned(home, identityId, instanceId, deps = REAL_FS) {
7497
+ const path = connectionFilePath(home, identityId);
7498
+ try {
7499
+ const cur = JSON.parse(deps.readFile(path));
7500
+ if (cur.instance_id === instanceId) deps.unlink(path);
7501
+ } catch {
6693
7502
  }
6694
- return { echo: lines.join("\n") + "\n", harnessSnippetShown };
6695
7503
  }
6696
7504
 
6697
7505
  // src/bin.ts
@@ -6709,7 +7517,15 @@ function argOrEnv(flag, env) {
6709
7517
  }
6710
7518
  function parseVerb(argv) {
6711
7519
  const v = argv[2];
6712
- return v === "install" || v === "uninstall" ? v : null;
7520
+ return v === "install" || v === "uninstall" || v === "status" || v === "wire" || v === "unwire" ? v : null;
7521
+ }
7522
+ function slugFromCredentialPath(credentialPath) {
7523
+ const dir = dirname7(credentialPath);
7524
+ const base = dir.split(/[\\/]/).pop() ?? "";
7525
+ const m = base.match(/^securecoms-agent-(.+)$/);
7526
+ if (m) return m[1];
7527
+ const file = credentialPath.split(/[\\/]/).pop() ?? "agent";
7528
+ return file.replace(/\.json$/, "");
6713
7529
  }
6714
7530
  function realServiceDeps() {
6715
7531
  return {
@@ -6718,13 +7534,41 @@ function realServiceDeps() {
6718
7534
  const r = spawnSync(cmd, args2, { encoding: "utf8" });
6719
7535
  return { status: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
6720
7536
  },
6721
- writeFile: (p, data) => writeFileSync2(p, data, { mode: 420 }),
7537
+ writeFile: (p, data) => writeFileSync3(p, data, { mode: 420 }),
6722
7538
  removeFile: (p) => rmSync(p, { force: true }),
6723
- mkdirp: (p) => mkdirSync(p, { recursive: true }),
7539
+ mkdirp: (p) => mkdirSync2(p, { recursive: true }),
6724
7540
  fileExists: (p) => existsSync2(p),
6725
7541
  user: userInfo().username
6726
7542
  };
6727
7543
  }
7544
+ function chooseInstallExecPath(runningExecPath, override, deps) {
7545
+ if (override) return override;
7546
+ const r = resolveStableNodePath(runningExecPath, deps);
7547
+ if (!r.stable) {
7548
+ throw new Error(
7549
+ "install: no reboot-stable Node found. Version-manager Nodes (nvm/asdf/fnm/volta/n) are removed on upgrade and will break the daemon after a reboot. Install a system Node (nodejs.org pkg \u2192 /usr/local/bin/node) or pass --exec-path <stable-path> to override."
7550
+ );
7551
+ }
7552
+ return r.path;
7553
+ }
7554
+ function realResolveNodeDeps() {
7555
+ const majorOf = (p) => {
7556
+ const r = spawnSync(p, ["--version"], { encoding: "utf8" });
7557
+ if (r.status !== 0) return null;
7558
+ const m = /v?(\d+)\./.exec((r.stdout ?? "").trim());
7559
+ return m ? Number(m[1]) : null;
7560
+ };
7561
+ return {
7562
+ realpath: (p) => realpathSync(p),
7563
+ fileExists: (p) => existsSync2(p),
7564
+ nodeMajorOf: majorOf,
7565
+ arch: () => process.arch,
7566
+ archOf: (p) => {
7567
+ const r = spawnSync(p, ["-p", "process.arch"], { encoding: "utf8" });
7568
+ return r.status === 0 ? (r.stdout ?? "").trim() : null;
7569
+ }
7570
+ };
7571
+ }
6728
7572
  function realResolveDeps() {
6729
7573
  return {
6730
7574
  run: (cmd, args2) => {
@@ -6736,10 +7580,10 @@ function realResolveDeps() {
6736
7580
  }
6737
7581
  function realPrepDeps() {
6738
7582
  return {
6739
- readFile: (p) => readFileSync6(p, "utf8"),
6740
- writeFileMode: (p, data, mode) => writeFileSync2(p, data, { mode }),
7583
+ readFile: (p) => readFileSync7(p, "utf8"),
7584
+ writeFileMode: (p, data, mode) => writeFileSync3(p, data, { mode }),
6741
7585
  mkdirp: (p, mode) => {
6742
- mkdirSync(p, { recursive: true, mode: mode ?? 493 });
7586
+ mkdirSync2(p, { recursive: true, mode: mode ?? 493 });
6743
7587
  },
6744
7588
  fileExists: (p) => existsSync2(p),
6745
7589
  realpath: (p) => realpathSync(p),
@@ -6770,11 +7614,111 @@ function realPrepDeps() {
6770
7614
  sleep: (ms) => new Promise((res) => setTimeout(res, ms))
6771
7615
  };
6772
7616
  }
7617
+ function realGetHealth(port) {
7618
+ const r = spawnSync(process.execPath, [
7619
+ "-e",
7620
+ `const http=require('http');const q=http.get({host:'127.0.0.1',port:${port},path:'/healthz',timeout:800},res=>{let d='';res.on('data',c=>d+=c);res.on('end',()=>{process.stdout.write(d);process.exit(0)})});q.on('error',()=>process.exit(1));q.on('timeout',()=>process.exit(1));`
7621
+ ], { encoding: "utf8" });
7622
+ if (r.status !== 0) return null;
7623
+ try {
7624
+ return JSON.parse(r.stdout);
7625
+ } catch {
7626
+ return null;
7627
+ }
7628
+ }
7629
+ function realReadFleet() {
7630
+ return readFleet({
7631
+ home: homedir2(),
7632
+ listDir: (p) => {
7633
+ try {
7634
+ return readdirSync(p);
7635
+ } catch {
7636
+ return [];
7637
+ }
7638
+ },
7639
+ readFile: (p) => readFileSync7(p, "utf8")
7640
+ });
7641
+ }
7642
+ function runStatus() {
7643
+ const deps = {
7644
+ readFleet: realReadFleet,
7645
+ probeConnect: (port) => {
7646
+ const r = spawnSync(process.execPath, [
7647
+ "-e",
7648
+ `const s=require('net').connect(${port},'127.0.0.1');s.once('connect',()=>{s.destroy();process.exit(0)});s.once('error',()=>process.exit(1));setTimeout(()=>process.exit(1),500);`
7649
+ ]);
7650
+ return r.status === 0;
7651
+ },
7652
+ getHealth: realGetHealth
7653
+ };
7654
+ const rows = readFleetStatus(deps);
7655
+ if (process.argv.includes("--json")) process.stdout.write(JSON.stringify(rows, null, 2) + "\n");
7656
+ else process.stdout.write(renderStatusTable(rows));
7657
+ process.exit(0);
7658
+ }
7659
+ function realCopyConnector(srcDir, dstDir) {
7660
+ mkdirSync2(dstDir, { recursive: true });
7661
+ for (const name of readdirSync(srcDir)) {
7662
+ const srcPath = join11(srcDir, name);
7663
+ if (statSync2(srcPath).isDirectory()) {
7664
+ realCopyConnector(srcPath, join11(dstDir, name));
7665
+ continue;
7666
+ }
7667
+ if (!/\.(js|json)$/.test(name)) continue;
7668
+ writeFileSync3(join11(dstDir, name), readFileSync7(srcPath));
7669
+ }
7670
+ }
7671
+ function realWriteBinding(dstDir, identityId) {
7672
+ mkdirSync2(dstDir, { recursive: true });
7673
+ writeFileSync3(join11(dstDir, "securecoms.binding.json"), JSON.stringify({ identity_id: identityId }) + "\n", { mode: 384 });
7674
+ }
7675
+ function realReadBinding(dstDir) {
7676
+ try {
7677
+ const parsed = JSON.parse(readFileSync7(join11(dstDir, "securecoms.binding.json"), "utf8"));
7678
+ return parsed.identity_id ?? null;
7679
+ } catch {
7680
+ return null;
7681
+ }
7682
+ }
7683
+ function realRemoveBinding(dstDir) {
7684
+ rmSync(join11(dstDir, "securecoms.binding.json"), { force: true });
7685
+ }
7686
+ function realStatDir(p) {
7687
+ try {
7688
+ return statSync2(p).isDirectory();
7689
+ } catch {
7690
+ return false;
7691
+ }
7692
+ }
7693
+ function realWireArgv() {
7694
+ if (process.argv.includes("--harness")) return process.argv;
7695
+ const detected = detectHarness(homedir2(), realStatDir);
7696
+ return detected ? [...process.argv, "--harness", detected] : process.argv;
7697
+ }
7698
+ function realWireDeps() {
7699
+ return {
7700
+ home: homedir2(),
7701
+ argv: realWireArgv(),
7702
+ readFleet: realReadFleet,
7703
+ copyConnector: realCopyConnector,
7704
+ writeBinding: realWriteBinding,
7705
+ removeBinding: realRemoveBinding,
7706
+ getHealth: realGetHealth,
7707
+ log: (s) => process.stdout.write(s + "\n"),
7708
+ // bin.ts's OWN import.meta.url — see connectorsSrcDir's doc comment in
7709
+ // wire.ts for why it must be the entry point's URL, not wire.ts's.
7710
+ connectorsSrcDir: connectorsSrcDir(import.meta.url)
7711
+ };
7712
+ }
7713
+ function runWireVerb(verb) {
7714
+ process.exit(verb === "wire" ? runWire(realWireDeps()) : runUnwire(realWireDeps()));
7715
+ }
6773
7716
  async function runServiceVerb(verb) {
6774
7717
  const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
6775
7718
  const label = argOrEnv("--label", "SECURECOMS_LABEL");
6776
7719
  if (verb === "install" ? !credentialPath : !credentialPath && !label) {
6777
- process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N] [--harness openclaw|hermes|custom]
7720
+ const execPathUsage = verb === "install" ? " [--exec-path <path>]" : "";
7721
+ process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N] [--harness openclaw|hermes|custom]${execPathUsage}
6778
7722
  `);
6779
7723
  process.exit(2);
6780
7724
  }
@@ -6787,12 +7731,28 @@ async function runServiceVerb(verb) {
6787
7731
  try {
6788
7732
  if (verb === "install") {
6789
7733
  const credAbs = isAbsolute2(credentialPath) ? credentialPath : resolve2(credentialPath);
7734
+ const installExecPath = chooseInstallExecPath(
7735
+ process.execPath,
7736
+ argOrEnv("--exec-path", "SECURECOMS_EXEC_PATH"),
7737
+ realResolveNodeDeps()
7738
+ );
6790
7739
  const r = await orchestrateInstall({
6791
7740
  credentialAbsPath: credAbs,
6792
7741
  label,
6793
7742
  explicitPort: portStr ? Number(portStr) : void 0,
6794
7743
  harness: argOrEnv("--harness", "SECURECOMS_HARNESS"),
6795
7744
  home: homedir2(),
7745
+ connectorsSrcDir: connectorsSrcDir(import.meta.url),
7746
+ autoWireDeps: {
7747
+ statDir: realStatDir,
7748
+ // reuse bin.ts:235
7749
+ readBinding: realReadBinding,
7750
+ copyConnector: realCopyConnector,
7751
+ // bin.ts:212 — one copy impl
7752
+ writeBinding: realWriteBinding,
7753
+ // bin.ts:226 — writes {identity_id}
7754
+ log: (s) => process.stderr.write(s + "\n")
7755
+ },
6796
7756
  prepDeps: realPrepDeps(),
6797
7757
  // O-1: the real installService is 2-arg; partially apply the deps so
6798
7758
  // orchestrateInstall only ever sees the 1-arg seam.
@@ -6803,13 +7763,14 @@ async function runServiceVerb(verb) {
6803
7763
  // O-2: not dropped
6804
7764
  port,
6805
7765
  label,
6806
- execPath: process.execPath,
7766
+ execPath: installExecPath,
6807
7767
  binScript,
6808
7768
  home: homedir2(),
6809
7769
  uid: userInfo().uid
6810
7770
  })
6811
7771
  });
6812
7772
  process.stdout.write(r.echo);
7773
+ if (r.harnessError) process.exit(1);
6813
7774
  } else {
6814
7775
  const input = {
6815
7776
  credentialPath: credentialPath ?? "",
@@ -6841,6 +7802,14 @@ async function main() {
6841
7802
  process.exit(1);
6842
7803
  }
6843
7804
  const verb = parseVerb(process.argv);
7805
+ if (verb === "status") {
7806
+ runStatus();
7807
+ return;
7808
+ }
7809
+ if (verb === "wire" || verb === "unwire") {
7810
+ runWireVerb(verb);
7811
+ return;
7812
+ }
6844
7813
  if (verb) {
6845
7814
  await runServiceVerb(verb);
6846
7815
  return;
@@ -6861,11 +7830,11 @@ async function main() {
6861
7830
  `);
6862
7831
  process.exit(1);
6863
7832
  }
6864
- const tokenFile = daemon.tokenFile ?? join9(dirname5(credentialPath), "bridge-token");
7833
+ const tokenFile = daemon.tokenFile ?? join11(dirname7(credentialPath), "bridge-token");
6865
7834
  const token = resolveToken({ tokenFile, token: daemon.token });
6866
7835
  const agent = new BridgeAgent(cfg);
6867
7836
  await agent.start();
6868
- const tls = daemon.tls ? { cert: readFileSync6(daemon.tlsCert, "utf8"), key: readFileSync6(daemon.tlsKey, "utf8") } : void 0;
7837
+ const tls = daemon.tls ? { cert: readFileSync7(daemon.tlsCert, "utf8"), key: readFileSync7(daemon.tlsKey, "utf8") } : void 0;
6869
7838
  const { server, closeAllSseStreams } = createHttpServer(agent, { token, allowOrigins: daemon.allowOrigins, tls, websocket: true });
6870
7839
  const agentWs = attachAgentWebSocket(server, agent, { token, allowOrigins: daemon.allowOrigins });
6871
7840
  let down = false;
@@ -6884,6 +7853,10 @@ async function main() {
6884
7853
  };
6885
7854
  process.on("SIGINT", shutdown);
6886
7855
  process.on("SIGTERM", shutdown);
7856
+ const instanceId = newInstanceId();
7857
+ const identityId = agent.health().identity_id;
7858
+ const slug = slugFromCredentialPath(credentialPath);
7859
+ process.once("exit", () => removeConnectionFileIfOwned(homedir2(), identityId, instanceId));
6887
7860
  server.listen(daemon.port, daemon.bind, () => {
6888
7861
  const scheme = tls ? "https" : "http";
6889
7862
  const wsScheme = tls ? "wss" : "ws";
@@ -6891,9 +7864,27 @@ async function main() {
6891
7864
  `);
6892
7865
  process.stderr.write(`[bridge] bearer token: ${tokenFingerprint(token)} (token file: ${tokenFile})
6893
7866
  `);
7867
+ const addr = server.address();
7868
+ const boundPort = typeof addr === "object" && addr ? addr.port : daemon.port;
7869
+ try {
7870
+ writeConnectionFile(homedir2(), buildConnectionFile({
7871
+ identityId,
7872
+ port: boundPort,
7873
+ tokenFile,
7874
+ slug,
7875
+ label: slug,
7876
+ instanceId,
7877
+ now: (/* @__PURE__ */ new Date()).toISOString()
7878
+ }));
7879
+ process.stderr.write(`[bridge] connection file: ~/.securecoms/connections/${identityId}.json
7880
+ `);
7881
+ } catch (e) {
7882
+ process.stderr.write(`[bridge] WARN: could not write connection file: ${e?.message ?? e}
7883
+ `);
7884
+ }
6894
7885
  });
6895
7886
  }
6896
- var here = fileURLToPath(import.meta.url);
7887
+ var here = fileURLToPath2(import.meta.url);
6897
7888
  var isMain = !!process.argv[1] && refersToSameFile(here, process.argv[1], realpathSync);
6898
7889
  if (isMain) {
6899
7890
  main().catch((e) => {
@@ -6903,7 +7894,9 @@ if (isMain) {
6903
7894
  });
6904
7895
  }
6905
7896
  export {
7897
+ chooseInstallExecPath,
6906
7898
  nodeMajorOk,
6907
7899
  parseVerb,
7900
+ slugFromCredentialPath,
6908
7901
  tokenFingerprint
6909
7902
  };