agim-cli 1.2.21 → 1.2.34

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 (286) hide show
  1. package/CHANGELOG.md +172 -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/service.d.ts +6 -5
  60. package/dist/core/commands/service.d.ts.map +1 -1
  61. package/dist/core/commands/service.js +59 -8
  62. package/dist/core/commands/service.js.map +1 -1
  63. package/dist/core/feature-flags.d.ts +10 -0
  64. package/dist/core/feature-flags.d.ts.map +1 -0
  65. package/dist/core/feature-flags.js +30 -0
  66. package/dist/core/feature-flags.js.map +1 -0
  67. package/dist/core/intent.d.ts.map +1 -1
  68. package/dist/core/intent.js +0 -4
  69. package/dist/core/intent.js.map +1 -1
  70. package/dist/core/logger.d.ts +31 -0
  71. package/dist/core/logger.d.ts.map +1 -1
  72. package/dist/core/logger.js +41 -1
  73. package/dist/core/logger.js.map +1 -1
  74. package/dist/core/memory-distill.js +1 -1
  75. package/dist/core/memory-distill.js.map +1 -1
  76. package/dist/core/memory-rpc.d.ts.map +1 -1
  77. package/dist/core/memory-rpc.js +55 -12
  78. package/dist/core/memory-rpc.js.map +1 -1
  79. package/dist/core/memory.d.ts +5 -0
  80. package/dist/core/memory.d.ts.map +1 -1
  81. package/dist/core/memory.js +151 -1
  82. package/dist/core/memory.js.map +1 -1
  83. package/dist/core/onboarding.d.ts +6 -0
  84. package/dist/core/onboarding.d.ts.map +1 -1
  85. package/dist/core/onboarding.js +21 -10
  86. package/dist/core/onboarding.js.map +1 -1
  87. package/dist/core/outbox.d.ts +8 -1
  88. package/dist/core/outbox.d.ts.map +1 -1
  89. package/dist/core/outbox.js +46 -2
  90. package/dist/core/outbox.js.map +1 -1
  91. package/dist/core/persona.d.ts.map +1 -1
  92. package/dist/core/persona.js +1 -18
  93. package/dist/core/persona.js.map +1 -1
  94. package/dist/core/prompt-injection-guard.d.ts +23 -0
  95. package/dist/core/prompt-injection-guard.d.ts.map +1 -0
  96. package/dist/core/prompt-injection-guard.js +94 -0
  97. package/dist/core/prompt-injection-guard.js.map +1 -0
  98. package/dist/core/registry.d.ts +8 -0
  99. package/dist/core/registry.d.ts.map +1 -1
  100. package/dist/core/registry.js +41 -7
  101. package/dist/core/registry.js.map +1 -1
  102. package/dist/core/restart-flow.d.ts.map +1 -1
  103. package/dist/core/restart-flow.js +27 -0
  104. package/dist/core/restart-flow.js.map +1 -1
  105. package/dist/core/router.d.ts.map +1 -1
  106. package/dist/core/router.js +4 -0
  107. package/dist/core/router.js.map +1 -1
  108. package/dist/core/sensitive-paths.d.ts +28 -0
  109. package/dist/core/sensitive-paths.d.ts.map +1 -0
  110. package/dist/core/sensitive-paths.js +234 -0
  111. package/dist/core/sensitive-paths.js.map +1 -0
  112. package/dist/core/session.d.ts +9 -0
  113. package/dist/core/session.d.ts.map +1 -1
  114. package/dist/core/session.js +35 -4
  115. package/dist/core/session.js.map +1 -1
  116. package/dist/core/types.d.ts +6 -1
  117. package/dist/core/types.d.ts.map +1 -1
  118. package/dist/core/viewer-local.d.ts +4 -0
  119. package/dist/core/viewer-local.d.ts.map +1 -1
  120. package/dist/core/viewer-local.js +74 -0
  121. package/dist/core/viewer-local.js.map +1 -1
  122. package/dist/plugins/agents/antigravity/ensure-mcp-config.d.ts +49 -0
  123. package/dist/plugins/agents/antigravity/ensure-mcp-config.d.ts.map +1 -0
  124. package/dist/plugins/agents/antigravity/ensure-mcp-config.js +110 -0
  125. package/dist/plugins/agents/antigravity/ensure-mcp-config.js.map +1 -0
  126. package/dist/plugins/agents/antigravity/index.d.ts +34 -0
  127. package/dist/plugins/agents/antigravity/index.d.ts.map +1 -0
  128. package/dist/plugins/agents/antigravity/index.js +330 -0
  129. package/dist/plugins/agents/antigravity/index.js.map +1 -0
  130. package/dist/plugins/agents/claude-code/mcp-approval-server.js +5 -5
  131. package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -1
  132. package/dist/plugins/messengers/feishu/card-builder.d.ts.map +1 -1
  133. package/dist/plugins/messengers/feishu/card-builder.js +0 -1
  134. package/dist/plugins/messengers/feishu/card-builder.js.map +1 -1
  135. package/dist/utils/cross-platform.d.ts +0 -5
  136. package/dist/utils/cross-platform.d.ts.map +1 -1
  137. package/dist/utils/cross-platform.js +1 -21
  138. package/dist/utils/cross-platform.js.map +1 -1
  139. package/dist/web/public/assets/{a2a-Dk2fSs33.js → a2a-BFM2Ojvs.js} +2 -2
  140. package/dist/web/public/assets/{a2a-Dk2fSs33.js.map → a2a-BFM2Ojvs.js.map} +1 -1
  141. package/dist/web/public/assets/{activity-eiIPshcV.js → activity-DNe1vn9s.js} +2 -2
  142. package/dist/web/public/assets/{activity-eiIPshcV.js.map → activity-DNe1vn9s.js.map} +1 -1
  143. package/dist/web/public/assets/{admins-DlbQYdW_.js → admins-DtIF8yji.js} +2 -2
  144. package/dist/web/public/assets/{admins-DlbQYdW_.js.map → admins-DtIF8yji.js.map} +1 -1
  145. package/dist/web/public/assets/agents-BJ3jyH1u.js +12 -0
  146. package/dist/web/public/assets/agents-BJ3jyH1u.js.map +1 -0
  147. package/dist/web/public/assets/{approvals-DlXS_sKD.js → approvals-8ifx3_0b.js} +2 -2
  148. package/dist/web/public/assets/{approvals-DlXS_sKD.js.map → approvals-8ifx3_0b.js.map} +1 -1
  149. package/dist/web/public/assets/{audit-C8I8xC_6.js → audit-BiFOljMy.js} +2 -2
  150. package/dist/web/public/assets/{audit-C8I8xC_6.js.map → audit-BiFOljMy.js.map} +1 -1
  151. package/dist/web/public/assets/{bgjobs-PFYinH7D.js → bgjobs-Dve01ab0.js} +2 -2
  152. package/dist/web/public/assets/{bgjobs-PFYinH7D.js.map → bgjobs-Dve01ab0.js.map} +1 -1
  153. package/dist/web/public/assets/{brain-DEEJttEL.js → brain-BI78EY6_.js} +2 -2
  154. package/dist/web/public/assets/{brain-DEEJttEL.js.map → brain-BI78EY6_.js.map} +1 -1
  155. package/dist/web/public/assets/{briefcase-BlMy8gI6.js → briefcase-CuwoOW31.js} +2 -2
  156. package/dist/web/public/assets/{briefcase-BlMy8gI6.js.map → briefcase-CuwoOW31.js.map} +1 -1
  157. package/dist/web/public/assets/{chevron-right-DmABPvoA.js → chevron-right-D5JanEQ3.js} +2 -2
  158. package/dist/web/public/assets/{chevron-right-DmABPvoA.js.map → chevron-right-D5JanEQ3.js.map} +1 -1
  159. package/dist/web/public/assets/{circle-check-C0Qpg1vL.js → circle-check-DXMzdFtD.js} +2 -2
  160. package/dist/web/public/assets/{circle-check-C0Qpg1vL.js.map → circle-check-DXMzdFtD.js.map} +1 -1
  161. package/dist/web/public/assets/{circle-check-big-C8LG3beV.js → circle-check-big-CyJIIhiq.js} +2 -2
  162. package/dist/web/public/assets/{circle-check-big-C8LG3beV.js.map → circle-check-big-CyJIIhiq.js.map} +1 -1
  163. package/dist/web/public/assets/{circle-x-D_cRHcHK.js → circle-x-DTwfNpvp.js} +2 -2
  164. package/dist/web/public/assets/{circle-x-D_cRHcHK.js.map → circle-x-DTwfNpvp.js.map} +1 -1
  165. package/dist/web/public/assets/{confirm-dialog-Baz_xFle.js → confirm-dialog-F0sdcLGN.js} +2 -2
  166. package/dist/web/public/assets/{confirm-dialog-Baz_xFle.js.map → confirm-dialog-F0sdcLGN.js.map} +1 -1
  167. package/dist/web/public/assets/{data-table--I_ktDF4.js → data-table-DN_-VLbp.js} +2 -2
  168. package/dist/web/public/assets/{data-table--I_ktDF4.js.map → data-table-DN_-VLbp.js.map} +1 -1
  169. package/dist/web/public/assets/{dialog-DZpoEskO.js → dialog-CM16nfWK.js} +2 -2
  170. package/dist/web/public/assets/{dialog-DZpoEskO.js.map → dialog-CM16nfWK.js.map} +1 -1
  171. package/dist/web/public/assets/{download-DbFGHwZ5.js → download-tFqY3Zj6.js} +2 -2
  172. package/dist/web/public/assets/{download-DbFGHwZ5.js.map → download-tFqY3Zj6.js.map} +1 -1
  173. package/dist/web/public/assets/{email-BB1Hq8eE.js → email-By2-ARPV.js} +2 -2
  174. package/dist/web/public/assets/{email-BB1Hq8eE.js.map → email-By2-ARPV.js.map} +1 -1
  175. package/dist/web/public/assets/{empty-state-DXNa90pP.js → empty-state-Dq9_8t-M.js} +2 -2
  176. package/dist/web/public/assets/{empty-state-DXNa90pP.js.map → empty-state-Dq9_8t-M.js.map} +1 -1
  177. package/dist/web/public/assets/{external-link-nhnJN0qg.js → external-link-amfAWSUX.js} +2 -2
  178. package/dist/web/public/assets/{external-link-nhnJN0qg.js.map → external-link-amfAWSUX.js.map} +1 -1
  179. package/dist/web/public/assets/{eye-IKkn_oUo.js → eye-BfgN09Lx.js} +2 -2
  180. package/dist/web/public/assets/{eye-IKkn_oUo.js.map → eye-BfgN09Lx.js.map} +1 -1
  181. package/dist/web/public/assets/{facts-C7Qy9vTw.js → facts-uvL__ilQ.js} +2 -2
  182. package/dist/web/public/assets/{facts-C7Qy9vTw.js.map → facts-uvL__ilQ.js.map} +1 -1
  183. package/dist/web/public/assets/{health-CMRdeNEW.js → health-fiU-ueEw.js} +2 -2
  184. package/dist/web/public/assets/{health-CMRdeNEW.js.map → health-fiU-ueEw.js.map} +1 -1
  185. package/dist/web/public/assets/{hot-Bh5Nrc7i.js → hot-Dt4V3jM_.js} +2 -2
  186. package/dist/web/public/assets/{hot-Bh5Nrc7i.js.map → hot-Dt4V3jM_.js.map} +1 -1
  187. package/dist/web/public/assets/{index-CpGWCLE5.js → index-6GMwymev.js} +8 -8
  188. package/dist/web/public/assets/index-6GMwymev.js.map +1 -0
  189. package/dist/web/public/assets/{index-GpceOxum.css → index-CDYTPZH0.css} +1 -1
  190. package/dist/web/public/assets/{installed-FYLkPij2.js → installed-B_x6no76.js} +2 -2
  191. package/dist/web/public/assets/{installed-FYLkPij2.js.map → installed-B_x6no76.js.map} +1 -1
  192. package/dist/web/public/assets/{jobs-BmqLUzHp.js → jobs-LqWH3CIe.js} +2 -2
  193. package/dist/web/public/assets/{jobs-BmqLUzHp.js.map → jobs-LqWH3CIe.js.map} +1 -1
  194. package/dist/web/public/assets/layout-CvxcdPD9.js +2 -0
  195. package/dist/web/public/assets/layout-CvxcdPD9.js.map +1 -0
  196. package/dist/web/public/assets/{layout-BZaHqf69.js → layout-DHUzlXrd.js} +2 -2
  197. package/dist/web/public/assets/{layout-BZaHqf69.js.map → layout-DHUzlXrd.js.map} +1 -1
  198. package/dist/web/public/assets/{layout-CXsUyEpG.js → layout-MNk0bLGe.js} +2 -2
  199. package/dist/web/public/assets/{layout-CXsUyEpG.js.map → layout-MNk0bLGe.js.map} +1 -1
  200. package/dist/web/public/assets/{layout-DFxtpNut.js → layout-WcrkE0es.js} +2 -2
  201. package/dist/web/public/assets/{layout-DFxtpNut.js.map → layout-WcrkE0es.js.map} +1 -1
  202. package/dist/web/public/assets/{layout-d8qxPKQk.js → layout-apvyE2JN.js} +2 -2
  203. package/dist/web/public/assets/{layout-d8qxPKQk.js.map → layout-apvyE2JN.js.map} +1 -1
  204. package/dist/web/public/assets/{loader-circle-JaKY-xMt.js → loader-circle-hxNy7hSm.js} +2 -2
  205. package/dist/web/public/assets/{loader-circle-JaKY-xMt.js.map → loader-circle-hxNy7hSm.js.map} +1 -1
  206. package/dist/web/public/assets/{map-pin-hFFSWZ3B.js → map-pin-CCmA7ke2.js} +2 -2
  207. package/dist/web/public/assets/{map-pin-hFFSWZ3B.js.map → map-pin-CCmA7ke2.js.map} +1 -1
  208. package/dist/web/public/assets/{memos-EhjMUvVZ.js → memos-pEjDfEj3.js} +2 -2
  209. package/dist/web/public/assets/{memos-EhjMUvVZ.js.map → memos-pEjDfEj3.js.map} +1 -1
  210. package/dist/web/public/assets/messengers-Ba7opEc1.js +7 -0
  211. package/dist/web/public/assets/messengers-Ba7opEc1.js.map +1 -0
  212. package/dist/web/public/assets/{network-DtCI2ZUU.js → network-DJw-ei_k.js} +2 -2
  213. package/dist/web/public/assets/{network-DtCI2ZUU.js.map → network-DJw-ei_k.js.map} +1 -1
  214. package/dist/web/public/assets/{outbox-CxUbMp6o.js → outbox-Ckq-VT5C.js} +2 -2
  215. package/dist/web/public/assets/{outbox-CxUbMp6o.js.map → outbox-Ckq-VT5C.js.map} +1 -1
  216. package/dist/web/public/assets/{pagination-CkZY8YNa.js → pagination-DGS-TnI5.js} +2 -2
  217. package/dist/web/public/assets/{pagination-CkZY8YNa.js.map → pagination-DGS-TnI5.js.map} +1 -1
  218. package/dist/web/public/assets/{persona-B6TFMSnI.js → persona--LsrhCVU.js} +2 -2
  219. package/dist/web/public/assets/{persona-B6TFMSnI.js.map → persona--LsrhCVU.js.map} +1 -1
  220. package/dist/web/public/assets/{play-BxRcWaH5.js → play-Cb7co2DX.js} +2 -2
  221. package/dist/web/public/assets/{play-BxRcWaH5.js.map → play-Cb7co2DX.js.map} +1 -1
  222. package/dist/web/public/assets/{policy-ndE1Y8zD.js → policy-CbCotzr6.js} +2 -2
  223. package/dist/web/public/assets/{policy-ndE1Y8zD.js.map → policy-CbCotzr6.js.map} +1 -1
  224. package/dist/web/public/assets/{refresh-ccw-Bx817_KW.js → refresh-ccw-CINxCmwV.js} +2 -2
  225. package/dist/web/public/assets/{refresh-ccw-Bx817_KW.js.map → refresh-ccw-CINxCmwV.js.map} +1 -1
  226. package/dist/web/public/assets/{reminders-XynkGQc5.js → reminders-CSKrWre3.js} +2 -2
  227. package/dist/web/public/assets/{reminders-XynkGQc5.js.map → reminders-CSKrWre3.js.map} +1 -1
  228. package/dist/web/public/assets/{save-CqMcATrh.js → save-Bib9iAA-.js} +2 -2
  229. package/dist/web/public/assets/{save-CqMcATrh.js.map → save-Bib9iAA-.js.map} +1 -1
  230. package/dist/web/public/assets/{schedules-VM02w_Om.js → schedules-DUD_FfEX.js} +2 -2
  231. package/dist/web/public/assets/{schedules-VM02w_Om.js.map → schedules-DUD_FfEX.js.map} +1 -1
  232. package/dist/web/public/assets/{search-Ba-e1t1P.js → search-DZOHNA81.js} +2 -2
  233. package/dist/web/public/assets/{search-Ba-e1t1P.js.map → search-DZOHNA81.js.map} +1 -1
  234. package/dist/web/public/assets/{service-C-wnwJ-b.js → service-Cf7EQ4Sj.js} +3 -3
  235. package/dist/web/public/assets/{service-C-wnwJ-b.js.map → service-Cf7EQ4Sj.js.map} +1 -1
  236. package/dist/web/public/assets/{status-badge-CsdJ6k8Q.js → status-badge-RpKtiHgj.js} +2 -2
  237. package/dist/web/public/assets/{status-badge-CsdJ6k8Q.js.map → status-badge-RpKtiHgj.js.map} +1 -1
  238. package/dist/web/public/assets/{subtasks-mGRKpF0G.js → subtasks-xCHP5uI6.js} +2 -2
  239. package/dist/web/public/assets/{subtasks-mGRKpF0G.js.map → subtasks-xCHP5uI6.js.map} +1 -1
  240. package/dist/web/public/assets/{table-vmLMgj6_.js → table-S43AHY-3.js} +2 -2
  241. package/dist/web/public/assets/{table-vmLMgj6_.js.map → table-S43AHY-3.js.map} +1 -1
  242. package/dist/web/public/assets/{topn-nu66Fotx.js → topn-C2tcvmnB.js} +2 -2
  243. package/dist/web/public/assets/{topn-nu66Fotx.js.map → topn-C2tcvmnB.js.map} +1 -1
  244. package/dist/web/public/assets/{trash-2-ZIitN_U3.js → trash-2-Ct7YJmZO.js} +2 -2
  245. package/dist/web/public/assets/{trash-2-ZIitN_U3.js.map → trash-2-Ct7YJmZO.js.map} +1 -1
  246. package/dist/web/public/assets/{use-memory-DgEqHEca.js → use-memory-CCn0h8EP.js} +2 -2
  247. package/dist/web/public/assets/{use-memory-DgEqHEca.js.map → use-memory-CCn0h8EP.js.map} +1 -1
  248. package/dist/web/public/assets/{use-observability-CQev_A8e.js → use-observability-Dbal-WXR.js} +2 -2
  249. package/dist/web/public/assets/{use-observability-CQev_A8e.js.map → use-observability-Dbal-WXR.js.map} +1 -1
  250. package/dist/web/public/assets/{use-settings-CU-UcrVD.js → use-settings-erlkhPqn.js} +2 -2
  251. package/dist/web/public/assets/{use-settings-CU-UcrVD.js.map → use-settings-erlkhPqn.js.map} +1 -1
  252. package/dist/web/public/assets/{use-skills-Dr77CXLA.js → use-skills-C8Ukv8B4.js} +2 -2
  253. package/dist/web/public/assets/{use-skills-Dr77CXLA.js.map → use-skills-C8Ukv8B4.js.map} +1 -1
  254. package/dist/web/public/assets/{use-workspace-PNv9Z4de.js → use-workspace-8VZDPppc.js} +2 -2
  255. package/dist/web/public/assets/{use-workspace-PNv9Z4de.js.map → use-workspace-8VZDPppc.js.map} +1 -1
  256. package/dist/web/public/assets/{useQuery-BTyugXYV.js → useQuery-BnCHQlxq.js} +2 -2
  257. package/dist/web/public/assets/{useQuery-BTyugXYV.js.map → useQuery-BnCHQlxq.js.map} +1 -1
  258. package/dist/web/public/assets/{vector-w-Ea3pg6.js → vector-DDnIidyp.js} +2 -2
  259. package/dist/web/public/assets/{vector-w-Ea3pg6.js.map → vector-DDnIidyp.js.map} +1 -1
  260. package/dist/web/public/assets/{viewer-DKA7QP9U.js → viewer-BV0Cux3V.js} +2 -2
  261. package/dist/web/public/assets/{viewer-DKA7QP9U.js.map → viewer-BV0Cux3V.js.map} +1 -1
  262. package/dist/web/public/assets/{workspace-DVLZca7t.js → workspace-DbLMyUTn.js} +2 -2
  263. package/dist/web/public/assets/{workspace-DVLZca7t.js.map → workspace-DbLMyUTn.js.map} +1 -1
  264. package/dist/web/public/assets/{workspaces-DYZsMmY-.js → workspaces-BhF0IAJg.js} +2 -2
  265. package/dist/web/public/assets/{workspaces-DYZsMmY-.js.map → workspaces-BhF0IAJg.js.map} +1 -1
  266. package/dist/web/public/assets/{x-Ru3rHT82.js → x-BmgfwQRl.js} +2 -2
  267. package/dist/web/public/assets/{x-Ru3rHT82.js.map → x-BmgfwQRl.js.map} +1 -1
  268. package/dist/web/public/index.html +2 -2
  269. package/dist/web/public/settings.html +0 -1
  270. package/dist/web/server.d.ts.map +1 -1
  271. package/dist/web/server.js +613 -28
  272. package/dist/web/server.js.map +1 -1
  273. package/package.json +3 -2
  274. package/dist/plugins/agents/copilot/index.d.ts +0 -35
  275. package/dist/plugins/agents/copilot/index.d.ts.map +0 -1
  276. package/dist/plugins/agents/copilot/index.js +0 -182
  277. package/dist/plugins/agents/copilot/index.js.map +0 -1
  278. package/dist/web/public/assets/agents-BMI1WbZj.js +0 -12
  279. package/dist/web/public/assets/agents-BMI1WbZj.js.map +0 -1
  280. package/dist/web/public/assets/env-Bqrb9XkC.js +0 -2
  281. package/dist/web/public/assets/env-Bqrb9XkC.js.map +0 -1
  282. package/dist/web/public/assets/index-CpGWCLE5.js.map +0 -1
  283. package/dist/web/public/assets/layout-9Gp_myEd.js +0 -2
  284. package/dist/web/public/assets/layout-9Gp_myEd.js.map +0 -1
  285. package/dist/web/public/assets/messengers-BRV1IVGX.js +0 -7
  286. 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,
