chainlesschain 0.162.147 → 0.162.148

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 (184) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/assets/web-panel/.build-hash +1 -1
  4. package/src/assets/web-panel/assets/{AIOps-DywJ7xTv.js → AIOps-DwpCCp64.js} +1 -1
  5. package/src/assets/web-panel/assets/{ActionButton-BTlqcY-q.js → ActionButton-C9pBYocA.js} +1 -1
  6. package/src/assets/web-panel/assets/{Analytics-FdG1Mvwy.js → Analytics-BWAkeXxK.js} +3 -3
  7. package/src/assets/web-panel/assets/{AppLayout-CVsnbvN1.js → AppLayout-CivFGH6B.js} +5 -5
  8. package/src/assets/web-panel/assets/{Audit-ITdpJ7vR.js → Audit-Cm8hRpZm.js} +1 -1
  9. package/src/assets/web-panel/assets/{Backup-BfXqnXo1.js → Backup-DXdFMsE8.js} +1 -1
  10. package/src/assets/web-panel/assets/{BaseInput-C09ip3_E.js → BaseInput-uAwSTeql.js} +1 -1
  11. package/src/assets/web-panel/assets/{Chat-qdkhB42l.js → Chat-KU8eeJJE.js} +6 -6
  12. package/src/assets/web-panel/assets/ChatBubbleRenderer-DCNfbdlx.js +1 -0
  13. package/src/assets/web-panel/assets/{Checkbox-j6WZCuff.js → Checkbox-C6AMDkjd.js} +1 -1
  14. package/src/assets/web-panel/assets/{Codegen-CGZ-K3uK.js → Codegen-t2moDxcO.js} +1 -1
  15. package/src/assets/web-panel/assets/{Col-CIlPOAu6.js → Col-DCcsRRhN.js} +1 -1
  16. package/src/assets/web-panel/assets/{Community-sY-i-hic.js → Community-DtB-8SAC.js} +1 -1
  17. package/src/assets/web-panel/assets/{Compact-DcoEfyL8.js → Compact-CZm8THYA.js} +1 -1
  18. package/src/assets/web-panel/assets/Compliance-LiQgC7g1.js +1 -0
  19. package/src/assets/web-panel/assets/{Cowork-DLY_fkLv.js → Cowork-CA8SAxht.js} +3 -3
  20. package/src/assets/web-panel/assets/{Cron-D049pZBC.js → Cron-CtRaF1wD.js} +2 -2
  21. package/src/assets/web-panel/assets/{Crosschain-DCXdE8M9.js → Crosschain-6-br6Hub.js} +1 -1
  22. package/src/assets/web-panel/assets/{DID-JCvPZtjW.js → DID-Do2EccrL.js} +2 -2
  23. package/src/assets/web-panel/assets/{Dashboard-Cg2xu3iE.js → Dashboard-wrXcbzGe.js} +2 -2
  24. package/src/assets/web-panel/assets/{Dropdown-DjJLjbLh.js → Dropdown-p3FsT245.js} +1 -1
  25. package/src/assets/web-panel/assets/{EmailListRenderer-DugNcSLR.js → EmailListRenderer-D95A2L8q.js} +1 -1
  26. package/src/assets/web-panel/assets/{FamilyGuardDashboard-J6Nb-E30.js → FamilyGuardDashboard-cLuJW2zo.js} +1 -1
  27. package/src/assets/web-panel/assets/{Federation-CQ3Ttpbl.js → Federation-Brgq_cbN.js} +1 -1
  28. package/src/assets/web-panel/assets/{FormItemContext-Cz0NKag4.js → FormItemContext-DWLCkNgh.js} +1 -1
  29. package/src/assets/web-panel/assets/{GenericCardRenderer-NYcMP6iz.js → GenericCardRenderer-BBqUfQd6.js} +1 -1
  30. package/src/assets/web-panel/assets/{Git-B4hYN18N.js → Git-8F090w4I.js} +2 -2
  31. package/src/assets/web-panel/assets/{Governance-DOQPCcC0.js → Governance-CCogEbxb.js} +1 -1
  32. package/src/assets/web-panel/assets/{Inference-HtLF746W.js → Inference-CfLD7v7C.js} +1 -1
  33. package/src/assets/web-panel/assets/{KnowledgeGraph-BXrk8rMm.js → KnowledgeGraph-DDuHJewd.js} +1 -1
  34. package/src/assets/web-panel/assets/{Logs-DS0JnD3-.js → Logs-CE8KSEVC.js} +2 -2
  35. package/src/assets/web-panel/assets/{Marketplace-Cqnjihrz.js → Marketplace-DmwpZmhT.js} +1 -1
  36. package/src/assets/web-panel/assets/{McpTools-Cx3Psk-U.js → McpTools-Bf7AazU8.js} +5 -5
  37. package/src/assets/web-panel/assets/{Memory-BjyysPtj.js → Memory-D0QU_00S.js} +2 -2
  38. package/src/assets/web-panel/assets/MobileBridge-D3Arxfeg.css +1 -0
  39. package/src/assets/web-panel/assets/MobileBridge-yXS9xdhx.js +1 -0
  40. package/src/assets/web-panel/assets/{MobileProjects-Xwf-fdOQ.js → MobileProjects-BuDUoGUP.js} +1 -1
  41. package/src/assets/web-panel/assets/{Mtc-Djkql5m5.js → Mtc-BYyHy28p.js} +2 -2
  42. package/src/assets/web-panel/assets/{MtcAudit-D2BAZlu0.js → MtcAudit-CK0aqpiZ.js} +4 -4
  43. package/src/assets/web-panel/assets/{Multisig-ByqP1ZzH.js → Multisig-mNimKYeb.js} +3 -3
  44. package/src/assets/web-panel/assets/{NLProgramming-C_kn0nE2.js → NLProgramming-AioAJ0YA.js} +1 -1
  45. package/src/assets/web-panel/assets/{Notes-7l7eXHPq.js → Notes-CVVNCLHE.js} +3 -3
  46. package/src/assets/web-panel/assets/{NotificationSettings-BrCDoitw.js → NotificationSettings-C4ABj-TK.js} +1 -1
  47. package/src/assets/web-panel/assets/{OrderTableRenderer-CmtqIdwr.js → OrderTableRenderer-DIp8Xcbd.js} +1 -1
  48. package/src/assets/web-panel/assets/{Organization-DdoNAxsM.js → Organization-uozl_gWK.js} +4 -4
  49. package/src/assets/web-panel/assets/{Overflow-Bw7R6dE3.js → Overflow-BCZOM2hK.js} +1 -1
  50. package/src/assets/web-panel/assets/{OverrideContext-C2r4vyBb.js → OverrideContext-BlgL9440.js} +1 -1
  51. package/src/assets/web-panel/assets/{P2P-DJNtBugA.js → P2P-CArcaiaj.js} +2 -2
  52. package/src/assets/web-panel/assets/{PdhVaultBrowser-Dwhd5_ue.js → PdhVaultBrowser-Sgq2Srr8.js} +3 -3
  53. package/src/assets/web-panel/assets/{Permissions-BYoSfhPR.js → Permissions-BYrQrXnH.js} +3 -3
  54. package/src/assets/web-panel/assets/{PersonalDataHub-aYCYJbnH.js → PersonalDataHub-BzHStAbz.js} +2 -2
  55. package/src/assets/web-panel/assets/{Pipeline-DJp6CprF.js → Pipeline-k7KqxX1M.js} +1 -1
  56. package/src/assets/web-panel/assets/{Privacy-Cq9HZweR.js → Privacy-IumTUWqx.js} +1 -1
  57. package/src/assets/web-panel/assets/{ProjectInit-CmF0HsSt.js → ProjectInit-BRyImYTf.js} +2 -2
  58. package/src/assets/web-panel/assets/{ProjectSettings-P8V5Aea2.js → ProjectSettings-BFIqTBFk.js} +2 -2
  59. package/src/assets/web-panel/assets/{Projects-DPd5-lNS.js → Projects-Dpid9CDg.js} +1 -1
  60. package/src/assets/web-panel/assets/{Providers-D589v0q2.js → Providers-CyyFnUPs.js} +1 -1
  61. package/src/assets/web-panel/assets/QrScannerModal-C82cRx-X.js +1 -0
  62. package/src/assets/web-panel/assets/{MobileBridge-zJpPuf4E.css → QrScannerModal-NwAHNfiJ.css} +1 -1
  63. package/src/assets/web-panel/assets/{QuickAsk-lvRV2osB.js → QuickAsk-DGxDF521.js} +1 -1
  64. package/src/assets/web-panel/assets/{Recommend-sb84MTpY.js → Recommend-Cns-Am1y.js} +1 -1
  65. package/src/assets/web-panel/assets/RemoteSession-R7OqAIlg.js +4 -0
  66. package/src/assets/web-panel/assets/{Reputation-d9Y3vy-W.js → Reputation-D7mgPIYV.js} +1 -1
  67. package/src/assets/web-panel/assets/{Row-DMdhLUTp.js → Row-B3lEURyd.js} +1 -1
  68. package/src/assets/web-panel/assets/{RssFeed-OL-SMDkz.js → RssFeed-9_UVrpBt.js} +2 -2
  69. package/src/assets/web-panel/assets/{Search-BjStLKq1.js → Search-ClZeO80q.js} +1 -1
  70. package/src/assets/web-panel/assets/{Security-MeI411qr.js → Security-BNNJczEp.js} +3 -3
  71. package/src/assets/web-panel/assets/{Services-UajosuhT.js → Services-C5SjrWUj.js} +2 -2
  72. package/src/assets/web-panel/assets/{Skeleton-CGpG-QJ1.js → Skeleton-Ci_obQ-g.js} +1 -1
  73. package/src/assets/web-panel/assets/{Skills-C6P2RPY-.js → Skills-DdebN15P.js} +1 -1
  74. package/src/assets/web-panel/assets/{Sla-B_fPprgw.js → Sla-OinZP2vi.js} +1 -1
  75. package/src/assets/web-panel/assets/{SpeechSettings-CqxLjSlQ.js → SpeechSettings-CxzbNF51.js} +1 -1
  76. package/src/assets/web-panel/assets/{SyncSettings-DixSfFOQ.js → SyncSettings-D06N_66b.js} +2 -2
  77. package/src/assets/web-panel/assets/{Tasks-PavzPBxc.js → Tasks-BdEdW0AZ.js} +1 -1
  78. package/src/assets/web-panel/assets/{Templates-BYdGRnfX.js → Templates-F1ctKmPa.js} +1 -1
  79. package/src/assets/web-panel/assets/{Tenant-BQLgnarD.js → Tenant-CVz1Urot.js} +1 -1
  80. package/src/assets/web-panel/assets/{Terminal-BzOHpq-B.js → Terminal-BvAK_Zel.js} +2 -2
  81. package/src/assets/web-panel/assets/TimelineRenderer-Doitzjmf.js +1 -0
  82. package/src/assets/web-panel/assets/{Tokens-DxhgszRJ.js → Tokens-BpyVRpVA.js} +1 -1
  83. package/src/assets/web-panel/assets/{Trigger-nQYHayuc.js → Trigger-nWhWYTbU.js} +1 -1
  84. package/src/assets/web-panel/assets/{Trust-CvqZDuZ4.js → Trust-DK_G-wLx.js} +1 -1
  85. package/src/assets/web-panel/assets/{UkeySign-DaR8GV2I.js → UkeySign-B5yBUojO.js} +1 -1
  86. package/src/assets/web-panel/assets/{VideoEditing-C6Mpsokw.js → VideoEditing-C5D51qAe.js} +1 -1
  87. package/src/assets/web-panel/assets/{Wallet-B57VRrWc.js → Wallet-CLbL9K7v.js} +4 -4
  88. package/src/assets/web-panel/assets/{WebAuthn-BbtEEtpq.js → WebAuthn-DZUelI3V.js} +4 -4
  89. package/src/assets/web-panel/assets/{WorkflowEditor-C5LwvKmH.js → WorkflowEditor-8RPxt4KW.js} +1 -1
  90. package/src/assets/web-panel/assets/{chat-MzuPR5jh.js → chat-4N4tOA3d.js} +1 -1
  91. package/src/assets/web-panel/assets/{collapseMotion-mxzzcDsg.js → collapseMotion-DVfhbsdP.js} +1 -1
  92. package/src/assets/web-panel/assets/{colors-HyW8mi_y.js → colors-zbbGVrua.js} +1 -1
  93. package/src/assets/web-panel/assets/{compact-item-DvUSzWAp.js → compact-item-D7iMkbnE.js} +1 -1
  94. package/src/assets/web-panel/assets/{createContext-4MppZl7X.js → createContext-CBzPJv-w.js} +1 -1
  95. package/src/assets/web-panel/assets/devWarning-C2Z5LRgq.js +1 -0
  96. package/src/assets/web-panel/assets/{echarts-jxgIOFUZ.js → echarts-CL0F2SnX.js} +1 -1
  97. package/src/assets/web-panel/assets/{hasIn-D9q3CJ-u.js → hasIn-bHdrQSqZ.js} +1 -1
  98. package/src/assets/web-panel/assets/{icons-DP3uiYxy.js → icons-BsDmGpcT.js} +1 -1
  99. package/src/assets/web-panel/assets/{index-Caqtu6Et.js → index-308gCGh7.js} +1 -1
  100. package/src/assets/web-panel/assets/{index-CgIfXOD9.js → index-B1s0Bz9O.js} +1 -1
  101. package/src/assets/web-panel/assets/{index-BcunsBIx.js → index-B7b1hGry.js} +1 -1
  102. package/src/assets/web-panel/assets/{index-DDZ6fP5Z.js → index-BBNr77E1.js} +1 -1
  103. package/src/assets/web-panel/assets/{index-BF2JsbiX.js → index-BCr0geV9.js} +1 -1
  104. package/src/assets/web-panel/assets/index-BGp6Yr-Y.js +1 -0
  105. package/src/assets/web-panel/assets/{index-CucRo4Vz.js → index-BJ4ZGyW0.js} +1 -1
  106. package/src/assets/web-panel/assets/{index-Bl-E4W4q.js → index-BS4_Mwk6.js} +1 -1
  107. package/src/assets/web-panel/assets/{index-Dv7ffI2g.js → index-BTIjjXry.js} +1 -1
  108. package/src/assets/web-panel/assets/index-BXiw9dQf.js +1 -0
  109. package/src/assets/web-panel/assets/{index-CifKUNtV.js → index-BZb8F8YG.js} +1 -1
  110. package/src/assets/web-panel/assets/{index-Btz8pU68.js → index-BnBOpqv3.js} +1 -1
  111. package/src/assets/web-panel/assets/{index-m0b6Fj9q.js → index-Bqmx24kh.js} +1 -1
  112. package/src/assets/web-panel/assets/{index-Dvg5xYuP.js → index-BsKehmmS.js} +1 -1
  113. package/src/assets/web-panel/assets/{index-UUMCA2Sq.js → index-BtbbdLnf.js} +4 -4
  114. package/src/assets/web-panel/assets/{index-CvAqCAo9.js → index-CPxkoKgA.js} +1 -1
  115. package/src/assets/web-panel/assets/{index-B5E4DhVc.js → index-CQ6NcfWz.js} +1 -1
  116. package/src/assets/web-panel/assets/{index-B994UQfE.js → index-CQfu1U78.js} +1 -1
  117. package/src/assets/web-panel/assets/{index-CLAZs1Vd.js → index-CahryTX0.js} +1 -1
  118. package/src/assets/web-panel/assets/{index-BFGfC5RS.js → index-CdmMoYN1.js} +1 -1
  119. package/src/assets/web-panel/assets/{index-DcMkjb92.js → index-Ci009ijp.js} +1 -1
  120. package/src/assets/web-panel/assets/{index-DNYqw0MO.js → index-CiG1IZao.js} +1 -1
  121. package/src/assets/web-panel/assets/{index--Bm6OqmU.js → index-CkxK86vf.js} +1 -1
  122. package/src/assets/web-panel/assets/{index-zLwYpvX0.js → index-CqdPyWm5.js} +1 -1
  123. package/src/assets/web-panel/assets/{index-Zvtru2oE.js → index-CvRMA9uT.js} +1 -1
  124. package/src/assets/web-panel/assets/{index-BmCo2Xdp.js → index-D1YDh7Wr.js} +1 -1
  125. package/src/assets/web-panel/assets/{index-D2jrnaq3.js → index-D4XKpj6c.js} +1 -1
  126. package/src/assets/web-panel/assets/{index-D7TDMMfu.js → index-DE9j5YgT.js} +1 -1
  127. package/src/assets/web-panel/assets/{index-ryFLxB1p.js → index-DISWAYce.js} +1 -1
  128. package/src/assets/web-panel/assets/{index-xvUVuFnA.js → index-DUyjUkY7.js} +1 -1
  129. package/src/assets/web-panel/assets/{index-C9I5MuJ0.js → index-DbE5D0WL.js} +1 -1
  130. package/src/assets/web-panel/assets/{index-BGQtFso0.js → index-DmqlJed-.js} +1 -1
  131. package/src/assets/web-panel/assets/{index-SDblbKj6.js → index-DyoNgbXl.js} +1 -1
  132. package/src/assets/web-panel/assets/{index-Ci6adKh8.js → index-QFObYeo1.js} +1 -1
  133. package/src/assets/web-panel/assets/{index-Cs-RKRUM.js → index-QNAG4JtR.js} +1 -1
  134. package/src/assets/web-panel/assets/{index-DFqvWeGc.js → index-S74FpAIn.js} +1 -1
  135. package/src/assets/web-panel/assets/{index-eOQO6KnV.js → index-kmh1TxRa.js} +1 -1
  136. package/src/assets/web-panel/assets/{index-DmZ6-suL.js → index-oDKNgCsP.js} +1 -1
  137. package/src/assets/web-panel/assets/{index-C0DiL97c.js → index-xYCm6W2h.js} +1 -1
  138. package/src/assets/web-panel/assets/{initDefaultProps-CC--PMsC.js → initDefaultProps-BD55yNBt.js} +1 -1
  139. package/src/assets/web-panel/assets/{motion-DdrBjRF1.js → motion-B5Bbe77U.js} +1 -1
  140. package/src/assets/web-panel/assets/{move-D712Gwl2.js → move-CTvIqHEH.js} +1 -1
  141. package/src/assets/web-panel/assets/{mtc-parser-39y-keKO.js → mtc-parser-BkNyPQKB.js} +1 -1
  142. package/src/assets/web-panel/assets/{omit-6Y51gDB9.js → omit-BaXJXgHA.js} +1 -1
  143. package/src/assets/web-panel/assets/{pickAttrs-Cy6EHbq9.js → pickAttrs-Bw6-YTxH.js} +1 -1
  144. package/src/assets/web-panel/assets/{placementArrow-BYxdAm0p.js → placementArrow-BLdbkLqJ.js} +1 -1
  145. package/src/assets/web-panel/assets/{responsiveObserve-C9R6q_cr.js → responsiveObserve-IVYkzntU.js} +1 -1
  146. package/src/assets/web-panel/assets/{slide-CXUJlilB.js → slide-DFMFwwtY.js} +1 -1
  147. package/src/assets/web-panel/assets/{statusUtils-DOtrOeql.js → statusUtils-CURYRtZ1.js} +1 -1
  148. package/src/assets/web-panel/assets/{styleChecker-D5r39o92.js → styleChecker-Cfz63s1r.js} +1 -1
  149. package/src/assets/web-panel/assets/useFlexGapSupport-COJL0KE3.js +1 -0
  150. package/src/assets/web-panel/assets/{useFs-BkrT-Zbc.js → useFs-YLLojQQf.js} +1 -1
  151. package/src/assets/web-panel/assets/{useMergedState-Cl2q1fes.js → useMergedState-Dc9sO2d7.js} +1 -1
  152. package/src/assets/web-panel/assets/{usePersonalDataHub-BwnnnZjU.js → usePersonalDataHub-BF_21qUC.js} +1 -1
  153. package/src/assets/web-panel/assets/{useRefs-DD9q4mOT.js → useRefs-CAeFRzPf.js} +1 -1
  154. package/src/assets/web-panel/assets/{useState-BK4hy8Ma.js → useState-LwExUYDO.js} +1 -1
  155. package/src/assets/web-panel/assets/{vendor-BvqAck49.js → vendor-BMxUs6UX.js} +16 -16
  156. package/src/assets/web-panel/assets/{vnode-SU-N4pum.js → vnode-9ZpkA7bb.js} +1 -1
  157. package/src/assets/web-panel/assets/{zoom-DHScfpTP.js → zoom-DhtAfhl8.js} +1 -1
  158. package/src/assets/web-panel/index.html +3 -3
  159. package/src/commands/serve.js +10 -0
  160. package/src/gateways/remote-session-relay.js +160 -0
  161. package/src/gateways/ws/message-dispatcher.js +32 -0
  162. package/src/gateways/ws/remote-session-protocol.js +429 -0
  163. package/src/gateways/ws/ws-server.js +299 -0
  164. package/src/harness/golden-transcript.js +65 -0
  165. package/src/harness/mcp-client.js +6 -2
  166. package/src/harness/remote-session-audit-sink.js +132 -0
  167. package/src/harness/remote-session-audit.js +110 -0
  168. package/src/harness/remote-session-crypto.js +217 -0
  169. package/src/harness/remote-session-push-fcm.js +254 -0
  170. package/src/harness/remote-session-push.js +104 -0
  171. package/src/harness/remote-session-registry.js +420 -0
  172. package/src/lib/did-manager.js +6 -4
  173. package/src/lib/mcp-oauth.js +23 -6
  174. package/src/lib/session-manager.js +7 -3
  175. package/src/runtime/agent-runtime.js +8 -13
  176. package/src/runtime/policies/agent-policy.js +2 -0
  177. package/src/assets/web-panel/assets/ChatBubbleRenderer-D2KfETZD.js +0 -1
  178. package/src/assets/web-panel/assets/Compliance-T8EtTecW.js +0 -1
  179. package/src/assets/web-panel/assets/MobileBridge-CYaITZ7w.js +0 -1
  180. package/src/assets/web-panel/assets/TimelineRenderer-Bv7uZRM7.js +0 -1
  181. package/src/assets/web-panel/assets/devWarning-6YJJMjSH.js +0 -1
  182. package/src/assets/web-panel/assets/index-D_oeGLr6.js +0 -1
  183. package/src/assets/web-panel/assets/index-Qa57YG19.js +0 -1
  184. package/src/assets/web-panel/assets/useFlexGapSupport-DfAD5U4Z.js +0 -1
