@securecoms/agent-bridge 0.3.2 → 0.5.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.
Files changed (47) hide show
  1. package/README.md +21 -1
  2. package/dist/bin.js +1073 -90
  3. package/dist/bridge-agent.d.ts +68 -7
  4. package/dist/config.d.ts +7 -0
  5. package/dist/http-server.d.ts +42 -0
  6. package/dist/index.js +837 -63
  7. package/dist/memory/hermes-provider.d.ts +21 -0
  8. package/dist/memory/hermes-session-map.d.ts +8 -0
  9. package/dist/memory/hermes-store.d.ts +32 -0
  10. package/dist/memory/hermes-test-fixture.d.ts +5 -0
  11. package/dist/memory/openclaw-provider.d.ts +24 -0
  12. package/dist/memory/openclaw-session-log.d.ts +15 -0
  13. package/dist/memory/openclaw-session-map.d.ts +7 -0
  14. package/dist/memory/provider.d.ts +29 -0
  15. package/dist/memory-taps.d.ts +13 -0
  16. package/dist/preflight-modern.cjs +35 -0
  17. package/dist/preflight-modern.d.cts +1 -0
  18. package/dist/preflight-upgrade.d.cts +30 -0
  19. package/dist/preflight.cjs +277 -0
  20. package/dist/preflight.d.cts +14 -0
  21. package/dist/service/config.d.ts +1 -0
  22. package/dist/service/install-prep.d.ts +68 -0
  23. package/package.json +3 -5
  24. package/dist/attachments.js +0 -49
  25. package/dist/auth.js +0 -56
  26. package/dist/bin.js.pre-c1-f803007 +0 -5795
  27. package/dist/bin.js.pre-c3-096a9a3 +0 -5832
  28. package/dist/bin.js.pre-ffrebuild-b162d4a +0 -5639
  29. package/dist/bin.js.pre-kpheal-20260708-150924 +0 -5531
  30. package/dist/bin.js.pre-kppersist-20260708-152341 +0 -5543
  31. package/dist/bin.js.pre-p0-20260708-102415 +0 -5196
  32. package/dist/bin.js.pre-p1b-02f47e1 +0 -5639
  33. package/dist/bin.js.pre-warmfix-20260708-153705 +0 -5613
  34. package/dist/bin.js.pretrace-113205 +0 -5395
  35. package/dist/bridge-agent.js +0 -831
  36. package/dist/config.js +0 -74
  37. package/dist/coordination.js +0 -63
  38. package/dist/errors.js +0 -25
  39. package/dist/http-server.js +0 -220
  40. package/dist/index.js.pre-p0-20260708-102415 +0 -4879
  41. package/dist/latency.js +0 -87
  42. package/dist/rate-limit.js +0 -56
  43. package/dist/service/config.js +0 -26
  44. package/dist/service/install.js +0 -60
  45. package/dist/service/launchd.js +0 -30
  46. package/dist/service/systemd.js +0 -25
  47. package/dist/ws-server.js +0 -254
package/dist/bin.js CHANGED
@@ -23,6 +23,26 @@ function loadConfig(opts) {
23
23
  const agentName = typeof json2.agent_name === "string" && json2.agent_name ? json2.agent_name : typeof json2.display_name === "string" && json2.display_name ? json2.display_name : void 0;
24
24
  const rateLimitCapacity = typeof json2.rate_limit_capacity === "number" ? json2.rate_limit_capacity : 20;
25
25
  const rateLimitRefillPerMinute = typeof json2.rate_limit_refill_per_minute === "number" ? json2.rate_limit_refill_per_minute : 20;
26
+ const memProvRaw = json2.memory?.provider;
27
+ let memoryProvider;
28
+ let memoryBaseDir;
29
+ let memoryAgent;
30
+ if (memProvRaw === "none") {
31
+ memoryProvider = "none";
32
+ } else if (memProvRaw === "hermes") {
33
+ memoryProvider = "hermes";
34
+ const bd = json2.memory?.base_dir;
35
+ memoryBaseDir = typeof bd === "string" && bd ? bd : "~/.hermes";
36
+ } else if (memProvRaw === "openclaw") {
37
+ memoryProvider = "openclaw";
38
+ const oc = json2.memory?.openclaw;
39
+ const bd = oc?.base_dir;
40
+ memoryBaseDir = typeof bd === "string" && bd ? bd : "~/.openclaw";
41
+ const ag = oc?.agent;
42
+ memoryAgent = typeof ag === "string" && ag ? ag : void 0;
43
+ } else {
44
+ memoryProvider = "securecoms";
45
+ }
26
46
  return {
27
47
  apiKey: json2.api_key,
28
48
  identityId: json2.identity_id,
@@ -33,7 +53,10 @@ function loadConfig(opts) {
33
53
  storePath,
34
54
  agentName,
35
55
  rateLimitCapacity,
36
- rateLimitRefillPerMinute
56
+ rateLimitRefillPerMinute,
57
+ memoryProvider,
58
+ memoryBaseDir,
59
+ memoryAgent
37
60
  };
38
61
  }
39
62
  function arg(argv, flag) {
@@ -640,6 +663,152 @@ var payloadsSchema = {
640
663
  }
641
664
  }
642
665
  ]
666
+ },
667
+ "call_control": {
668
+ "description": 'The call_control signaling body for 1:1 voice calls (docs/superpowers/specs/2026-07-12-voice-human-to-human-calling-design.md). NOT a message_type \u2014 it rides envelope message_type "system_event"; the "kind":"call_control" field is a body-level discriminator and "event" selects the variant. Compiled standalone via validate.ts validateCallControlBody. SERVER-BLIND KEY CONTRACT: NO variant ever carries media key material (the media key is derived independently from the MLS exporter, never transmitted). Every variant sets additionalProperties:false so a stray media_key/key/secret is rejected \u2014 the only key-adjacent field is `key_confirmation` on accept, which is HMAC(media_key, call_id), not the key itself.',
669
+ "oneOf": [
670
+ {
671
+ "type": "object",
672
+ "additionalProperties": false,
673
+ "required": [
674
+ "kind",
675
+ "event",
676
+ "call_id",
677
+ "room",
678
+ "caller_identity_id",
679
+ "epoch",
680
+ "ts"
681
+ ],
682
+ "properties": {
683
+ "kind": {
684
+ "const": "call_control"
685
+ },
686
+ "event": {
687
+ "const": "invite"
688
+ },
689
+ "call_id": {
690
+ "type": "string",
691
+ "minLength": 1
692
+ },
693
+ "room": {
694
+ "type": "string",
695
+ "minLength": 1
696
+ },
697
+ "caller_identity_id": {
698
+ "type": "string",
699
+ "format": "uuid"
700
+ },
701
+ "epoch": {
702
+ "type": "integer",
703
+ "minimum": 0
704
+ },
705
+ "ts": {
706
+ "type": "number"
707
+ }
708
+ }
709
+ },
710
+ {
711
+ "type": "object",
712
+ "additionalProperties": false,
713
+ "required": [
714
+ "kind",
715
+ "event",
716
+ "call_id",
717
+ "accepting_device_id",
718
+ "key_confirmation"
719
+ ],
720
+ "properties": {
721
+ "kind": {
722
+ "const": "call_control"
723
+ },
724
+ "event": {
725
+ "const": "accept"
726
+ },
727
+ "call_id": {
728
+ "type": "string",
729
+ "minLength": 1
730
+ },
731
+ "accepting_device_id": {
732
+ "type": "string",
733
+ "minLength": 1
734
+ },
735
+ "key_confirmation": {
736
+ "type": "string",
737
+ "minLength": 1
738
+ }
739
+ }
740
+ },
741
+ {
742
+ "type": "object",
743
+ "additionalProperties": false,
744
+ "required": [
745
+ "kind",
746
+ "event",
747
+ "call_id"
748
+ ],
749
+ "properties": {
750
+ "kind": {
751
+ "const": "call_control"
752
+ },
753
+ "event": {
754
+ "const": "decline"
755
+ },
756
+ "call_id": {
757
+ "type": "string",
758
+ "minLength": 1
759
+ },
760
+ "reason": {
761
+ "type": "string",
762
+ "enum": [
763
+ "busy",
764
+ "declined"
765
+ ]
766
+ }
767
+ }
768
+ },
769
+ {
770
+ "type": "object",
771
+ "additionalProperties": false,
772
+ "required": [
773
+ "kind",
774
+ "event",
775
+ "call_id"
776
+ ],
777
+ "properties": {
778
+ "kind": {
779
+ "const": "call_control"
780
+ },
781
+ "event": {
782
+ "const": "cancel"
783
+ },
784
+ "call_id": {
785
+ "type": "string",
786
+ "minLength": 1
787
+ }
788
+ }
789
+ },
790
+ {
791
+ "type": "object",
792
+ "additionalProperties": false,
793
+ "required": [
794
+ "kind",
795
+ "event",
796
+ "call_id"
797
+ ],
798
+ "properties": {
799
+ "kind": {
800
+ "const": "call_control"
801
+ },
802
+ "event": {
803
+ "const": "end"
804
+ },
805
+ "call_id": {
806
+ "type": "string",
807
+ "minLength": 1
808
+ }
809
+ }
810
+ }
811
+ ]
643
812
  }
644
813
  }
645
814
  };
@@ -664,6 +833,11 @@ if (!agentActionDef) {
664
833
  throw new Error(`payloads.schema.json is missing the "agent_action" $defs schema`);
665
834
  }
666
835
  var agentActionValidator = ajv.compile(agentActionDef);
836
+ var callControlDef = payloadDefs["call_control"];
837
+ if (!callControlDef) {
838
+ throw new Error(`payloads.schema.json is missing the "call_control" $defs schema`);
839
+ }
840
+ var callControlValidator = ajv.compile(callControlDef);
667
841
 
668
842
  // ../connect-core/dist/envelope/sign.js
669
843
  import { canonicalize } from "@agentvault/crypto/canonical";
@@ -725,6 +899,7 @@ import { gcm } from "@noble/ciphers/aes";
725
899
  import { xchacha20poly1305 } from "@noble/ciphers/chacha";
726
900
  import { randomBytes } from "@noble/hashes/utils";
727
901
  import { canonicalize as canonicalize2 } from "@agentvault/crypto/canonical";
902
+ var VOICE_MEDIA_EXPORTER_LABEL = "securecoms-voice-media-v1";
728
903
  var APPLICATION_KEY_BYTES = 32;
729
904
  var REPLAY_NONCE_BYTES = 24;