@@ -200,19 +357,20 @@ export async function startWebServer(options) {
200
357
  // Loopback peers + the public paths below bypass.
201
358
  if (!checkAuth(req, res, url))
202
359
  return;
203
- // v2 SPA fallback — opt-in via IMHUB_WEB_V2=1. When enabled, any
204
- // GET that doesn't match an API / WS / SSE / static-asset path
205
- // and isn't a public SSR page falls through to serving the v2
206
- // index.html. The client-side router (react-router) then resolves
207
- // the path. Anything served from src/web-app/dist/* is built in
208
- // the M5 PR; until then, set IMHUB_WEB_V2=1 only if you've run
209
- // `npm --prefix src/web-app run build` and copied dist/* into
210
- // src/web/public/`.
360
+ // v2 SPA fallback — default ON since 1.2.21 (the v2 SPA shell is now
361
+ // the only `index.html` we ship; the v1 monolithic HTML was retired
362
+ // during M1 / R10). This branch serves the SPA chunks under
363
+ // `/assets/*.js`, the favicon / manifest, and falls back to
364
+ // index.html for any client-side route. Without this branch a fresh
365
+ // install hits a white screen `<script src="/assets/…js">` 404s
366
+ // because no other handler claims that path.
211
367
  //
212
- // Default IMHUB_WEB_V2=0 existing behavior unchanged: tasks.html
213
- // / reminders.html / memos.html / settings.html / index.html are
214
- // served as before. Zero impact on production deployments.
215
- if (process.env.IMHUB_WEB_V2 === '1' && req.method === 'GET') {
368
+ // Operators on a deeply-customised v1 layout can opt out with
369
+ // `IMHUB_WEB_V2=0`; that drops back into the v1 handler block below,
370
+ // which still serves the legacy `tasks.html` / `reminders.html` /
371
+ // `memos.html` / `settings.html` directly. The default-off shape
372
+ // shipped through 1.2.20.
373
+ if (process.env.IMHUB_WEB_V2 !== '0' && req.method === 'GET') {
216
374
  const p = url.pathname;
217
375
  const isApi = p.startsWith('/api/');
218
376
  const isWs = p.startsWith('/ws');
@@ -472,15 +630,21 @@ export async function startWebServer(options) {
472
630
  return handleGetConfig(req, res);
473
631
  }
474
632
  if (url.pathname === '/api/config' && req.method === 'PUT') {
633
+ if (!requireAdmin(req, res))
634
+ return;
475
635
  return handlePutConfig(req, res);
476
636
  }
477
637
  if (url.pathname === '/api/agents/status' && req.method === 'GET') {
478
638
  return handleAgentsStatus(req, res);
479
639
  }
480
640
  if (url.pathname === '/api/agents/acp/test' && req.method === 'POST') {
641
+ if (!requireAdmin(req, res))
642
+ return;
481
643
  return handleAcpTest(req, res);
482
644
  }
483
645
  if (url.pathname === '/api/agents/acp/discover' && req.method === 'POST') {
646
+ if (!requireAdmin(req, res))
647
+ return;
484
648
  return handleAcpDiscover(req, res);
485
649
  }
486
650
  // Jobs
@@ -550,30 +714,51 @@ export async function startWebServer(options) {
550
714
  return handleGetEnv(req, res, url);
551
715
  }
552
716
  if (url.pathname === '/api/env' && req.method === 'PUT') {
717
+ if (!requireAdmin(req, res))
718
+ return;
553
719
  return handlePutEnv(req, res);
554
720
  }
555
721
  if (url.pathname === '/api/messengers/email/test' && req.method === 'POST') {
722
+ if (!requireAdmin(req, res))
723
+ return;
556
724
  return handleEmailTest(req, res);
557
725
  }
558
726
  if (url.pathname === '/api/workspaces' && req.method === 'GET') {
559
727
  return handleListWorkspaces(req, res, url);
560
728
  }
561
729
  if (url.pathname === '/api/workspaces' && req.method === 'POST') {
730
+ if (!requireAdmin(req, res))
731
+ return;
562
732
  return handleCreateOrUpdateWorkspace(req, res);
563
733
  }
564
734
  const workspaceIdMatch = url.pathname.match(/^\/api\/workspaces\/([^/]+)$/);
565
735
  if (workspaceIdMatch && req.method === 'PATCH') {
736
+ if (!requireAdmin(req, res))
737
+ return;
566
738
  return handleCreateOrUpdateWorkspace(req, res, workspaceIdMatch[1]);
567
739
  }
568
740
  if (workspaceIdMatch && req.method === 'DELETE') {
741
+ if (!requireAdmin(req, res))
742
+ return;
569
743
  return handleDeleteWorkspace(req, res, workspaceIdMatch[1]);
570
744
  }
571
745
  if (url.pathname === '/api/metrics' && req.method === 'GET') {
572
746
  return handleMetrics(req, res, url);
573
747
  }
574
748
  if (url.pathname === '/api/audit' && req.method === 'GET') {
749
+ if (!requireAdmin(req, res))
750
+ return;
575
751
  return handleAudit(req, res, url);
576
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
+ }
577
762
  // v1.1.2 — Outbox tab. List rows by status, plus aggregate stats and
578
763
  // a retry endpoint for the giving_up row state.
579
764
  if (url.pathname === '/api/outbox' && req.method === 'GET') {
@@ -584,6 +769,8 @@ export async function startWebServer(options) {
584
769
  }
585
770
  const outboxRetryMatch = url.pathname.match(/^\/api\/outbox\/(\d+)\/retry$/);
586
771
  if (outboxRetryMatch && req.method === 'POST') {
772
+ if (!requireAdmin(req, res))
773
+ return;
587
774
  return handleOutboxRetry(req, res, parseInt(outboxRetryMatch[1], 10));
588
775
  }
589
776
  // v1.1.3 — A2A tab. Stats over inline rows with parent_id; recent
@@ -649,6 +836,8 @@ export async function startWebServer(options) {
649
836
  return handleMemoryFacts(req, res, url);
650
837
  }
651
838
  if (url.pathname === '/api/memory/facts' && req.method === 'DELETE') {
839
+ if (!requireAdmin(req, res))
840
+ return;
652
841
  return handleMemoryBulkDelete(req, res, url);
653
842
  }
654
843
  const memFactIdMatch = url.pathname.match(/^\/api\/memory\/facts\/(\d+)$/);
@@ -659,9 +848,13 @@ export async function startWebServer(options) {
659
848
  return handleMemoryPersona(req, res, url);
660
849
  }
661
850
  if (url.pathname === '/api/memory/persona' && req.method === 'PUT') {
851
+ if (!requireAdmin(req, res))
852
+ return;
662
853
  return handleMemoryPersonaPut(req, res, url);
663
854
  }
664
855
  if (url.pathname === '/api/memory/persona' && req.method === 'DELETE') {
856
+ if (!requireAdmin(req, res))
857
+ return;
665
858
  return handleMemoryPersonaDelete(req, res, url);
666
859
  }
667
860
  if (url.pathname === '/api/memory/export' && req.method === 'GET') {
@@ -672,20 +865,30 @@ export async function startWebServer(options) {
672
865
  return handleVectorStatus(req, res, url);
673
866
  }
674
867
  if (url.pathname === '/api/memory/vector/test' && req.method === 'POST') {
868
+ if (!requireAdmin(req, res))
869
+ return;
675
870
  return handleVectorTest(req, res);
676
871
  }
677
872
  if (url.pathname === '/api/memory/vector/download' && req.method === 'POST') {
873
+ if (!requireAdmin(req, res))
874
+ return;
678
875
  return handleVectorDownload(req, res);
679
876
  }
680
877
  if (url.pathname === '/api/memory/vector/backfill' && req.method === 'POST') {
878
+ if (!requireAdmin(req, res))
879
+ return;
681
880
  return handleVectorBackfill(req, res, url);
682
881
  }
683
882
  if (url.pathname === '/api/memory/vector/clear' && req.method === 'POST') {
883
+ if (!requireAdmin(req, res))
884
+ return;
684
885
  return handleVectorClear(req, res, url);
685
886
  }
686
887
  // v1.2.2 — manual trigger for the daily consolidation. Useful when the
687
888
  // user just wants a persona summary now without waiting 24h.
688
889
  if (url.pathname === '/api/memory/consolidate' && req.method === 'POST') {
890
+ if (!requireAdmin(req, res))
891
+ return;
689
892
  return handleMemoryConsolidate(req, res);
690
893
  }
691
894
  if (url.pathname === '/api/memory/consolidate/status' && req.method === 'GET') {
@@ -719,15 +922,21 @@ export async function startWebServer(options) {
719
922
  return handleWorkspaceFiles(req, res, url);
720
923
  }
721
924
  if (url.pathname === '/api/workspace-files' && req.method === 'PUT') {
925
+ if (!requireAdmin(req, res))
926
+ return;
722
927
  return handleWorkspaceFileWrite(req, res, url);
723
928
  }
724
929
  // PR-D: Job batch operations. Same semantics as /api/jobs/:id/cancel
725
930
  // and /run but accepts an array of ids in one request — saves N
726
931
  // round-trips when the user multi-selects a long list.
727
932
  if (url.pathname === '/api/jobs/batch-cancel' && req.method === 'POST') {
933
+ if (!requireAdmin(req, res))
934
+ return;
728
935
  return handleBatchJob(req, res, 'cancel');
729
936
  }
730
937
  if (url.pathname === '/api/jobs/batch-run' && req.method === 'POST') {
938
+ if (!requireAdmin(req, res))
939
+ return;
731
940
  return handleBatchJob(req, res, 'run', options.defaultAgent);
732
941
  }
733
942
  // PR-C: SSE event stream — audit / approval / job / metrics events
@@ -737,9 +946,13 @@ export async function startWebServer(options) {
737
946
  return handleEventsSSE(req, res);
738
947
  }
739
948
  if (url.pathname === '/api/notify' && req.method === 'POST') {
949
+ if (!requireAdmin(req, res))
950
+ return;
740
951
  return handleNotify(req, res);
741
952
  }
742
953
  if (url.pathname === '/api/invoke' && req.method === 'POST') {
954
+ if (!requireAdmin(req, res))
955
+ return;
743
956
  return handleInvoke(req, res, options.defaultAgent);
744
957
  }
745
958
  // WeChat QR login — drives the "扫码登录" button in the web settings
@@ -748,6 +961,8 @@ export async function startWebServer(options) {
748
961
  // credentials to disk AND adds 'wechat-ilink' into config.messengers
749
962
  // so the next service restart picks the channel up.
750
963
  if (url.pathname === '/api/messengers/wechat/qr-start' && req.method === 'POST') {
964
+ if (!requireAdmin(req, res))
965
+ return;
751
966
  return handleWechatQrStart(res);
752
967
  }
753
968
  if (url.pathname === '/api/messengers/wechat/qr-status' && req.method === 'GET') {
@@ -763,12 +978,18 @@ export async function startWebServer(options) {
763
978
  return handleServiceStatus(res);
764
979
  }
765
980
  if (url.pathname === '/api/service/start' && req.method === 'POST') {
981
+ if (!requireAdmin(req, res))
982
+ return;
766
983
  return handleServiceStart(res);
767
984
  }
768
985
  if (url.pathname === '/api/service/stop' && req.method === 'POST') {
986
+ if (!requireAdmin(req, res))
987
+ return;
769
988
  return handleServiceStop(res);
770
989
  }
771
990
  if (url.pathname === '/api/service/restart' && req.method === 'POST') {
991
+ if (!requireAdmin(req, res))
992
+ return;
772
993
  return handleServiceRestart(res);
773
994
  }
774
995
  // Admin allowlist — list + add/remove via the Safety card. Locally
@@ -779,9 +1000,13 @@ export async function startWebServer(options) {
779
1000
  return handleAdminAllowlistGet(res);
780
1001
  }
781
1002
  if (url.pathname === '/api/admin-allowlist' && req.method === 'POST') {
1003
+ if (!requireAdmin(req, res))
1004
+ return;
782
1005
  return handleAdminAllowlistAdd(req, res, bindHost);
783
1006
  }
784
1007
  if (url.pathname === '/api/admin-allowlist' && req.method === 'DELETE') {
1008
+ if (!requireAdmin(req, res))
1009
+ return;
785
1010
  return handleAdminAllowlistRemove(req, res, bindHost);
786
1011
  }
787
1012
  res.writeHead(404);
@@ -796,6 +1021,18 @@ export async function startWebServer(options) {
796
1021
  const wss = new WebSocketServer({
797
1022
  server: httpServer,
798
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
+ }
799
1036
  // Auth-off / loopback bypass — mirror checkAuth's two short-circuits
800
1037
  // so dev / local CLI sessions still work without a token.
801
1038
  if ((process.env.IMHUB_WEB_AUTH || '').toLowerCase() === 'off')
@@ -846,6 +1083,89 @@ export async function startWebServer(options) {
846
1083
  }
847
1084
  return 100;
848
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
+ }
849
1169
  wss.on('connection', (ws, req) => {
850
1170
  if (clients.size >= maxWsClients) {
851
1171
  // 1013 = "Try Again Later" per RFC 6455. Slightly nicer than a flat
@@ -858,6 +1178,9 @@ export async function startWebServer(options) {
858
1178
  ws.close(1013, 'Server too busy');
859
1179
  return;
860
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);
861
1184
  // R9: token verified at upgrade time by verifyClient; we read the
862
1185
  // tokenId here so get-history can enforce ownership when the client
863
1186
  // wants to rekey to a persisted threadId. 'anon' is the loopback /
@@ -978,6 +1301,7 @@ export async function startWebServer(options) {
978
1301
  const liveId = client.id;
979
1302
  webLog.info({ clientId: liveId }, 'Client disconnected');
980
1303
  clients.delete(liveId);
1304
+ recordWsIpClose(req); // R13 A4
981
1305
  // R9: drop the ownership entry only for server-generated ids that
982
1306
  // were never rekeyed to a persistent client-side threadId. Rekeyed
983
1307
  // ids stay registered so the operator's next reconnect with the
@@ -989,6 +1313,7 @@ export async function startWebServer(options) {
989
1313
  const liveId = client.id;
990
1314
  webLog.error({ clientId: liveId, err: err instanceof Error ? err.message : String(err) }, 'Client WebSocket error');
991
1315
  clients.delete(liveId);
1316
+ recordWsIpClose(req); // R13 A4
992
1317
  if (liveId === clientId)
993
1318
  threadOwners.delete(liveId);
994
1319
  });
@@ -1101,6 +1426,19 @@ export async function startWebServer(options) {
1101
1426
  }
1102
1427
  wss.close();
1103
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 */ }
1104
1442
  },
1105
1443
  };
1106
1444
  }
@@ -1111,11 +1449,18 @@ async function handleGetConfig(_req, res) {
1111
1449
  try {
1112
1450
  const config = await loadConfig();
1113
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();
1114
1459
  sendJson(res, 200, {
1115
1460
  messengers: config.messengers,
1116
1461
  agents: config.agents,
1117
1462
  defaultAgent: config.defaultAgent,
1118
- telegram: config.telegram
1463
+ telegram: showGlobalIm && config.telegram
1119
1464
  ? { botToken: mask(config.telegram.botToken), channelId: config.telegram.channelId }
1120
1465
  : undefined,
1121
1466
  feishu: config.feishu
@@ -1124,7 +1469,7 @@ async function handleGetConfig(_req, res) {
1124
1469
  dingtalk: config.dingtalk
1125
1470
  ? { clientId: config.dingtalk.clientId, clientSecret: mask(config.dingtalk.clientSecret), channelId: config.dingtalk.channelId }
1126
1471
  : undefined,
1127
- discord: config.discord
1472
+ discord: showGlobalIm && config.discord
1128
1473
  ? {
1129
1474
  botToken: mask(config.discord.botToken),
1130
1475
  channelId: config.discord.channelId,
@@ -1132,13 +1477,29 @@ async function handleGetConfig(_req, res) {
1132
1477
  allowedChannels: config.discord.allowedChannels,
1133
1478
  }
1134
1479
  : undefined,
1135
- acpAgents: config.acpAgents?.map(a => ({
1136
- ...a,
1137
- auth: a.auth
1138
- ? { ...a.auth, token: a.auth.token ? mask(a.auth.token) : undefined }
1139
- : undefined,
1140
- })),
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
+ },
1141
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,
1142
1503
  agentStatus,
1143
1504
  });
1144
1505
  }
@@ -1151,6 +1512,29 @@ async function handlePutConfig(req, res) {
1151
1512
  const body = await readBody(req, res);
1152
1513
  const incoming = JSON.parse(body);
1153
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
+ }
1154
1538
  const merged = { ...existing };
1155
1539
  for (const key of Object.keys(incoming)) {
1156
1540
  const val = incoming[key];
@@ -1216,6 +1600,20 @@ async function handlePutConfig(req, res) {
1216
1600
  return;
1217
1601
  }
1218
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 */ }
1219
1617
  sendJson(res, 200, { ok: true });
1220
1618
  }
1221
1619
  catch (err) {
@@ -1317,13 +1715,26 @@ async function handleCreateOrUpdateWorkspace(req, res, expectedId) {
1317
1715
  const { workspaceRegistry } = await import('../core/workspace.js');
1318
1716
  workspaceRegistry.add(v.cfg);
1319
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 */ }
1320
1731
  sendJson(res, 200, { ok: true, workspace: v.cfg });
1321
1732
  }
1322
1733
  catch (err) {
1323
1734
  sendJson(res, 500, { ok: false, error: err instanceof Error ? err.message : String(err) });
1324
1735
  }
1325
1736
  }
1326
- async function handleDeleteWorkspace(_req, res, id) {
1737
+ async function handleDeleteWorkspace(req, res, id) {
1327
1738
  try {
1328
1739
  const { workspaceRegistry } = await import('../core/workspace.js');
1329
1740
  if (id === 'default') {
@@ -1336,6 +1747,16 @@ async function handleDeleteWorkspace(_req, res, id) {
1336
1747
  return;
1337
1748
  }
1338
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 */ }
1339
1760
  sendJson(res, 200, { ok: true });
1340
1761
  }
1341
1762
  catch (err) {
@@ -1456,8 +1877,54 @@ async function handleServiceStatus(res) {
1456
1877
  }
1457
1878
  async function handleServiceStart(res) {
1458
1879
  try {
1459
- const { detectService, spawnBackground } = await import('../cli-ui/service.js');
1880
+ const { detectService, spawnBackground, reapStrayAgimProcesses } = await import('../cli-ui/service.js');
1460
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.
1461
1928
  if (st.mode !== 'none') {
1462
1929
  sendJson(res, 200, { ok: true, alreadyRunning: true, mode: st.mode, pid: st.pid });
1463
1930
  return;
@@ -1473,7 +1940,7 @@ async function handleServiceStart(res) {
1473
1940
  }
1474
1941
  }
1475
1942
  async function handleServiceStop(res) {
1476
- const { detectService } = await import('../cli-ui/service.js');
1943
+ const { detectService, reapStrayAgimProcesses } = await import('../cli-ui/service.js');
1477
1944
  const st = detectService();
1478
1945
  if (st.mode === 'systemd') {
1479
1946
  // systemctl stop handles the kill for us — no self-exit needed.
@@ -1482,6 +1949,24 @@ async function handleServiceStop(res) {
1482
1949
  // Match service.ts's detection: prefer agim.service, fall back.
1483
1950
  const unitName = existsSync('/etc/systemd/system/agim.service') ? 'agim.service' : 'im-hub.service';
1484
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 */ }
1485
1970
  sendJson(res, 200, { ok: true, mode: 'systemd' });
1486
1971
  }
1487
1972
  catch (err) {
@@ -1501,9 +1986,48 @@ async function handleServiceStop(res) {
1501
1986
  }, 200);
1502
1987
  }
1503
1988
  async function handleServiceRestart(res) {
1504
- const { detectService } = await import('../cli-ui/service.js');
1989
+ const { detectService, reapStrayAgimProcesses } = await import('../cli-ui/service.js');
1505
1990
  const st = detectService();
1506
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
+ }
1507
2031
  try {
1508
2032
  const { spawn } = await import('node:child_process');
1509
2033
  const unitName = existsSync('/etc/systemd/system/agim.service') ? 'agim.service' : 'im-hub.service';
@@ -2115,6 +2639,18 @@ async function handlePutEnv(req, res) {
2115
2639
  else
2116
2640
  process.env[k] = v;
2117
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 */ }
2118
2654
  sendJson(res, 200, { ok: true, updated: Object.keys(safe) });
2119
2655
  }
2120
2656
  catch (err) {
@@ -2264,6 +2800,36 @@ async function handleAudit(_req, res, url) {
2264
2800
  sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
2265
2801
  }
2266
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
+ }
2267
2833
  /**
2268
2834
  * Per-agent operational health snapshot. Drives the Health tab in /tasks.
2269
2835
  *
@@ -2794,9 +3360,28 @@ async function handleSkillsList(_req, res) {
2794
3360
  // skillhub.cn proxy with 5-min cache — shows top-50 popular skills so the
2795
3361
  // user can discover new ones without leaving the dashboard. The actual
2796
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.
2797
3370
  let remoteHotCache = null;
2798
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
+ }
2799
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
+ }
2800
3385
  try {
2801
3386
  if (remoteHotCache && Date.now() - remoteHotCache.fetchedAt < REMOTE_HOT_TTL_MS) {
2802
3387
  sendJson(res, 200, { ...remoteHotCache.data, cached: true, fetchedAt: remoteHotCache.fetchedAt });
@@ -4027,7 +4612,7 @@ threadOwners) {
4027
4612
  logger,
4028
4613
  userId: `web:${clientId}`,
4029
4614
  };
4030
- logger.info({ event: 'message.received', text: text.substring(0, 120) });
4615
+ logger.info({ event: 'message.received', ...sanitizeUserText(text) });
4031
4616
  const result = await routeMessage(parsed, routeCtx);
4032
4617
  // String response (built-in commands, errors)
4033
4618
  if (typeof result === 'string') {