@@ -27,6 +27,19 @@ import {
27
27
  createRuntimeEvent,
28
28
  } from "../../runtime/runtime-events.js";
29
29
  import { createWsMessageDispatcher } from "./message-dispatcher.js";
30
+ import {
31
+ RemoteSessionRegistry,
32
+ RemoteSessionPolicy,
33
+ } from "../../harness/remote-session-registry.js";
34
+ import { RemoteSessionAuditLog } from "../../harness/remote-session-audit.js";
35
+ import { RemoteSessionAuditFileSink } from "../../harness/remote-session-audit-sink.js";
36
+ import {
37
+ RemoteSessionPushDispatcher,
38
+ isApprovalRequestEvent,
39
+ } from "../../harness/remote-session-push.js";
40
+ import { createRemoteSessionPushSender } from "../../harness/remote-session-push-fcm.js";
41
+ import { RemoteSessionRelay } from "../remote-session-relay.js";
42
+ import { handleRemoteSessionPublish } from "./remote-session-protocol.js";
30
43
  import { handleTaskDetail, handleTaskHistory } from "./task-protocol.js";
31
44
  import {
32
45
  handleSessionCreate,
@@ -226,6 +239,63 @@ export class ChainlessChainWSServer extends EventEmitter {
226
239
 
227
240
  /** Session handlers: sessionId → WSAgentHandler | WSChatHandler */
228
241
  this.sessionHandlers = new Map();
242
+ /** Org-policy constraints for Remote Sessions (scopes, device cap, TTL). */
243
+ this.remoteSessionPolicy =
244
+ options.remoteSessionPolicy || RemoteSessionPolicy.fromEnv(process.env);
245
+ /** Local authorization state for multi-device Remote Sessions. */
246
+ this.remoteSessions =
247
+ options.remoteSessionRegistry ||
248
+ new RemoteSessionRegistry({ policy: this.remoteSessionPolicy });
249
+ /**
250
+ * Optional durable sink (JSONL) for the audit trail. Enabled only when a
251
+ * sink is injected or CHAINLESSCHAIN_REMOTE_SESSION_AUDIT_FILE is set; the
252
+ * in-memory ring stays the primary store and query surface.
253
+ */
254
+ this.remoteSessionAuditSink =
255
+ options.remoteSessionAuditSink ||
256
+ RemoteSessionAuditFileSink.fromEnv(process.env);
257
+ /** Bounded in-memory audit trail for Remote Session lifecycle + control. */
258
+ this.remoteSessionAudit =
259
+ options.remoteSessionAudit ||
260
+ new RemoteSessionAuditLog(
261
+ this.remoteSessionAuditSink
262
+ ? {
263
+ sink: this.remoteSessionAuditSink.handler,
264
+ // Hydrate so audit queries survive a restart; cap to the ring size.
265
+ initialEntries: this.remoteSessionAuditSink.readAll({
266
+ limit: 1000,
267
+ }),
268
+ }
269
+ : {},
270
+ );
271
+ /**
272
+ * Vendor-push dispatcher — wakes backgrounded paired devices for approval
273
+ * requests. Credential-free by default (a no-op until a `sender` is
274
+ * injected); FCM/APNs delivery is host-supplied.
275
+ */
276
+ this.remoteSessionPush =
277
+ options.remoteSessionPush ||
278
+ RemoteSessionPushDispatcher.fromEnv(process.env, {
279
+ // Prefer an explicitly injected sender (tests / custom transport); else
280
+ // build a real FCM sender from env when service-account creds are
281
+ // configured. Stays a no-op (null) when neither is present.
282
+ sender:
283
+ options.remoteSessionPushSender ||
284
+ createRemoteSessionPushSender(process.env),
285
+ });
286
+ this.remoteSessionRelayUrl = options.remoteSessionRelayUrl || null;
287
+ this.remoteSessionPeerId = options.remoteSessionPeerId || null;
288
+ this.remoteSessionCrypto = new Map();
289
+ this.remoteSessionPairingSecrets = new Map();
290
+ this.remoteSessionRelay =
291
+ options.remoteSessionRelay ||
292
+ (this.remoteSessionRelayUrl && this.remoteSessionPeerId
293
+ ? new RemoteSessionRelay({
294
+ relayUrl: this.remoteSessionRelayUrl,
295
+ peerId: this.remoteSessionPeerId,
296
+ })
297
+ : null);
298
+ this._attachRemoteSessionRelay();
229
299
  this._dispatcher = createWsMessageDispatcher(this);
230
300
 
231
301
  this._heartbeatTimer = null;
@@ -251,6 +321,12 @@ export class ChainlessChainWSServer extends EventEmitter {
251
321
  this.port = addr.port;
252
322
  }
253
323
  this._startHeartbeat();
324
+ this.remoteSessionRelay?.connect().catch((error) => {
325
+ this.emit("client-error", {
326
+ clientId: "remote-session-relay",
327
+ error: error.message,
328
+ });
329
+ });
254
330
  this.emit("listening", { port: this.port, host: this.host });
255
331
  resolve();
256
332
  });
