agim-cli 1.2.22 → 1.2.35

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 (307) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +8 -8
  3. package/README.zh-CN.md +6 -6
  4. package/dist/cli-ui/cmd-handlers.d.ts.map +1 -1
  5. package/dist/cli-ui/cmd-handlers.js +172 -13
  6. package/dist/cli-ui/cmd-handlers.js.map +1 -1
  7. package/dist/cli-ui/config-wizard.d.ts.map +1 -1
  8. package/dist/cli-ui/config-wizard.js +134 -24
  9. package/dist/cli-ui/config-wizard.js.map +1 -1
  10. package/dist/cli-ui/i18n.d.ts +33 -4
  11. package/dist/cli-ui/i18n.d.ts.map +1 -1
  12. package/dist/cli-ui/i18n.js +66 -8
  13. package/dist/cli-ui/i18n.js.map +1 -1
  14. package/dist/cli-ui/service.d.ts +44 -0
  15. package/dist/cli-ui/service.d.ts.map +1 -1
  16. package/dist/cli-ui/service.js +176 -8
  17. package/dist/cli-ui/service.js.map +1 -1
  18. package/dist/cli.js +193 -33
  19. package/dist/cli.js.map +1 -1
  20. package/dist/core/access-token.d.ts +51 -5
  21. package/dist/core/access-token.d.ts.map +1 -1
  22. package/dist/core/access-token.js +114 -15
  23. package/dist/core/access-token.js.map +1 -1
  24. package/dist/core/acp-server.d.ts.map +1 -1
  25. package/dist/core/acp-server.js +44 -3
  26. package/dist/core/acp-server.js.map +1 -1
  27. package/dist/core/admin-allowlist.d.ts +4 -1
  28. package/dist/core/admin-allowlist.d.ts.map +1 -1
  29. package/dist/core/admin-allowlist.js +14 -1
  30. package/dist/core/admin-allowlist.js.map +1 -1
  31. package/dist/core/admin-bootstrap.d.ts.map +1 -1
  32. package/dist/core/admin-bootstrap.js +8 -5
  33. package/dist/core/admin-bootstrap.js.map +1 -1
  34. package/dist/core/agent-base.d.ts.map +1 -1
  35. package/dist/core/agent-base.js +25 -0
  36. package/dist/core/agent-base.js.map +1 -1
  37. package/dist/core/agent-cwd.d.ts +1 -1
  38. package/dist/core/agent-cwd.d.ts.map +1 -1
  39. package/dist/core/agent-cwd.js +73 -2
  40. package/dist/core/agent-cwd.js.map +1 -1
  41. package/dist/core/agent-helper.d.ts +1 -1
  42. package/dist/core/agent-helper.js +4 -4
  43. package/dist/core/agent-helper.js.map +1 -1
  44. package/dist/core/approval-bus.d.ts +10 -0
  45. package/dist/core/approval-bus.d.ts.map +1 -1
  46. package/dist/core/approval-bus.js +130 -0
  47. package/dist/core/approval-bus.js.map +1 -1
  48. package/dist/core/approval-router.d.ts.map +1 -1
  49. package/dist/core/approval-router.js +18 -0
  50. package/dist/core/approval-router.js.map +1 -1
  51. package/dist/core/artifacts.d.ts +9 -1
  52. package/dist/core/artifacts.d.ts.map +1 -1
  53. package/dist/core/artifacts.js +22 -5
  54. package/dist/core/artifacts.js.map +1 -1
  55. package/dist/core/audit-log.d.ts +28 -0
  56. package/dist/core/audit-log.d.ts.map +1 -1
  57. package/dist/core/audit-log.js +100 -2
  58. package/dist/core/audit-log.js.map +1 -1
  59. package/dist/core/commands/coord.d.ts +4 -0
  60. package/dist/core/commands/coord.d.ts.map +1 -0
  61. package/dist/core/commands/coord.js +88 -0
  62. package/dist/core/commands/coord.js.map +1 -0
  63. package/dist/core/commands/service.d.ts +6 -5
  64. package/dist/core/commands/service.d.ts.map +1 -1
  65. package/dist/core/commands/service.js +59 -8
  66. package/dist/core/commands/service.js.map +1 -1
  67. package/dist/core/coord-systems.d.ts +10 -1
  68. package/dist/core/coord-systems.d.ts.map +1 -1
  69. package/dist/core/coord-systems.js +42 -17
  70. package/dist/core/coord-systems.js.map +1 -1
  71. package/dist/core/feature-flags.d.ts +10 -0
  72. package/dist/core/feature-flags.d.ts.map +1 -0
  73. package/dist/core/feature-flags.js +30 -0
  74. package/dist/core/feature-flags.js.map +1 -0
  75. package/dist/core/intent.d.ts.map +1 -1
  76. package/dist/core/intent.js +0 -4
  77. package/dist/core/intent.js.map +1 -1
  78. package/dist/core/logger.d.ts +31 -0
  79. package/dist/core/logger.d.ts.map +1 -1
  80. package/dist/core/logger.js +41 -1
  81. package/dist/core/logger.js.map +1 -1
  82. package/dist/core/memo-rpc.d.ts.map +1 -1
  83. package/dist/core/memo-rpc.js +6 -0
  84. package/dist/core/memo-rpc.js.map +1 -1
  85. package/dist/core/memory-distill.js +1 -1
  86. package/dist/core/memory-distill.js.map +1 -1
  87. package/dist/core/memory-rpc.d.ts.map +1 -1
  88. package/dist/core/memory-rpc.js +55 -12
  89. package/dist/core/memory-rpc.js.map +1 -1
  90. package/dist/core/memory.d.ts +5 -0
  91. package/dist/core/memory.d.ts.map +1 -1
  92. package/dist/core/memory.js +151 -1
  93. package/dist/core/memory.js.map +1 -1
  94. package/dist/core/memos.d.ts.map +1 -1
  95. package/dist/core/memos.js +20 -2
  96. package/dist/core/memos.js.map +1 -1
  97. package/dist/core/onboarding.d.ts +6 -0
  98. package/dist/core/onboarding.d.ts.map +1 -1
  99. package/dist/core/onboarding.js +21 -10
  100. package/dist/core/onboarding.js.map +1 -1
  101. package/dist/core/outbox.d.ts +8 -1
  102. package/dist/core/outbox.d.ts.map +1 -1
  103. package/dist/core/outbox.js +46 -2
  104. package/dist/core/outbox.js.map +1 -1
  105. package/dist/core/persona.d.ts.map +1 -1
  106. package/dist/core/persona.js +1 -18
  107. package/dist/core/persona.js.map +1 -1
  108. package/dist/core/prompt-injection-guard.d.ts +23 -0
  109. package/dist/core/prompt-injection-guard.d.ts.map +1 -0
  110. package/dist/core/prompt-injection-guard.js +94 -0
  111. package/dist/core/prompt-injection-guard.js.map +1 -0
  112. package/dist/core/registry.d.ts +8 -0
  113. package/dist/core/registry.d.ts.map +1 -1
  114. package/dist/core/registry.js +41 -7
  115. package/dist/core/registry.js.map +1 -1
  116. package/dist/core/restart-flow.d.ts.map +1 -1
  117. package/dist/core/restart-flow.js +27 -0
  118. package/dist/core/restart-flow.js.map +1 -1
  119. package/dist/core/router.d.ts.map +1 -1
  120. package/dist/core/router.js +22 -0
  121. package/dist/core/router.js.map +1 -1
  122. package/dist/core/sensitive-paths.d.ts +28 -0
  123. package/dist/core/sensitive-paths.d.ts.map +1 -0
  124. package/dist/core/sensitive-paths.js +234 -0
  125. package/dist/core/sensitive-paths.js.map +1 -0
  126. package/dist/core/session.d.ts +9 -0
  127. package/dist/core/session.d.ts.map +1 -1
  128. package/dist/core/session.js +35 -4
  129. package/dist/core/session.js.map +1 -1
  130. package/dist/core/types.d.ts +9 -1
  131. package/dist/core/types.d.ts.map +1 -1
  132. package/dist/core/user-coord-prefs.d.ts +17 -0
  133. package/dist/core/user-coord-prefs.d.ts.map +1 -0
  134. package/dist/core/user-coord-prefs.js +86 -0
  135. package/dist/core/user-coord-prefs.js.map +1 -0
  136. package/dist/core/viewer-local.d.ts +4 -0
  137. package/dist/core/viewer-local.d.ts.map +1 -1
  138. package/dist/core/viewer-local.js +74 -0
  139. package/dist/core/viewer-local.js.map +1 -1
  140. package/dist/plugins/agents/antigravity/ensure-mcp-config.d.ts +49 -0
  141. package/dist/plugins/agents/antigravity/ensure-mcp-config.d.ts.map +1 -0
  142. package/dist/plugins/agents/antigravity/ensure-mcp-config.js +110 -0
  143. package/dist/plugins/agents/antigravity/ensure-mcp-config.js.map +1 -0
  144. package/dist/plugins/agents/antigravity/index.d.ts +34 -0
  145. package/dist/plugins/agents/antigravity/index.d.ts.map +1 -0
  146. package/dist/plugins/agents/antigravity/index.js +330 -0
  147. package/dist/plugins/agents/antigravity/index.js.map +1 -0
  148. package/dist/plugins/agents/claude-code/mcp-approval-server.js +5 -5
  149. package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -1
  150. package/dist/plugins/messengers/feishu/card-builder.d.ts.map +1 -1
  151. package/dist/plugins/messengers/feishu/card-builder.js +0 -1
  152. package/dist/plugins/messengers/feishu/card-builder.js.map +1 -1
  153. package/dist/plugins/messengers/telegram/telegram-adapter.d.ts.map +1 -1
  154. package/dist/plugins/messengers/telegram/telegram-adapter.js +11 -2
  155. package/dist/plugins/messengers/telegram/telegram-adapter.js.map +1 -1
  156. package/dist/utils/cross-platform.d.ts +0 -5
  157. package/dist/utils/cross-platform.d.ts.map +1 -1
  158. package/dist/utils/cross-platform.js +1 -21
  159. package/dist/utils/cross-platform.js.map +1 -1
  160. package/dist/web/public/assets/{a2a-Dk2fSs33.js → a2a-BFM2Ojvs.js} +2 -2
  161. package/dist/web/public/assets/{a2a-Dk2fSs33.js.map → a2a-BFM2Ojvs.js.map} +1 -1
  162. package/dist/web/public/assets/{activity-eiIPshcV.js → activity-DNe1vn9s.js} +2 -2
  163. package/dist/web/public/assets/{activity-eiIPshcV.js.map → activity-DNe1vn9s.js.map} +1 -1
  164. package/dist/web/public/assets/{admins-DlbQYdW_.js → admins-DtIF8yji.js} +2 -2
  165. package/dist/web/public/assets/{admins-DlbQYdW_.js.map → admins-DtIF8yji.js.map} +1 -1
  166. package/dist/web/public/assets/agents-BJ3jyH1u.js +12 -0
  167. package/dist/web/public/assets/agents-BJ3jyH1u.js.map +1 -0
  168. package/dist/web/public/assets/{approvals-DlXS_sKD.js → approvals-8ifx3_0b.js} +2 -2
  169. package/dist/web/public/assets/{approvals-DlXS_sKD.js.map → approvals-8ifx3_0b.js.map} +1 -1
  170. package/dist/web/public/assets/{audit-C8I8xC_6.js → audit-BiFOljMy.js} +2 -2
  171. package/dist/web/public/assets/{audit-C8I8xC_6.js.map → audit-BiFOljMy.js.map} +1 -1
  172. package/dist/web/public/assets/{bgjobs-PFYinH7D.js → bgjobs-Dve01ab0.js} +2 -2
  173. package/dist/web/public/assets/{bgjobs-PFYinH7D.js.map → bgjobs-Dve01ab0.js.map} +1 -1
  174. package/dist/web/public/assets/{brain-DEEJttEL.js → brain-BI78EY6_.js} +2 -2
  175. package/dist/web/public/assets/{brain-DEEJttEL.js.map → brain-BI78EY6_.js.map} +1 -1
  176. package/dist/web/public/assets/{briefcase-BlMy8gI6.js → briefcase-CuwoOW31.js} +2 -2
  177. package/dist/web/public/assets/{briefcase-BlMy8gI6.js.map → briefcase-CuwoOW31.js.map} +1 -1
  178. package/dist/web/public/assets/{chevron-right-DmABPvoA.js → chevron-right-D5JanEQ3.js} +2 -2
  179. package/dist/web/public/assets/{chevron-right-DmABPvoA.js.map → chevron-right-D5JanEQ3.js.map} +1 -1
  180. package/dist/web/public/assets/{circle-check-C0Qpg1vL.js → circle-check-DXMzdFtD.js} +2 -2
  181. package/dist/web/public/assets/{circle-check-C0Qpg1vL.js.map → circle-check-DXMzdFtD.js.map} +1 -1
  182. package/dist/web/public/assets/{circle-check-big-C8LG3beV.js → circle-check-big-CyJIIhiq.js} +2 -2
  183. package/dist/web/public/assets/{circle-check-big-C8LG3beV.js.map → circle-check-big-CyJIIhiq.js.map} +1 -1
  184. package/dist/web/public/assets/{circle-x-D_cRHcHK.js → circle-x-DTwfNpvp.js} +2 -2
  185. package/dist/web/public/assets/{circle-x-D_cRHcHK.js.map → circle-x-DTwfNpvp.js.map} +1 -1
  186. package/dist/web/public/assets/{confirm-dialog-Baz_xFle.js → confirm-dialog-F0sdcLGN.js} +2 -2
  187. package/dist/web/public/assets/{confirm-dialog-Baz_xFle.js.map → confirm-dialog-F0sdcLGN.js.map} +1 -1
  188. package/dist/web/public/assets/{data-table--I_ktDF4.js → data-table-DN_-VLbp.js} +2 -2
  189. package/dist/web/public/assets/{data-table--I_ktDF4.js.map → data-table-DN_-VLbp.js.map} +1 -1
  190. package/dist/web/public/assets/{dialog-DZpoEskO.js → dialog-CM16nfWK.js} +2 -2
  191. package/dist/web/public/assets/{dialog-DZpoEskO.js.map → dialog-CM16nfWK.js.map} +1 -1
  192. package/dist/web/public/assets/{download-DbFGHwZ5.js → download-tFqY3Zj6.js} +2 -2
  193. package/dist/web/public/assets/{download-DbFGHwZ5.js.map → download-tFqY3Zj6.js.map} +1 -1
  194. package/dist/web/public/assets/{email-BB1Hq8eE.js → email-By2-ARPV.js} +2 -2
  195. package/dist/web/public/assets/{email-BB1Hq8eE.js.map → email-By2-ARPV.js.map} +1 -1
  196. package/dist/web/public/assets/{empty-state-DXNa90pP.js → empty-state-Dq9_8t-M.js} +2 -2
  197. package/dist/web/public/assets/{empty-state-DXNa90pP.js.map → empty-state-Dq9_8t-M.js.map} +1 -1
  198. package/dist/web/public/assets/{external-link-nhnJN0qg.js → external-link-amfAWSUX.js} +2 -2
  199. package/dist/web/public/assets/{external-link-nhnJN0qg.js.map → external-link-amfAWSUX.js.map} +1 -1
  200. package/dist/web/public/assets/{eye-IKkn_oUo.js → eye-BfgN09Lx.js} +2 -2
  201. package/dist/web/public/assets/{eye-IKkn_oUo.js.map → eye-BfgN09Lx.js.map} +1 -1
  202. package/dist/web/public/assets/{facts-C7Qy9vTw.js → facts-uvL__ilQ.js} +2 -2
  203. package/dist/web/public/assets/{facts-C7Qy9vTw.js.map → facts-uvL__ilQ.js.map} +1 -1
  204. package/dist/web/public/assets/{health-CMRdeNEW.js → health-fiU-ueEw.js} +2 -2
  205. package/dist/web/public/assets/{health-CMRdeNEW.js.map → health-fiU-ueEw.js.map} +1 -1
  206. package/dist/web/public/assets/{hot-Bh5Nrc7i.js → hot-Dt4V3jM_.js} +2 -2
  207. package/dist/web/public/assets/{hot-Bh5Nrc7i.js.map → hot-Dt4V3jM_.js.map} +1 -1
  208. package/dist/web/public/assets/{index-CpGWCLE5.js → index-6GMwymev.js} +8 -8
  209. package/dist/web/public/assets/index-6GMwymev.js.map +1 -0
  210. package/dist/web/public/assets/{index-GpceOxum.css → index-CDYTPZH0.css} +1 -1
  211. package/dist/web/public/assets/{installed-FYLkPij2.js → installed-B_x6no76.js} +2 -2
  212. package/dist/web/public/assets/{installed-FYLkPij2.js.map → installed-B_x6no76.js.map} +1 -1
  213. package/dist/web/public/assets/{jobs-BmqLUzHp.js → jobs-LqWH3CIe.js} +2 -2
  214. package/dist/web/public/assets/{jobs-BmqLUzHp.js.map → jobs-LqWH3CIe.js.map} +1 -1
  215. package/dist/web/public/assets/layout-CvxcdPD9.js +2 -0
  216. package/dist/web/public/assets/layout-CvxcdPD9.js.map +1 -0
  217. package/dist/web/public/assets/{layout-BZaHqf69.js → layout-DHUzlXrd.js} +2 -2
  218. package/dist/web/public/assets/{layout-BZaHqf69.js.map → layout-DHUzlXrd.js.map} +1 -1
  219. package/dist/web/public/assets/{layout-CXsUyEpG.js → layout-MNk0bLGe.js} +2 -2
  220. package/dist/web/public/assets/{layout-CXsUyEpG.js.map → layout-MNk0bLGe.js.map} +1 -1
  221. package/dist/web/public/assets/{layout-DFxtpNut.js → layout-WcrkE0es.js} +2 -2
  222. package/dist/web/public/assets/{layout-DFxtpNut.js.map → layout-WcrkE0es.js.map} +1 -1
  223. package/dist/web/public/assets/{layout-d8qxPKQk.js → layout-apvyE2JN.js} +2 -2
  224. package/dist/web/public/assets/{layout-d8qxPKQk.js.map → layout-apvyE2JN.js.map} +1 -1
  225. package/dist/web/public/assets/{loader-circle-JaKY-xMt.js → loader-circle-hxNy7hSm.js} +2 -2
  226. package/dist/web/public/assets/{loader-circle-JaKY-xMt.js.map → loader-circle-hxNy7hSm.js.map} +1 -1
  227. package/dist/web/public/assets/{map-pin-hFFSWZ3B.js → map-pin-CCmA7ke2.js} +2 -2
  228. package/dist/web/public/assets/{map-pin-hFFSWZ3B.js.map → map-pin-CCmA7ke2.js.map} +1 -1
  229. package/dist/web/public/assets/{memos-EhjMUvVZ.js → memos-pEjDfEj3.js} +2 -2
  230. package/dist/web/public/assets/{memos-EhjMUvVZ.js.map → memos-pEjDfEj3.js.map} +1 -1
  231. package/dist/web/public/assets/messengers-Ba7opEc1.js +7 -0
  232. package/dist/web/public/assets/messengers-Ba7opEc1.js.map +1 -0
  233. package/dist/web/public/assets/{network-DtCI2ZUU.js → network-DJw-ei_k.js} +2 -2
  234. package/dist/web/public/assets/{network-DtCI2ZUU.js.map → network-DJw-ei_k.js.map} +1 -1
  235. package/dist/web/public/assets/{outbox-CxUbMp6o.js → outbox-Ckq-VT5C.js} +2 -2
  236. package/dist/web/public/assets/{outbox-CxUbMp6o.js.map → outbox-Ckq-VT5C.js.map} +1 -1
  237. package/dist/web/public/assets/{pagination-CkZY8YNa.js → pagination-DGS-TnI5.js} +2 -2
  238. package/dist/web/public/assets/{pagination-CkZY8YNa.js.map → pagination-DGS-TnI5.js.map} +1 -1
  239. package/dist/web/public/assets/{persona-B6TFMSnI.js → persona--LsrhCVU.js} +2 -2
  240. package/dist/web/public/assets/{persona-B6TFMSnI.js.map → persona--LsrhCVU.js.map} +1 -1
  241. package/dist/web/public/assets/{play-BxRcWaH5.js → play-Cb7co2DX.js} +2 -2
  242. package/dist/web/public/assets/{play-BxRcWaH5.js.map → play-Cb7co2DX.js.map} +1 -1
  243. package/dist/web/public/assets/{policy-ndE1Y8zD.js → policy-CbCotzr6.js} +2 -2
  244. package/dist/web/public/assets/{policy-ndE1Y8zD.js.map → policy-CbCotzr6.js.map} +1 -1
  245. package/dist/web/public/assets/{refresh-ccw-Bx817_KW.js → refresh-ccw-CINxCmwV.js} +2 -2
  246. package/dist/web/public/assets/{refresh-ccw-Bx817_KW.js.map → refresh-ccw-CINxCmwV.js.map} +1 -1
  247. package/dist/web/public/assets/{reminders-XynkGQc5.js → reminders-CSKrWre3.js} +2 -2
  248. package/dist/web/public/assets/{reminders-XynkGQc5.js.map → reminders-CSKrWre3.js.map} +1 -1
  249. package/dist/web/public/assets/{save-CqMcATrh.js → save-Bib9iAA-.js} +2 -2
  250. package/dist/web/public/assets/{save-CqMcATrh.js.map → save-Bib9iAA-.js.map} +1 -1
  251. package/dist/web/public/assets/{schedules-VM02w_Om.js → schedules-DUD_FfEX.js} +2 -2
  252. package/dist/web/public/assets/{schedules-VM02w_Om.js.map → schedules-DUD_FfEX.js.map} +1 -1
  253. package/dist/web/public/assets/{search-Ba-e1t1P.js → search-DZOHNA81.js} +2 -2
  254. package/dist/web/public/assets/{search-Ba-e1t1P.js.map → search-DZOHNA81.js.map} +1 -1
  255. package/dist/web/public/assets/{service-C-wnwJ-b.js → service-Cf7EQ4Sj.js} +3 -3
  256. package/dist/web/public/assets/{service-C-wnwJ-b.js.map → service-Cf7EQ4Sj.js.map} +1 -1
  257. package/dist/web/public/assets/{status-badge-CsdJ6k8Q.js → status-badge-RpKtiHgj.js} +2 -2
  258. package/dist/web/public/assets/{status-badge-CsdJ6k8Q.js.map → status-badge-RpKtiHgj.js.map} +1 -1
  259. package/dist/web/public/assets/{subtasks-mGRKpF0G.js → subtasks-xCHP5uI6.js} +2 -2
  260. package/dist/web/public/assets/{subtasks-mGRKpF0G.js.map → subtasks-xCHP5uI6.js.map} +1 -1
  261. package/dist/web/public/assets/{table-vmLMgj6_.js → table-S43AHY-3.js} +2 -2
  262. package/dist/web/public/assets/{table-vmLMgj6_.js.map → table-S43AHY-3.js.map} +1 -1
  263. package/dist/web/public/assets/{topn-nu66Fotx.js → topn-C2tcvmnB.js} +2 -2
  264. package/dist/web/public/assets/{topn-nu66Fotx.js.map → topn-C2tcvmnB.js.map} +1 -1
  265. package/dist/web/public/assets/{trash-2-ZIitN_U3.js → trash-2-Ct7YJmZO.js} +2 -2
  266. package/dist/web/public/assets/{trash-2-ZIitN_U3.js.map → trash-2-Ct7YJmZO.js.map} +1 -1
  267. package/dist/web/public/assets/{use-memory-DgEqHEca.js → use-memory-CCn0h8EP.js} +2 -2
  268. package/dist/web/public/assets/{use-memory-DgEqHEca.js.map → use-memory-CCn0h8EP.js.map} +1 -1
  269. package/dist/web/public/assets/{use-observability-CQev_A8e.js → use-observability-Dbal-WXR.js} +2 -2
  270. package/dist/web/public/assets/{use-observability-CQev_A8e.js.map → use-observability-Dbal-WXR.js.map} +1 -1
  271. package/dist/web/public/assets/{use-settings-CU-UcrVD.js → use-settings-erlkhPqn.js} +2 -2
  272. package/dist/web/public/assets/{use-settings-CU-UcrVD.js.map → use-settings-erlkhPqn.js.map} +1 -1
  273. package/dist/web/public/assets/{use-skills-Dr77CXLA.js → use-skills-C8Ukv8B4.js} +2 -2
  274. package/dist/web/public/assets/{use-skills-Dr77CXLA.js.map → use-skills-C8Ukv8B4.js.map} +1 -1
  275. package/dist/web/public/assets/{use-workspace-PNv9Z4de.js → use-workspace-8VZDPppc.js} +2 -2
  276. package/dist/web/public/assets/{use-workspace-PNv9Z4de.js.map → use-workspace-8VZDPppc.js.map} +1 -1
  277. package/dist/web/public/assets/{useQuery-BTyugXYV.js → useQuery-BnCHQlxq.js} +2 -2
  278. package/dist/web/public/assets/{useQuery-BTyugXYV.js.map → useQuery-BnCHQlxq.js.map} +1 -1
  279. package/dist/web/public/assets/{vector-w-Ea3pg6.js → vector-DDnIidyp.js} +2 -2
  280. package/dist/web/public/assets/{vector-w-Ea3pg6.js.map → vector-DDnIidyp.js.map} +1 -1
  281. package/dist/web/public/assets/{viewer-DKA7QP9U.js → viewer-BV0Cux3V.js} +2 -2
  282. package/dist/web/public/assets/{viewer-DKA7QP9U.js.map → viewer-BV0Cux3V.js.map} +1 -1
  283. package/dist/web/public/assets/{workspace-DVLZca7t.js → workspace-DbLMyUTn.js} +2 -2
  284. package/dist/web/public/assets/{workspace-DVLZca7t.js.map → workspace-DbLMyUTn.js.map} +1 -1
  285. package/dist/web/public/assets/{workspaces-DYZsMmY-.js → workspaces-BhF0IAJg.js} +2 -2
  286. package/dist/web/public/assets/{workspaces-DYZsMmY-.js.map → workspaces-BhF0IAJg.js.map} +1 -1
  287. package/dist/web/public/assets/{x-Ru3rHT82.js → x-BmgfwQRl.js} +2 -2
  288. package/dist/web/public/assets/{x-Ru3rHT82.js.map → x-BmgfwQRl.js.map} +1 -1
  289. package/dist/web/public/index.html +2 -2
  290. package/dist/web/public/settings.html +0 -1
  291. package/dist/web/server.d.ts.map +1 -1
  292. package/dist/web/server.js +600 -16
  293. package/dist/web/server.js.map +1 -1
  294. package/package.json +3 -2
  295. package/dist/plugins/agents/copilot/index.d.ts +0 -35
  296. package/dist/plugins/agents/copilot/index.d.ts.map +0 -1
  297. package/dist/plugins/agents/copilot/index.js +0 -182
  298. package/dist/plugins/agents/copilot/index.js.map +0 -1
  299. package/dist/web/public/assets/agents-BMI1WbZj.js +0 -12
  300. package/dist/web/public/assets/agents-BMI1WbZj.js.map +0 -1
  301. package/dist/web/public/assets/env-Bqrb9XkC.js +0 -2
  302. package/dist/web/public/assets/env-Bqrb9XkC.js.map +0 -1
  303. package/dist/web/public/assets/index-CpGWCLE5.js.map +0 -1
  304. package/dist/web/public/assets/layout-9Gp_myEd.js +0 -2
  305. package/dist/web/public/assets/layout-9Gp_myEd.js.map +0 -1
  306. package/dist/web/public/assets/messengers-BRV1IVGX.js +0 -7
  307. package/dist/web/public/assets/messengers-BRV1IVGX.js.map +0 -1