730
905
  var NONCE_BYTES = {
@@ -746,10 +921,20 @@ function epochToBytes(epoch) {
746
921
  new DataView(b.buffer).setBigUint64(0, BigInt(epoch), false);
747
922
  return b;
748
923
  }
924
+ function u32ToBytes(n) {
925
+ const b = new Uint8Array(4);
926
+ new DataView(b.buffer).setUint32(0, n >>> 0, false);
927
+ return b;
928
+ }
749
929
  function buildExporterContext(enclaveId, channelId, epoch) {
750
930
  const enc = new TextEncoder();
751
931
  return concatBytes(enc.encode(enclaveId), enc.encode(channelId), epochToBytes(epoch));
752
932
  }
933
+ function buildVoiceExporterContext(enclaveId, channelId, epoch, callId) {
934
+ const enc = new TextEncoder();
935
+ const callIdBytes = enc.encode(callId);
936
+ return concatBytes(buildExporterContext(enclaveId, channelId, epoch), u32ToBytes(callIdBytes.length), callIdBytes);
937
+ }
753
938
  function cipherFor(suite, key, nonce, aad) {
754
939
  return suite === "aes256gcm" ? gcm(key, nonce, aad) : xchacha20poly1305(key, nonce, aad);
755
940
  }
@@ -1117,8 +1302,13 @@ var ALWAYS_SAMPLE = /* @__PURE__ */ new Set([
1117
1302
  // ../connect-sdk-ts/dist/spans/emitter.js
1118
1303
  var KNOWN = new Set(Object.values(SPAN));
1119
1304
 
1305
+ // ../connect-sdk-ts/dist/voice-key.js
1306
+ import { hmac } from "@noble/hashes/hmac";
1307
+ import { sha256 as sha2562 } from "@noble/hashes/sha256";
1308
+ import { bytesToHex } from "@agentvault/crypto";
1309
+
1120
1310
  // ../connect-sdk-ts/dist/groupkey/mls.js
1121
- import { MLSGroupManager, bytesToHex, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1311
+ import { MLSGroupManager, bytesToHex as bytesToHex2, hexToBytes, verifyApprovalArtifact } from "@agentvault/crypto";
1122
1312
 
1123
1313
  // ../connect-sdk-ts/dist/groupkey/operation-queue.js
1124
1314
  var MlsOperationQueue = class {
@@ -1498,6 +1688,28 @@ var MlsGroupKeyProvider = class {
1498
1688
  const mgr = await this.manager(channelId);
1499
1689
  return mgr.exportSecret(LABEL, buildExporterContext(enclaveId, channelId, epoch), 32);
1500
1690
  }
1691
+ /**
1692
+ * The 1:1 voice media key: taken STRAIGHT from the MLS exporter under a
1693
+ * DEDICATED label ({@link VOICE_MEDIA_EXPORTER_LABEL}) and a context that
1694
+ * binds `callId` (voice-h2h spec, audit H-P1) — domain-separated from the
1695
+ * message key ({@link exporterKey}) and per-call, with NO HKDF layered on
1696
+ * top. Taken under the per-channel lock (as the receive-side message-key
1697
+ * read is, mls.ts) so the exporter read is epoch-stable against a concurrent
1698
+ * commit. Never transmitted or persisted; both call participants derive it
1699
+ * independently at the invite epoch.
1700
+ *
1701
+ * CONTRACT: unlike {@link exporterKey} (lock-free — its callers wrap it), this
1702
+ * takes the channel lock INTERNALLY. The lock queue is non-reentrant, so this
1703
+ * MUST NOT be called while already holding this channel's lock (doing so
1704
+ * self-deadlocks). Call it stand-alone from the call-setup path, never from
1705
+ * inside another `locks.run(channelId, ...)` for the same channel.
1706
+ */
1707
+ async voiceMediaKey(enclaveId, channelId, epoch, callId) {
1708
+ return this.locks.run(channelId, async () => {
1709
+ const mgr = await this.manager(channelId);
1710
+ return mgr.exportSecret(VOICE_MEDIA_EXPORTER_LABEL, buildVoiceExporterContext(enclaveId, channelId, epoch, callId), 32);
1711
+ });
1712
+ }
1501
1713
  /**
1502
1714
  * Join a channel from the host-committed Welcome, then drain any commits that
1503
1715
  * landed after the Welcome so we converge on the true current epoch. Without
@@ -1626,7 +1838,7 @@ var MlsGroupKeyProvider = class {
1626
1838
  const bundle = await this.o.store.loadPendingBundle(this.o.deviceId);
1627
1839
  if (!bundle)
1628
1840
  return false;
1629
- const heldHex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
1841
+ const heldHex = bytesToHex2(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
1630
1842
  const published = await this.o.backend.fetchKeyPackages([this.o.deviceId]);
1631
1843
  const pubHex = published[this.o.deviceId];
1632
1844
  return !!pubHex && pubHex === heldHex;
@@ -1642,7 +1854,7 @@ var MlsGroupKeyProvider = class {
1642
1854
  console.warn(`publishKeyPackage: minting a fresh KeyPackage (rotation) for device ${this.o.deviceId.slice(0, 8)}`);
1643
1855
  const mgr = new MLSGroupManager();
1644
1856
  const bundle = await mgr.generateKeyPackage(this.o.identity);
1645
- const hex = bytesToHex(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
1857
+ const hex = bytesToHex2(MLSGroupManager.serializeKeyPackage(bundle.publicPackage));
1646
1858
  const id = await this.o.backend.publishKeyPackage(this.o.deviceId, hex);
1647
1859
  await this.o.store.saveLastKpAndBundle(this.o.deviceId, id, bundle);
1648
1860
  return id;
@@ -1682,11 +1894,11 @@ var MlsGroupKeyProvider = class {
1682
1894
  const { commit, welcome } = await mgr.addMembers(pkgs);
1683
1895
  await this.o.backend.distribute(reinit.group_id, {
1684
1896
  device_id: this.o.deviceId,
1685
- commit: bytesToHex(commit),
1897
+ commit: bytesToHex2(commit),
1686
1898
  commit_epoch: Number(mgr.epoch),
1687
1899
  expected_epoch: reinit.current_epoch,
1688
1900
  member_device_ids: [this.o.deviceId, ...others],
1689
- welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
1901
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
1690
1902
  });
1691
1903
  await this.o.store.save(channelId, mgr.exportState());
1692
1904
  this.managers.set(channelId, mgr);
@@ -1709,11 +1921,11 @@ var MlsGroupKeyProvider = class {
1709
1921
  const { commit, welcome } = await mgr.addMembers(pkgs);
1710
1922
  await this.o.backend.distribute(init.group_id, {
1711
1923
  device_id: this.o.deviceId,
1712
- commit: bytesToHex(commit),
1924
+ commit: bytesToHex2(commit),
1713
1925
  commit_epoch: Number(mgr.epoch),
1714
1926
  expected_epoch: init.current_epoch,
1715
1927
  member_device_ids: [this.o.deviceId, ...others],
1716
- welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
1928
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
1717
1929
  });
1718
1930
  this.managers.set(channelId, mgr);
1719
1931
  await this.o.store.save(channelId, mgr.exportState());
@@ -1773,12 +1985,12 @@ var MlsGroupKeyProvider = class {
1773
1985
  try {
1774
1986
  await this.o.backend.distribute(init.group_id, {
1775
1987
  device_id: this.o.deviceId,
1776
- commit: bytesToHex(commit),
1988
+ commit: bytesToHex2(commit),
1777
1989
  commit_epoch: Number(mgr.epoch),
1778
1990
  expected_epoch: priorEpoch,
1779
1991
  member_device_ids: members,
1780
1992
  op: "add",
1781
- welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex(welcome) }] : []
1993
+ welcomes: welcome ? [{ target_device_id: newDeviceId, payload: bytesToHex2(welcome) }] : []
1782
1994
  });
1783
1995
  } catch (e) {
1784
1996
  this.managers.delete(channelId);
@@ -1836,7 +2048,7 @@ var MlsGroupKeyProvider = class {
1836
2048
  try {
1837
2049
  await this.o.backend.distribute(init.group_id, {
1838
2050
  device_id: this.o.deviceId,
1839
- commit: bytesToHex(commit),
2051
+ commit: bytesToHex2(commit),
1840
2052
  commit_epoch: Number(mgr.epoch),
1841
2053
  expected_epoch: priorEpoch,
1842
2054
  member_device_ids: members,
@@ -1904,11 +2116,11 @@ var MlsGroupKeyProvider = class {
1904
2116
  }
1905
2117
  await this.o.store.saveStaging(channelId, mgr.exportState());
1906
2118
  const members = init.member_device_ids ?? [this.o.deviceId];
1907
- const removedIdentity = typeof opts.identity === "string" ? opts.identity : bytesToHex(opts.identity);
2119
+ const removedIdentity = typeof opts.identity === "string" ? opts.identity : bytesToHex2(opts.identity);
1908
2120
  try {
1909
2121
  await this.o.backend.distribute(init.group_id, {
1910
2122
  device_id: this.o.deviceId,
1911
- commit: bytesToHex(commit),
2123
+ commit: bytesToHex2(commit),
1912
2124
  commit_epoch: Number(mgr.epoch),
1913
2125
  expected_epoch: priorEpoch,
1914
2126
  member_device_ids: members,
@@ -2139,11 +2351,11 @@ var MlsGroupKeyProvider = class {
2139
2351
  const { commit, welcome } = await mgr.addMembers(pkgs);
2140
2352
  await this.o.backend.distribute(reinit.group_id, {
2141
2353
  device_id: this.o.deviceId,
2142
- commit: bytesToHex(commit),
2354
+ commit: bytesToHex2(commit),
2143
2355
  commit_epoch: Number(mgr.epoch),
2144
2356
  expected_epoch: reinit.current_epoch,
2145
2357
  member_device_ids: [this.o.deviceId, ...others],
2146
- welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex(welcome) })) : []
2358
+ welcomes: welcome ? others.map((d) => ({ target_device_id: d, payload: bytesToHex2(welcome) })) : []
2147
2359
  });
2148
2360
  await this.o.store.save(channelId, mgr.exportState());
2149
2361
  this.managers.set(channelId, mgr);
@@ -2513,6 +2725,15 @@ function parseServerFrame(frame) {
2513
2725
  }
2514
2726
 
2515
2727
  // ../connect-sdk-ts/dist/transport/ws-pings.js
2728
+ function base64UrlNoPad(s) {
2729
+ const bytes = new TextEncoder().encode(s);
2730
+ let bin = "";
2731
+ for (const b of bytes)
2732
+ bin += String.fromCharCode(b);
2733
+ const b64 = typeof btoa === "function" ? btoa(bin) : Buffer.from(bytes).toString("base64");
2734
+ return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
2735
+ }
2736
+ var BEARER_SUBPROTOCOL = "securecoms.bearer.v1";
2516
2737
  function openDeliveryPings(o) {
2517
2738
  const base = o.backoffBaseMs ?? 500;
2518
2739
  const cap = o.backoffCapMs ?? 8e3;
@@ -2661,8 +2882,7 @@ function openDeliveryPings(o) {
2661
2882
  }
2662
2883
  if (closed || gen !== connectGen)
2663
2884
  return;
2664
- const sep = o.url.includes("?") ? "&" : "?";
2665
- const ws = new o.wsImpl(`${o.url}${sep}token=${encodeURIComponent(token)}`);
2885
+ const ws = new o.wsImpl(o.url, [BEARER_SUBPROTOCOL, base64UrlNoPad(token)]);
2666
2886
  sock = ws;
2667
2887
  ws.onopen = () => {
2668
2888
  push({ reconnected: true });
@@ -2762,8 +2982,8 @@ function openDeliveryPings(o) {
2762
2982
  return Promise.resolve({ value: void 0, done: true });
2763
2983
  if (queue.length)
2764
2984
  return Promise.resolve({ value: queue.shift(), done: false });
2765
- return new Promise((resolve2) => {
2766
- waiter = resolve2;
2985
+ return new Promise((resolve3) => {
2986
+ waiter = resolve3;
2767
2987
  });
2768
2988
  }
2769
2989
  };
@@ -3095,11 +3315,11 @@ var RestMlsBackend = class {
3095
3315
  };
3096
3316
 
3097
3317
  // ../connect-sdk-ts/dist/mls/fake-backend.js
3098
- import { bytesToHex as bytesToHex2, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
3318
+ import { bytesToHex as bytesToHex3, hexToBytes as hexToBytes2 } from "@agentvault/crypto";
3099
3319
 
3100
3320
  // ../connect-sdk-ts/dist/receive/inbox-cipher.js
3101
3321
  import { hkdf } from "@noble/hashes/hkdf";
3102
- import { sha256 as sha2562 } from "@noble/hashes/sha256";
3322
+ import { sha256 as sha2563 } from "@noble/hashes/sha256";
3103
3323
  var SUITE = "xchacha20poly1305";
3104
3324
  var INFO = new TextEncoder().encode("agentvault-connect-inbox-v1");
3105
3325
  var EMPTY_AAD = new Uint8Array(0);
@@ -3127,12 +3347,12 @@ var KeyedInboxCipher = class {
3127
3347
  };
3128
3348
  var MLS_STORE_INFO = new TextEncoder().encode("agentvault-connect-mls-store-v1");
3129
3349
  function deriveMlsStoreKey(identityKeyMaterial) {
3130
- return hkdf(sha2562, identityKeyMaterial, void 0, MLS_STORE_INFO, 32);
3350
+ return hkdf(sha2563, identityKeyMaterial, void 0, MLS_STORE_INFO, 32);
3131
3351
  }
3132
3352
 
3133
3353
  // ../connect-sdk-ts/dist/recovery/backup-crypto.js
3134
3354
  import { hkdf as hkdf2 } from "@noble/hashes/hkdf";
3135
- import { sha256 as sha2563 } from "@noble/hashes/sha256";
3355
+ import { sha256 as sha2564 } from "@noble/hashes/sha256";
3136
3356
  import { randomBytes as randomBytes2 } from "@noble/hashes/utils";
3137
3357
  var BACKUP_INFO = new TextEncoder().encode("connect-backup-v1");
3138
3358
  var RECOVERY_INFO = new TextEncoder().encode("connect-recovery-v1");
@@ -3140,15 +3360,15 @@ var RECOVERY_INFO = new TextEncoder().encode("connect-recovery-v1");
3140
3360
  // ../connect-sdk-ts/dist/recovery/guest-recovery-crypto.js
3141
3361
  import { argon2idAsync } from "@noble/hashes/argon2";
3142
3362
  import { hkdf as hkdf3 } from "@noble/hashes/hkdf";
3143
- import { sha256 as sha2564 } from "@noble/hashes/sha256";
3363
+ import { sha256 as sha2565 } from "@noble/hashes/sha256";
3144
3364
  var HKDF_INFO = new TextEncoder().encode("connect-guest-recovery-wrap-v1");
3145
3365
 
3146
3366
  // ../connect-mls-store/dist/sqlite-store.js
3147
3367
  import { DatabaseSync } from "node:sqlite";
3148
- import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex3, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
3368
+ import { MLSGroupManager as MLSGroupManager2, bytesToHex as bytesToHex4, hexToBytes as hexToBytes3 } from "@agentvault/crypto";
3149
3369
 
3150
3370
  // ../connect-mls-store/dist/schema.js
3151
- var SCHEMA_VERSION = 4;
3371
+ var SCHEMA_VERSION = 6;
3152
3372
  function openSchema(db) {
3153
3373
  db.exec("PRAGMA journal_mode=WAL");
3154
3374
  const row = db.prepare("PRAGMA user_version").get();
@@ -3195,6 +3415,51 @@ function openSchema(db) {
3195
3415
  );
3196
3416
  `);
3197
3417
  }
3418
+ if (row.user_version < 5) {
3419
+ db.exec(`
3420
+ CREATE TABLE IF NOT EXISTS channel_meta (
3421
+ identity_id TEXT NOT NULL,
3422
+ channel_id TEXT NOT NULL,
3423
+ enclave_id TEXT,
3424
+ is_dm INTEGER,
3425
+ updated_at INTEGER NOT NULL,
3426
+ PRIMARY KEY (identity_id, channel_id)
3427
+ );
3428
+ `);
3429
+ }
3430
+ if (row.user_version < 6) {
3431
+ db.exec(`
3432
+ CREATE TABLE IF NOT EXISTS agent_memory (
3433
+ identity_id TEXT NOT NULL,
3434
+ conversation_id TEXT NOT NULL,
3435
+ seq INTEGER NOT NULL,
3436
+ role TEXT NOT NULL,
3437
+ peer_id TEXT,
3438
+ sealed_text BLOB NOT NULL,
3439
+ modality TEXT NOT NULL,
3440
+ message_id TEXT,
3441
+ idempotency_key TEXT,
3442
+ dedup_key TEXT,
3443
+ recorded_at INTEGER NOT NULL,
3444
+ PRIMARY KEY (identity_id, conversation_id, seq)
3445
+ );
3446
+ `);
3447
+ db.exec(`
3448
+ CREATE UNIQUE INDEX IF NOT EXISTS agent_memory_dedup
3449
+ ON agent_memory (identity_id, conversation_id, dedup_key)
3450
+ WHERE dedup_key IS NOT NULL;
3451
+ `);
3452
+ db.exec(`
3453
+ CREATE TABLE IF NOT EXISTS agent_memory_fact (
3454
+ identity_id TEXT NOT NULL,
3455
+ conversation_id TEXT NOT NULL,
3456
+ fact_id INTEGER NOT NULL,
3457
+ sealed_fact BLOB NOT NULL,
3458
+ added_at INTEGER NOT NULL,
3459
+ PRIMARY KEY (identity_id, conversation_id, fact_id)
3460
+ );
3461
+ `);
3462
+ }
3198
3463
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
3199
3464
  }
3200
3465
 
@@ -3216,10 +3481,10 @@ var MlsStoreSealError = class extends Error {
3216
3481
  function encodeBundle(bundle) {
3217
3482
  const pp = bundle.privatePackage;
3218
3483
  const obj = {
3219
- pub: bytesToHex3(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage)),
3220
- initPriv: bytesToHex3(pp.initPrivateKey),
3221
- hpkePriv: bytesToHex3(pp.hpkePrivateKey),
3222
- sigPriv: bytesToHex3(pp.signaturePrivateKey)
3484
+ pub: bytesToHex4(MLSGroupManager2.serializeKeyPackage(bundle.publicPackage)),
3485
+ initPriv: bytesToHex4(pp.initPrivateKey),
3486
+ hpkePriv: bytesToHex4(pp.hpkePrivateKey),
3487
+ sigPriv: bytesToHex4(pp.signaturePrivateKey)
3223
3488
  };
3224
3489
  return new TextEncoder().encode(JSON.stringify(obj));
3225
3490
  }
@@ -3246,9 +3511,11 @@ var SqliteMlsStateStore = class {
3246
3511
  // the source of truth across process restarts.
3247
3512
  bundles = /* @__PURE__ */ new Map();
3248
3513
  lastKpIds = /* @__PURE__ */ new Map();
3514
+ memoryRetentionK;
3249
3515
  constructor(opts) {
3250
3516
  this.id = opts.identityId;
3251
3517
  this.cipher = opts.cipherFor(opts.identityId);
3518
+ this.memoryRetentionK = opts.memoryRetentionK ?? 500;
3252
3519
  try {
3253
3520
  this.db = new DatabaseSync(opts.dbPath);
3254
3521
  } catch (e) {
@@ -3383,6 +3650,74 @@ var SqliteMlsStateStore = class {
3383
3650
  const rows = this.db.prepare("SELECT group_id FROM mls_retired_group WHERE channel_id=?").all(channelId);
3384
3651
  return rows.map((r) => r.group_id);
3385
3652
  }
3653
+ recordChannelEnclave(channelId, enclaveId) {
3654
+ this.db.prepare(`INSERT INTO channel_meta (identity_id, channel_id, enclave_id, updated_at)
3655
+ VALUES (?,?,?,?)
3656
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET enclave_id=excluded.enclave_id, updated_at=excluded.updated_at`).run(this.id, channelId, enclaveId, Date.now());
3657
+ }
3658
+ recordChannelIsDm(channelId, isDm) {
3659
+ this.db.prepare(`INSERT INTO channel_meta (identity_id, channel_id, is_dm, updated_at)
3660
+ VALUES (?,?,?,?)
3661
+ ON CONFLICT(identity_id, channel_id) DO UPDATE SET is_dm=excluded.is_dm, updated_at=excluded.updated_at`).run(this.id, channelId, isDm ? 1 : 0, Date.now());
3662
+ }
3663
+ getChannelMeta(channelId) {
3664
+ const row = this.db.prepare("SELECT enclave_id, is_dm FROM channel_meta WHERE identity_id=? AND channel_id=?").get(this.id, channelId);
3665
+ if (!row)
3666
+ return void 0;
3667
+ return {
3668
+ enclaveId: row.enclave_id ?? void 0,
3669
+ isDm: row.is_dm == null ? void 0 : row.is_dm === 1
3670
+ };
3671
+ }
3672
+ /**
3673
+ * The set of enclave ids this identity has learned about (from inbound
3674
+ * envelopes or a prior listChannels discovery), beyond its own home
3675
+ * enclave. Task 3: refreshChannelMeta unions this with cfg.enclaveId so a
3676
+ * cross-enclave room's is_dm gets resolved instead of staying UNKNOWN.
3677
+ */
3678
+ distinctEnclaveIds() {
3679
+ const rows = this.db.prepare("SELECT DISTINCT enclave_id FROM channel_meta WHERE identity_id=? AND enclave_id IS NOT NULL").all(this.id);
3680
+ return rows.map((r) => r.enclave_id);
3681
+ }
3682
+ async recordMemory(input) {
3683
+ const dedupKey = input.messageId ?? input.idempotencyKey ?? null;
3684
+ if (!dedupKey)
3685
+ throw new Error("recordMemory requires message_id or idempotency_key");
3686
+ const sealed = await this.cipher.seal(new TextEncoder().encode(input.text));
3687
+ const info = this.db.prepare(`INSERT OR IGNORE INTO agent_memory
3688
+ (identity_id, conversation_id, seq, role, peer_id, sealed_text, modality, message_id, idempotency_key, dedup_key, recorded_at)
3689
+ VALUES (?, ?,
3690
+ (SELECT COALESCE(MAX(seq),0)+1 FROM agent_memory WHERE identity_id=? AND conversation_id=?),
3691
+ ?, ?, ?, ?, ?, ?, ?, ?)`).run(this.id, input.conversationId, this.id, input.conversationId, input.role, input.peerId ?? null, sealed, input.modality, input.messageId ?? null, input.idempotencyKey ?? null, dedupKey, Date.now());
3692
+ if (info.changes === 0) {
3693
+ const row2 = this.db.prepare("SELECT seq FROM agent_memory WHERE identity_id=? AND conversation_id=? AND dedup_key=?").get(this.id, input.conversationId, dedupKey);
3694
+ return { seq: row2.seq, deduped: true };
3695
+ }
3696
+ const row = this.db.prepare("SELECT MAX(seq) AS seq FROM agent_memory WHERE identity_id=? AND conversation_id=?").get(this.id, input.conversationId);
3697
+ if (this.memoryRetentionK > 0) {
3698
+ this.db.prepare("DELETE FROM agent_memory WHERE identity_id=? AND conversation_id=? AND seq <= ?").run(this.id, input.conversationId, row.seq - this.memoryRetentionK);
3699
+ }
3700
+ return { seq: row.seq, deduped: false };
3701
+ }
3702
+ async memoryContext(conversationId, limit = 50) {
3703
+ const rows = this.db.prepare(`SELECT seq, role, peer_id, sealed_text, recorded_at FROM agent_memory
3704
+ WHERE identity_id=? AND conversation_id=? ORDER BY seq DESC LIMIT ?`).all(this.id, conversationId, limit);
3705
+ const recent = [];
3706
+ for (const r of rows.reverse()) {
3707
+ const text = new TextDecoder().decode(await this.cipher.open(r.sealed_text));
3708
+ recent.push({ seq: r.seq, role: r.role, peerId: r.peer_id ?? void 0, text, ts: r.recorded_at });
3709
+ }
3710
+ const factRows = this.db.prepare("SELECT sealed_fact FROM agent_memory_fact WHERE identity_id=? AND conversation_id=? ORDER BY fact_id ASC").all(this.id, conversationId);
3711
+ const facts = [];
3712
+ for (const f of factRows)
3713
+ facts.push(new TextDecoder().decode(await this.cipher.open(f.sealed_fact)));
3714
+ return { recent, facts };
3715
+ }
3716
+ async recordMemoryFact(conversationId, fact) {
3717
+ const sealed = await this.cipher.seal(new TextEncoder().encode(fact));
3718
+ this.db.prepare(`INSERT INTO agent_memory_fact (identity_id, conversation_id, fact_id, sealed_fact, added_at)
3719
+ VALUES (?, ?, (SELECT COALESCE(MAX(fact_id),0)+1 FROM agent_memory_fact WHERE identity_id=? AND conversation_id=?), ?, ?)`).run(this.id, conversationId, this.id, conversationId, sealed, Date.now());
3720
+ }
3386
3721
  /** @internal test-only */
3387
3722
  _rawMainForTest(identityId, channelId) {
3388
3723
  const row = this.db.prepare("SELECT sealed_main FROM mls_group_state WHERE identity_id=? AND channel_id=?").get(identityId, channelId);
@@ -3447,7 +3782,7 @@ function loadPackageMeta(importMetaUrl) {
3447
3782
 
3448
3783
  // src/bridge-agent.ts
3449
3784
  import { promises as fsp } from "node:fs";
3450
- import { join as join3, dirname as dirname3 } from "node:path";
3785
+ import { join as join7, dirname as dirname3 } from "node:path";
3451
3786
 
3452
3787
  // src/latency.ts
3453
3788
  var DEFAULT_CAPACITY = 1024;
@@ -3721,8 +4056,8 @@ async function prepareAttachment(filePath, caption, channelId, deps) {
3721
4056
  }
3722
4057
  async function materializeAttachment(body, ctx, deps) {
3723
4058
  const ciphertext = await deps.downloadBlob(body.blobId);
3724
- const sha2565 = deps.computeFileDigest(ciphertext);
3725
- if (sha2565 !== body.digest) {
4059
+ const sha2566 = deps.computeFileDigest(ciphertext);
4060
+ if (sha2566 !== body.digest) {
3726
4061
  throw new Error(`attachment digest mismatch for blob ${body.blobId}`);
3727
4062
  }
3728
4063
  const plain = deps.decryptFile(
@@ -3735,7 +4070,328 @@ async function materializeAttachment(body, ctx, deps) {
3735
4070
  const path = `${dir}/${ctx.messageId}-${safeName}`;
3736
4071
  await deps.mkdirp(dir);
3737
4072
  await deps.writeFile(path, plain);
3738
- return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2565 };
4073
+ return { filename: safeName, mime: body.mime, size: plain.length, path, sha256: sha2566 };
4074
+ }
4075
+
4076
+ // src/memory/hermes-session-map.ts
4077
+ import { readFileSync as readFileSync2 } from "node:fs";
4078
+ import { homedir } from "node:os";
4079
+ import { join as join3 } from "node:path";
4080
+ function expandHome(p) {
4081
+ return p === "~" || p.startsWith("~/") ? join3(homedir(), p.slice(1)) : p;
4082
+ }
4083
+ function resolveHermesSession(baseDir, conversationId) {
4084
+ try {
4085
+ const path = join3(expandHome(baseDir), "sessions", "sessions.json");
4086
+ const map = JSON.parse(readFileSync2(path, "utf8"));
4087
+ const entry = map[`agent:main:securecoms:dm:${conversationId}`];
4088
+ const sid = entry?.session_id;
4089
+ return typeof sid === "string" && sid ? sid : null;
4090
+ } catch {
4091
+ return null;
4092
+ }
4093
+ }
4094
+
4095
+ // src/memory/hermes-store.ts
4096
+ import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
4097
+ import { join as join4 } from "node:path";
4098
+ var HermesStore = class {
4099
+ dbPath;
4100
+ constructor(baseDir) {
4101
+ this.dbPath = join4(expandHome(baseDir), "state.db");
4102
+ }
4103
+ /** Recent-K conversational turns for a session, chronological. Fail-safe → []. */
4104
+ readRecent(sessionId, limit) {
4105
+ let db;
4106
+ try {
4107
+ db = new DatabaseSync2(this.dbPath, { readOnly: true });
4108
+ const rows = db.prepare(
4109
+ `SELECT id, role, content, timestamp FROM messages
4110
+ WHERE session_id = ? AND active = 1 AND content IS NOT NULL AND role IN ('user','assistant')
4111
+ ORDER BY timestamp DESC LIMIT ?`
4112
+ ).all(sessionId, limit);
4113
+ return rows.reverse().map((r) => ({ id: r.id, role: r.role, content: r.content, timestamp: r.timestamp }));
4114
+ } catch {
4115
+ return [];
4116
+ } finally {
4117
+ db?.close();
4118
+ }
4119
+ }
4120
+ open(busyMs = 5e3) {
4121
+ const db = new DatabaseSync2(this.dbPath);
4122
+ db.exec(`PRAGMA busy_timeout = ${busyMs}`);
4123
+ return db;
4124
+ }
4125
+ /**
4126
+ * Append a voice turn to a session in one synchronous BEGIN IMMEDIATE block
4127
+ * (no await between statements → atomic against Hermes and our own retries).
4128
+ * Guards: skip when the session is absent/archived/ended; in-transaction dedup
4129
+ * on (session_id, platform_message_id). Writes only `messages`. Fail-safe.
4130
+ */
4131
+ writeVoiceTurn(input) {
4132
+ let db;
4133
+ try {
4134
+ db = this.open();
4135
+ db.exec("BEGIN IMMEDIATE");
4136
+ try {
4137
+ const s = db.prepare("SELECT archived, ended_at FROM sessions WHERE id = ?").get(input.sessionId);
4138
+ if (!s || s.archived === 1 || s.ended_at != null) {
4139
+ db.exec("ROLLBACK");
4140
+ return "skipped";
4141
+ }
4142
+ if (input.dedupKey) {
4143
+ const dup = db.prepare("SELECT 1 FROM messages WHERE session_id = ? AND platform_message_id = ?").get(input.sessionId, input.dedupKey);
4144
+ if (dup) {
4145
+ db.exec("ROLLBACK");
4146
+ return "deduped";
4147
+ }
4148
+ }
4149
+ db.prepare(
4150
+ `INSERT INTO messages (session_id, role, content, timestamp, active, platform_message_id) VALUES (?, ?, ?, ?, 1, ?)`
4151
+ ).run(input.sessionId, input.role, input.content, input.timestamp, input.dedupKey);
4152
+ db.exec("COMMIT");
4153
+ return "written";
4154
+ } catch (e) {
4155
+ try {
4156
+ db.exec("ROLLBACK");
4157
+ } catch {
4158
+ }
4159
+ throw e;
4160
+ }
4161
+ } catch {
4162
+ return "failed";
4163
+ } finally {
4164
+ db?.close();
4165
+ }
4166
+ }
4167
+ /**
4168
+ * Startup write-capability probe: insert a sentinel against any existing
4169
+ * session inside a rolled-back transaction. Throws if the write path is broken
4170
+ * (e.g. a schema regression). No-op when the db has no sessions.
4171
+ */
4172
+ probeWrite() {
4173
+ let db;
4174
+ try {
4175
+ db = this.open(500);
4176
+ const s = db.prepare("SELECT id FROM sessions LIMIT 1").get();
4177
+ if (!s) return;
4178
+ db.exec("BEGIN IMMEDIATE");
4179
+ try {
4180
+ db.prepare(
4181
+ `INSERT INTO messages (session_id, role, content, timestamp, active, platform_message_id) VALUES (?, 'assistant', '[voice] __probe__', ?, 1, ?)`
4182
+ ).run(s.id, Date.now() / 1e3, `__probe__${Date.now()}`);
4183
+ } finally {
4184
+ db.exec("ROLLBACK");
4185
+ }
4186
+ } finally {
4187
+ db?.close();
4188
+ }
4189
+ }
4190
+ };
4191
+
4192
+ // src/memory/hermes-provider.ts
4193
+ var DEFAULT_K = 30;
4194
+ var HermesMemoryProvider = class {
4195
+ constructor(baseDir) {
4196
+ this.baseDir = baseDir;
4197
+ this.store = new HermesStore(baseDir);
4198
+ try {
4199
+ this.store.probeWrite();
4200
+ } catch (e) {
4201
+ process.stderr.write(`[bridge] HERMES MEMORY WRITE-BACK NON-FUNCTIONAL (schema probe failed): ${String(e)}
4202
+ `);
4203
+ }
4204
+ }
4205
+ store;
4206
+ noSession = 0;
4207
+ skipped = 0;
4208
+ writeFailed = 0;
4209
+ async record(input) {
4210
+ if (input.modality !== "voice") return { seq: 0, deduped: true };
4211
+ const sessionId = resolveHermesSession(this.baseDir, input.conversationId);
4212
+ if (!sessionId) {
4213
+ this.noSession++;
4214
+ return { seq: 0, deduped: false };
4215
+ }
4216
+ const role = input.role === "peer" ? "user" : "assistant";
4217
+ const res = this.store.writeVoiceTurn({
4218
+ sessionId,
4219
+ role,
4220
+ content: `[voice] ${input.text}`,
4221
+ dedupKey: input.idempotencyKey ?? input.messageId ?? null,
4222
+ timestamp: Date.now() / 1e3
4223
+ });
4224
+ if (res === "failed") this.writeFailed++;
4225
+ else if (res === "skipped") this.skipped++;
4226
+ return { seq: 0, deduped: res === "deduped" };
4227
+ }
4228
+ async context(conversationId, limit) {
4229
+ const sessionId = resolveHermesSession(this.baseDir, conversationId);
4230
+ if (!sessionId) return { recent: [], facts: [] };
4231
+ const turns = this.store.readRecent(sessionId, limit ?? DEFAULT_K);
4232
+ return {
4233
+ recent: turns.map((t) => ({ seq: t.id, role: t.role === "user" ? "peer" : "agent", text: t.content })),
4234
+ facts: []
4235
+ };
4236
+ }
4237
+ stats() {
4238
+ return { hermes_no_session: this.noSession, hermes_skipped: this.skipped, hermes_write_failed: this.writeFailed };
4239
+ }
4240
+ };
4241
+
4242
+ // src/memory/openclaw-session-map.ts
4243
+ import { readFileSync as readFileSync3 } from "node:fs";
4244
+ import { join as join5 } from "node:path";
4245
+ function resolveOwnerDmSession(baseDir, agent) {
4246
+ try {
4247
+ const path = join5(expandHome(baseDir), "agents", agent, "sessions", "sessions.json");
4248
+ const map = JSON.parse(readFileSync3(path, "utf8"));
4249
+ let best = null;
4250
+ for (const [key, entry] of Object.entries(map)) {
4251
+ if (!key.includes(":direct:") || !key.endsWith(":owner")) continue;
4252
+ const sid = entry?.sessionId;
4253
+ if (typeof sid !== "string" || !sid) continue;
4254
+ const updatedAt = typeof entry?.updatedAt === "number" ? entry.updatedAt : 0;
4255
+ if (!best || updatedAt > best.updatedAt) best = { sid, updatedAt };
4256
+ }
4257
+ return best?.sid ?? null;
4258
+ } catch {
4259
+ return null;
4260
+ }
4261
+ }
4262
+
4263
+ // src/memory/openclaw-session-log.ts
4264
+ import { readFileSync as readFileSync4 } from "node:fs";
4265
+ import { join as join6 } from "node:path";
4266
+ function readRecentTurns(baseDir, agent, sessionId, k) {
4267
+ let raw;
4268
+ try {
4269
+ raw = readFileSync4(join6(expandHome(baseDir), "agents", agent, "sessions", `${sessionId}.jsonl`), "utf8");
4270
+ } catch {
4271
+ return [];
4272
+ }
4273
+ const turns = [];
4274
+ for (const line of raw.split("\n")) {
4275
+ if (!line.trim()) continue;
4276
+ let o;
4277
+ try {
4278
+ o = JSON.parse(line);
4279
+ } catch {
4280
+ continue;
4281
+ }
4282
+ if (o?.type !== "message" || !o.message) continue;
4283
+ const ts = typeof o.timestamp === "string" ? Date.parse(o.timestamp) : NaN;
4284
+ if (Number.isNaN(ts)) continue;
4285
+ const role = o.message.role;
4286
+ if (role === "user") {
4287
+ const text = typeof o.message.content === "string" ? o.message.content.trim() : "";
4288
+ if (text) turns.push({ role: "peer", text, ts });
4289
+ } else if (role === "assistant") {
4290
+ const blocks = Array.isArray(o.message.content) ? o.message.content : [];
4291
+ const text = blocks.filter((b) => !!b && b.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n").trim();
4292
+ if (text) turns.push({ role: "agent", text, ts });
4293
+ }
4294
+ }
4295
+ return k > 0 ? turns.slice(-k) : [];
4296
+ }
4297
+
4298
+ // src/memory/openclaw-provider.ts
4299
+ var DEFAULT_K2 = 200;
4300
+ var OpenClawMemoryProvider = class {
4301
+ constructor(store, baseDir, agent) {
4302
+ this.store = store;
4303
+ this.baseDir = baseDir;
4304
+ this.agent = agent;
4305
+ }
4306
+ noSession = 0;
4307
+ readFailed = 0;
4308
+ async record(input) {
4309
+ if (input.modality !== "voice") return { seq: 0, deduped: true };
4310
+ return this.store.recordMemory({ ...input, text: `[voice] ${input.text}` });
4311
+ }
4312
+ async context(conversationId, limit) {
4313
+ const k = limit ?? DEFAULT_K2;
4314
+ const voice = await this.store.memoryContext(conversationId, k);
4315
+ let textTurns = [];
4316
+ try {
4317
+ const sid = resolveOwnerDmSession(this.baseDir, this.agent);
4318
+ if (!sid) {
4319
+ this.noSession++;
4320
+ return voice;
4321
+ }
4322
+ textTurns = readRecentTurns(this.baseDir, this.agent, sid, k).map((t) => ({
4323
+ seq: 0,
4324
+ role: t.role,
4325
+ text: t.text,
4326
+ ts: t.ts
4327
+ }));
4328
+ } catch {
4329
+ this.readFailed++;
4330
+ return voice;
4331
+ }
4332
+ const tagged = [
4333
+ ...textTurns.map((t, i) => ({ ...t, _src: 0, _i: i })),
4334
+ ...voice.recent.map((t, i) => ({ ...t, _src: 1, _i: i }))
4335
+ ];
4336
+ tagged.sort((a, b) => {
4337
+ const at = a.ts ?? 0, bt = b.ts ?? 0;
4338
+ if (at !== bt) return at - bt;
4339
+ if (a._src !== b._src) return a._src - b._src;
4340
+ return a._i - b._i;
4341
+ });
4342
+ const recent = (k > 0 ? tagged.slice(-k) : []).map((t, i) => ({
4343
+ seq: i + 1,
4344
+ role: t.role,
4345
+ peerId: t.peerId,
4346
+ text: t.text,
4347
+ ts: t.ts
4348
+ }));
4349
+ return { recent, facts: voice.facts };
4350
+ }
4351
+ stats() {
4352
+ return { openclaw_no_session: this.noSession, openclaw_read_failed: this.readFailed };
4353
+ }
4354
+ };
4355
+
4356
+ // src/memory/provider.ts
4357
+ var SecureComsLocalMemory = class {
4358
+ constructor(store) {
4359
+ this.store = store;
4360
+ }
4361
+ record(input) {
4362
+ return this.store.recordMemory(input);
4363
+ }
4364
+ context(conversationId, limit) {
4365
+ return this.store.memoryContext(conversationId, limit);
4366
+ }
4367
+ };
4368
+ var NullMemory = class {
4369
+ async record() {
4370
+ return { seq: 0, deduped: false };
4371
+ }
4372
+ async context() {
4373
+ return { recent: [], facts: [] };
4374
+ }
4375
+ };
4376
+ function createMemoryProvider(provider, store, baseDir, agent) {
4377
+ if (provider === "none") return new NullMemory();
4378
+ if (provider === "hermes") return new HermesMemoryProvider(baseDir ?? "~/.hermes");
4379
+ if (provider === "openclaw") return new OpenClawMemoryProvider(store, baseDir ?? "~/.openclaw", agent ?? "");
4380
+ return new SecureComsLocalMemory(store);
4381
+ }
4382
+
4383
+ // src/memory-taps.ts
4384
+ async function recordInboundMemory(sink, channelId, senderId, text, messageId) {
4385
+ try {
4386
+ await sink.record({ conversationId: channelId, role: "peer", peerId: senderId, text, modality: "text", messageId });
4387
+ } catch {
4388
+ }
4389
+ }
4390
+ async function recordOutboundMemory(sink, channelId, text, messageId) {
4391
+ try {
4392
+ await sink.record({ conversationId: channelId, role: "agent", text, modality: "text", messageId });
4393
+ } catch {
4394
+ }
3739
4395
  }
3740
4396
 
3741
4397
  // src/bridge-agent.ts
@@ -3812,6 +4468,7 @@ var BridgeAgent = class _BridgeAgent {
3812
4468
  identityId: cfg.identityId,
3813
4469
  cipherFor: () => new KeyedInboxCipher(deriveMlsStoreKey(seed))
3814
4470
  });
4471
+ this.memory = createMemoryProvider(this.cfg.memoryProvider, this.store, this.cfg.memoryBaseDir, this.cfg.memoryAgent);
3815
4472
  this.provider = new MlsGroupKeyProvider({
3816
4473
  store: this.store,
3817
4474
  backend: this.backend,
@@ -3891,7 +4548,7 @@ var BridgeAgent = class _BridgeAgent {
3891
4548
  const j = await res.json();
3892
4549
  return String(j.attachment_id);
3893
4550
  };
3894
- this.attachmentsDirImpl = deps.attachmentsDir ?? join3(dirname3(cfg.storePath), "attachments");
4551
+ this.attachmentsDirImpl = deps.attachmentsDir ?? join7(dirname3(cfg.storePath), "attachments");
3895
4552
  this.action = {
3896
4553
  begin: (channelId) => this.actionBegin(channelId),
3897
4554
  start: (opts) => this.actionStart(opts),
@@ -4063,6 +4720,13 @@ var BridgeAgent = class _BridgeAgent {
4063
4720
  listeners = /* @__PURE__ */ new Set();
4064
4721
  startedAt = 0;
4065
4722
  store;
4723
+ memory;
4724
+ /** Task 2: warn-once-per-channel guard for the enclave-map fallback (below). */
4725
+ enclaveFallbackWarned = /* @__PURE__ */ new Set();
4726
+ /** Task 2: dedupe guard so ingestOnce writes a channel's enclave at most once
4727
+ * per process — a channel's enclave is immutable, so no need to UPSERT on
4728
+ * every message on the hot receive path (audit M2). */
4729
+ channelEnclaveRecorded = /* @__PURE__ */ new Set();
4066
4730
  latency;
4067
4731
  /** Track 5: injectable blob download fn (default: fetch-based). */
4068
4732
  downloadBlobImpl;
@@ -4102,6 +4766,26 @@ var BridgeAgent = class _BridgeAgent {
4102
4766
  sweepJitter() {
4103
4767
  return Math.floor(this.rng() * _BridgeAgent.SELF_HEAL_SWEEP_JITTER_MS);
4104
4768
  }
4769
+ /**
4770
+ * Task 2 (multi-enclave core fix): resolve the enclave to stamp on outbound
4771
+ * traffic for `channelId`. The daemon previously stamped `cfg.enclaveId`
4772
+ * (its "home" enclave) on EVERY channel, so an agent admitted to a second
4773
+ * enclave got a backend 400 on every send/reply there. The channel→enclave
4774
+ * map (`channel_meta`, Task 1) is fed by inbound envelopes (`ingestOnce`)
4775
+ * and by `listChannels`; a channel not yet in the map (never seen inbound,
4776
+ * never discovered) falls back to home and logs once so the fallback is
4777
+ * diagnosable (audit H3) rather than silently wrong.
4778
+ */
4779
+ resolveEnclave(channelId) {
4780
+ const mapped = this.store.getChannelMeta(channelId)?.enclaveId;
4781
+ if (mapped) return mapped;
4782
+ if (!this.enclaveFallbackWarned.has(channelId)) {
4783
+ this.enclaveFallbackWarned.add(channelId);
4784
+ process.stderr.write(`[bridge] send.enclave_fallback channel=${channelId} stamped=home
4785
+ `);
4786
+ }
4787
+ return this.cfg.enclaveId;
4788
+ }
4105
4789
  async reconcileJoin(channelId) {
4106
4790
  if (this.joinState.get(channelId) === "JOINED") return true;
4107
4791
  try {
@@ -4315,7 +4999,7 @@ var BridgeAgent = class _BridgeAgent {
4315
4999
  groupKey: this.provider,
4316
5000
  messageId: id,
4317
5001
  channelId,
4318
- enclaveId: this.cfg.enclaveId,
5002
+ enclaveId: this.resolveEnclave(channelId),
4319
5003
  senderId: this.cfg.identityId,
4320
5004
  senderType: "agent",
4321
5005
  type: "text",
@@ -4333,6 +5017,7 @@ var BridgeAgent = class _BridgeAgent {
4333
5017
  (envelope) => this.latency.time("send.publish", () => this.transport.publish(envelope))
4334
5018
  );
4335
5019
  this.latency.record("send.total", performance.now() - sendStart);
5020
+ void recordOutboundMemory(this.memory, channelId, text, id);
4336
5021
  return { message_id: id, status: "sent" };
4337
5022
  }
4338
5023
  async sendA2A(channelId, a2a, messageId) {
@@ -4351,7 +5036,7 @@ var BridgeAgent = class _BridgeAgent {
4351
5036
  groupKey: this.provider,
4352
5037
  messageId: id,
4353
5038
  channelId,
4354
- enclaveId: this.cfg.enclaveId,
5039
+ enclaveId: this.resolveEnclave(channelId),
4355
5040
  senderId: this.cfg.identityId,
4356
5041
  senderType: "agent",
4357
5042
  type: "a2a",
@@ -4404,7 +5089,7 @@ var BridgeAgent = class _BridgeAgent {
4404
5089
  groupKey: this.provider,
4405
5090
  messageId: id,
4406
5091
  channelId,
4407
- enclaveId: this.cfg.enclaveId,
5092
+ enclaveId: this.resolveEnclave(channelId),
4408
5093
  senderId: this.cfg.identityId,
4409
5094
  senderType: "agent",
4410
5095
  type: "artifact_share",
@@ -4424,42 +5109,91 @@ var BridgeAgent = class _BridgeAgent {
4424
5109
  this.latency.record("send.total", performance.now() - sendStart);
4425
5110
  return { message_id: id, status: "sent" };
4426
5111
  }
4427
- async listChannels() {
4428
- const url = `${this.cfg.apiBase}/api/v1/enclaves/${this.cfg.enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
5112
+ /** Task 3: delegates to the configured MemoryProvider (SecureComsLocal | Null). */
5113
+ async recordMemory(input) {
5114
+ return this.memory.record(input);
5115
+ }
5116
+ async memoryContext(conversationId, limit) {
5117
+ return this.memory.context(conversationId, limit);
5118
+ }
5119
+ /**
5120
+ * List channels in one enclave (default: the daemon's own home enclave).
5121
+ * Task 3: called per-*known* enclave by refreshChannelMeta so a
5122
+ * cross-enclave room's is_dm gets resolved — see the scope caveat there
5123
+ * (same-tenant only; a cross-tenant enclave 404s under the agent's bearer).
5124
+ */
5125
+ async listChannels(enclaveId = this.cfg.enclaveId) {
5126
+ const url = `${this.cfg.apiBase}/api/v1/enclaves/${enclaveId}/channels?caller_identity_id=${encodeURIComponent(this.cfg.identityId)}`;
4429
5127
  const res = await this.fetchImpl(url, {
4430
5128
  headers: { authorization: `Bearer ${this.cfg.apiKey}` }
4431
5129
  });
4432
5130
  if (!res.ok) throw new Error(`list_channels failed: ${res.status}`);
4433
5131
  const rows = await res.json();
5132
+ for (const r of rows) this.store.recordChannelEnclave(r.channel_id, r.enclave_id);
4434
5133
  return rows.map((r) => ({
4435
5134
  channel_id: r.channel_id,
4436
5135
  name: r.name ?? null,
4437
5136
  is_dm: r.is_dm,
4438
- joined: true
5137
+ joined: true,
5138
+ enclave_id: r.enclave_id
4439
5139
  }));
4440
5140
  }
4441
- /** A live channelMeta entry, or undefined if missing OR expired past the TTL. */
5141
+ /**
5142
+ * A live channelMeta entry, or undefined if missing OR expired past the
5143
+ * TTL. A never-cached-this-process channel (no in-memory entry at all —
5144
+ * cold start, or every listChannels() call in refreshChannelMeta failed,
5145
+ * e.g. network down) falls back to the store's persisted is_dm (Task 3) so
5146
+ * a cross-enclave room stays classified across a restart instead of
5147
+ * reverting to UNKNOWN/fail-open. The store has no TTL (is_dm is immutable
5148
+ * per channel); `name` is not persisted so it comes back undefined on this
5149
+ * path and re-resolves once a listChannels() call succeeds.
5150
+ *
5151
+ * A TTL-*expired* in-memory entry deliberately does NOT use the store
5152
+ * fallback and is still treated as a miss — that miss is what drives
5153
+ * ingestOnce to retry a refresh each cycle (S-2: keeping `name` fresh after
5154
+ * a rename); serving the persisted is_dm there would silently suppress
5155
+ * that retry forever.
5156
+ */
4442
5157
  freshMeta(channelId) {
4443
5158
  const hit = this.channelMeta.get(channelId);
4444
- if (!hit) return void 0;
4445
- if (Date.now() - hit.at >= CHANNEL_META_TTL_MS) return void 0;
4446
- return { is_dm: hit.is_dm, name: hit.name };
5159
+ if (hit) {
5160
+ if (Date.now() - hit.at < CHANNEL_META_TTL_MS) return { is_dm: hit.is_dm, name: hit.name };
5161
+ return void 0;
5162
+ }
5163
+ const persisted = this.store.getChannelMeta(channelId);
5164
+ if (persisted?.isDm !== void 0) {
5165
+ return { is_dm: persisted.isDm, name: void 0 };
5166
+ }
5167
+ return void 0;
4447
5168
  }
4448
5169
  /**
4449
- * One-shot, whole-listing refresh of channelMeta. Called AT MOST ONCE per
4450
- * ingest cycle (the caller guards with a cycle-local "refreshed" flag), so a
4451
- * burst of K unknown channels fires a single listChannels() instead of K. Any
4452
- * listChannels failure is swallowed (UNKNOWN → fail open) so ingest never
4453
- * blocks on the network.
5170
+ * Whole-listing refresh of channelMeta, one listChannels() call per *known*
5171
+ * enclave — the daemon's own home enclave plus every enclave it has learned
5172
+ * about from inbound envelopes or a prior discovery (store.distinctEnclaveIds()).
5173
+ * Called AT MOST ONCE per ingest cycle (the caller guards with a
5174
+ * cycle-local "refreshed" flag). Task 3 (M1): without this, a cross-enclave
5175
+ * room is never listed under the home-enclave-only call, so its is_dm never
5176
+ * resolves and the @mention filter fails open for that room.
5177
+ *
5178
+ * Each enclave is wrapped in its own try/catch (scope caveat — audit
5179
+ * H2/tenant): list_channels_in_enclave filters on tenant_id, so a
5180
+ * same-tenant enclave the agent has learned about resolves fine, but a
5181
+ * cross-TENANT federated-partner enclave 404s under this agent's bearer;
5182
+ * that enclave's channels are left unresolved (fail open) without aborting
5183
+ * the other enclaves in the set.
4454
5184
  */
4455
5185
  async refreshChannelMeta() {
4456
- try {
4457
- const now = Date.now();
4458
- const channels = await this.listChannels();
4459
- for (const c of channels) {
4460
- this.channelMeta.set(c.channel_id, { is_dm: c.is_dm, name: c.name, at: now });
5186
+ const enclaves = /* @__PURE__ */ new Set([this.cfg.enclaveId, ...this.store.distinctEnclaveIds()]);
5187
+ const now = Date.now();
5188
+ for (const encId of enclaves) {
5189
+ try {
5190
+ const channels = await this.listChannels(encId);
5191
+ for (const c of channels) {
5192
+ this.channelMeta.set(c.channel_id, { is_dm: c.is_dm, name: c.name, at: now });
5193
+ this.store.recordChannelIsDm(c.channel_id, c.is_dm);
5194
+ }
5195
+ } catch {
4461
5196
  }
4462
- } catch {
4463
5197
  }
4464
5198
  }
4465
5199
  /** A live roster for a channel, or undefined if missing OR expired past the TTL. */
@@ -4565,6 +5299,8 @@ var BridgeAgent = class _BridgeAgent {
4565
5299
  async reportStatus(channelId, state, detail) {
4566
5300
  const joined = await this.reconcileJoin(channelId);
4567
5301
  if (!joined) throw new ChannelPendingError(channelId);
5302
+ const enc = this.store.getChannelMeta(channelId)?.enclaveId;
5303
+ if (!enc) return;
4568
5304
  await sendWithSelfHeal(
4569
5305
  this.provider,
4570
5306
  channelId,
@@ -4573,7 +5309,7 @@ var BridgeAgent = class _BridgeAgent {
4573
5309
  groupKey: this.provider,
4574
5310
  messageId: crypto.randomUUID(),
4575
5311
  channelId,
4576
- enclaveId: this.cfg.enclaveId,
5312
+ enclaveId: enc,
4577
5313
  senderId: this.cfg.identityId,
4578
5314
  senderType: "agent",
4579
5315
  type: "system_event",
@@ -4620,7 +5356,7 @@ var BridgeAgent = class _BridgeAgent {
4620
5356
  groupKey: this.provider,
4621
5357
  messageId: crypto.randomUUID(),
4622
5358
  channelId,
4623
- enclaveId: this.cfg.enclaveId,
5359
+ enclaveId: this.resolveEnclave(channelId),
4624
5360
  senderId: this.cfg.identityId,
4625
5361
  senderType: "agent",
4626
5362
  type: "system_event",
@@ -4864,6 +5600,11 @@ var BridgeAgent = class _BridgeAgent {
4864
5600
  const acked = [];
4865
5601
  for (const m of entries) {
4866
5602
  try {
5603
+ const encId = m.envelope.enclave_id;
5604
+ if (encId && !this.channelEnclaveRecorded.has(m.channelId)) {
5605
+ this.channelEnclaveRecorded.add(m.channelId);
5606
+ this.store.recordChannelEnclave(m.channelId, encId);
5607
+ }
4867
5608
  const parsed = JSON.parse(
4868
5609
  new TextDecoder().decode(m.plaintext)
4869
5610
  );
@@ -4935,6 +5676,7 @@ var BridgeAgent = class _BridgeAgent {
4935
5676
  sender_is_agent: senderIsAgent
4936
5677
  });
4937
5678
  this.capBuffer();
5679
+ void recordInboundMemory(this.memory, m.channelId, m.envelope.sender_id, parsed.text, m.messageId);
4938
5680
  }
4939
5681
  }
4940
5682
  acked.push(m.queueId);
@@ -4959,7 +5701,7 @@ var BridgeAgent = class _BridgeAgent {
4959
5701
  let got = this.takeBuffered(channelId);
4960
5702
  if (got.length || waitSeconds <= 0) return got;
4961
5703
  const cap = Math.min(waitSeconds, 50) * 1e3;
4962
- await new Promise((resolve2) => {
5704
+ await new Promise((resolve3) => {
4963
5705
  let fired = false;
4964
5706
  let timer;
4965
5707
  const wake = () => {
@@ -4968,7 +5710,7 @@ var BridgeAgent = class _BridgeAgent {
4968
5710
  clearTimeout(timer);
4969
5711
  const i = this.waiters.indexOf(wake);
4970
5712
  if (i >= 0) this.waiters.splice(i, 1);
4971
- resolve2();
5713
+ resolve3();
4972
5714
  };
4973
5715
  timer = setTimeout(wake, cap);
4974
5716
  this.waiters.push(wake);
@@ -5031,7 +5773,8 @@ var BridgeAgent = class _BridgeAgent {
5031
5773
  failed_attachments: this.failedAttachments,
5032
5774
  delivery_pings: this.pingsReceived,
5033
5775
  uptime_s: this.startedAt ? Math.floor((Date.now() - this.startedAt) / 1e3) : 0,
5034
- version: BRIDGE_VERSION
5776
+ version: BRIDGE_VERSION,
5777
+ ...this.memory.stats?.() ?? {}
5035
5778
  };
5036
5779
  }
5037
5780
  /**
@@ -5087,11 +5830,11 @@ var BridgeAgent = class _BridgeAgent {
5087
5830
 
5088
5831
  // src/auth.ts
5089
5832
  import { randomBytes as randomBytes3, timingSafeEqual } from "node:crypto";
5090
- import { existsSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
5833
+ import { existsSync, readFileSync as readFileSync5, writeFileSync } from "node:fs";
5091
5834
  function resolveToken(opts) {
5092
5835
  if (opts.token) return opts.token;
5093
5836
  const f = opts.tokenFile;
5094
- if (f && existsSync(f)) return readFileSync2(f, "utf8").trim();
5837
+ if (f && existsSync(f)) return readFileSync5(f, "utf8").trim();
5095
5838
  const tok = randomBytes3(32).toString("base64url");
5096
5839
  if (f) writeFileSync(f, tok, { mode: 384 });
5097
5840
  return tok;
@@ -5206,6 +5949,17 @@ var statusSchema = z.object({
5206
5949
  state: z.enum(["idle", "working", "stalled", "error"]),
5207
5950
  detail: z.string().optional()
5208
5951
  });
5952
+ var memoryRecordSchema = z.object({
5953
+ conversation_id: z.string().min(1),
5954
+ role: z.enum(["peer", "agent"]),
5955
+ peer_id: z.string().optional(),
5956
+ text: z.string(),
5957
+ modality: z.enum(["text", "voice"]),
5958
+ message_id: z.string().optional(),
5959
+ idempotency_key: z.string().optional()
5960
+ }).refine((d) => Boolean(d.message_id || d.idempotency_key), {
5961
+ message: "memory record requires message_id or idempotency_key"
5962
+ });
5209
5963
  function json(res, status, body) {
5210
5964
  const s = JSON.stringify(body);
5211
5965
  res.writeHead(status, { "content-type": "application/json" });
@@ -5215,7 +5969,7 @@ function jsonError(res, code, detail) {
5215
5969
  json(res, httpStatus(code), errorBody(code, detail));
5216
5970
  }
5217
5971
  function readBody(req, cap) {
5218
- return new Promise((resolve2, reject) => {
5972
+ return new Promise((resolve3, reject) => {
5219
5973
  let size = 0;
5220
5974
  let done = false;
5221
5975
  const chunks = [];
@@ -5235,7 +5989,7 @@ function readBody(req, cap) {
5235
5989
  req.on("end", () => {
5236
5990
  if (!done) {
5237
5991
  done = true;
5238
- resolve2(Buffer.concat(chunks).toString("utf8"));
5992
+ resolve3(Buffer.concat(chunks).toString("utf8"));
5239
5993
  }
5240
5994
  });
5241
5995
  req.on("error", (e) => {
@@ -5299,6 +6053,26 @@ function createHttpServer(agent, opts) {
5299
6053
  await agent.reportStatus(parsed.data.channel_id, parsed.data.state, parsed.data.detail);
5300
6054
  return json(res, 200, { ok: true });
5301
6055
  }
6056
+ if (method === "POST" && path === "/v1/memory/record") {
6057
+ const parsed = memoryRecordSchema.safeParse(JSON.parse(await readBody(req, OTHER_CAP)));
6058
+ if (!parsed.success) return jsonError(res, "invalid_request", parsed.error.issues);
6059
+ const r = await agent.recordMemory({
6060
+ conversationId: parsed.data.conversation_id,
6061
+ role: parsed.data.role,
6062
+ peerId: parsed.data.peer_id,
6063
+ text: parsed.data.text,
6064
+ modality: parsed.data.modality,
6065
+ messageId: parsed.data.message_id,
6066
+ idempotencyKey: parsed.data.idempotency_key
6067
+ });
6068
+ return json(res, 200, r);
6069
+ }
6070
+ if (method === "GET" && path === "/v1/memory/context") {
6071
+ const conv = url.searchParams.get("conversation_id");
6072
+ if (!conv) return jsonError(res, "invalid_request", "conversation_id required");
6073
+ const limit = Math.max(1, Math.min(200, Number(url.searchParams.get("limit") ?? 50)));
6074
+ return json(res, 200, await agent.memoryContext(conv, limit));
6075
+ }
5302
6076
  if (method === "GET" && path === "/v1/messages") {
5303
6077
  const channel = url.searchParams.get("channel_id") ?? void 0;
5304
6078
  const wait = Math.max(0, Math.min(50, Number(url.searchParams.get("wait") ?? 0)));
@@ -5629,10 +6403,10 @@ async function handleInbound(sock, agent, data, inflight) {
5629
6403
  }
5630
6404
 
5631
6405
  // src/bin.ts
5632
- import { readFileSync as readFileSync3, mkdirSync, writeFileSync as writeFileSync2, rmSync, existsSync as existsSync2, realpathSync } from "node:fs";
5633
- import { join as join4, dirname as dirname5 } from "node:path";
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";
5634
6408
  import { fileURLToPath } from "node:url";
5635
- import { homedir, userInfo } from "node:os";
6409
+ import { homedir as homedir2, userInfo } from "node:os";
5636
6410
  import { spawnSync } from "node:child_process";
5637
6411
 
5638
6412
  // src/service/install.ts
@@ -5765,6 +6539,161 @@ function uninstallService(input, deps) {
5765
6539
  throw new ServiceUnsupportedError(`Service uninstall isn't supported on ${deps.platform} yet.`);
5766
6540
  }
5767
6541
 
6542
+ // src/service/install-prep.ts
6543
+ import { join as join8 } from "node:path";
6544
+ function assertReadableCredential(absPath, deps) {
6545
+ let raw;
6546
+ try {
6547
+ raw = deps.readFile(absPath);
6548
+ } catch {
6549
+ throw new Error(`install: credential file not found or unreadable: ${absPath}`);
6550
+ }
6551
+ let json2;
6552
+ try {
6553
+ json2 = JSON.parse(raw);
6554
+ } catch {
6555
+ throw new Error(`install: credential file is not valid JSON: ${absPath}`);
6556
+ }
6557
+ for (const k of REQUIRED) {
6558
+ if (typeof json2[k] !== "string" || !json2[k]) {
6559
+ throw new Error(`install: credential file missing required field '${k}': ${absPath}`);
6560
+ }
6561
+ }
6562
+ const issuedAt = typeof json2.issued_at === "string" && json2.issued_at ? json2.issued_at : null;
6563
+ return { deviceId: json2.device_id, identityId: json2.identity_id, issuedAt };
6564
+ }
6565
+ function durableDir(home, slug) {
6566
+ return join8(home, `securecoms-agent-${slug}`);
6567
+ }
6568
+ function durableCredentialPath(home, slug) {
6569
+ return join8(durableDir(home, slug), `${slug}.json`);
6570
+ }
6571
+ function takeCustody(args2, deps) {
6572
+ const dir = durableDir(args2.home, args2.slug);
6573
+ const dest = durableCredentialPath(args2.home, args2.slug);
6574
+ try {
6575
+ if (deps.fileExists(dest) && deps.realpath(args2.sourceAbsPath) === deps.realpath(dest)) return dest;
6576
+ } catch {
6577
+ }
6578
+ if (deps.fileExists(dest)) {
6579
+ try {
6580
+ const existing = JSON.parse(deps.readFile(dest));
6581
+ if (existing.device_id && existing.device_id !== args2.parsed.deviceId) {
6582
+ throw new Error(
6583
+ `install: ${dir} already belongs to a different agent (device ${existing.device_id}). Pass a distinct --label to install this agent in its own directory.`
6584
+ );
6585
+ }
6586
+ } catch (e) {
6587
+ if (e instanceof Error && e.message.startsWith("install:")) throw e;
6588
+ }
6589
+ }
6590
+ deps.mkdirp(dir, 448);
6591
+ deps.writeFileMode(dest, deps.readFile(args2.sourceAbsPath), 384);
6592
+ return dest;
6593
+ }
6594
+ var PORT_MIN = 7077;
6595
+ var PORT_MAX = 7276;
6596
+ function parseClaimedPorts(fileContents) {
6597
+ const claimed = /* @__PURE__ */ new Set();
6598
+ const re = /--port(?:<\/string>\s*<string>|\s+)(\d+)/g;
6599
+ for (const text of fileContents) {
6600
+ let m;
6601
+ while ((m = re.exec(text)) !== null) claimed.add(Number(m[1]));
6602
+ }
6603
+ return claimed;
6604
+ }
6605
+ function readSiblingDefs(dir, prefix, deps) {
6606
+ let names;
6607
+ try {
6608
+ names = deps.listDir(dir);
6609
+ } catch {
6610
+ return [];
6611
+ }
6612
+ const out = [];
6613
+ for (const n of names) {
6614
+ if (!n.startsWith(prefix)) continue;
6615
+ try {
6616
+ out.push(deps.readFile(join8(dir, n)));
6617
+ } catch {
6618
+ }
6619
+ }
6620
+ return out;
6621
+ }
6622
+ function selectPort(args2, deps) {
6623
+ const defs = [
6624
+ ...readSiblingDefs(args2.launchAgentsDir, "com.securecoms.bridge.", deps),
6625
+ ...readSiblingDefs(args2.systemdDir, "securecoms-bridge-", deps)
6626
+ ];
6627
+ const claimed = parseClaimedPorts(defs);
6628
+ for (let port = PORT_MIN; port <= PORT_MAX; port++) {
6629
+ if (claimed.has(port)) continue;
6630
+ if (!deps.probeBindFree(port)) continue;
6631
+ return port;
6632
+ }
6633
+ throw new Error(`install: no free port in ${PORT_MIN}-${PORT_MAX} \u2014 stop an unused bridge or pass --port`);
6634
+ }
6635
+ async function verifyDaemonUp(args2, deps) {
6636
+ const attempts = args2.attempts ?? 20;
6637
+ const delayMs = args2.delayMs ?? 250;
6638
+ for (let i = 0; i < attempts; i++) {
6639
+ if (deps.probeConnect(args2.port)) return true;
6640
+ try {
6641
+ if (deps.readFile(args2.logPath).includes("[bridge] listening on")) return true;
6642
+ } catch {
6643
+ }
6644
+ await deps.sleep(delayMs);
6645
+ }
6646
+ return false;
6647
+ }
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
+ async function orchestrateInstall(ctx) {
6657
+ const parsed = assertReadableCredential(ctx.credentialAbsPath, ctx.prepDeps);
6658
+ const stemForSlug = ctx.label ?? ctx.credentialAbsPath.replace(/^.*[\\/]/, "").replace(/\.[^.]*$/, "");
6659
+ const slug = slugify(stemForSlug);
6660
+ const durablePath = takeCustody(
6661
+ { home: ctx.home, slug, sourceAbsPath: ctx.credentialAbsPath, parsed },
6662
+ ctx.prepDeps
6663
+ );
6664
+ const port = ctx.explicitPort ?? selectPort(
6665
+ { launchAgentsDir: ctx.launchAgentsDir ?? `${ctx.home}/Library/LaunchAgents`, systemdDir: ctx.systemdDir ?? `${ctx.home}/.config/systemd/user` },
6666
+ ctx.prepDeps
6667
+ );
6668
+ const input = ctx.buildServiceInput(durablePath, port, slug);
6669
+ const result = ctx.installService(input);
6670
+ const up = await verifyDaemonUp(
6671
+ { port, logPath: result.logHint, attempts: ctx.verifyAttempts, delayMs: ctx.verifyDelayMs },
6672
+ ctx.prepDeps
6673
+ );
6674
+ if (!up) {
6675
+ throw new Error(
6676
+ `install: the daemon did not come up \u2014 check its log: ${result.logHint}`
6677
+ );
6678
+ }
6679
+ const apiKey = JSON.parse(ctx.prepDeps.readFile(durablePath)).api_key;
6680
+ const lines = [
6681
+ `Installed service ${result.label}`,
6682
+ ` device: ${parsed.deviceId}`,
6683
+ parsed.issuedAt ? ` issued: ${parsed.issuedAt}` : ` issued: (unknown \u2014 older credential)`,
6684
+ ` api key: ${tokenFingerprint(apiKey)}`,
6685
+ ` port: ${port}`,
6686
+ ` token: ${result.tokenHint}`,
6687
+ ` (Re-running install is the only way to update a live credential \u2014 replacing the download alone does nothing.)`
6688
+ ];
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;
6693
+ }
6694
+ return { echo: lines.join("\n") + "\n", harnessSnippetShown };
6695
+ }
6696
+
5768
6697
  // src/bin.ts
5769
6698
  function nodeMajorOk(version) {
5770
6699
  return Number(version.split(".")[0]) >= 24;
@@ -5805,11 +6734,47 @@ function realResolveDeps() {
5805
6734
  fileExists: (p) => existsSync2(p)
5806
6735
  };
5807
6736
  }
5808
- function runServiceVerb(verb) {
6737
+ function realPrepDeps() {
6738
+ return {
6739
+ readFile: (p) => readFileSync6(p, "utf8"),
6740
+ writeFileMode: (p, data, mode) => writeFileSync2(p, data, { mode }),
6741
+ mkdirp: (p, mode) => {
6742
+ mkdirSync(p, { recursive: true, mode: mode ?? 493 });
6743
+ },
6744
+ fileExists: (p) => existsSync2(p),
6745
+ realpath: (p) => realpathSync(p),
6746
+ listDir: (p) => {
6747
+ try {
6748
+ return readdirSync(p);
6749
+ } catch {
6750
+ return [];
6751
+ }
6752
+ },
6753
+ // Synchronous spawn-free-loop probes: node's net API is async-only, so each
6754
+ // probe is a tiny `node -e` child that binds/connects and exits 0/1.
6755
+ probeBindFree: (port) => {
6756
+ const r = spawnSync(process.execPath, [
6757
+ "-e",
6758
+ `const s=require('net').createServer();s.once('error',()=>process.exit(1));s.listen(${port},'127.0.0.1',()=>{s.close(()=>process.exit(0))});`
6759
+ ]);
6760
+ return r.status === 0;
6761
+ },
6762
+ probeConnect: (port) => {
6763
+ const r = spawnSync(process.execPath, [
6764
+ "-e",
6765
+ `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);`
6766
+ ]);
6767
+ return r.status === 0;
6768
+ },
6769
+ // No unref: an unref'd timer would let the process exit mid-verification.
6770
+ sleep: (ms) => new Promise((res) => setTimeout(res, ms))
6771
+ };
6772
+ }
6773
+ async function runServiceVerb(verb) {
5809
6774
  const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
5810
6775
  const label = argOrEnv("--label", "SECURECOMS_LABEL");
5811
- if (!credentialPath && !label) {
5812
- process.stderr.write(`usage: agent-bridge ${verb} --credential <path> [--label <name>] [--store <path>] [--port N]
6776
+ 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]
5813
6778
  `);
5814
6779
  process.exit(2);
5815
6780
  }
@@ -5819,25 +6784,43 @@ function runServiceVerb(verb) {
5819
6784
  const meta = loadPackageMeta(import.meta.url);
5820
6785
  binScript = resolveStableBinScript({ binScript, packageName: meta.name }, realResolveDeps());
5821
6786
  }
5822
- const input = {
5823
- credentialPath: credentialPath ?? "",
5824
- storePath: argOrEnv("--store", "SECURECOMS_STORE"),
5825
- port: portStr ? Number(portStr) : void 0,
5826
- label,
5827
- execPath: process.execPath,
5828
- binScript,
5829
- home: homedir(),
5830
- uid: userInfo().uid
5831
- };
5832
6787
  try {
5833
6788
  if (verb === "install") {
5834
- const r = installService(input, realServiceDeps());
5835
- process.stdout.write(`Installed service ${r.label}
5836
- file: ${r.filePath}
5837
- logs: ${r.logHint}
5838
- token: ${r.tokenHint} (the local BRIDGE_TOKEN)
5839
- `);
6789
+ const credAbs = isAbsolute2(credentialPath) ? credentialPath : resolve2(credentialPath);
6790
+ const r = await orchestrateInstall({
6791
+ credentialAbsPath: credAbs,
6792
+ label,
6793
+ explicitPort: portStr ? Number(portStr) : void 0,
6794
+ harness: argOrEnv("--harness", "SECURECOMS_HARNESS"),
6795
+ home: homedir2(),
6796
+ prepDeps: realPrepDeps(),
6797
+ // O-1: the real installService is 2-arg; partially apply the deps so
6798
+ // orchestrateInstall only ever sees the 1-arg seam.
6799
+ installService: (input) => installService(input, realServiceDeps()),
6800
+ buildServiceInput: (durableCredentialPath2, port, _slug) => ({
6801
+ credentialPath: durableCredentialPath2,
6802
+ storePath: argOrEnv("--store", "SECURECOMS_STORE"),
6803
+ // O-2: not dropped
6804
+ port,
6805
+ label,
6806
+ execPath: process.execPath,
6807
+ binScript,
6808
+ home: homedir2(),
6809
+ uid: userInfo().uid
6810
+ })
6811
+ });
6812
+ process.stdout.write(r.echo);
5840
6813
  } else {
6814
+ const input = {
6815
+ credentialPath: credentialPath ?? "",
6816
+ storePath: argOrEnv("--store", "SECURECOMS_STORE"),
6817
+ port: portStr ? Number(portStr) : void 0,
6818
+ label,
6819
+ execPath: process.execPath,
6820
+ binScript,
6821
+ home: homedir2(),
6822
+ uid: userInfo().uid
6823
+ };
5841
6824
  const r = uninstallService(input, realServiceDeps());
5842
6825
  process.stdout.write(r.removed ? `Removed service (${r.filePath})
5843
6826
  ` : `No service installed for this agent
@@ -5859,7 +6842,7 @@ async function main() {
5859
6842
  }
5860
6843
  const verb = parseVerb(process.argv);
5861
6844
  if (verb) {
5862
- runServiceVerb(verb);
6845
+ await runServiceVerb(verb);
5863
6846
  return;
5864
6847
  }
5865
6848
  const credentialPath = argOrEnv("--credential", "SECURECOMS_CREDENTIAL");
@@ -5878,11 +6861,11 @@ async function main() {
5878
6861
  `);
5879
6862
  process.exit(1);
5880
6863
  }
5881
- const tokenFile = daemon.tokenFile ?? join4(dirname5(credentialPath), "bridge-token");
6864
+ const tokenFile = daemon.tokenFile ?? join9(dirname5(credentialPath), "bridge-token");
5882
6865
  const token = resolveToken({ tokenFile, token: daemon.token });
5883
6866
  const agent = new BridgeAgent(cfg);
5884
6867
  await agent.start();
5885
- const tls = daemon.tls ? { cert: readFileSync3(daemon.tlsCert, "utf8"), key: readFileSync3(daemon.tlsKey, "utf8") } : void 0;
6868
+ const tls = daemon.tls ? { cert: readFileSync6(daemon.tlsCert, "utf8"), key: readFileSync6(daemon.tlsKey, "utf8") } : void 0;
5886
6869
  const { server, closeAllSseStreams } = createHttpServer(agent, { token, allowOrigins: daemon.allowOrigins, tls, websocket: true });
5887
6870
  const agentWs = attachAgentWebSocket(server, agent, { token, allowOrigins: daemon.allowOrigins });
5888
6871
  let down = false;