@@ -285,6 +361,9 @@ export class ChainlessChainWSServer extends EventEmitter {
285
361
  }
286
362
  }
287
363
  this.sessionHandlers.clear();
364
+ this.remoteSessionCrypto.clear();
365
+ this.remoteSessionPairingSecrets.clear();
366
+ this.remoteSessionRelay?.close();
288
367
 
289
368
  // Kill all running child processes
290
369
  for (const [id, child] of this.processes) {
@@ -391,6 +470,18 @@ export class ChainlessChainWSServer extends EventEmitter {
391
470
  client.authTimer = null;
392
471
  }
393
472
  this.clients.delete(clientId);
473
+ for (const affected of this.remoteSessions.removeClient(clientId)) {
474
+ if (affected.closed)
475
+ this.remoteSessionCrypto.delete(affected.sessionId);
476
+ if (affected.closed)
477
+ this.remoteSessionPairingSecrets.delete(affected.sessionId);
478
+ this.remoteSessionAudit?.record({
479
+ sessionId: affected.sessionId,
480
+ actor: clientId,
481
+ action: affected.closed ? "session.closed" : "device.disconnected",
482
+ detail: { reason: "disconnect" },
483
+ });
484
+ }
394
485
  this.emit("disconnection", { clientId });
395
486
  });