@@ -10,7 +10,7 @@ import { parseMessage, routeMessage } from '../core/router.js';
10
10
  import { sessionManager } from '../core/session.js';
11
11
  import { registry } from '../core/registry.js';
12
12
  import { sink, resolveMessenger } from '../core/message-sink.js';
13
- import { generateTraceId, createLogger, logger as rootLogger } from '../core/logger.js';
13
+ import { generateTraceId, createLogger, logger as rootLogger, sanitizeUserText } from '../core/logger.js';
14
14
  import { validateConfig } from '../core/config-schema.js';
15
15
  import { isMasked, maskSecret } from './env-mask.js';
16
16
  import { consumeToken, peekToken } from '../core/location-token.js';
@@ -66,6 +66,11 @@ function isLoopbackPeer(req) {
66
66
  const ip = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
67
67
  return ip === '127.0.0.1' || ip === '::1';
68
68
  }
69
+ /** R13 A5 — track once-per-process whether the deprecated `?token=`
70
+ * URL fallback has been used, so we warn at most once per service
71
+ * lifetime instead of spamming the journal. Cleared by tests via
72
+ * re-importing the module fresh. */
73
+ let _queryTokenWarned = false;
69
74
  function extractToken(req, url) {
70
75
  // 1. Authorization: Bearer <token>
71
76
  const auth = req.headers['authorization'];
@@ -83,10 +88,44 @@ function extractToken(req, url) {
83
88
  return decodeURIComponent(rest.join('=').trim());
84
89
  }
85
90
  }