396
487
 
@@ -975,12 +1066,220 @@ export class ChainlessChainWSServer extends EventEmitter {
975
1066
  if (ws.readyState === ws.OPEN) {
976
1067
  try {
977
1068
  ws.send(JSON.stringify(data));
1069
+ this._mirrorRemoteSessionEvent(ws, data);
978
1070
  } catch (_err) {
979
1071
  // Connection may have just closed
980
1072
  }
981
1073
  }
982
1074
  }
983
1075
 
1076
+ /** Mirror canonical Agent Session output to paired Remote Session clients. */
1077
+ _mirrorRemoteSessionEvent(hostWs, data) {
1078
+ if (
1079
+ !data ||
1080
+ typeof data !== "object" ||
1081
+ !data.sessionId ||
1082
+ String(data.type || "").startsWith("remote-session-")
1083
+ ) {
1084
+ return;
1085
+ }
1086
+ let hostClientId = null;
1087
+ for (const [clientId, client] of this.clients) {
1088
+ if (client.ws === hostWs) {
1089
+ hostClientId = clientId;
1090
+ break;
1091
+ }
1092
+ }
1093
+ if (!hostClientId) return;
1094
+
1095
+ for (const remoteSession of this.remoteSessions.findHosted(
1096
+ data.sessionId,
1097
+ hostClientId,
1098
+ )) {
1099
+ for (const member of remoteSession.members.values()) {
1100
+ if (
1101
+ member.clientId === hostClientId ||
1102
+ !member.scopes.includes("observe")
1103
+ ) {
1104
+ continue;
1105
+ }
1106
+ // Wake a backgrounded device via vendor push for approval requests.
1107
+ // Independent of the WS mirror below — a relay-paired mobile app may
1108
+ // report a live socket while its process is suspended and will never
1109
+ // read the mirrored event until the user taps the push.
1110
+ if (member.pushToken && isApprovalRequestEvent(data.type)) {
1111
+ this._dispatchApprovalPush(remoteSession, member, data);
1112
+ }
1113
+ const target = this.clients.get(member.clientId);
1114
+ if (!target && this.remoteSessionRelay) {
1115
+ const crypto = this.remoteSessionCrypto.get(remoteSession.sessionId);
1116
+ if (crypto) {
1117
+ this.remoteSessionRelay.sendEncrypted(
1118
+ member.clientId,
1119
+ crypto.encrypt(member.clientId, data),
1120
+ );
1121
+ }
1122
+ continue;
1123
+ }
1124
+ if (!target) continue;
1125
+ this._send(target.ws, {
1126
+ type: "remote-session-event",
1127
+ remoteSessionId: remoteSession.sessionId,
1128
+ agentSessionId: remoteSession.agentSessionId,
1129
+ event: data,
1130
+ });
1131
+ }
1132
+ }
1133
+ }
1134
+
1135
+ /**
1136
+ * Fire-and-forget a vendor push to one member for an approval request, then
1137
+ * record the outcome in the audit trail. Never throws (the dispatcher is
1138
+ * best-effort); the WS mirror + in-app notification remain primary.
1139
+ */
1140
+ _dispatchApprovalPush(remoteSession, member, data) {
1141
+ const dispatcher = this.remoteSessionPush;
1142
+ if (!dispatcher || !dispatcher.enabled) return;
1143
+ const requestId =
1144
+ data.requestId || data.approvalId || data.id || `approval:${data.type}`;
1145
+ dispatcher
1146
+ .dispatch({
1147
+ token: member.pushToken,
1148
+ provider: member.pushProvider,
1149
+ sessionId: remoteSession.sessionId,
1150
+ clientId: member.clientId,
1151
+ dedupeKey: requestId,
1152
+ notification: {
1153
+ title: "Approval requested",
1154
+ body: "A coding session needs your approval",
1155
+ },
1156
+ })
1157
+ .then((outcome) => {
1158
+ // A vendor that reports the token as retired means the app was
1159
+ // uninstalled / the token rotated — prune it so it is never retried.
1160
+ if (outcome.code === "PUSH_TOKEN_UNREGISTERED") {
1161
+ try {
1162
+ this.remoteSessions.registerPush(
1163
+ remoteSession.sessionId,
1164
+ member.clientId,
1165
+ { token: null },
1166
+ );
1167
+ } catch {
1168
+ // Member may have been revoked meanwhile — nothing to prune.
1169
+ }
1170
+ this.remoteSessionAudit?.record({
1171
+ sessionId: remoteSession.sessionId,
1172
+ actor: member.clientId,
1173
+ action: "push.unregistered",
1174
+ detail: { provider: member.pushProvider || null },
1175
+ });
1176
+ return;
1177
+ }
1178
+ this.remoteSessionAudit?.record({
1179
+ sessionId: remoteSession.sessionId,
1180
+ actor: member.clientId,
1181
+ action:
1182
+ outcome.status === "sent"
1183
+ ? "push.sent"
1184
+ : outcome.status === "failed"
1185
+ ? "push.failed"
1186
+ : "push.skipped",
1187
+ detail: {
1188
+ provider: member.pushProvider || null,
1189
+ reason: outcome.reason || null,
1190
+ error: outcome.error || null,
1191
+ },
1192
+ });
1193
+ })
1194
+ .catch(() => {
1195
+ // Dispatcher never rejects, but guard anyway — a failed courtesy push
1196
+ // must not surface as an unhandled rejection.
1197
+ });
1198
+ }
1199
+
1200
+ _attachRemoteSessionRelay() {
1201
+ if (!this.remoteSessionRelay) return;
1202
+ this.remoteSessionRelay.on("relay-message", (message) => {
1203
+ this._handleRemoteRelayMessage(message).catch((error) => {
1204
+ this.emit("client-error", {
1205
+ clientId: "remote-session-relay",
1206
+ error: error.message,
1207
+ });
1208
+ });
1209
+ });
1210
+ this.remoteSessionRelay.on("encrypted-message", ({ from, envelope }) => {
1211
+ this._handleRemoteEncryptedControl(from, envelope).catch((error) => {
1212
+ this.emit("client-error", { clientId: from, error: error.message });
1213
+ });
1214
+ });
1215
+ }
1216
+
1217
+ async _handleRemoteRelayMessage(message) {
1218
+ if (
1219
+ message?.type !== "message" ||
1220
+ message.payload?.type !== "remote-session.pair"
1221
+ )
1222
+ return;
1223
+ const payload = message.payload;
1224
+ const sessionId = payload.envelope?.sessionId;
1225
+ const crypto = this.remoteSessionCrypto.get(sessionId);
1226
+ const token = this.remoteSessionPairingSecrets.get(sessionId);
1227
+ if (!crypto || !token)
1228
+ throw new Error("Remote Session pairing is unavailable or expired");
1229
+ crypto.pair(payload.mobilePeerId, payload.mobilePublicKey, token);
1230
+ const join = crypto.decrypt(payload.envelope);
1231
+ if (join.type !== "pair.join" || join.token !== token) {
1232
+ throw new Error("Invalid encrypted Remote Session pairing request");
1233
+ }
1234
+ const relayMember = this.remoteSessions.join({
1235
+ sessionId,
1236
+ clientId: payload.mobilePeerId,
1237
+ token: join.token,
1238
+ via: "relay",
1239
+ pushToken: join.pushToken,
1240
+ pushProvider: join.pushProvider,
1241
+ });
1242
+ this.remoteSessionAudit?.record({
1243
+ sessionId,
1244
+ actor: payload.mobilePeerId,
1245
+ action: "device.joined",
1246
+ detail: {
1247
+ via: "relay",
1248
+ hasPush: relayMember?.member?.pushToken ? true : false,
1249
+ },
1250
+ });
1251
+ this.remoteSessionPairingSecrets.delete(sessionId);
1252
+ this.remoteSessionRelay.sendEncrypted(
1253
+ payload.mobilePeerId,
1254
+ crypto.encrypt(payload.mobilePeerId, {
1255
+ type: "pair.accepted",
1256
+ remoteSessionId: sessionId,
1257
+ }),
1258
+ );
1259
+ }
1260
+
1261
+ async _handleRemoteEncryptedControl(from, envelope) {
1262
+ const crypto = this.remoteSessionCrypto.get(envelope?.sessionId);
1263
+ if (!crypto) throw new Error("Remote Session crypto context not found");
1264
+ const event = crypto.decrypt(envelope);
1265
+ const virtualWs = {
1266
+ OPEN: 1,
1267
+ readyState: 1,
1268
+ send: (raw) => {
1269
+ const response = JSON.parse(raw);
1270
+ this.remoteSessionRelay.sendEncrypted(
1271
+ from,
1272
+ crypto.encrypt(from, response),
1273
+ );
1274
+ },
1275
+ };
1276
+ await handleRemoteSessionPublish(this, from, virtualWs, {
1277
+ id: `remote-${Date.now()}`,
1278
+ remoteSessionId: envelope.sessionId,
1279
+ event,
1280
+ });
1281
+ }
1282
+
984
1283
  /** @private — broadcast a message to all connected, authenticated clients */
985
1284
  _broadcast(data) {
986
1285
  for (const [, client] of this.clients) {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Normalize runtime events before comparing them with a golden transcript.
3
+ *
4
+ * Runtime envelopes intentionally contain volatile identity and timing fields.
5
+ * Golden files should describe observable behavior, not a particular run, so
6
+ * those fields are removed recursively and platform-specific workspace paths
7
+ * are replaced with a stable token.
8
+ */
9
+
10
+ const VOLATILE_KEYS = new Set([
11
+ "eventId",
12
+ "id",
13
+ "requestId",
14
+ "sessionId",
15
+ "sequence",
16
+ "timestamp",
17
+ "createdAt",
18
+ "updatedAt",
19
+ "duration",
20
+ "durationMs",
21
+ "toolTelemetryRecord",
22
+ ]);
23
+
24
+ function normalizeString(value, workspace) {
25
+ if (!workspace) return value;
26
+ const variants = new Set([
27
+ workspace,
28
+ workspace.replaceAll("\\", "/"),
29
+ workspace.replaceAll("/", "\\"),
30
+ ]);
31
+ let normalized = value;
32
+ for (const variant of variants) {
33
+ normalized = normalized.split(variant).join("<WORKSPACE>");
34
+ }
35
+ return normalized.replaceAll("<WORKSPACE>\\", "<WORKSPACE>/");
36
+ }
37
+
38
+ function normalizeValue(value, workspace) {
39
+ if (typeof value === "string") return normalizeString(value, workspace);
40
+ if (Array.isArray(value)) {
41
+ return value.map((item) => normalizeValue(item, workspace));
42
+ }
43
+ if (!value || typeof value !== "object") return value;
44
+
45
+ return Object.fromEntries(
46
+ Object.entries(value)
47
+ .filter(([key]) => !VOLATILE_KEYS.has(key))
48
+ .sort(([left], [right]) => left.localeCompare(right))
49
+ .map(([key, item]) => [key, normalizeValue(item, workspace)]),
50
+ );
51
+ }
52
+
53
+ /**
54
+ * @param {Array<object>} events
55
+ * @param {{workspace?: string, ignoredTypes?: string[]}} [options]
56
+ */
57
+ export function normalizeGoldenTranscript(events, options = {}) {
58
+ if (!Array.isArray(events)) {
59
+ throw new TypeError("Golden transcript input must be an array");
60
+ }
61
+ const ignored = new Set(options.ignoredTypes || ["run-started", "run-ended"]);
62
+ return events
63
+ .filter((event) => !ignored.has(event?.type))
64
+ .map((event) => normalizeValue(event, options.workspace));
65
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { spawn } from "child_process";
12
12
  import { EventEmitter } from "events";
13
+ import { safeJsonParse } from "../lib/safe-json.js";
13
14
 
14
15
  /**
15
16
  * Injectable dependencies — overridable from tests.
@@ -1156,8 +1157,11 @@ export class MCPServerConfig {
1156
1157
  return {
1157
1158
  name: row.name,
1158
1159
  command: row.command || null,
1159
- args: JSON.parse(row.args || "[]"),
1160
- env: JSON.parse(row.env || "{}"),
1160
+ // safeJsonParse, not bare JSON.parse: list()/getAutoConnect() map every
1161
+ // row through here, so one corrupt cell must not take down the whole
1162
+ // MCP server list (`|| "[]"` only guards NULL, not a corrupt non-empty string).
1163
+ args: safeJsonParse(row.args, []),
1164
+ env: safeJsonParse(row.env, {}),
1161
1165
  autoConnect: row.auto_connect === 1,
1162
1166
  url: row.url || null,
1163
1167
  transport:
@@ -0,0 +1,132 @@
1
+ // Remote Session audit log — durable JSONL sink.
2
+ //
3
+ // Attaches to RemoteSessionAuditLog's `sink` seam to persist the (already
4
+ // privacy-filtered) audit trail to a newline-delimited JSON file so it survives
5
+ // a host restart for forensics. Each entry is one JSON line; the file is
6
+ // size-bounded with simple rotation (audit.jsonl → audit.jsonl.1 → …) so it can
7
+ // never grow unbounded. Reads tolerate a torn last line (crash mid-append) by
8
+ // skipping any line that will not parse.
9
+ //
10
+ // Deliberately dependency-free (node:fs only) and injectable — a SQLite-backed
11
+ // sink could implement the same { handler, readAll } shape later without
12
+ // touching the audit log or its call sites.
13
+
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; // 5 MiB
18
+ const DEFAULT_BACKUPS = 1;
19
+
20
+ export class RemoteSessionAuditFileSink {
21
+ constructor(options = {}) {
22
+ if (!options.path) {
23
+ throw new Error("RemoteSessionAuditFileSink requires a path");
24
+ }
25
+ this.path = options.path;
26
+ this.maxBytes = options.maxBytes || DEFAULT_MAX_BYTES;
27
+ this.backups = Number.isInteger(options.backups)
28
+ ? options.backups
29
+ : DEFAULT_BACKUPS;
30
+ this.fs = options.fs || fs;
31
+ this._ensureDir();
32
+ }
33
+
34
+ _ensureDir() {
35
+ try {
36
+ this.fs.mkdirSync(path.dirname(this.path), { recursive: true });
37
+ } catch {
38
+ // Best effort — a later append will surface a real error if the dir is
39
+ // genuinely unwritable, and the audit log swallows sink throws anyway.
40
+ }
41
+ }
42
+
43
+ /** Bound function to pass as RemoteSessionAuditLog({ sink }). */
44
+ get handler() {
45
+ return (entry) => this.append(entry);
46
+ }
47
+
48
+ /** Append one entry as a JSON line, rotating first if it would overflow. */
49
+ append(entry) {
50
+ const line = `${JSON.stringify(entry)}\n`;
51
+ this._rotateIfNeeded(Buffer.byteLength(line, "utf8"));
52
+ this.fs.appendFileSync(this.path, line, "utf8");
53
+ }
54
+
55
+ _rotateIfNeeded(incomingBytes) {
56
+ let size = 0;
57
+ try {
58
+ size = this.fs.statSync(this.path).size;
59
+ } catch {
60
+ return; // No file yet — nothing to rotate.
61
+ }
62
+ if (size + incomingBytes <= this.maxBytes) return;
63
+ if (this.backups <= 0) {
64
+ // No history kept — truncate by removing the active file.
65
+ try {
66
+ this.fs.rmSync(this.path);
67
+ } catch {
68
+ /* ignore */
69
+ }
70
+ return;
71
+ }
72
+ // Shift backups: .(<n-1>) → .(<n>), …, active → .1, dropping the oldest.
73
+ for (let i = this.backups; i >= 1; i -= 1) {
74
+ const src = i === 1 ? this.path : `${this.path}.${i - 1}`;
75
+ const dst = `${this.path}.${i}`;
76
+ try {
77
+ if (this.fs.existsSync(src)) this.fs.renameSync(src, dst);
78
+ } catch {
79
+ /* ignore a single failed rotation step */
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Read persisted entries oldest-first across the active file and its backups.
86
+ * Corrupt/partial lines are skipped. `limit` keeps only the newest N.
87
+ */
88
+ readAll({ limit } = {}) {
89
+ const files = [];
90
+ for (let i = this.backups; i >= 1; i -= 1) files.push(`${this.path}.${i}`);
91
+ files.push(this.path);
92
+ const entries = [];
93
+ for (const file of files) {
94
+ let raw;
95
+ try {
96
+ raw = this.fs.readFileSync(file, "utf8");
97
+ } catch {
98
+ continue; // Missing backup slot — expected.
99
+ }
100
+ for (const line of raw.split("\n")) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed) continue;
103
+ try {
104
+ entries.push(JSON.parse(trimmed));
105
+ } catch {
106
+ // Torn write (crash mid-append) or manual edit — skip the line.
107
+ }
108
+ }
109
+ }
110
+ if (limit && limit > 0 && entries.length > limit) {
111
+ return entries.slice(entries.length - limit);
112
+ }
113
+ return entries;
114
+ }
115
+
116
+ /**
117
+ * Build a sink from env, or null when no audit file is configured. Enable with
118
+ * CHAINLESSCHAIN_REMOTE_SESSION_AUDIT_FILE (path); tune the rotation ceiling
119
+ * with CHAINLESSCHAIN_REMOTE_SESSION_AUDIT_MAX_BYTES.
120
+ */
121
+ static fromEnv(env = {}, options = {}) {
122
+ const filePath = env.CHAINLESSCHAIN_REMOTE_SESSION_AUDIT_FILE;
123
+ if (!filePath) return null;
124
+ const maxBytes =
125
+ Number(env.CHAINLESSCHAIN_REMOTE_SESSION_AUDIT_MAX_BYTES) || undefined;
126
+ return new RemoteSessionAuditFileSink({
127
+ path: filePath,
128
+ maxBytes,
129
+ ...options,
130
+ });
131
+ }
132
+ }
@@ -0,0 +1,110 @@
1
+ // Remote Session audit log.
2
+ //
3
+ // A bounded, in-memory, privacy-respecting audit trail for one CLI host process
4
+ // — matched to the ephemeral (in-memory, 12h TTL) nature of remote sessions.
5
+ // Captures WHO did WHAT and WHEN across a session's lifecycle and remote control
6
+ // events, but deliberately does NOT record prompt/tool payloads (only shapes
7
+ // like content length), keeping the trail useful for forensics without
8
+ // hoarding session content. A pluggable `sink` seam lets a durable backend
9
+ // (JSONL file, SQLite audit_log) be attached later without touching call sites.
10
+
11
+ const DEFAULT_MAX_ENTRIES = 1000;
12
+
13
+ export const REMOTE_SESSION_AUDIT_ACTIONS = Object.freeze([
14
+ "session.created",
15
+ "pairing-token.issued",
16
+ "device.joined",
17
+ "device.revoked",
18
+ "device.disconnected",
19
+ "session.closed",
20
+ "control.prompt",
21
+ "control.approval",
22
+ "control.interrupt",
23
+ "push.registered",
24
+ "push.sent",
25
+ "push.failed",
26
+ "push.skipped",
27
+ "push.unregistered",
28
+ ]);
29
+
30
+ export class RemoteSessionAuditLog {
31
+ constructor(options = {}) {
32
+ this.now = options.now || Date.now;
33
+ this.maxEntries = options.maxEntries || DEFAULT_MAX_ENTRIES;
34
+ this.sink = typeof options.sink === "function" ? options.sink : null;
35
+ this.entries = [];
36
+ this.seq = 0;
37
+ // Hydrate from a durable sink so audit queries survive a host restart. The
38
+ // persisted entries are NOT re-written to the sink (they are already
39
+ // there); we only pre-fill the in-memory ring and carry the seq high-water
40
+ // forward so new entries stay strictly monotonic.
41
+ if (Array.isArray(options.initialEntries)) {
42
+ this._hydrate(options.initialEntries);
43
+ }
44
+ }
45
+
46
+ _hydrate(entries) {
47
+ const recent = entries.slice(-this.maxEntries);
48
+ for (const entry of recent) {
49
+ if (!entry || !entry.action) continue;
50
+ this.entries.push({ ...entry });
51
+ if (typeof entry.seq === "number" && entry.seq > this.seq) {
52
+ this.seq = entry.seq;
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Append an audit entry. Never throws into the caller — a broken sink or bad
59
+ * argument must not take down the live session it is auditing.
60
+ */
61
+ record({ sessionId = null, actor = null, action, detail = null } = {}) {
62
+ if (!action) return null;
63
+ const entry = {
64
+ seq: (this.seq += 1),
65
+ timestamp: this.now(),
66
+ sessionId,
67
+ actor,
68
+ action,
69
+ detail: detail && typeof detail === "object" ? { ...detail } : detail,
70
+ };
71
+ this.entries.push(entry);
72
+ if (this.entries.length > this.maxEntries) {
73
+ this.entries.splice(0, this.entries.length - this.maxEntries);
74
+ }
75
+ if (this.sink) {
76
+ try {
77
+ this.sink(entry);
78
+ } catch {
79
+ // A durable-sink failure must not break the session being audited.
80
+ }
81
+ }
82
+ return entry;
83
+ }
84
+
85
+ /** Newest-first, optionally filtered by session and/or action. */
86
+ list({ sessionId, action, limit } = {}) {
87
+ let out = this.entries;
88
+ if (sessionId) out = out.filter((entry) => entry.sessionId === sessionId);
89
+ if (action) out = out.filter((entry) => entry.action === action);
90
+ out = [...out].reverse();
91
+ if (limit && limit > 0) out = out.slice(0, limit);
92
+ return out.map((entry) => ({ ...entry }));
93
+ }
94
+
95
+ stats(sessionId) {
96
+ const scope = sessionId
97
+ ? this.entries.filter((entry) => entry.sessionId === sessionId)
98
+ : this.entries;
99
+ const byAction = {};
100
+ for (const entry of scope) {
101
+ byAction[entry.action] = (byAction[entry.action] || 0) + 1;
102
+ }
103
+ return { total: scope.length, byAction };
104
+ }
105
+
106
+ clear() {
107
+ this.entries = [];
108
+ this.seq = 0;
109
+ }
110
+ }