86
- // 3. ?token=... (last resort, for WS upgrade / iframe)
91
+ // 3. ?token=... (deprecated kept for WS upgrade / iframe compat)
92
+ //
93
+ // R13 A5 — query-string tokens leak to:
94
+ // * browser history / bookmarks
95
+ // * Referer headers when the page links to a third party
96
+ // * any HTTP-proxy / WAF / CDN access log between client and agim
97
+ //
98
+ // We warn loudly the first time it's used per process lifetime
99
+ // (rate-limited so a misbehaving client doesn't fill the journal)
100
+ // + emit a one-shot audit-event row. Operators should migrate
101
+ // callers to Authorization: Bearer or the agim_token cookie.
87
102
  const q = url.searchParams.get('token');
88
- if (q)
103
+ if (q) {
104
+ if (!_queryTokenWarned) {
105
+ _queryTokenWarned = true;
106
+ rootLogger.warn({
107
+ component: 'web',
108
+ event: 'web.auth.query_token_used',
109
+ path: url.pathname,
110
+ msg: '?token= URL fallback used; prefer Authorization: Bearer or agim_token cookie '
111
+ + '(query token leaks to browser history / Referer / proxy logs)',
112
+ });
113
+ try {
114
+ void (async () => {
115
+ const { logAuditEvent } = await import('../core/audit-log.js');
116
+ logAuditEvent({
117
+ eventType: 'config.put',
118
+ actor: 'system',
119
+ target: 'web.auth.query_token',
120
+ outcome: 'ok',
121
+ details: { reason: 'query_token_used', path: url.pathname },
122
+ });
123
+ })();
124
+ }
125
+ catch { /* best-effort */ }
126
+ }
89
127
  return q;
128
+ }
90
129
  return null;
91
130
  }
92
131
  /** Returns true if the request is authenticated (or auth not required). */
@@ -143,6 +182,80 @@ function verifyTokenSync(raw) {
143
182
  }
144
183
  return _tokenModule.verifyToken(raw);
145
184
  }
185
+ /** Resolve a stable actor string for audit-event rows. Mirrors checkAuth's
186
+ * identity model:
187
+ * - auth disabled → 'web:auth-off'
188
+ * - loopback peer (no auth needed) → 'web:loopback'
189
+ * - verified token → 'web:<tokenId>'
190
+ * - otherwise → 'web:unknown' (request should already have been rejected
191
+ * by checkAuth — this branch exists for defensive logging). */
192
+ function getRequestActor(req) {
193
+ if ((process.env.IMHUB_WEB_AUTH || '').toLowerCase() === 'off')
194
+ return 'web:auth-off';
195
+ if (isLoopbackPeer(req))
196
+ return 'web:loopback';
197
+ let url;
198
+ try {
199
+ url = new URL(req.url || '', 'http://localhost');
200
+ }
201
+ catch {
202
+ return 'web:unknown';
203
+ }
204
+ const tok = extractToken(req, url);
205
+ if (tok) {
206
+ const id = verifyTokenSync(tok);
207
+ if (id)
208
+ return `web:${id}`;
209
+ }
210
+ return 'web:unknown';
211
+ }
212
+ /** Resolve whether the request's actor has admin role. Used to gate
213
+ * mutation + privileged-read endpoints so a stolen viewer-role token
214
+ * can't elevate to control plane (R13 A1).
215
+ *
216
+ * Trust order:
217
+ * 1. IMHUB_WEB_AUTH=off → admin (operator explicitly disabled auth)
218
+ * 2. Loopback peer → admin (operator on the host)
219
+ * 3. Bearer token → token.role === 'admin'
220
+ * 4. Otherwise → not admin
221
+ *
222
+ * Note: when no token has been created yet (pre-bootstrap), the
223
+ * loopback-peer branch still grants admin so the CLI bootstrap flow
224
+ * works. */
225
+ function isRequestAdmin(req) {
226
+ if ((process.env.IMHUB_WEB_AUTH || '').toLowerCase() === 'off')
227
+ return true;
228
+ if (isLoopbackPeer(req))
229
+ return true;
230
+ let url;
231
+ try {
232
+ url = new URL(req.url || '', 'http://localhost');
233
+ }
234
+ catch {
235
+ return false;
236
+ }
237
+ const tok = extractToken(req, url);
238
+ if (!tok)
239
+ return false;
240
+ const id = verifyTokenSync(tok);
241
+ if (!id)
242
+ return false;
243
+ if (!_tokenModule)
244
+ return false;
245
+ // isAdminTokenId returns true for legacy tokens missing the role
246
+ // field (back-compat) so existing deployments stay functional after
247
+ // upgrade — see access-token.ts:getTokenRole.
248
+ return _tokenModule.isAdminTokenId(id);
249
+ }
250
+ /** Send 403 and return false when the actor isn't admin. Use as guard:
251
+ * `if (!requireAdmin(req, res)) return` */
252
+ function requireAdmin(req, res) {
253
+ if (isRequestAdmin(req))
254
+ return true;
255
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
256
+ res.end(JSON.stringify({ error: 'forbidden', message: 'admin role required' }));
257
+ return false;
258
+ }
146
259
  export function createSerialQueue() {
147
260
  let queue = Promise.resolve();
148
261
  return (fn) => {
@@ -187,6 +300,50 @@ export async function startWebServer(options) {
187
300
  bind: bindHost,
188
301
  }, 'IMHUB_WEB_AUTH=off + public bind — auth deliberately off, ensure your reverse proxy handles access control');
189
302
  }
303
+ // R13 C2 — when binding to a non-loopback address, the operator
304
+ // almost certainly intends to put a TLS-terminating reverse proxy
305
+ // in front of agim. We can't reliably detect from inside whether
306
+ // that's been done (X-Forwarded-Proto could be forged), so we
307
+ // surface a one-time banner + audit row at boot. Suppress with
308
+ // IMHUB_WEB_TLS_ACK=1 for the operator who's already done the
309
+ // checklist and wants quiet logs.
310
+ if (isPublicBind && process.env.IMHUB_WEB_TLS_ACK !== '1') {
311
+ const banner = [
312
+ '',
313
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
314
+ `⚠️ WEB SERVER BOUND TO ${bindHost}:${port} (non-loopback)`,
315
+ '',
316
+ ' Tokens travel as Bearer headers / cookies — if any leg between',
317
+ ' client and agim is HTTP, they go on the wire in cleartext.',
318
+ ' Confirm a TLS-terminating reverse proxy fronts this listener',
319
+ ' (nginx, caddy, traefik, k8s ingress, …) before exposing it to',
320
+ ' any network you do not control.',
321
+ '',
322
+ ' Silence this banner once your terminator is verified:',
323
+ ' IMHUB_WEB_TLS_ACK=1',
324
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━',
325
+ '',
326
+ ].join('\n');
327
+ process.stdout.write(banner);
328
+ webLog.warn({
329
+ event: 'web.public_bind_no_tls_ack',
330
+ bind: bindHost,
331
+ port,
332
+ }, `non-loopback bind without IMHUB_WEB_TLS_ACK=1 — confirm reverse-proxy TLS termination`);
333
+ try {
334
+ void (async () => {
335
+ const { logAuditEvent } = await import('../core/audit-log.js');
336
+ logAuditEvent({
337
+ eventType: 'config.put',
338
+ actor: 'system',
339
+ target: 'web.bind',
340
+ outcome: 'ok',
341
+ details: { bind: bindHost, port, tls_ack: false },
342
+ });
343
+ })();
344
+ }
345
+ catch { /* best-effort */ }
346
+ }
190
347
  webLog.info({
191
348
  event: 'web.auth_mode',
192
349
  bind: bindHost,
@@ -473,15 +630,21 @@ export async function startWebServer(options) {
473
630
  return handleGetConfig(req, res);
474
631
  }
475
632
  if (url.pathname === '/api/config' && req.method === 'PUT') {
633
+ if (!requireAdmin(req, res))
634
+ return;
476
635
  return handlePutConfig(req, res);
477
636
  }
478
637
  if (url.pathname === '/api/agents/status' && req.method === 'GET') {
479
638
  return handleAgentsStatus(req, res);
480
639
  }
481
640
  if (url.pathname === '/api/agents/acp/test' && req.method === 'POST') {
641
+ if (!requireAdmin(req, res))
642
+ return;
482
643
  return handleAcpTest(req, res);
483
644
  }
484
645
  if (url.pathname === '/api/agents/acp/discover' && req.method === 'POST') {
646
+ if (!requireAdmin(req, res))
647
+ return;
485
648
  return handleAcpDiscover(req, res);
486
649
  }
487
650
  // Jobs
@@ -551,30 +714,51 @@ export async function startWebServer(options) {
551
714
  return handleGetEnv(req, res, url);
552
715
  }
553
716
  if (url.pathname === '/api/env' && req.method === 'PUT') {
717
+ if (!requireAdmin(req, res))
718
+ return;
554
719
  return handlePutEnv(req, res);
555
720
  }
556
721
  if (url.pathname === '/api/messengers/email/test' && req.method === 'POST') {
722
+ if (!requireAdmin(req, res))
723
+ return;
557
724
  return handleEmailTest(req, res);
558
725
  }
559
726
  if (url.pathname === '/api/workspaces' && req.method === 'GET') {
560
727
  return handleListWorkspaces(req, res, url);
561
728
  }
562
729
  if (url.pathname === '/api/workspaces' && req.method === 'POST') {
730
+ if (!requireAdmin(req, res))
731
+ return;
563
732
  return handleCreateOrUpdateWorkspace(req, res);
564
733
  }
565
734
  const workspaceIdMatch = url.pathname.match(/^\/api\/workspaces\/([^/]+)$/);
566
735
  if (workspaceIdMatch && req.method === 'PATCH') {
736
+ if (!requireAdmin(req, res))
737
+ return;
567
738
  return handleCreateOrUpdateWorkspace(req, res, workspaceIdMatch[1]);
568
739
  }
569
740
  if (workspaceIdMatch && req.method === 'DELETE') {
741
+ if (!requireAdmin(req, res))
742
+ return;
570
743
  return handleDeleteWorkspace(req, res, workspaceIdMatch[1]);
571
744
  }
572
745
  if (url.pathname === '/api/metrics' && req.method === 'GET') {
573
746
  return handleMetrics(req, res, url);
574
747
  }
575
748
  if (url.pathname === '/api/audit' && req.method === 'GET') {
749
+ if (!requireAdmin(req, res))
750
+ return;
576
751
  return handleAudit(req, res, url);
577
752
  }
753
+ // R12 ⑤ — governance audit events (approvals, admin changes, config /
754
+ // env / token / workspace mutations). Separate retention from
755
+ // invocations (default 180d). Admin-only — these rows can expose
756
+ // who promoted whom and which config keys were rotated when.
757
+ if (url.pathname === '/api/audit/events' && req.method === 'GET') {
758
+ if (!requireAdmin(req, res))
759
+ return;
760
+ return handleAuditEvents(req, res, url);
761
+ }
578
762
  // v1.1.2 — Outbox tab. List rows by status, plus aggregate stats and
579
763
  // a retry endpoint for the giving_up row state.
580
764
  if (url.pathname === '/api/outbox' && req.method === 'GET') {
@@ -585,6 +769,8 @@ export async function startWebServer(options) {
585
769
  }
586
770
  const outboxRetryMatch = url.pathname.match(/^\/api\/outbox\/(\d+)\/retry$/);
587
771
  if (outboxRetryMatch && req.method === 'POST') {
772
+ if (!requireAdmin(req, res))
773
+ return;
588
774
  return handleOutboxRetry(req, res, parseInt(outboxRetryMatch[1], 10));
589
775
  }
590
776
  // v1.1.3 — A2A tab. Stats over inline rows with parent_id; recent
@@ -650,6 +836,8 @@ export async function startWebServer(options) {
650
836
  return handleMemoryFacts(req, res, url);
651
837
  }
652
838
  if (url.pathname === '/api/memory/facts' && req.method === 'DELETE') {
839
+ if (!requireAdmin(req, res))
840
+ return;
653
841
  return handleMemoryBulkDelete(req, res, url);
654
842
  }
655
843
  const memFactIdMatch = url.pathname.match(/^\/api\/memory\/facts\/(\d+)$/);
@@ -660,9 +848,13 @@ export async function startWebServer(options) {
660
848
  return handleMemoryPersona(req, res, url);
661
849
  }
662
850
  if (url.pathname === '/api/memory/persona' && req.method === 'PUT') {
851
+ if (!requireAdmin(req, res))
852
+ return;
663
853
  return handleMemoryPersonaPut(req, res, url);
664
854
  }
665
855
  if (url.pathname === '/api/memory/persona' && req.method === 'DELETE') {
856
+ if (!requireAdmin(req, res))
857
+ return;
666
858
  return handleMemoryPersonaDelete(req, res, url);
667
859
  }
668
860
  if (url.pathname === '/api/memory/export' && req.method === 'GET') {
@@ -673,20 +865,30 @@ export async function startWebServer(options) {
673
865
  return handleVectorStatus(req, res, url);
674
866
  }
675
867
  if (url.pathname === '/api/memory/vector/test' && req.method === 'POST') {
868
+ if (!requireAdmin(req, res))
869
+ return;
676
870
  return handleVectorTest(req, res);
677
871
  }
678
872
  if (url.pathname === '/api/memory/vector/download' && req.method === 'POST') {
873
+ if (!requireAdmin(req, res))
874
+ return;
679
875
  return handleVectorDownload(req, res);
680
876
  }
681
877
  if (url.pathname === '/api/memory/vector/backfill' && req.method === 'POST') {
878
+ if (!requireAdmin(req, res))
879
+ return;
682
880
  return handleVectorBackfill(req, res, url);
683
881
  }
684
882
  if (url.pathname === '/api/memory/vector/clear' && req.method === 'POST') {
883
+ if (!requireAdmin(req, res))
884
+ return;
685
885
  return handleVectorClear(req, res, url);
686
886
  }
687
887
  // v1.2.2 — manual trigger for the daily consolidation. Useful when the
688
888
  // user just wants a persona summary now without waiting 24h.
689
889
  if (url.pathname === '/api/memory/consolidate' && req.method === 'POST') {
890
+ if (!requireAdmin(req, res))
891
+ return;
690
892
  return handleMemoryConsolidate(req, res);
691
893
  }
692
894
  if (url.pathname === '/api/memory/consolidate/status' && req.method === 'GET') {
@@ -720,15 +922,21 @@ export async function startWebServer(options) {
720
922
  return handleWorkspaceFiles(req, res, url);
721
923
  }
722
924
  if (url.pathname === '/api/workspace-files' && req.method === 'PUT') {
925
+ if (!requireAdmin(req, res))
926
+ return;
723
927
  return handleWorkspaceFileWrite(req, res, url);
724
928
  }
725
929
  // PR-D: Job batch operations. Same semantics as /api/jobs/:id/cancel
726
930
  // and /run but accepts an array of ids in one request — saves N
727
931
  // round-trips when the user multi-selects a long list.
728
932
  if (url.pathname === '/api/jobs/batch-cancel' && req.method === 'POST') {
933
+ if (!requireAdmin(req, res))
934
+ return;
729
935
  return handleBatchJob(req, res, 'cancel');
730
936
  }
731
937
  if (url.pathname === '/api/jobs/batch-run' && req.method === 'POST') {
938
+ if (!requireAdmin(req, res))
939
+ return;
732
940
  return handleBatchJob(req, res, 'run', options.defaultAgent);
733
941
  }
734
942
  // PR-C: SSE event stream — audit / approval / job / metrics events
@@ -738,9 +946,13 @@ export async function startWebServer(options) {
738
946
  return handleEventsSSE(req, res);
739
947
  }
740
948
  if (url.pathname === '/api/notify' && req.method === 'POST') {
949
+ if (!requireAdmin(req, res))
950
+ return;
741
951
  return handleNotify(req, res);
742
952
  }
743
953
  if (url.pathname === '/api/invoke' && req.method === 'POST') {
954
+ if (!requireAdmin(req, res))
955
+ return;
744
956
  return handleInvoke(req, res, options.defaultAgent);
745
957
  }
746
958
  // WeChat QR login — drives the "扫码登录" button in the web settings
@@ -749,6 +961,8 @@ export async function startWebServer(options) {
749
961
  // credentials to disk AND adds 'wechat-ilink' into config.messengers
750
962
  // so the next service restart picks the channel up.
751
963
  if (url.pathname === '/api/messengers/wechat/qr-start' && req.method === 'POST') {
964
+ if (!requireAdmin(req, res))
965
+ return;
752
966
  return handleWechatQrStart(res);
753
967
  }
754
968
  if (url.pathname === '/api/messengers/wechat/qr-status' && req.method === 'GET') {
@@ -764,12 +978,18 @@ export async function startWebServer(options) {
764
978
  return handleServiceStatus(res);
765
979
  }
766
980
  if (url.pathname === '/api/service/start' && req.method === 'POST') {
981
+ if (!requireAdmin(req, res))
982
+ return;
767
983
  return handleServiceStart(res);
768
984
  }
769
985
  if (url.pathname === '/api/service/stop' && req.method === 'POST') {
986
+ if (!requireAdmin(req, res))
987
+ return;
770
988
  return handleServiceStop(res);
771
989
  }
772
990
  if (url.pathname === '/api/service/restart' && req.method === 'POST') {
991
+ if (!requireAdmin(req, res))
992
+ return;
773
993
  return handleServiceRestart(res);
774
994
  }
775
995
  // Admin allowlist — list + add/remove via the Safety card. Locally
@@ -780,9 +1000,13 @@ export async function startWebServer(options) {
780
1000
  return handleAdminAllowlistGet(res);
781
1001
  }
782
1002
  if (url.pathname === '/api/admin-allowlist' && req.method === 'POST') {
1003
+ if (!requireAdmin(req, res))
1004
+ return;
783
1005
  return handleAdminAllowlistAdd(req, res, bindHost);
784
1006
  }
785
1007
  if (url.pathname === '/api/admin-allowlist' && req.method === 'DELETE') {
1008
+ if (!requireAdmin(req, res))
1009
+ return;
786
1010
  return handleAdminAllowlistRemove(req, res, bindHost);
787
1011
  }
788
1012
  res.writeHead(404);
@@ -797,6 +1021,18 @@ export async function startWebServer(options) {
797
1021
  const wss = new WebSocketServer({
798
1022
  server: httpServer,
799
1023
  verifyClient: (info, cb) => {
1024
+ // R13 A4 — per-IP rate limit runs BEFORE auth so an attacker
1025
+ // hammering verifyClient with bad tokens can't burn CPU on
1026
+ // sha256-hashing every attempt.
1027
+ const limit = checkWsIpRateLimit(info.req);
1028
+ if (!limit.ok) {
1029
+ webLog.warn({
1030
+ event: 'ws.rate_limited',
1031
+ ip: peerIp(info.req),
1032
+ reason: limit.reason,
1033
+ }, 'WS upgrade refused (per-IP rate limit)');
1034
+ return cb(false, 429, 'rate limited');
1035
+ }
800
1036
  // Auth-off / loopback bypass — mirror checkAuth's two short-circuits
801
1037
  // so dev / local CLI sessions still work without a token.
802
1038
  if ((process.env.IMHUB_WEB_AUTH || '').toLowerCase() === 'off')
@@ -847,6 +1083,89 @@ export async function startWebServer(options) {
847
1083
  }
848
1084
  return 100;
849
1085
  })();
1086
+ // R13 A4 — per-IP rate limit. The per-token cap above protects
1087
+ // memory but not connection rate; one attacker IP with a valid
1088
+ // token can still spawn N parallel browser tabs / processes and
1089
+ // saturate the file-descriptor pool.
1090
+ //
1091
+ // IMHUB_WS_MAX_PER_IP active connections per IP (default 20)
1092
+ // IMHUB_WS_MAX_NEW_PER_IP_PER_MIN new connections per IP per minute (default 30)
1093
+ //
1094
+ // Loopback bypasses both — local dev / CLI tooling makes many
1095
+ // short connections legitimately.
1096
+ const wsMaxPerIp = (() => {
1097
+ const raw = process.env.IMHUB_WS_MAX_PER_IP;
1098
+ if (raw) {
1099
+ const n = parseInt(raw, 10);
1100
+ if (Number.isFinite(n) && n > 0)
1101
+ return n;
1102
+ }
1103
+ return 20;
1104
+ })();
1105
+ const wsMaxNewPerIpPerMin = (() => {
1106
+ const raw = process.env.IMHUB_WS_MAX_NEW_PER_IP_PER_MIN;
1107
+ if (raw) {
1108
+ const n = parseInt(raw, 10);
1109
+ if (Number.isFinite(n) && n > 0)
1110
+ return n;
1111
+ }
1112
+ return 30;
1113
+ })();
1114
+ /** Per-IP active count + recent connection timestamps for sliding-window
1115
+ * rate limiting. We don't periodically prune unused entries — at
1116
+ * reasonable defaults the map size is bounded by the number of distinct
1117
+ * client IPs in any 60-second window, far below memory concerns. */
1118
+ const wsPerIp = new Map();
1119
+ function peerIp(req) {
1120
+ return (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
1121
+ }
1122
+ /** Returns {ok:true} when the IP may open a new WS, else {ok:false, reason}. */
1123
+ function checkWsIpRateLimit(req) {
1124
+ if (isLoopbackPeer(req))
1125
+ return { ok: true };
1126
+ const ip = peerIp(req);
1127
+ if (!ip)
1128
+ return { ok: false, reason: 'empty peer ip' }; // defensive — matches isLoopbackPeer policy
1129
+ const now = Date.now();
1130
+ const cutoff = now - 60_000;
1131
+ const slot = wsPerIp.get(ip) ?? { active: 0, recent: [] };
1132
+ slot.recent = slot.recent.filter((t) => t > cutoff);
1133
+ if (slot.active >= wsMaxPerIp) {
1134
+ return { ok: false, reason: `per-IP active cap (${slot.active}/${wsMaxPerIp})` };
1135
+ }
1136
+ if (slot.recent.length >= wsMaxNewPerIpPerMin) {
1137
+ return { ok: false, reason: `per-IP new-conn cap (${slot.recent.length}/${wsMaxNewPerIpPerMin}/min)` };
1138
+ }
1139
+ return { ok: true };
1140
+ }
1141
+ function recordWsIpOpen(req) {
1142
+ if (isLoopbackPeer(req))
1143
+ return;
1144
+ const ip = peerIp(req);
1145
+ if (!ip)
1146
+ return;
1147
+ const slot = wsPerIp.get(ip) ?? { active: 0, recent: [] };
1148
+ slot.active++;
1149
+ slot.recent.push(Date.now());
1150
+ wsPerIp.set(ip, slot);
1151
+ }
1152
+ function recordWsIpClose(req) {
1153
+ if (isLoopbackPeer(req))
1154
+ return;
1155
+ const ip = peerIp(req);
1156
+ if (!ip)
1157
+ return;
1158
+ const slot = wsPerIp.get(ip);
1159
+ if (!slot)
1160
+ return;
1161
+ slot.active = Math.max(0, slot.active - 1);
1162
+ // GC empty slots so the map doesn't grow unboundedly across the
1163
+ // lifetime of the process. A slot is "empty" iff no active conn
1164
+ // AND no recent connection within the window.
1165
+ if (slot.active === 0 && slot.recent.filter((t) => t > Date.now() - 60_000).length === 0) {
1166
+ wsPerIp.delete(ip);
1167
+ }
1168
+ }
850
1169
  wss.on('connection', (ws, req) => {
851
1170
  if (clients.size >= maxWsClients) {
852
1171
  // 1013 = "Try Again Later" per RFC 6455. Slightly nicer than a flat
@@ -859,6 +1178,9 @@ export async function startWebServer(options) {
859
1178
  ws.close(1013, 'Server too busy');
860
1179
  return;
861
1180
  }
1181
+ // R13 A4 — book the per-IP slot now that we know the upgrade
1182
+ // succeeded. The matching close hook decrements below.
1183
+ recordWsIpOpen(req);
862
1184
  // R9: token verified at upgrade time by verifyClient; we read the
863
1185
  // tokenId here so get-history can enforce ownership when the client
864
1186
  // wants to rekey to a persisted threadId. 'anon' is the loopback /
@@ -979,6 +1301,7 @@ export async function startWebServer(options) {
979
1301
  const liveId = client.id;
980
1302
  webLog.info({ clientId: liveId }, 'Client disconnected');
981
1303
  clients.delete(liveId);
1304
+ recordWsIpClose(req); // R13 A4
982
1305
  // R9: drop the ownership entry only for server-generated ids that
983
1306
  // were never rekeyed to a persistent client-side threadId. Rekeyed
984
1307
  // ids stay registered so the operator's next reconnect with the
@@ -990,6 +1313,7 @@ export async function startWebServer(options) {
990
1313
  const liveId = client.id;
991
1314
  webLog.error({ clientId: liveId, err: err instanceof Error ? err.message : String(err) }, 'Client WebSocket error');
992
1315
  clients.delete(liveId);
1316
+ recordWsIpClose(req); // R13 A4
993
1317
  if (liveId === clientId)
994
1318
  threadOwners.delete(liveId);
995
1319
  });
@@ -1102,6 +1426,19 @@ export async function startWebServer(options) {
1102
1426
  }
1103
1427
  wss.close();
1104
1428
  httpServer.close();
1429
+ // R14 — force-drop in-flight HTTP connections so the listening
1430
+ // port releases immediately. httpServer.close() alone only stops
1431
+ // accepting NEW connections; existing keep-alive sockets keep
1432
+ // port 3000 bound until they drain, which races a `systemctl
1433
+ // restart` and produces EADDRINUSE on the new process.
1434
+ // closeAllConnections is Node 18.2+; older runtimes get the
1435
+ // legacy (slower) behavior.
1436
+ try {
1437
+ const fn = httpServer.closeAllConnections;
1438
+ if (typeof fn === 'function')
1439
+ fn.call(httpServer);
1440
+ }
1441
+ catch { /* ignore */ }
1105
1442
  },
1106
1443
  };
1107
1444
  }
@@ -1112,11 +1449,18 @@ async function handleGetConfig(_req, res) {
1112
1449
  try {
1113
1450
  const config = await loadConfig();
1114
1451
  const agentStatus = await getAgentStatuses();
1452
+ // Compliance gates — when the env flag is off, strip the
1453
+ // corresponding keys from the response so the settings UI never
1454
+ // sees them and renders no tabs / cards. On-disk config is left
1455
+ // untouched: flipping the env back on restores visibility.
1456
+ const { isGlobalImEnabled, isRemoteAgentEnabled } = await import('../core/feature-flags.js');
1457
+ const showGlobalIm = isGlobalImEnabled();
1458
+ const showRemoteAgent = isRemoteAgentEnabled();
1115
1459
  sendJson(res, 200, {
1116
1460
  messengers: config.messengers,
1117
1461
  agents: config.agents,
1118
1462
  defaultAgent: config.defaultAgent,
1119
- telegram: config.telegram
1463
+ telegram: showGlobalIm && config.telegram
1120
1464
  ? { botToken: mask(config.telegram.botToken), channelId: config.telegram.channelId }
1121
1465
  : undefined,
1122
1466
  feishu: config.feishu
@@ -1125,7 +1469,7 @@ async function handleGetConfig(_req, res) {
1125
1469
  dingtalk: config.dingtalk
1126
1470
  ? { clientId: config.dingtalk.clientId, clientSecret: mask(config.dingtalk.clientSecret), channelId: config.dingtalk.channelId }
1127
1471
  : undefined,
1128
- discord: config.discord
1472
+ discord: showGlobalIm && config.discord
1129
1473
  ? {
1130
1474
  botToken: mask(config.discord.botToken),
1131
1475
  channelId: config.discord.channelId,
@@ -1133,13 +1477,29 @@ async function handleGetConfig(_req, res) {
1133
1477
  allowedChannels: config.discord.allowedChannels,
1134
1478
  }
1135
1479
  : undefined,
1136
- acpAgents: config.acpAgents?.map(a => ({
1137
- ...a,
1138
- auth: a.auth
1139
- ? { ...a.auth, token: a.auth.token ? mask(a.auth.token) : undefined }
1140
- : undefined,
1141
- })),
1480
+ acpAgents: showRemoteAgent
1481
+ ? config.acpAgents?.map(a => ({
1482
+ ...a,
1483
+ auth: a.auth
1484
+ ? { ...a.auth, token: a.auth.token ? mask(a.auth.token) : undefined }
1485
+ : undefined,
1486
+ }))
1487
+ : undefined,
1488
+ // Feature flags surfaced for the SPA so it can hide the
1489
+ // corresponding tabs / nav entries. Field names are
1490
+ // intentionally neutral — they don't reveal which IMs / agent
1491
+ // types the flag controls beyond what the settings page
1492
+ // already exposes.
1493
+ features: {
1494
+ globalIm: showGlobalIm,
1495
+ remoteAgent: showRemoteAgent,
1496
+ },
1142
1497
  webPort: config.webPort,
1498
+ // R15 — acpPort travels only when remote-agent feature is on.
1499
+ // Pairs with the SPA hiding the ACP-port input under the same
1500
+ // gate (see web-app/src/routes/settings/service.tsx). When off,
1501
+ // PUT /api/config also drops incoming.acpPort (below).
1502
+ acpPort: showRemoteAgent ? config.acpPort : undefined,
1143
1503
  agentStatus,
1144
1504
  });
1145
1505
  }
@@ -1152,6 +1512,29 @@ async function handlePutConfig(req, res) {
1152
1512
  const body = await readBody(req, res);
1153
1513
  const incoming = JSON.parse(body);
1154
1514
  const existing = await loadConfig();
1515
+ // Compliance gates — silently drop keys the operator's deployment
1516
+ // is not allowed to surface. We don't 4xx here because the SPA may
1517
+ // round-trip a stale snapshot that happens to include these keys;
1518
+ // dropping them keeps existing on-disk values untouched (the
1519
+ // round-trip becomes a no-op for that field).
1520
+ //
1521
+ // We also scrub `messengers[]` so a hidden slot can't be toggled
1522
+ // via direct PUT (the SPA filters in render but a stale draft from
1523
+ // before the env flag was disabled would otherwise re-add the
1524
+ // entry on next save).
1525
+ const { isGlobalImEnabled, isRemoteAgentEnabled } = await import('../core/feature-flags.js');
1526
+ if (!isGlobalImEnabled()) {
1527
+ delete incoming.telegram;
1528
+ delete incoming.discord;
1529
+ if (Array.isArray(incoming.messengers)) {
1530
+ incoming.messengers = incoming.messengers
1531
+ .filter((m) => m !== 'telegram' && m !== 'discord');
1532
+ }
1533
+ }
1534
+ if (!isRemoteAgentEnabled()) {
1535
+ delete incoming.acpAgents;
1536
+ delete incoming.acpPort;
1537
+ }
1155
1538
  const merged = { ...existing };
1156
1539
  for (const key of Object.keys(incoming)) {
1157
1540
  const val = incoming[key];
@@ -1217,6 +1600,20 @@ async function handlePutConfig(req, res) {
1217
1600
  return;
1218
1601
  }
1219
1602
  await saveConfig(result.config);
1603
+ // R12 ⑤ — persistent audit. `details.keys` is the list of top-level
1604
+ // config keys touched (e.g. ['feishu', 'discord']) — never the raw
1605
+ // values, which would leak tokens / app secrets through the audit
1606
+ // table.
1607
+ try {
1608
+ const { logAuditEvent } = await import('../core/audit-log.js');
1609
+ logAuditEvent({
1610
+ eventType: 'config.put',
1611
+ actor: getRequestActor(req),
1612
+ outcome: 'ok',
1613
+ details: { keys: Object.keys(incoming) },
1614
+ });
1615
+ }
1616
+ catch { /* best-effort */ }
1220
1617
  sendJson(res, 200, { ok: true });
1221
1618
  }
1222
1619
  catch (err) {
@@ -1318,13 +1715,26 @@ async function handleCreateOrUpdateWorkspace(req, res, expectedId) {
1318
1715
  const { workspaceRegistry } = await import('../core/workspace.js');
1319
1716
  workspaceRegistry.add(v.cfg);
1320
1717
  await persistWorkspacesToConfig(workspaceRegistry.listFull().filter((w) => w.id !== 'default'));
1718
+ // R12 ⑤ — persistent audit. Only id + agent count go into details;
1719
+ // member list (potentially identifiers) stays out of the audit row.
1720
+ try {
1721
+ const { logAuditEvent } = await import('../core/audit-log.js');
1722
+ logAuditEvent({
1723
+ eventType: 'workspace.add',
1724
+ actor: getRequestActor(req),
1725
+ target: v.cfg.id,
1726
+ outcome: 'ok',
1727
+ details: { agents: v.cfg.agents.length, members: v.cfg.members?.length ?? 0 },
1728
+ });
1729
+ }
1730
+ catch { /* best-effort */ }
1321
1731
  sendJson(res, 200, { ok: true, workspace: v.cfg });
1322
1732
  }
1323
1733
  catch (err) {
1324
1734
  sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
1325
1735
  }
1326
1736
  }
1327
- async function handleDeleteWorkspace(_req, res, id) {
1737
+ async function handleDeleteWorkspace(req, res, id) {
1328
1738
  try {
1329
1739
  const { workspaceRegistry } = await import('../core/workspace.js');
1330
1740
  if (id === 'default') {
@@ -1337,6 +1747,16 @@ async function handleDeleteWorkspace(_req, res, id) {
1337
1747
  return;
1338
1748
  }
1339
1749
  await persistWorkspacesToConfig(workspaceRegistry.listFull().filter((w) => w.id !== 'default'));
1750
+ try {
1751
+ const { logAuditEvent } = await import('../core/audit-log.js');
1752
+ logAuditEvent({
1753
+ eventType: 'workspace.delete',
1754
+ actor: getRequestActor(req),
1755
+ target: id,
1756
+ outcome: 'ok',
1757
+ });
1758
+ }
1759
+ catch { /* best-effort */ }
1340
1760
  sendJson(res, 200, { ok: true });
1341
1761
  }
1342
1762
  catch (err) {
@@ -1457,8 +1877,54 @@ async function handleServiceStatus(res) {
1457
1877
  }
1458
1878
  async function handleServiceStart(res) {
1459
1879
  try {
1460
- const { detectService, spawnBackground } = await import('../cli-ui/service.js');
1880
+ const { detectService, spawnBackground, reapStrayAgimProcesses } = await import('../cli-ui/service.js');
1461
1881
  const st = detectService();
1882
+ // R14 §四 — when systemd owns the unit, ALL lifecycle commands
1883
+ // must route through systemctl so systemd stays the single
1884
+ // authority. Previously this handler bypassed systemd entirely
1885
+ // (always spawnBackground), creating a second instance
1886
+ // competing for port 3000. Now:
1887
+ // - systemd active → no-op, report alreadyRunning
1888
+ // - systemd inactive/failed/activating → systemctl start
1889
+ // (preceded by reap so any stray pids from a botched
1890
+ // previous run get cleared first; same cgroup-escape
1891
+ // recovery as restart).
1892
+ if (st.mode === 'systemd') {
1893
+ if (st.active === 'active') {
1894
+ sendJson(res, 200, { ok: true, alreadyRunning: true, mode: 'systemd', pid: st.pid, active: st.active });
1895
+ return;
1896
+ }
1897
+ try {
1898
+ const reap = await reapStrayAgimProcesses({
1899
+ excludePids: st.pid != null ? [st.pid] : [],
1900
+ sigtermTimeoutMs: 5_000,
1901
+ });
1902
+ if (reap.reaped.length > 0 || reap.survived.length > 0) {
1903
+ webLog.warn({
1904
+ event: 'web.service.start.reap',
1905
+ reaped: reap.reaped,
1906
+ survived: reap.survived,
1907
+ }, `R14 reap before systemctl start: killed ${reap.reaped.length} stray pid(s)`);
1908
+ }
1909
+ }
1910
+ catch (err) {
1911
+ webLog.warn({ event: 'web.service.start.reap_failed', err: String(err) });
1912
+ }
1913
+ try {
1914
+ const { spawn } = await import('node:child_process');
1915
+ const unitName = existsSync('/etc/systemd/system/agim.service') ? 'agim.service' : 'im-hub.service';
1916
+ // Detached + fire-and-forget so the HTTP response isn't held
1917
+ // for the full systemd activation timeline.
1918
+ const child = spawn('systemctl', ['start', unitName], { detached: true, stdio: 'ignore' });
1919
+ child.unref();
1920
+ sendJson(res, 200, { ok: true, mode: 'systemd', starting: true, previousActive: st.active });
1921
+ }
1922
+ catch (err) {
1923
+ sendJson(res, 500, { error: 'systemctl start spawn failed: ' + (err instanceof Error ? err.message : String(err)) });
1924
+ }
1925
+ return;
1926
+ }
1927
+ // Non-systemd: existing behavior.
1462
1928
  if (st.mode !== 'none') {
1463
1929
  sendJson(res, 200, { ok: true, alreadyRunning: true, mode: st.mode, pid: st.pid });
1464
1930
  return;
@@ -1474,7 +1940,7 @@ async function handleServiceStart(res) {
1474
1940
  }
1475
1941
  }
1476
1942
  async function handleServiceStop(res) {
1477
- const { detectService } = await import('../cli-ui/service.js');
1943
+ const { detectService, reapStrayAgimProcesses } = await import('../cli-ui/service.js');
1478
1944
  const st = detectService();
1479
1945
  if (st.mode === 'systemd') {
1480
1946
  // systemctl stop handles the kill for us — no self-exit needed.
@@ -1483,6 +1949,24 @@ async function handleServiceStop(res) {
1483
1949
  // Match service.ts's detection: prefer agim.service, fall back.
1484
1950
  const unitName = existsSync('/etc/systemd/system/agim.service') ? 'agim.service' : 'im-hub.service';
1485
1951
  execSync(`systemctl stop ${unitName}`);
1952
+ // R14 §四 — sweep stray pids after systemctl stop, same way
1953
+ // restart does pre-stop. systemctl reports success when its
1954
+ // cgroup-kill returns, even if a child escaped the control
1955
+ // group (the dbus-run-session bug). We finish the job here so
1956
+ // a subsequent web Start doesn't EADDRINUSE-loop. Exclude
1957
+ // process.pid via reap's built-in self-exclusion — we don't
1958
+ // want to kill the very process answering this HTTP request.
1959
+ try {
1960
+ const reap = await reapStrayAgimProcesses({ sigtermTimeoutMs: 5_000 });
1961
+ if (reap.reaped.length > 0 || reap.survived.length > 0) {
1962
+ webLog.warn({
1963
+ event: 'web.service.stop.reap',
1964
+ reaped: reap.reaped,
1965
+ survived: reap.survived,
1966
+ }, `R14 reap after systemctl stop: killed ${reap.reaped.length} stray pid(s)`);
1967
+ }
1968
+ }
1969
+ catch { /* best-effort */ }
1486
1970
  sendJson(res, 200, { ok: true, mode: 'systemd' });
1487
1971
  }
1488
1972
  catch (err) {
@@ -1502,9 +1986,48 @@ async function handleServiceStop(res) {
1502
1986
  }, 200);
1503
1987
  }
1504
1988
  async function handleServiceRestart(res) {
1505
- const { detectService } = await import('../cli-ui/service.js');
1989
+ const { detectService, reapStrayAgimProcesses } = await import('../cli-ui/service.js');
1506
1990
  const st = detectService();
1507
1991
  if (st.mode === 'systemd') {
1992
+ // R14 — reap stray agim processes BEFORE systemctl restart. The
1993
+ // motivating bug: a dbus-run-session wrapper in keyring.conf
1994
+ // detached its children from systemd's cgroup, so old agim
1995
+ // outlived `systemctl stop` and the new instance hit EADDRINUSE
1996
+ // on port 3000. We can't fix the unit file from here, but we can
1997
+ // make web restart self-healing by sweeping pids that pgrep can
1998
+ // still see. excludePids = the current MainPID (systemd will
1999
+ // handle that one) and our own pid (this very web request is
2000
+ // running inside agim).
2001
+ try {
2002
+ const result = await reapStrayAgimProcesses({
2003
+ excludePids: st.pid != null ? [st.pid] : [],
2004
+ sigtermTimeoutMs: 5_000,
2005
+ });
2006
+ if (result.reaped.length > 0 || result.survived.length > 0) {
2007
+ webLog.warn({
2008
+ event: 'web.service.restart.reap',
2009
+ reaped: result.reaped,
2010
+ survived: result.survived,
2011
+ }, `R14 reap: killed ${result.reaped.length} stray agim pid(s), ${result.survived.length} survived to SIGKILL`);
2012
+ try {
2013
+ const { logAuditEvent } = await import('../core/audit-log.js');
2014
+ logAuditEvent({
2015
+ eventType: 'config.put',
2016
+ actor: 'system',
2017
+ target: 'service.restart.reap',
2018
+ outcome: 'ok',
2019
+ details: { reaped: result.reaped.length, survived: result.survived.length, mainPid: st.pid },
2020
+ });
2021
+ }
2022
+ catch { /* best-effort */ }
2023
+ }
2024
+ }
2025
+ catch (err) {
2026
+ // Reap is best-effort — log + continue to systemctl. If reap
2027
+ // failed because pgrep isn't available, systemctl restart will
2028
+ // still try (and may succeed if the cgroup is OK).
2029
+ webLog.warn({ event: 'web.service.restart.reap_failed', err: String(err) });
2030
+ }
1508
2031
  try {
1509
2032
  const { spawn } = await import('node:child_process');
1510
2033
  const unitName = existsSync('/etc/systemd/system/agim.service') ? 'agim.service' : 'im-hub.service';
@@ -2116,6 +2639,18 @@ async function handlePutEnv(req, res) {
2116
2639
  else
2117
2640
  process.env[k] = v;
2118
2641
  }
2642
+ // R12 ⑤ — persistent audit. Track WHICH keys were touched, never the
2643
+ // values themselves (env can hold SMTP_PASS, BAIDU_AK, etc.).
2644
+ try {
2645
+ const { logAuditEvent } = await import('../core/audit-log.js');
2646
+ logAuditEvent({
2647
+ eventType: 'env.put',
2648
+ actor: getRequestActor(req),
2649
+ outcome: 'ok',
2650
+ details: { keys: Object.keys(safe) },
2651
+ });
2652
+ }
2653
+ catch { /* best-effort */ }
2119
2654
  sendJson(res, 200, { ok: true, updated: Object.keys(safe) });
2120
2655
  }
2121
2656
  catch (err) {
@@ -2265,6 +2800,36 @@ async function handleAudit(_req, res, url) {
2265
2800
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
2266
2801
  }
2267
2802
  }
2803
+ /** R12 ⑤ — GET /api/audit/events. Queryable by type, actor, days, limit.
2804
+ * Returns the JSON-encoded `details` field as a string; the client can
2805
+ * JSON.parse on demand (avoids accidental schema coupling). */
2806
+ async function handleAuditEvents(_req, res, url) {
2807
+ try {
2808
+ const { queryAuditEvents } = await import('../core/audit-log.js');
2809
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '100', 10) || 100, 1), 1000);
2810
+ const days = parseInt(url.searchParams.get('days') || '30', 10) || 30;
2811
+ const actor = url.searchParams.get('actor') || undefined;
2812
+ const typesParam = url.searchParams.get('types');
2813
+ // Accept comma-separated event types; reject anything not in the
2814
+ // known union (defensive against open-ended filters that miss the
2815
+ // SQL prepared-statement param check).
2816
+ const KNOWN_TYPES = [
2817
+ 'approval.allow', 'approval.deny',
2818
+ 'admin.elevate', 'admin.revoke',
2819
+ 'config.put', 'env.put',
2820
+ 'token.create', 'token.revoke',
2821
+ 'workspace.add', 'workspace.delete',
2822
+ ];
2823
+ const types = typesParam
2824
+ ? typesParam.split(',').map((s) => s.trim()).filter((s) => KNOWN_TYPES.includes(s))
2825
+ : undefined;
2826
+ const events = queryAuditEvents({ limit, days, actor, types });
2827
+ sendJson(res, 200, { events });
2828
+ }
2829
+ catch (err) {
2830
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
2831
+ }
2832
+ }
2268
2833
  /**
2269
2834
  * Per-agent operational health snapshot. Drives the Health tab in /tasks.
2270
2835
  *
@@ -2795,9 +3360,28 @@ async function handleSkillsList(_req, res) {
2795
3360
  // skillhub.cn proxy with 5-min cache — shows top-50 popular skills so the
2796
3361
  // user can discover new ones without leaving the dashboard. The actual
2797
3362
  // install still happens via the skillhub CLI on the host (see docs).
3363
+ //
3364
+ // Enterprise / air-gapped deployments can disable this endpoint via
3365
+ // `IMHUB_SKILLHUB_ENABLED=0`. When disabled, the API returns a stub
3366
+ // shape `{ disabled: true, items: [] }` so the UI can show "disabled
3367
+ // by enterprise policy" without breaking the page. This is the only
3368
+ // product-default outbound call to a non-IM domain, so the toggle
3369
+ // matters for organisations doing tcpdump-level egress audits.
2798
3370
  let remoteHotCache = null;
2799
3371
  const REMOTE_HOT_TTL_MS = 5 * 60_000;
3372
+ function isSkillhubEnabled() {
3373
+ const v = (process.env.IMHUB_SKILLHUB_ENABLED ?? '1').trim().toLowerCase();
3374
+ return v !== '0' && v !== 'false' && v !== 'no' && v !== 'off';
3375
+ }
2800
3376
  async function handleSkillsRemoteHot(_req, res) {
3377
+ if (!isSkillhubEnabled()) {
3378
+ // Disabled by enterprise policy. Return an empty-but-valid shape
3379
+ // so the SPA can render "disabled" copy without a network error
3380
+ // toast. Status 200 because this is a deliberate operator choice,
3381
+ // not a transient failure.
3382
+ sendJson(res, 200, { disabled: true, items: [], reason: 'IMHUB_SKILLHUB_ENABLED=0' });
3383
+ return;
3384
+ }
2801
3385
  try {
2802
3386
  if (remoteHotCache && Date.now() - remoteHotCache.fetchedAt < REMOTE_HOT_TTL_MS) {
2803
3387
  sendJson(res, 200, { ...remoteHotCache.data, cached: true, fetchedAt: remoteHotCache.fetchedAt });
@@ -4028,7 +4612,7 @@ threadOwners) {
4028
4612
  logger,
4029
4613
  userId: `web:${clientId}`,
4030
4614
  };
4031
- logger.info({ event: 'message.received', text: text.substring(0, 120) });
4615
+ logger.info({ event: 'message.received', ...sanitizeUserText(text) });
4032
4616
  const result = await routeMessage(parsed, routeCtx);
4033
4617
  // String response (built-in commands, errors)
4034
4618
  if (typeof result === 'string') {