cli-claw-kit 0.0.1

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 (295) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -0
  3. package/config/default-groups.json +1 -0
  4. package/config/global-agents-md.template.md +37 -0
  5. package/config/mount-allowlist.json +11 -0
  6. package/container/Dockerfile +160 -0
  7. package/container/agent-runner/dist/.tsbuildinfo +1 -0
  8. package/container/agent-runner/dist/agent-definitions.js +22 -0
  9. package/container/agent-runner/dist/channel-prefixes.js +16 -0
  10. package/container/agent-runner/dist/codex-config.js +29 -0
  11. package/container/agent-runner/dist/image-detector.js +96 -0
  12. package/container/agent-runner/dist/index.js +2587 -0
  13. package/container/agent-runner/dist/mcp-tools.js +1076 -0
  14. package/container/agent-runner/dist/stream-event.types.js +5 -0
  15. package/container/agent-runner/dist/stream-processor.js +867 -0
  16. package/container/agent-runner/dist/types.js +6 -0
  17. package/container/agent-runner/dist/utils.js +115 -0
  18. package/container/agent-runner/package.json +36 -0
  19. package/container/agent-runner/prompts/security-rules.md +31 -0
  20. package/container/agent-runner/src/agent-definitions.ts +27 -0
  21. package/container/agent-runner/src/channel-prefixes.ts +16 -0
  22. package/container/agent-runner/src/codex-config.ts +40 -0
  23. package/container/agent-runner/src/image-detector.ts +116 -0
  24. package/container/agent-runner/src/index.ts +3107 -0
  25. package/container/agent-runner/src/mcp-tools.ts +1295 -0
  26. package/container/agent-runner/src/stream-event.types.ts +10 -0
  27. package/container/agent-runner/src/stream-processor.ts +932 -0
  28. package/container/agent-runner/src/types.ts +75 -0
  29. package/container/agent-runner/src/utils.ts +114 -0
  30. package/container/agent-runner/tsconfig.json +17 -0
  31. package/container/build.sh +28 -0
  32. package/container/entrypoint.sh +64 -0
  33. package/container/skills/agent-browser/SKILL.md +159 -0
  34. package/container/skills/install-skill/SKILL.md +64 -0
  35. package/container/skills/post-test-cleanup/SKILL.md +121 -0
  36. package/dist/.tsbuildinfo +1 -0
  37. package/dist/agent-output-parser.js +459 -0
  38. package/dist/app-root.js +52 -0
  39. package/dist/assistant-meta-footer.js +1 -0
  40. package/dist/auth.js +91 -0
  41. package/dist/billing.js +694 -0
  42. package/dist/channel-prefixes.js +16 -0
  43. package/dist/cli.js +86 -0
  44. package/dist/commands.js +79 -0
  45. package/dist/config.js +120 -0
  46. package/dist/container-runner.js +981 -0
  47. package/dist/daily-summary.js +210 -0
  48. package/dist/db.js +3683 -0
  49. package/dist/dingtalk.js +1347 -0
  50. package/dist/feishu-markdown-style.js +97 -0
  51. package/dist/feishu-streaming-card.js +1875 -0
  52. package/dist/feishu.js +1628 -0
  53. package/dist/file-manager.js +270 -0
  54. package/dist/group-queue.js +1070 -0
  55. package/dist/group-runtime.js +35 -0
  56. package/dist/host-workspace-cwd.js +85 -0
  57. package/dist/im-channel.js +384 -0
  58. package/dist/im-command-utils.js +142 -0
  59. package/dist/im-downloader.js +45 -0
  60. package/dist/im-manager.js +527 -0
  61. package/dist/im-utils.js +53 -0
  62. package/dist/image-detector.js +96 -0
  63. package/dist/index.js +5828 -0
  64. package/dist/logger.js +22 -0
  65. package/dist/mcp-utils.js +66 -0
  66. package/dist/message-attachments.js +69 -0
  67. package/dist/message-notifier.js +36 -0
  68. package/dist/middleware/auth.js +85 -0
  69. package/dist/mount-security.js +315 -0
  70. package/dist/permissions.js +67 -0
  71. package/dist/project-memory.js +6 -0
  72. package/dist/provider-pool.js +189 -0
  73. package/dist/qq.js +826 -0
  74. package/dist/reset-admin.js +42 -0
  75. package/dist/routes/admin.js +543 -0
  76. package/dist/routes/agent-definitions.js +241 -0
  77. package/dist/routes/agents.js +533 -0
  78. package/dist/routes/auth.js +675 -0
  79. package/dist/routes/billing.js +490 -0
  80. package/dist/routes/browse.js +210 -0
  81. package/dist/routes/bug-report.js +387 -0
  82. package/dist/routes/config.js +1868 -0
  83. package/dist/routes/files.js +671 -0
  84. package/dist/routes/groups.js +1367 -0
  85. package/dist/routes/mcp-servers.js +320 -0
  86. package/dist/routes/memory.js +523 -0
  87. package/dist/routes/monitor.js +307 -0
  88. package/dist/routes/skills.js +777 -0
  89. package/dist/routes/tasks.js +509 -0
  90. package/dist/routes/usage.js +64 -0
  91. package/dist/routes/workspace-config.js +458 -0
  92. package/dist/runtime-build.js +112 -0
  93. package/dist/runtime-command-handler.js +189 -0
  94. package/dist/runtime-command-registry.js +1 -0
  95. package/dist/runtime-config.js +1777 -0
  96. package/dist/runtime-identity.js +52 -0
  97. package/dist/schemas.js +590 -0
  98. package/dist/script-runner.js +64 -0
  99. package/dist/sdk-query.js +82 -0
  100. package/dist/skill-utils.js +145 -0
  101. package/dist/sqlite-compat.js +19 -0
  102. package/dist/stream-event.types.js +5 -0
  103. package/dist/streaming-runtime-meta.js +29 -0
  104. package/dist/task-scheduler.js +695 -0
  105. package/dist/task-utils.js +13 -0
  106. package/dist/telegram-pairing.js +59 -0
  107. package/dist/telegram.js +897 -0
  108. package/dist/terminal-manager.js +307 -0
  109. package/dist/tool-step-display.js +1 -0
  110. package/dist/types.js +1 -0
  111. package/dist/utils.js +85 -0
  112. package/dist/web-context.js +161 -0
  113. package/dist/web.js +1377 -0
  114. package/dist/wechat-crypto.js +182 -0
  115. package/dist/wechat.js +589 -0
  116. package/dist/workspace-runtime-reset.js +35 -0
  117. package/package.json +107 -0
  118. package/shared/assistant-meta-footer.ts +127 -0
  119. package/shared/channel-prefixes.ts +16 -0
  120. package/shared/dist/assistant-meta-footer.d.ts +29 -0
  121. package/shared/dist/assistant-meta-footer.js +85 -0
  122. package/shared/dist/channel-prefixes.d.ts +4 -0
  123. package/shared/dist/channel-prefixes.js +16 -0
  124. package/shared/dist/image-detector.d.ts +20 -0
  125. package/shared/dist/image-detector.js +96 -0
  126. package/shared/dist/runtime-command-registry.d.ts +38 -0
  127. package/shared/dist/runtime-command-registry.js +185 -0
  128. package/shared/dist/stream-event.d.ts +65 -0
  129. package/shared/dist/stream-event.js +8 -0
  130. package/shared/dist/tool-step-display.d.ts +4 -0
  131. package/shared/dist/tool-step-display.js +11 -0
  132. package/shared/image-detector.ts +116 -0
  133. package/shared/runtime-command-registry.ts +252 -0
  134. package/shared/stream-event.ts +67 -0
  135. package/shared/tool-step-display.ts +21 -0
  136. package/shared/tsconfig.json +24 -0
  137. package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
  138. package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
  139. package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
  140. package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  141. package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  142. package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  143. package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  144. package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  145. package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  146. package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  147. package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  148. package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  149. package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  150. package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  151. package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  152. package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  153. package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  154. package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  155. package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  156. package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  157. package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  158. package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  159. package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  160. package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  161. package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  162. package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  163. package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  164. package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  165. package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  166. package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  167. package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  168. package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  169. package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  170. package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  171. package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  172. package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  173. package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  174. package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  175. package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  176. package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  177. package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  178. package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  179. package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  180. package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  181. package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  182. package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  183. package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  184. package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  185. package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  186. package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  187. package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  188. package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  189. package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  190. package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  191. package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  192. package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  193. package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  194. package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  195. package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  196. package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  197. package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  198. package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  199. package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
  200. package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
  201. package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
  202. package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
  203. package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
  204. package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
  205. package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
  206. package/web/dist/assets/band-CquvqAHh.js +1 -0
  207. package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
  208. package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
  209. package/web/dist/assets/channel-BOVj73LR.js +1 -0
  210. package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
  211. package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
  212. package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
  213. package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
  214. package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
  215. package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
  216. package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
  217. package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
  218. package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
  219. package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
  220. package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
  221. package/web/dist/assets/clone-BmaCesfa.js +1 -0
  222. package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
  223. package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  224. package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
  225. package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  226. package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
  227. package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
  228. package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
  229. package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
  230. package/web/dist/assets/error-CGD5mp5f.js +1 -0
  231. package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
  232. package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
  233. package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
  234. package/web/dist/assets/graph-CeAEckur.js +1 -0
  235. package/web/dist/assets/index-CPnL1_qC.js +768 -0
  236. package/web/dist/assets/index-DVevCbcO.css +10 -0
  237. package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
  238. package/web/dist/assets/init-Dmth1JHB.js +1 -0
  239. package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
  240. package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
  241. package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
  242. package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
  243. package/web/dist/assets/linear-DiaJloY5.js +1 -0
  244. package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
  245. package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
  246. package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
  247. package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
  248. package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
  249. package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
  250. package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
  251. package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
  252. package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
  253. package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
  254. package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
  255. package/web/dist/assets/square-0CqMX1Q3.js +11 -0
  256. package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
  257. package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
  258. package/web/dist/assets/step-D51IIHGA.js +1 -0
  259. package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
  260. package/web/dist/assets/time-O8zIGux3.js +1 -0
  261. package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
  262. package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
  263. package/web/dist/assets/utils-KGAn0XTg.js +11 -0
  264. package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
  265. package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
  266. package/web/dist/assets/zap-_hKJYy7J.js +6 -0
  267. package/web/dist/favicon.svg +332 -0
  268. package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
  269. package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
  270. package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
  271. package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
  272. package/web/dist/fonts/DMSans-latin.woff2 +0 -0
  273. package/web/dist/icons/README.md +20 -0
  274. package/web/dist/icons/apple-touch-icon-180.png +0 -0
  275. package/web/dist/icons/icon-128.png +0 -0
  276. package/web/dist/icons/icon-144.png +0 -0
  277. package/web/dist/icons/icon-152.png +0 -0
  278. package/web/dist/icons/icon-192.png +0 -0
  279. package/web/dist/icons/icon-192.svg +332 -0
  280. package/web/dist/icons/icon-384.png +0 -0
  281. package/web/dist/icons/icon-48.png +0 -0
  282. package/web/dist/icons/icon-512-maskable.png +0 -0
  283. package/web/dist/icons/icon-512.png +0 -0
  284. package/web/dist/icons/icon-512.svg +332 -0
  285. package/web/dist/icons/icon-72.png +0 -0
  286. package/web/dist/icons/icon-96.png +0 -0
  287. package/web/dist/icons/loading-logo.svg +332 -0
  288. package/web/dist/icons/logo-1024.png +0 -0
  289. package/web/dist/icons/logo-icon.svg +332 -0
  290. package/web/dist/icons/logo-text.svg +332 -0
  291. package/web/dist/index.html +30 -0
  292. package/web/dist/manifest.webmanifest +1 -0
  293. package/web/dist/registerSW.js +1 -0
  294. package/web/dist/sw.js +1 -0
  295. package/web/dist/workbox-08d6266a.js +1 -0
@@ -0,0 +1,1777 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { ASSISTANT_NAME, DATA_DIR } from './config.js';
5
+ import { logger } from './logger.js';
6
+ const MAX_FIELD_LENGTH = 2000;
7
+ const CLAUDE_CONFIG_DIR = path.join(DATA_DIR, 'config');
8
+ const CLAUDE_CONFIG_FILE = path.join(CLAUDE_CONFIG_DIR, 'claude-provider.json');
9
+ const CLAUDE_CONFIG_KEY_FILE = path.join(CLAUDE_CONFIG_DIR, 'claude-provider.key');
10
+ const CLAUDE_CONFIG_AUDIT_FILE = path.join(CLAUDE_CONFIG_DIR, 'claude-provider.audit.log');
11
+ const FEISHU_CONFIG_FILE = path.join(CLAUDE_CONFIG_DIR, 'feishu-provider.json');
12
+ const TELEGRAM_CONFIG_FILE = path.join(CLAUDE_CONFIG_DIR, 'telegram-provider.json');
13
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
14
+ const RESERVED_CLAUDE_ENV_KEYS = new Set([
15
+ 'CLAUDE_CODE_OAUTH_TOKEN',
16
+ 'ANTHROPIC_BASE_URL',
17
+ 'ANTHROPIC_AUTH_TOKEN',
18
+ 'ANTHROPIC_MODEL',
19
+ ]);
20
+ const DANGEROUS_ENV_VARS = new Set([
21
+ // Code execution / preload attacks
22
+ 'LD_PRELOAD',
23
+ 'LD_LIBRARY_PATH',
24
+ 'LD_AUDIT',
25
+ 'DYLD_INSERT_LIBRARIES',
26
+ 'DYLD_LIBRARY_PATH',
27
+ 'DYLD_FRAMEWORK_PATH',
28
+ 'NODE_OPTIONS',
29
+ 'JAVA_TOOL_OPTIONS',
30
+ 'PERL5OPT',
31
+ // Path manipulation
32
+ 'PATH',
33
+ 'PYTHONPATH',
34
+ 'RUBYLIB',
35
+ 'PERL5LIB',
36
+ 'GIT_EXEC_PATH',
37
+ 'CDPATH',
38
+ // Shell behavior
39
+ 'BASH_ENV',
40
+ 'ENV',
41
+ 'PROMPT_COMMAND',
42
+ 'ZDOTDIR',
43
+ // Editor / terminal (可被利用执行命令)
44
+ 'EDITOR',
45
+ 'VISUAL',
46
+ 'PAGER',
47
+ // SSH / Git(防止凭据泄露或命令注入)
48
+ 'SSH_AUTH_SOCK',
49
+ 'SSH_AGENT_PID',
50
+ 'GIT_SSH',
51
+ 'GIT_SSH_COMMAND',
52
+ 'GIT_ASKPASS',
53
+ // Sensitive directories
54
+ 'HOME',
55
+ 'TMPDIR',
56
+ 'TEMP',
57
+ 'TMP',
58
+ // cli-claw 内部路径映射
59
+ 'CLI_CLAW_WORKSPACE_GROUP',
60
+ 'CLI_CLAW_WORKSPACE_GLOBAL',
61
+ 'CLI_CLAW_WORKSPACE_IPC',
62
+ 'CLAUDE_CONFIG_DIR',
63
+ ]);
64
+ const MAX_CUSTOM_ENV_ENTRIES = 50;
65
+ // Fallback scopes for .credentials.json when stored credentials lack scopes.
66
+ // Differs from OAUTH_SCOPES in routes/config.ts (the authorize-flow request):
67
+ // authorize requests org:create_api_key; credential files need user:sessions:claude_code.
68
+ const DEFAULT_CREDENTIAL_SCOPES = [
69
+ 'user:inference',
70
+ 'user:profile',
71
+ 'user:sessions:claude_code',
72
+ ];
73
+ const DEFAULT_BALANCING_CONFIG = {
74
+ strategy: 'round-robin',
75
+ unhealthyThreshold: 3,
76
+ recoveryIntervalMs: 300_000,
77
+ };
78
+ const MAX_PROVIDERS = 20;
79
+ function normalizeSecret(input, fieldName) {
80
+ if (typeof input !== 'string') {
81
+ throw new Error(`Invalid field: ${fieldName}`);
82
+ }
83
+ // Strip ALL whitespace and non-ASCII characters — API keys/tokens are always ASCII;
84
+ // users often paste with accidental spaces, line breaks, or smart quotes (e.g. U+2019).
85
+ // eslint-disable-next-line no-control-regex
86
+ const value = input.replace(/\s+/g, '').replace(/[^\x00-\x7F]/g, '');
87
+ if (value.length > MAX_FIELD_LENGTH) {
88
+ throw new Error(`Field too long: ${fieldName}`);
89
+ }
90
+ return value;
91
+ }
92
+ function normalizeBaseUrl(input) {
93
+ if (typeof input !== 'string') {
94
+ throw new Error('Invalid field: anthropicBaseUrl');
95
+ }
96
+ const value = input.trim();
97
+ if (!value)
98
+ return '';
99
+ if (value.length > MAX_FIELD_LENGTH) {
100
+ throw new Error('Field too long: anthropicBaseUrl');
101
+ }
102
+ let parsed;
103
+ try {
104
+ parsed = new URL(value);
105
+ }
106
+ catch {
107
+ throw new Error('Invalid field: anthropicBaseUrl');
108
+ }
109
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
110
+ throw new Error('Invalid field: anthropicBaseUrl');
111
+ }
112
+ return value;
113
+ }
114
+ function normalizeModel(input) {
115
+ if (typeof input !== 'string') {
116
+ throw new Error('Invalid field: anthropicModel');
117
+ }
118
+ const value = input.trim();
119
+ if (!value)
120
+ return '';
121
+ if (value.length > 128) {
122
+ throw new Error('Field too long: anthropicModel');
123
+ }
124
+ return value;
125
+ }
126
+ function normalizeFeishuAppId(input) {
127
+ if (typeof input !== 'string') {
128
+ throw new Error('Invalid field: appId');
129
+ }
130
+ const value = input.trim();
131
+ if (!value)
132
+ return '';
133
+ if (value.length > MAX_FIELD_LENGTH) {
134
+ throw new Error('Field too long: appId');
135
+ }
136
+ return value;
137
+ }
138
+ function normalizeTelegramProxyUrl(input) {
139
+ if (input === undefined || input === null)
140
+ return '';
141
+ if (typeof input !== 'string') {
142
+ throw new Error('Invalid field: proxyUrl');
143
+ }
144
+ const value = input.trim();
145
+ if (!value)
146
+ return '';
147
+ if (value.length > MAX_FIELD_LENGTH) {
148
+ throw new Error('Field too long: proxyUrl');
149
+ }
150
+ let parsed;
151
+ try {
152
+ parsed = new URL(value);
153
+ }
154
+ catch {
155
+ throw new Error('Invalid field: proxyUrl');
156
+ }
157
+ const protocol = parsed.protocol.toLowerCase();
158
+ if (!['http:', 'https:', 'socks:', 'socks5:'].includes(protocol)) {
159
+ throw new Error('Invalid field: proxyUrl');
160
+ }
161
+ return value;
162
+ }
163
+ function normalizeProfileName(input) {
164
+ if (typeof input !== 'string') {
165
+ throw new Error('Invalid field: name');
166
+ }
167
+ const value = input.trim();
168
+ if (!value) {
169
+ throw new Error('Invalid field: name');
170
+ }
171
+ if (value.length > 64) {
172
+ throw new Error('Field too long: name');
173
+ }
174
+ return value;
175
+ }
176
+ function sanitizeCustomEnvMap(input, options) {
177
+ const entries = Object.entries(input);
178
+ if (entries.length > MAX_CUSTOM_ENV_ENTRIES) {
179
+ throw new Error(`customEnv must have at most ${MAX_CUSTOM_ENV_ENTRIES} entries`);
180
+ }
181
+ const out = {};
182
+ for (const [key, rawValue] of entries) {
183
+ if (!ENV_KEY_RE.test(key)) {
184
+ throw new Error(`Invalid env key: ${key}`);
185
+ }
186
+ if (options?.skipReservedClaudeKeys && RESERVED_CLAUDE_ENV_KEYS.has(key)) {
187
+ continue;
188
+ }
189
+ out[key] = sanitizeEnvValue(typeof rawValue === 'string' ? rawValue : String(rawValue));
190
+ }
191
+ return out;
192
+ }
193
+ function normalizeConfig(input) {
194
+ return {
195
+ anthropicBaseUrl: normalizeBaseUrl(input.anthropicBaseUrl),
196
+ anthropicAuthToken: normalizeSecret(input.anthropicAuthToken, 'anthropicAuthToken'),
197
+ anthropicApiKey: normalizeSecret(input.anthropicApiKey, 'anthropicApiKey'),
198
+ claudeCodeOauthToken: normalizeSecret(input.claudeCodeOauthToken, 'claudeCodeOauthToken'),
199
+ claudeOAuthCredentials: input.claudeOAuthCredentials ?? null,
200
+ anthropicModel: normalizeModel(input.anthropicModel),
201
+ };
202
+ }
203
+ function buildConfig(input, updatedAt) {
204
+ return {
205
+ ...normalizeConfig(input),
206
+ updatedAt,
207
+ };
208
+ }
209
+ function getOrCreateEncryptionKey() {
210
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
211
+ if (fs.existsSync(CLAUDE_CONFIG_KEY_FILE)) {
212
+ const raw = fs.readFileSync(CLAUDE_CONFIG_KEY_FILE, 'utf-8').trim();
213
+ const key = Buffer.from(raw, 'hex');
214
+ if (key.length === 32)
215
+ return key;
216
+ throw new Error('Invalid encryption key file');
217
+ }
218
+ const key = crypto.randomBytes(32);
219
+ fs.writeFileSync(CLAUDE_CONFIG_KEY_FILE, key.toString('hex') + '\n', {
220
+ encoding: 'utf-8',
221
+ mode: 0o600,
222
+ });
223
+ return key;
224
+ }
225
+ function encryptSecrets(payload) {
226
+ const key = getOrCreateEncryptionKey();
227
+ const iv = crypto.randomBytes(12);
228
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
229
+ const plaintext = Buffer.from(JSON.stringify(payload), 'utf-8');
230
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
231
+ const tag = cipher.getAuthTag();
232
+ return {
233
+ iv: iv.toString('base64'),
234
+ tag: tag.toString('base64'),
235
+ data: encrypted.toString('base64'),
236
+ };
237
+ }
238
+ function decryptSecrets(secrets) {
239
+ const key = getOrCreateEncryptionKey();
240
+ const iv = Buffer.from(secrets.iv, 'base64');
241
+ const tag = Buffer.from(secrets.tag, 'base64');
242
+ const encrypted = Buffer.from(secrets.data, 'base64');
243
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
244
+ decipher.setAuthTag(tag);
245
+ const decrypted = Buffer.concat([
246
+ decipher.update(encrypted),
247
+ decipher.final(),
248
+ ]).toString('utf-8');
249
+ const parsed = JSON.parse(decrypted);
250
+ const result = {
251
+ anthropicAuthToken: normalizeSecret(parsed.anthropicAuthToken ?? '', 'anthropicAuthToken'),
252
+ anthropicApiKey: normalizeSecret(parsed.anthropicApiKey ?? '', 'anthropicApiKey'),
253
+ claudeCodeOauthToken: normalizeSecret(parsed.claudeCodeOauthToken ?? '', 'claudeCodeOauthToken'),
254
+ };
255
+ // Restore OAuth credentials if present
256
+ if (parsed.claudeOAuthCredentials &&
257
+ typeof parsed.claudeOAuthCredentials === 'object') {
258
+ const creds = parsed.claudeOAuthCredentials;
259
+ if (typeof creds.accessToken === 'string' &&
260
+ typeof creds.refreshToken === 'string') {
261
+ result.claudeOAuthCredentials = {
262
+ accessToken: creds.accessToken,
263
+ refreshToken: creds.refreshToken,
264
+ expiresAt: typeof creds.expiresAt === 'number' ? creds.expiresAt : 0,
265
+ scopes: Array.isArray(creds.scopes) ? creds.scopes : [],
266
+ ...(typeof creds.subscriptionType === 'string'
267
+ ? { subscriptionType: creds.subscriptionType }
268
+ : {}),
269
+ };
270
+ }
271
+ }
272
+ return result;
273
+ }
274
+ function encryptChannelSecret(payload) {
275
+ const key = getOrCreateEncryptionKey();
276
+ const iv = crypto.randomBytes(12);
277
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
278
+ const plaintext = Buffer.from(JSON.stringify(payload), 'utf-8');
279
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
280
+ const tag = cipher.getAuthTag();
281
+ return {
282
+ iv: iv.toString('base64'),
283
+ tag: tag.toString('base64'),
284
+ data: encrypted.toString('base64'),
285
+ };
286
+ }
287
+ function decryptChannelSecret(secrets) {
288
+ const key = getOrCreateEncryptionKey();
289
+ const iv = Buffer.from(secrets.iv, 'base64');
290
+ const tag = Buffer.from(secrets.tag, 'base64');
291
+ const encrypted = Buffer.from(secrets.data, 'base64');
292
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
293
+ decipher.setAuthTag(tag);
294
+ const decrypted = Buffer.concat([
295
+ decipher.update(encrypted),
296
+ decipher.final(),
297
+ ]).toString('utf-8');
298
+ return JSON.parse(decrypted);
299
+ }
300
+ function toStoredProviderV4(provider) {
301
+ const secrets = {
302
+ anthropicAuthToken: provider.anthropicAuthToken || '',
303
+ anthropicApiKey: provider.anthropicApiKey || '',
304
+ claudeCodeOauthToken: provider.claudeCodeOauthToken || '',
305
+ claudeOAuthCredentials: provider.claudeOAuthCredentials ?? null,
306
+ };
307
+ const sanitizedEnv = sanitizeCustomEnvMap(provider.customEnv || {}, {
308
+ skipReservedClaudeKeys: true,
309
+ });
310
+ return {
311
+ id: provider.id,
312
+ name: provider.name,
313
+ type: provider.type,
314
+ enabled: provider.enabled,
315
+ weight: Math.max(1, Math.min(100, provider.weight || 1)),
316
+ anthropicBaseUrl: provider.anthropicBaseUrl || '',
317
+ anthropicModel: provider.anthropicModel || '',
318
+ secrets: encryptSecrets(secrets),
319
+ ...(Object.keys(sanitizedEnv).length > 0
320
+ ? { customEnv: sanitizedEnv }
321
+ : {}),
322
+ updatedAt: provider.updatedAt || new Date().toISOString(),
323
+ };
324
+ }
325
+ function fromStoredProviderV4(stored) {
326
+ const secrets = decryptSecrets(stored.secrets);
327
+ return {
328
+ id: stored.id,
329
+ name: stored.name,
330
+ type: stored.type,
331
+ enabled: stored.enabled,
332
+ weight: Math.max(1, Math.min(100, stored.weight || 1)),
333
+ anthropicBaseUrl: stored.anthropicBaseUrl || '',
334
+ anthropicAuthToken: secrets.anthropicAuthToken || '',
335
+ anthropicModel: stored.anthropicModel || '',
336
+ anthropicApiKey: secrets.anthropicApiKey || '',
337
+ claudeCodeOauthToken: secrets.claudeCodeOauthToken || '',
338
+ claudeOAuthCredentials: secrets.claudeOAuthCredentials ?? null,
339
+ customEnv: sanitizeCustomEnvMap(stored.customEnv || {}, {
340
+ skipReservedClaudeKeys: true,
341
+ }),
342
+ updatedAt: stored.updatedAt || '',
343
+ };
344
+ }
345
+ function readStoredStateV4() {
346
+ if (!fs.existsSync(CLAUDE_CONFIG_FILE))
347
+ return null;
348
+ try {
349
+ const content = fs.readFileSync(CLAUDE_CONFIG_FILE, 'utf-8');
350
+ const parsed = JSON.parse(content);
351
+ if (parsed.version === 4) {
352
+ const v4 = parsed;
353
+ return {
354
+ providers: v4.providers.map(fromStoredProviderV4),
355
+ balancing: {
356
+ strategy: v4.balancing?.strategy || DEFAULT_BALANCING_CONFIG.strategy,
357
+ unhealthyThreshold: v4.balancing?.unhealthyThreshold ??
358
+ DEFAULT_BALANCING_CONFIG.unhealthyThreshold,
359
+ recoveryIntervalMs: v4.balancing?.recoveryIntervalMs ??
360
+ DEFAULT_BALANCING_CONFIG.recoveryIntervalMs,
361
+ },
362
+ };
363
+ }
364
+ logger.warn({
365
+ file: CLAUDE_CONFIG_FILE,
366
+ version: typeof parsed.version === 'number' ? parsed.version : 'unknown',
367
+ }, 'Ignoring unsupported Claude provider config version');
368
+ return null;
369
+ }
370
+ catch (err) {
371
+ logger.error({ err, file: CLAUDE_CONFIG_FILE }, 'Failed to read Claude provider config V4');
372
+ return null;
373
+ }
374
+ }
375
+ function writeStoredStateV4(providers, balancing) {
376
+ const payload = {
377
+ version: 4,
378
+ providers: providers.map(toStoredProviderV4),
379
+ balancing,
380
+ updatedAt: new Date().toISOString(),
381
+ };
382
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
383
+ const tmp = `${CLAUDE_CONFIG_FILE}.tmp`;
384
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
385
+ fs.renameSync(tmp, CLAUDE_CONFIG_FILE);
386
+ }
387
+ export function getProviders() {
388
+ const state = readStoredStateV4();
389
+ return state?.providers ?? [];
390
+ }
391
+ export function getEnabledProviders() {
392
+ return getProviders().filter((p) => p.enabled);
393
+ }
394
+ export function getBalancingConfig() {
395
+ const state = readStoredStateV4();
396
+ return state?.balancing ?? { ...DEFAULT_BALANCING_CONFIG };
397
+ }
398
+ export function saveBalancingConfig(config) {
399
+ const state = readStoredStateV4() || {
400
+ providers: [],
401
+ balancing: { ...DEFAULT_BALANCING_CONFIG },
402
+ };
403
+ const merged = {
404
+ ...state.balancing,
405
+ ...config,
406
+ };
407
+ writeStoredStateV4(state.providers, merged);
408
+ return merged;
409
+ }
410
+ export function createProvider(input) {
411
+ const state = readStoredStateV4() || {
412
+ providers: [],
413
+ balancing: { ...DEFAULT_BALANCING_CONFIG },
414
+ };
415
+ if (state.providers.length >= MAX_PROVIDERS) {
416
+ throw new Error(`最多只能创建 ${MAX_PROVIDERS} 个供应商`);
417
+ }
418
+ const now = new Date().toISOString();
419
+ const provider = {
420
+ id: crypto.randomBytes(8).toString('hex'),
421
+ name: normalizeProfileName(input.name),
422
+ type: input.type,
423
+ enabled: input.enabled ?? state.providers.length === 0,
424
+ weight: Math.max(1, Math.min(100, input.weight ?? 1)),
425
+ anthropicBaseUrl: input.anthropicBaseUrl
426
+ ? normalizeBaseUrl(input.anthropicBaseUrl)
427
+ : '',
428
+ anthropicAuthToken: input.anthropicAuthToken
429
+ ? normalizeSecret(input.anthropicAuthToken, 'anthropicAuthToken')
430
+ : '',
431
+ anthropicModel: input.anthropicModel
432
+ ? normalizeModel(input.anthropicModel)
433
+ : '',
434
+ anthropicApiKey: input.anthropicApiKey
435
+ ? normalizeSecret(input.anthropicApiKey, 'anthropicApiKey')
436
+ : '',
437
+ claudeCodeOauthToken: input.claudeCodeOauthToken
438
+ ? normalizeSecret(input.claudeCodeOauthToken, 'claudeCodeOauthToken')
439
+ : '',
440
+ claudeOAuthCredentials: input.claudeOAuthCredentials ?? null,
441
+ customEnv: sanitizeCustomEnvMap(input.customEnv || {}, {
442
+ skipReservedClaudeKeys: true,
443
+ }),
444
+ updatedAt: now,
445
+ };
446
+ state.providers.push(provider);
447
+ writeStoredStateV4(state.providers, state.balancing);
448
+ return provider;
449
+ }
450
+ export function updateProvider(id, patch) {
451
+ const state = readStoredStateV4();
452
+ if (!state)
453
+ throw new Error('Claude 配置不存在');
454
+ const idx = state.providers.findIndex((p) => p.id === id);
455
+ if (idx < 0)
456
+ throw new Error('未找到指定供应商');
457
+ const current = state.providers[idx];
458
+ const updated = {
459
+ ...current,
460
+ ...(patch.name !== undefined
461
+ ? { name: normalizeProfileName(patch.name) }
462
+ : {}),
463
+ ...(patch.anthropicBaseUrl !== undefined
464
+ ? { anthropicBaseUrl: normalizeBaseUrl(patch.anthropicBaseUrl) }
465
+ : {}),
466
+ ...(patch.anthropicModel !== undefined
467
+ ? { anthropicModel: normalizeModel(patch.anthropicModel) }
468
+ : {}),
469
+ ...(patch.customEnv !== undefined
470
+ ? {
471
+ customEnv: sanitizeCustomEnvMap(patch.customEnv, {
472
+ skipReservedClaudeKeys: true,
473
+ }),
474
+ }
475
+ : {}),
476
+ ...(patch.weight !== undefined
477
+ ? { weight: Math.max(1, Math.min(100, patch.weight)) }
478
+ : {}),
479
+ updatedAt: new Date().toISOString(),
480
+ };
481
+ state.providers[idx] = updated;
482
+ writeStoredStateV4(state.providers, state.balancing);
483
+ return updated;
484
+ }
485
+ export function updateProviderSecrets(id, secrets) {
486
+ const state = readStoredStateV4();
487
+ if (!state)
488
+ throw new Error('Claude 配置不存在');
489
+ const idx = state.providers.findIndex((p) => p.id === id);
490
+ if (idx < 0)
491
+ throw new Error('未找到指定供应商');
492
+ const current = state.providers[idx];
493
+ const updated = { ...current, updatedAt: new Date().toISOString() };
494
+ if (typeof secrets.anthropicAuthToken === 'string') {
495
+ updated.anthropicAuthToken = normalizeSecret(secrets.anthropicAuthToken, 'anthropicAuthToken');
496
+ }
497
+ else if (secrets.clearAnthropicAuthToken) {
498
+ updated.anthropicAuthToken = '';
499
+ }
500
+ if (typeof secrets.anthropicApiKey === 'string') {
501
+ updated.anthropicApiKey = normalizeSecret(secrets.anthropicApiKey, 'anthropicApiKey');
502
+ }
503
+ else if (secrets.clearAnthropicApiKey) {
504
+ updated.anthropicApiKey = '';
505
+ }
506
+ if (typeof secrets.claudeCodeOauthToken === 'string') {
507
+ updated.claudeCodeOauthToken = normalizeSecret(secrets.claudeCodeOauthToken, 'claudeCodeOauthToken');
508
+ }
509
+ else if (secrets.clearClaudeCodeOauthToken) {
510
+ updated.claudeCodeOauthToken = '';
511
+ }
512
+ if (secrets.claudeOAuthCredentials) {
513
+ updated.claudeOAuthCredentials = secrets.claudeOAuthCredentials;
514
+ // When full OAuth creds set, clear legacy single token
515
+ updated.claudeCodeOauthToken = '';
516
+ }
517
+ else if (secrets.clearClaudeOAuthCredentials) {
518
+ updated.claudeOAuthCredentials = null;
519
+ }
520
+ state.providers[idx] = updated;
521
+ writeStoredStateV4(state.providers, state.balancing);
522
+ return updated;
523
+ }
524
+ export function toggleProvider(id) {
525
+ const state = readStoredStateV4();
526
+ if (!state)
527
+ throw new Error('Claude 配置不存在');
528
+ const idx = state.providers.findIndex((p) => p.id === id);
529
+ if (idx < 0)
530
+ throw new Error('未找到指定供应商');
531
+ const provider = state.providers[idx];
532
+ const newEnabled = !provider.enabled;
533
+ // Prevent disabling the last enabled provider
534
+ if (!newEnabled && state.providers.filter((p) => p.enabled).length <= 1) {
535
+ throw new Error('至少需要保留一个启用的供应商');
536
+ }
537
+ state.providers[idx] = {
538
+ ...provider,
539
+ enabled: newEnabled,
540
+ updatedAt: new Date().toISOString(),
541
+ };
542
+ writeStoredStateV4(state.providers, state.balancing);
543
+ return state.providers[idx];
544
+ }
545
+ export function deleteProvider(id) {
546
+ const state = readStoredStateV4();
547
+ if (!state)
548
+ throw new Error('Claude 配置不存在');
549
+ const idx = state.providers.findIndex((p) => p.id === id);
550
+ if (idx < 0)
551
+ throw new Error('未找到指定供应商');
552
+ if (state.providers.length <= 1) {
553
+ throw new Error('至少需要保留一个供应商');
554
+ }
555
+ const wasEnabled = state.providers[idx].enabled;
556
+ state.providers.splice(idx, 1);
557
+ // If deleted provider was the only enabled one, enable the first remaining
558
+ if (wasEnabled && !state.providers.some((p) => p.enabled)) {
559
+ state.providers[0].enabled = true;
560
+ }
561
+ writeStoredStateV4(state.providers, state.balancing);
562
+ }
563
+ /** Convert a UnifiedProvider to the flat ClaudeProviderConfig used by container runner */
564
+ export function providerToConfig(provider) {
565
+ return {
566
+ anthropicBaseUrl: provider.anthropicBaseUrl,
567
+ anthropicAuthToken: provider.anthropicAuthToken,
568
+ anthropicApiKey: provider.anthropicApiKey,
569
+ claudeCodeOauthToken: provider.claudeCodeOauthToken,
570
+ claudeOAuthCredentials: provider.claudeOAuthCredentials,
571
+ anthropicModel: provider.anthropicModel,
572
+ updatedAt: provider.updatedAt,
573
+ };
574
+ }
575
+ /** Convert UnifiedProvider to public (masked) representation */
576
+ export function toPublicProvider(provider) {
577
+ return {
578
+ id: provider.id,
579
+ name: provider.name,
580
+ type: provider.type,
581
+ enabled: provider.enabled,
582
+ weight: provider.weight,
583
+ anthropicBaseUrl: provider.anthropicBaseUrl,
584
+ anthropicModel: provider.anthropicModel,
585
+ hasAnthropicAuthToken: !!provider.anthropicAuthToken,
586
+ anthropicAuthTokenMasked: maskSecret(provider.anthropicAuthToken),
587
+ hasAnthropicApiKey: !!provider.anthropicApiKey,
588
+ anthropicApiKeyMasked: maskSecret(provider.anthropicApiKey),
589
+ hasClaudeCodeOauthToken: !!provider.claudeCodeOauthToken,
590
+ claudeCodeOauthTokenMasked: maskSecret(provider.claudeCodeOauthToken),
591
+ hasClaudeOAuthCredentials: !!provider.claudeOAuthCredentials,
592
+ claudeOAuthCredentialsExpiresAt: provider.claudeOAuthCredentials?.expiresAt ?? null,
593
+ claudeOAuthCredentialsAccessTokenMasked: provider.claudeOAuthCredentials
594
+ ? maskSecret(provider.claudeOAuthCredentials.accessToken)
595
+ : null,
596
+ customEnv: provider.customEnv || {},
597
+ updatedAt: provider.updatedAt,
598
+ };
599
+ }
600
+ /**
601
+ * Resolve a provider by ID to { config, customEnv } in a single disk read.
602
+ * Used by container-runner for pool-selected providers.
603
+ */
604
+ export function resolveProviderById(providerId) {
605
+ const state = readStoredStateV4();
606
+ if (!state)
607
+ return { config: defaultsFromEnv(), customEnv: {} };
608
+ const provider = state.providers.find((p) => p.id === providerId);
609
+ if (!provider) {
610
+ logger.warn({ providerId }, 'resolveProviderById: provider not found, falling back to first enabled');
611
+ const fallback = state.providers.find((p) => p.enabled) || state.providers[0];
612
+ if (!fallback)
613
+ return { config: defaultsFromEnv(), customEnv: {} };
614
+ return {
615
+ config: providerToConfig(fallback),
616
+ customEnv: fallback.customEnv,
617
+ };
618
+ }
619
+ return {
620
+ config: providerToConfig(provider),
621
+ customEnv: provider.customEnv,
622
+ };
623
+ }
624
+ function defaultsFromEnv() {
625
+ const raw = {
626
+ anthropicBaseUrl: process.env.ANTHROPIC_BASE_URL || '',
627
+ anthropicAuthToken: process.env.ANTHROPIC_AUTH_TOKEN || '',
628
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY || '',
629
+ claudeCodeOauthToken: process.env.CLAUDE_CODE_OAUTH_TOKEN || '',
630
+ claudeOAuthCredentials: null,
631
+ anthropicModel: process.env.ANTHROPIC_MODEL || '',
632
+ };
633
+ try {
634
+ return buildConfig(raw, null);
635
+ }
636
+ catch {
637
+ return {
638
+ anthropicBaseUrl: '',
639
+ anthropicAuthToken: raw.anthropicAuthToken.trim(),
640
+ anthropicApiKey: raw.anthropicApiKey.trim(),
641
+ claudeCodeOauthToken: raw.claudeCodeOauthToken.trim(),
642
+ claudeOAuthCredentials: null,
643
+ anthropicModel: raw.anthropicModel.trim(),
644
+ updatedAt: null,
645
+ };
646
+ }
647
+ }
648
+ function readStoredFeishuConfig() {
649
+ if (!fs.existsSync(FEISHU_CONFIG_FILE))
650
+ return null;
651
+ const content = fs.readFileSync(FEISHU_CONFIG_FILE, 'utf-8');
652
+ const parsed = JSON.parse(content);
653
+ if (parsed.version !== 1)
654
+ return null;
655
+ const stored = parsed;
656
+ const secret = decryptChannelSecret(stored.secret);
657
+ return {
658
+ appId: normalizeFeishuAppId(stored.appId ?? ''),
659
+ appSecret: secret.appSecret,
660
+ enabled: stored.enabled,
661
+ updatedAt: stored.updatedAt || null,
662
+ };
663
+ }
664
+ function defaultsFeishuFromEnv() {
665
+ const raw = {
666
+ appId: process.env.FEISHU_APP_ID || '',
667
+ appSecret: process.env.FEISHU_APP_SECRET || '',
668
+ };
669
+ return {
670
+ appId: raw.appId.trim(),
671
+ appSecret: raw.appSecret.trim(),
672
+ updatedAt: null,
673
+ };
674
+ }
675
+ export function getFeishuProviderConfigWithSource() {
676
+ try {
677
+ const stored = readStoredFeishuConfig();
678
+ if (stored)
679
+ return { config: stored, source: 'runtime' };
680
+ }
681
+ catch (err) {
682
+ logger.warn({ err }, 'Failed to read runtime Feishu config, falling back to env');
683
+ }
684
+ const fromEnv = defaultsFeishuFromEnv();
685
+ if (fromEnv.appId || fromEnv.appSecret) {
686
+ return { config: fromEnv, source: 'env' };
687
+ }
688
+ return { config: fromEnv, source: 'none' };
689
+ }
690
+ export function getFeishuProviderConfig() {
691
+ return getFeishuProviderConfigWithSource().config;
692
+ }
693
+ export function saveFeishuProviderConfig(next) {
694
+ const normalized = {
695
+ appId: normalizeFeishuAppId(next.appId),
696
+ appSecret: normalizeSecret(next.appSecret, 'appSecret'),
697
+ enabled: next.enabled,
698
+ updatedAt: new Date().toISOString(),
699
+ };
700
+ const payload = {
701
+ version: 1,
702
+ appId: normalized.appId,
703
+ enabled: normalized.enabled,
704
+ updatedAt: normalized.updatedAt || new Date().toISOString(),
705
+ secret: encryptChannelSecret({
706
+ appSecret: normalized.appSecret,
707
+ }),
708
+ };
709
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
710
+ const tmp = `${FEISHU_CONFIG_FILE}.tmp`;
711
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
712
+ fs.renameSync(tmp, FEISHU_CONFIG_FILE);
713
+ return normalized;
714
+ }
715
+ export function toPublicFeishuProviderConfig(config, source) {
716
+ return {
717
+ appId: config.appId,
718
+ hasAppSecret: !!config.appSecret,
719
+ appSecretMasked: maskSecret(config.appSecret),
720
+ enabled: config.enabled !== false,
721
+ updatedAt: config.updatedAt,
722
+ source,
723
+ };
724
+ }
725
+ // ========== Telegram Provider Config ==========
726
+ function readStoredTelegramConfig() {
727
+ if (!fs.existsSync(TELEGRAM_CONFIG_FILE))
728
+ return null;
729
+ const content = fs.readFileSync(TELEGRAM_CONFIG_FILE, 'utf-8');
730
+ const parsed = JSON.parse(content);
731
+ if (parsed.version !== 1)
732
+ return null;
733
+ const stored = parsed;
734
+ const secret = decryptChannelSecret(stored.secret);
735
+ return {
736
+ botToken: secret.botToken,
737
+ proxyUrl: normalizeTelegramProxyUrl(stored.proxyUrl ?? ''),
738
+ enabled: stored.enabled,
739
+ updatedAt: stored.updatedAt || null,
740
+ };
741
+ }
742
+ function defaultsTelegramFromEnv() {
743
+ const raw = {
744
+ botToken: process.env.TELEGRAM_BOT_TOKEN || '',
745
+ proxyUrl: process.env.TELEGRAM_PROXY_URL || '',
746
+ };
747
+ return {
748
+ botToken: raw.botToken.trim(),
749
+ proxyUrl: normalizeTelegramProxyUrl(raw.proxyUrl),
750
+ updatedAt: null,
751
+ };
752
+ }
753
+ export function getTelegramProviderConfigWithSource() {
754
+ try {
755
+ const stored = readStoredTelegramConfig();
756
+ if (stored)
757
+ return { config: stored, source: 'runtime' };
758
+ }
759
+ catch (err) {
760
+ logger.warn({ err }, 'Failed to read runtime Telegram config, falling back to env');
761
+ }
762
+ const fromEnv = defaultsTelegramFromEnv();
763
+ if (fromEnv.botToken) {
764
+ return { config: fromEnv, source: 'env' };
765
+ }
766
+ return { config: fromEnv, source: 'none' };
767
+ }
768
+ export function getTelegramProviderConfig() {
769
+ return getTelegramProviderConfigWithSource().config;
770
+ }
771
+ export function saveTelegramProviderConfig(next) {
772
+ const normalized = {
773
+ botToken: normalizeSecret(next.botToken, 'botToken'),
774
+ proxyUrl: normalizeTelegramProxyUrl(next.proxyUrl),
775
+ enabled: next.enabled,
776
+ updatedAt: new Date().toISOString(),
777
+ };
778
+ const payload = {
779
+ version: 1,
780
+ proxyUrl: normalized.proxyUrl,
781
+ enabled: normalized.enabled,
782
+ updatedAt: normalized.updatedAt || new Date().toISOString(),
783
+ secret: encryptChannelSecret({
784
+ botToken: normalized.botToken,
785
+ }),
786
+ };
787
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
788
+ const tmp = `${TELEGRAM_CONFIG_FILE}.tmp`;
789
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
790
+ fs.renameSync(tmp, TELEGRAM_CONFIG_FILE);
791
+ return normalized;
792
+ }
793
+ export function toPublicTelegramProviderConfig(config, source) {
794
+ return {
795
+ hasBotToken: !!config.botToken,
796
+ botTokenMasked: maskSecret(config.botToken),
797
+ proxyUrl: config.proxyUrl ?? '',
798
+ enabled: config.enabled !== false,
799
+ updatedAt: config.updatedAt,
800
+ source,
801
+ };
802
+ }
803
+ function maskSecret(value) {
804
+ if (!value)
805
+ return null;
806
+ if (value.length <= 8)
807
+ return `${'*'.repeat(Math.max(value.length - 2, 1))}${value.slice(-2)}`;
808
+ return `${value.slice(0, 3)}${'*'.repeat(Math.max(value.length - 7, 4))}${value.slice(-4)}`;
809
+ }
810
+ export function toPublicClaudeProviderConfig(config) {
811
+ return {
812
+ anthropicBaseUrl: config.anthropicBaseUrl,
813
+ anthropicModel: config.anthropicModel,
814
+ updatedAt: config.updatedAt,
815
+ hasAnthropicAuthToken: !!config.anthropicAuthToken,
816
+ hasAnthropicApiKey: !!config.anthropicApiKey,
817
+ hasClaudeCodeOauthToken: !!config.claudeCodeOauthToken,
818
+ anthropicAuthTokenMasked: maskSecret(config.anthropicAuthToken),
819
+ anthropicApiKeyMasked: maskSecret(config.anthropicApiKey),
820
+ claudeCodeOauthTokenMasked: maskSecret(config.claudeCodeOauthToken),
821
+ hasClaudeOAuthCredentials: !!config.claudeOAuthCredentials,
822
+ claudeOAuthCredentialsExpiresAt: config.claudeOAuthCredentials?.expiresAt ?? null,
823
+ claudeOAuthCredentialsAccessTokenMasked: config.claudeOAuthCredentials
824
+ ? maskSecret(config.claudeOAuthCredentials.accessToken)
825
+ : null,
826
+ };
827
+ }
828
+ export function validateClaudeProviderConfig(config) {
829
+ const errors = [];
830
+ if (config.anthropicAuthToken && !config.anthropicBaseUrl) {
831
+ errors.push('使用 ANTHROPIC_AUTH_TOKEN 时必须配置 ANTHROPIC_BASE_URL');
832
+ }
833
+ if (config.anthropicBaseUrl) {
834
+ try {
835
+ const parsed = new URL(config.anthropicBaseUrl);
836
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
837
+ errors.push('ANTHROPIC_BASE_URL 必须是 http 或 https 地址');
838
+ }
839
+ }
840
+ catch {
841
+ errors.push('ANTHROPIC_BASE_URL 格式不正确');
842
+ }
843
+ }
844
+ return errors;
845
+ }
846
+ export function getClaudeProviderConfig() {
847
+ try {
848
+ const state = readStoredStateV4();
849
+ if (state) {
850
+ const enabled = state.providers.find((p) => p.enabled) || state.providers[0];
851
+ if (enabled)
852
+ return providerToConfig(enabled);
853
+ }
854
+ }
855
+ catch {
856
+ // ignore corrupted file and use env fallback
857
+ }
858
+ return defaultsFromEnv();
859
+ }
860
+ /** Strip control characters from a value before writing to env file (defense-in-depth) */
861
+ function sanitizeEnvValue(value) {
862
+ return value.replace(/[\r\n\0]/g, '');
863
+ }
864
+ /** Convert KEY=value lines to shell-safe format by single-quoting values.
865
+ * Used when writing env files that are `source`d by bash. */
866
+ export function shellQuoteEnvLines(lines) {
867
+ return lines.map((line) => {
868
+ const eqIdx = line.indexOf('=');
869
+ if (eqIdx <= 0)
870
+ return line;
871
+ const key = line.slice(0, eqIdx);
872
+ const value = line.slice(eqIdx + 1);
873
+ // Escape embedded single quotes: ' → '\''
874
+ const quoted = "'" + value.replace(/'/g, "'\\''") + "'";
875
+ return `${key}=${quoted}`;
876
+ });
877
+ }
878
+ export function buildClaudeEnvLines(config, profileCustomEnv) {
879
+ const lines = [];
880
+ // When full OAuth credentials exist, authentication is handled by .credentials.json file.
881
+ // Only fall back to CLAUDE_CODE_OAUTH_TOKEN env var for legacy single-token mode.
882
+ if (!config.claudeOAuthCredentials && config.claudeCodeOauthToken) {
883
+ lines.push(`CLAUDE_CODE_OAUTH_TOKEN=${sanitizeEnvValue(config.claudeCodeOauthToken)}`);
884
+ }
885
+ if (config.anthropicApiKey) {
886
+ lines.push(`ANTHROPIC_API_KEY=${sanitizeEnvValue(config.anthropicApiKey)}`);
887
+ }
888
+ if (config.anthropicBaseUrl) {
889
+ lines.push(`ANTHROPIC_BASE_URL=${sanitizeEnvValue(config.anthropicBaseUrl)}`);
890
+ }
891
+ if (config.anthropicAuthToken) {
892
+ lines.push(`ANTHROPIC_AUTH_TOKEN=${sanitizeEnvValue(config.anthropicAuthToken)}`);
893
+ }
894
+ if (config.anthropicModel) {
895
+ lines.push(`ANTHROPIC_MODEL=${sanitizeEnvValue(config.anthropicModel)}`);
896
+ }
897
+ // Use explicit profileCustomEnv if provided (pool mode), otherwise active profile
898
+ const customEnv = profileCustomEnv ?? getActiveProfileCustomEnv();
899
+ for (const [key, value] of Object.entries(customEnv)) {
900
+ if (RESERVED_CLAUDE_ENV_KEYS.has(key))
901
+ continue;
902
+ lines.push(`${key}=${sanitizeEnvValue(value)}`);
903
+ }
904
+ return lines;
905
+ }
906
+ export function getActiveProfileCustomEnv() {
907
+ const state = readStoredStateV4();
908
+ if (!state)
909
+ return {};
910
+ const enabled = state.providers.find((p) => p.enabled) || state.providers[0];
911
+ if (!enabled)
912
+ return {};
913
+ return sanitizeCustomEnvMap(enabled.customEnv || {}, {
914
+ skipReservedClaudeKeys: true,
915
+ });
916
+ }
917
+ export function appendClaudeConfigAudit(actor, action, changedFields, metadata) {
918
+ const entry = {
919
+ timestamp: new Date().toISOString(),
920
+ actor,
921
+ action,
922
+ changedFields,
923
+ metadata,
924
+ };
925
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
926
+ fs.appendFileSync(CLAUDE_CONFIG_AUDIT_FILE, `${JSON.stringify(entry)}\n`, 'utf-8');
927
+ }
928
+ // ─── Per-container environment config ───────────────────────────
929
+ const CONTAINER_ENV_DIR = path.join(DATA_DIR, 'config', 'container-env');
930
+ function containerEnvPath(folder) {
931
+ if (folder.includes('..') || folder.includes('/')) {
932
+ throw new Error('Invalid folder name');
933
+ }
934
+ return path.join(CONTAINER_ENV_DIR, `${folder}.json`);
935
+ }
936
+ export function getContainerEnvConfig(folder) {
937
+ const filePath = containerEnvPath(folder);
938
+ try {
939
+ if (fs.existsSync(filePath)) {
940
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
941
+ }
942
+ }
943
+ catch (err) {
944
+ logger.warn({ err, folder }, 'Failed to read container env config, returning defaults');
945
+ }
946
+ return {};
947
+ }
948
+ export function saveContainerEnvConfig(folder, config) {
949
+ // Sanitize all string fields to prevent env injection
950
+ const sanitized = { ...config };
951
+ if (sanitized.anthropicBaseUrl)
952
+ sanitized.anthropicBaseUrl = sanitizeEnvValue(sanitized.anthropicBaseUrl);
953
+ if (sanitized.anthropicAuthToken)
954
+ sanitized.anthropicAuthToken = sanitizeEnvValue(sanitized.anthropicAuthToken);
955
+ if (sanitized.anthropicApiKey)
956
+ sanitized.anthropicApiKey = sanitizeEnvValue(sanitized.anthropicApiKey);
957
+ if (sanitized.claudeCodeOauthToken)
958
+ sanitized.claudeCodeOauthToken = sanitizeEnvValue(sanitized.claudeCodeOauthToken);
959
+ if (sanitized.anthropicModel)
960
+ sanitized.anthropicModel = sanitizeEnvValue(sanitized.anthropicModel);
961
+ if (sanitized.customEnv) {
962
+ const cleanEnv = {};
963
+ for (const [k, v] of Object.entries(sanitized.customEnv)) {
964
+ if (DANGEROUS_ENV_VARS.has(k)) {
965
+ logger.warn({ key: k }, 'Rejected dangerous env variable in saveContainerEnvConfig');
966
+ continue;
967
+ }
968
+ cleanEnv[k] = sanitizeEnvValue(v);
969
+ }
970
+ sanitized.customEnv = cleanEnv;
971
+ }
972
+ fs.mkdirSync(CONTAINER_ENV_DIR, { recursive: true });
973
+ const tmp = `${containerEnvPath(folder)}.tmp`;
974
+ fs.writeFileSync(tmp, JSON.stringify(sanitized, null, 2) + '\n', 'utf-8');
975
+ fs.renameSync(tmp, containerEnvPath(folder));
976
+ }
977
+ export function deleteContainerEnvConfig(folder) {
978
+ const filePath = containerEnvPath(folder);
979
+ try {
980
+ if (fs.existsSync(filePath))
981
+ fs.unlinkSync(filePath);
982
+ }
983
+ catch {
984
+ // ignore
985
+ }
986
+ }
987
+ export function toPublicContainerEnvConfig(config) {
988
+ return {
989
+ anthropicBaseUrl: config.anthropicBaseUrl || '',
990
+ hasAnthropicAuthToken: !!config.anthropicAuthToken,
991
+ hasAnthropicApiKey: !!config.anthropicApiKey,
992
+ hasClaudeCodeOauthToken: !!config.claudeCodeOauthToken,
993
+ anthropicAuthTokenMasked: maskSecret(config.anthropicAuthToken || ''),
994
+ anthropicApiKeyMasked: maskSecret(config.anthropicApiKey || ''),
995
+ claudeCodeOauthTokenMasked: maskSecret(config.claudeCodeOauthToken || ''),
996
+ anthropicModel: config.anthropicModel || '',
997
+ customEnv: config.customEnv || {},
998
+ };
999
+ }
1000
+ /**
1001
+ * Merge global config with per-container overrides.
1002
+ * Non-empty per-container fields override the global value.
1003
+ */
1004
+ export function mergeClaudeEnvConfig(global, override) {
1005
+ return {
1006
+ anthropicBaseUrl: override.anthropicBaseUrl || global.anthropicBaseUrl,
1007
+ anthropicAuthToken: override.anthropicAuthToken || global.anthropicAuthToken,
1008
+ anthropicApiKey: override.anthropicApiKey || global.anthropicApiKey,
1009
+ claudeCodeOauthToken: override.claudeCodeOauthToken || global.claudeCodeOauthToken,
1010
+ claudeOAuthCredentials: override.claudeOAuthCredentials ?? global.claudeOAuthCredentials,
1011
+ anthropicModel: override.anthropicModel || global.anthropicModel,
1012
+ updatedAt: global.updatedAt,
1013
+ };
1014
+ }
1015
+ // ─── Registration config (plain JSON, no encryption) ─────────────
1016
+ const REGISTRATION_CONFIG_FILE = path.join(CLAUDE_CONFIG_DIR, 'registration.json');
1017
+ const DEFAULT_REGISTRATION_CONFIG = {
1018
+ allowRegistration: true,
1019
+ requireInviteCode: true,
1020
+ updatedAt: null,
1021
+ };
1022
+ export function getRegistrationConfig() {
1023
+ try {
1024
+ if (!fs.existsSync(REGISTRATION_CONFIG_FILE)) {
1025
+ return { ...DEFAULT_REGISTRATION_CONFIG };
1026
+ }
1027
+ const raw = JSON.parse(fs.readFileSync(REGISTRATION_CONFIG_FILE, 'utf-8'));
1028
+ return {
1029
+ allowRegistration: typeof raw.allowRegistration === 'boolean'
1030
+ ? raw.allowRegistration
1031
+ : true,
1032
+ requireInviteCode: typeof raw.requireInviteCode === 'boolean'
1033
+ ? raw.requireInviteCode
1034
+ : true,
1035
+ updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : null,
1036
+ };
1037
+ }
1038
+ catch (err) {
1039
+ logger.warn({ err }, 'Failed to read registration config, returning defaults');
1040
+ return { ...DEFAULT_REGISTRATION_CONFIG };
1041
+ }
1042
+ }
1043
+ export function saveRegistrationConfig(next) {
1044
+ const config = {
1045
+ allowRegistration: next.allowRegistration,
1046
+ requireInviteCode: next.requireInviteCode,
1047
+ updatedAt: new Date().toISOString(),
1048
+ };
1049
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
1050
+ const tmp = `${REGISTRATION_CONFIG_FILE}.tmp`;
1051
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf-8');
1052
+ fs.renameSync(tmp, REGISTRATION_CONFIG_FILE);
1053
+ return config;
1054
+ }
1055
+ /**
1056
+ * Build full env lines: merged Claude config + custom env vars.
1057
+ */
1058
+ export function buildContainerEnvLines(global, override, profileCustomEnv) {
1059
+ const merged = mergeClaudeEnvConfig(global, override);
1060
+ const lines = buildClaudeEnvLines(merged, profileCustomEnv);
1061
+ // Append custom env vars (with safety sanitization as defense-in-depth)
1062
+ if (override.customEnv) {
1063
+ for (const [key, value] of Object.entries(override.customEnv)) {
1064
+ if (!key || value === undefined)
1065
+ continue;
1066
+ if (!ENV_KEY_RE.test(key)) {
1067
+ logger.warn({ key }, 'Skipping invalid env key in buildContainerEnvLines');
1068
+ continue;
1069
+ }
1070
+ // Block dangerous environment variables
1071
+ if (DANGEROUS_ENV_VARS.has(key)) {
1072
+ logger.warn({ key }, 'Blocked dangerous env variable in buildContainerEnvLines');
1073
+ continue;
1074
+ }
1075
+ // Strip control characters to prevent env injection
1076
+ const sanitized = value.replace(/[\r\n\0]/g, '');
1077
+ lines.push(`${key}=${sanitized}`);
1078
+ }
1079
+ }
1080
+ return lines;
1081
+ }
1082
+ // ─── OAuth credentials file management ────────────────────────────
1083
+ /**
1084
+ * Write .credentials.json to a Claude session directory.
1085
+ * Format matches what Claude Code CLI/Agent SDK natively reads.
1086
+ */
1087
+ export function writeCredentialsFile(sessionDir, config) {
1088
+ const creds = config.claudeOAuthCredentials;
1089
+ if (!creds)
1090
+ return;
1091
+ // Claude CLI requires scopes to recognize the token as valid.
1092
+ // Fall back to a sensible default when the stored credentials lack scopes
1093
+ // (e.g. tokens imported before scopes were captured).
1094
+ const scopes = creds.scopes?.length
1095
+ ? creds.scopes
1096
+ : DEFAULT_CREDENTIAL_SCOPES;
1097
+ const claudeAiOauth = {
1098
+ accessToken: creds.accessToken,
1099
+ refreshToken: creds.refreshToken,
1100
+ expiresAt: creds.expiresAt,
1101
+ scopes,
1102
+ };
1103
+ // Only include subscriptionType when explicitly configured — avoids
1104
+ // misleading Claude CLI when the actual subscription tier is unknown.
1105
+ if (creds.subscriptionType) {
1106
+ claudeAiOauth.subscriptionType = creds.subscriptionType;
1107
+ }
1108
+ const credentialsData = { claudeAiOauth };
1109
+ const filePath = path.join(sessionDir, '.credentials.json');
1110
+ const tmp = `${filePath}.tmp`;
1111
+ fs.writeFileSync(tmp, JSON.stringify(credentialsData, null, 2) + '\n', {
1112
+ encoding: 'utf-8',
1113
+ mode: 0o644,
1114
+ });
1115
+ fs.renameSync(tmp, filePath);
1116
+ }
1117
+ /**
1118
+ * Update .credentials.json in all existing session directories + host ~/.claude/
1119
+ */
1120
+ export function updateAllSessionCredentials(config) {
1121
+ if (!config.claudeOAuthCredentials)
1122
+ return;
1123
+ const sessionsDir = path.join(DATA_DIR, 'sessions');
1124
+ try {
1125
+ if (!fs.existsSync(sessionsDir))
1126
+ return;
1127
+ for (const folder of fs.readdirSync(sessionsDir)) {
1128
+ const claudeDir = path.join(sessionsDir, folder, '.claude');
1129
+ if (fs.existsSync(claudeDir) && fs.statSync(claudeDir).isDirectory()) {
1130
+ try {
1131
+ writeCredentialsFile(claudeDir, config);
1132
+ }
1133
+ catch (err) {
1134
+ logger.warn({ err, folder }, 'Failed to write .credentials.json for session');
1135
+ }
1136
+ }
1137
+ // Also update sub-agent session dirs
1138
+ const agentsDir = path.join(sessionsDir, folder, 'agents');
1139
+ if (fs.existsSync(agentsDir) && fs.statSync(agentsDir).isDirectory()) {
1140
+ for (const agentId of fs.readdirSync(agentsDir)) {
1141
+ const agentClaudeDir = path.join(agentsDir, agentId, '.claude');
1142
+ if (fs.existsSync(agentClaudeDir) &&
1143
+ fs.statSync(agentClaudeDir).isDirectory()) {
1144
+ try {
1145
+ writeCredentialsFile(agentClaudeDir, config);
1146
+ }
1147
+ catch (err) {
1148
+ logger.warn({ err, folder, agentId }, 'Failed to write .credentials.json for agent session');
1149
+ }
1150
+ }
1151
+ }
1152
+ }
1153
+ }
1154
+ }
1155
+ catch (err) {
1156
+ logger.warn({ err }, 'Failed to update session credentials');
1157
+ }
1158
+ // Host mode uses CLAUDE_CONFIG_DIR=~/.cli-claw/sessions/{folder}/.claude for isolation,
1159
+ // so we must NOT touch ~/.claude/.credentials.json to avoid interfering with
1160
+ // the user's local Claude Code installation.
1161
+ }
1162
+ /**
1163
+ * Read and parse OAuth credentials from ~/.claude/.credentials.json.
1164
+ * Returns the raw oauth object with accessToken, refreshToken, expiresAt, scopes,
1165
+ * or null if the file is missing / invalid / incomplete.
1166
+ */
1167
+ function readLocalOAuthCredentials() {
1168
+ const homeDir = process.env.HOME || '/root';
1169
+ const credFile = path.join(homeDir, '.claude', '.credentials.json');
1170
+ try {
1171
+ if (!fs.existsSync(credFile))
1172
+ return null;
1173
+ const content = JSON.parse(fs.readFileSync(credFile, 'utf-8'));
1174
+ const oauth = content?.claudeAiOauth;
1175
+ if (oauth?.accessToken && oauth?.refreshToken) {
1176
+ return {
1177
+ accessToken: oauth.accessToken,
1178
+ refreshToken: oauth.refreshToken,
1179
+ expiresAt: typeof oauth.expiresAt === 'number' ? oauth.expiresAt : undefined,
1180
+ scopes: Array.isArray(oauth.scopes) ? oauth.scopes : undefined,
1181
+ subscriptionType: typeof oauth.subscriptionType === 'string'
1182
+ ? oauth.subscriptionType
1183
+ : undefined,
1184
+ };
1185
+ }
1186
+ return null;
1187
+ }
1188
+ catch {
1189
+ return null;
1190
+ }
1191
+ }
1192
+ /**
1193
+ * Detect if the host machine has a valid ~/.claude/.credentials.json
1194
+ * (i.e. user has logged into Claude Code locally).
1195
+ */
1196
+ export function detectLocalClaudeCode() {
1197
+ const oauth = readLocalOAuthCredentials();
1198
+ if (oauth) {
1199
+ return {
1200
+ detected: true,
1201
+ hasCredentials: true,
1202
+ expiresAt: oauth.expiresAt ?? null,
1203
+ accessTokenMasked: maskSecret(oauth.accessToken),
1204
+ };
1205
+ }
1206
+ // Check if the file exists at all (detected but no valid credentials)
1207
+ const homeDir = process.env.HOME || '/root';
1208
+ const credFile = path.join(homeDir, '.claude', '.credentials.json');
1209
+ const fileExists = fs.existsSync(credFile);
1210
+ return {
1211
+ detected: fileExists,
1212
+ hasCredentials: false,
1213
+ expiresAt: null,
1214
+ accessTokenMasked: null,
1215
+ };
1216
+ }
1217
+ /**
1218
+ * Read local ~/.claude/.credentials.json and return parsed OAuth credentials.
1219
+ * Returns null if not found or invalid.
1220
+ */
1221
+ export function importLocalClaudeCredentials() {
1222
+ const oauth = readLocalOAuthCredentials();
1223
+ if (!oauth)
1224
+ return null;
1225
+ return {
1226
+ accessToken: oauth.accessToken,
1227
+ refreshToken: oauth.refreshToken,
1228
+ expiresAt: oauth.expiresAt ?? Date.now() + 8 * 3600_000,
1229
+ scopes: oauth.scopes ?? [],
1230
+ ...(oauth.subscriptionType
1231
+ ? { subscriptionType: oauth.subscriptionType }
1232
+ : {}),
1233
+ };
1234
+ }
1235
+ // ─── Appearance config (plain JSON, no encryption) ────────────────
1236
+ const APPEARANCE_CONFIG_FILE = path.join(CLAUDE_CONFIG_DIR, 'appearance.json');
1237
+ const DEFAULT_APPEARANCE_CONFIG = {
1238
+ appName: ASSISTANT_NAME,
1239
+ aiName: ASSISTANT_NAME,
1240
+ aiAvatarEmoji: '\u{1F431}',
1241
+ aiAvatarColor: '#0d9488',
1242
+ };
1243
+ export function getAppearanceConfig() {
1244
+ try {
1245
+ if (!fs.existsSync(APPEARANCE_CONFIG_FILE)) {
1246
+ return { ...DEFAULT_APPEARANCE_CONFIG };
1247
+ }
1248
+ const raw = JSON.parse(fs.readFileSync(APPEARANCE_CONFIG_FILE, 'utf-8'));
1249
+ return {
1250
+ appName: typeof raw.appName === 'string' && raw.appName
1251
+ ? raw.appName
1252
+ : DEFAULT_APPEARANCE_CONFIG.appName,
1253
+ aiName: typeof raw.aiName === 'string' && raw.aiName
1254
+ ? raw.aiName
1255
+ : DEFAULT_APPEARANCE_CONFIG.aiName,
1256
+ aiAvatarEmoji: typeof raw.aiAvatarEmoji === 'string' && raw.aiAvatarEmoji
1257
+ ? raw.aiAvatarEmoji
1258
+ : DEFAULT_APPEARANCE_CONFIG.aiAvatarEmoji,
1259
+ aiAvatarColor: typeof raw.aiAvatarColor === 'string' && raw.aiAvatarColor
1260
+ ? raw.aiAvatarColor
1261
+ : DEFAULT_APPEARANCE_CONFIG.aiAvatarColor,
1262
+ };
1263
+ }
1264
+ catch (err) {
1265
+ logger.warn({ err }, 'Failed to read appearance config, returning defaults');
1266
+ return { ...DEFAULT_APPEARANCE_CONFIG };
1267
+ }
1268
+ }
1269
+ export function saveAppearanceConfig(next) {
1270
+ const existing = getAppearanceConfig();
1271
+ const config = {
1272
+ appName: next.appName || existing.appName,
1273
+ aiName: next.aiName,
1274
+ aiAvatarEmoji: next.aiAvatarEmoji,
1275
+ aiAvatarColor: next.aiAvatarColor,
1276
+ updatedAt: new Date().toISOString(),
1277
+ };
1278
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
1279
+ const tmp = `${APPEARANCE_CONFIG_FILE}.tmp`;
1280
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + '\n', 'utf-8');
1281
+ fs.renameSync(tmp, APPEARANCE_CONFIG_FILE);
1282
+ return {
1283
+ appName: config.appName,
1284
+ aiName: config.aiName,
1285
+ aiAvatarEmoji: config.aiAvatarEmoji,
1286
+ aiAvatarColor: config.aiAvatarColor,
1287
+ };
1288
+ }
1289
+ // ─── Per-user IM config (AES-256-GCM encrypted) ─────────────────
1290
+ const USER_IM_CONFIG_DIR = path.join(DATA_DIR, 'config', 'user-im');
1291
+ function userImDir(userId) {
1292
+ if (!/^[a-zA-Z0-9_-]+$/.test(userId)) {
1293
+ throw new Error('Invalid userId');
1294
+ }
1295
+ return path.join(USER_IM_CONFIG_DIR, userId);
1296
+ }
1297
+ export function getUserFeishuConfig(userId) {
1298
+ const filePath = path.join(userImDir(userId), 'feishu.json');
1299
+ try {
1300
+ if (!fs.existsSync(filePath))
1301
+ return null;
1302
+ const content = fs.readFileSync(filePath, 'utf-8');
1303
+ const parsed = JSON.parse(content);
1304
+ if (parsed.version !== 1)
1305
+ return null;
1306
+ const stored = parsed;
1307
+ const secret = decryptChannelSecret(stored.secret);
1308
+ return {
1309
+ appId: normalizeFeishuAppId(stored.appId ?? ''),
1310
+ appSecret: secret.appSecret,
1311
+ enabled: stored.enabled,
1312
+ updatedAt: stored.updatedAt || null,
1313
+ };
1314
+ }
1315
+ catch (err) {
1316
+ logger.warn({ err, userId }, 'Failed to read user Feishu config');
1317
+ return null;
1318
+ }
1319
+ }
1320
+ export function saveUserFeishuConfig(userId, next) {
1321
+ const normalized = {
1322
+ appId: normalizeFeishuAppId(next.appId),
1323
+ appSecret: normalizeSecret(next.appSecret, 'appSecret'),
1324
+ enabled: next.enabled,
1325
+ updatedAt: new Date().toISOString(),
1326
+ };
1327
+ const payload = {
1328
+ version: 1,
1329
+ appId: normalized.appId,
1330
+ enabled: normalized.enabled,
1331
+ updatedAt: normalized.updatedAt || new Date().toISOString(),
1332
+ secret: encryptChannelSecret({
1333
+ appSecret: normalized.appSecret,
1334
+ }),
1335
+ };
1336
+ const dir = userImDir(userId);
1337
+ fs.mkdirSync(dir, { recursive: true });
1338
+ const filePath = path.join(dir, 'feishu.json');
1339
+ const tmp = `${filePath}.tmp`;
1340
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
1341
+ fs.renameSync(tmp, filePath);
1342
+ return normalized;
1343
+ }
1344
+ export function getUserTelegramConfig(userId) {
1345
+ const filePath = path.join(userImDir(userId), 'telegram.json');
1346
+ try {
1347
+ if (!fs.existsSync(filePath))
1348
+ return null;
1349
+ const content = fs.readFileSync(filePath, 'utf-8');
1350
+ const parsed = JSON.parse(content);
1351
+ if (parsed.version !== 1)
1352
+ return null;
1353
+ const stored = parsed;
1354
+ const secret = decryptChannelSecret(stored.secret);
1355
+ return {
1356
+ botToken: secret.botToken,
1357
+ proxyUrl: normalizeTelegramProxyUrl(stored.proxyUrl ?? ''),
1358
+ enabled: stored.enabled,
1359
+ updatedAt: stored.updatedAt || null,
1360
+ };
1361
+ }
1362
+ catch (err) {
1363
+ logger.warn({ err, userId }, 'Failed to read user Telegram config');
1364
+ return null;
1365
+ }
1366
+ }
1367
+ export function saveUserTelegramConfig(userId, next) {
1368
+ const normalizedProxyUrl = next.proxyUrl
1369
+ ? normalizeTelegramProxyUrl(next.proxyUrl)
1370
+ : '';
1371
+ const normalized = {
1372
+ botToken: normalizeSecret(next.botToken, 'botToken'),
1373
+ proxyUrl: normalizedProxyUrl || undefined,
1374
+ enabled: next.enabled,
1375
+ updatedAt: new Date().toISOString(),
1376
+ };
1377
+ const payload = {
1378
+ version: 1,
1379
+ proxyUrl: normalizedProxyUrl || undefined,
1380
+ enabled: normalized.enabled,
1381
+ updatedAt: normalized.updatedAt || new Date().toISOString(),
1382
+ secret: encryptChannelSecret({
1383
+ botToken: normalized.botToken,
1384
+ }),
1385
+ };
1386
+ const dir = userImDir(userId);
1387
+ fs.mkdirSync(dir, { recursive: true });
1388
+ const filePath = path.join(dir, 'telegram.json');
1389
+ const tmp = `${filePath}.tmp`;
1390
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
1391
+ fs.renameSync(tmp, filePath);
1392
+ return normalized;
1393
+ }
1394
+ // ========== QQ User IM Config ==========
1395
+ export function getUserQQConfig(userId) {
1396
+ const filePath = path.join(userImDir(userId), 'qq.json');
1397
+ try {
1398
+ if (!fs.existsSync(filePath))
1399
+ return null;
1400
+ const content = fs.readFileSync(filePath, 'utf-8');
1401
+ const parsed = JSON.parse(content);
1402
+ if (parsed.version !== 1)
1403
+ return null;
1404
+ const stored = parsed;
1405
+ const secret = decryptChannelSecret(stored.secret);
1406
+ return {
1407
+ appId: normalizeFeishuAppId(stored.appId ?? ''),
1408
+ appSecret: secret.appSecret,
1409
+ enabled: stored.enabled,
1410
+ updatedAt: stored.updatedAt || null,
1411
+ };
1412
+ }
1413
+ catch (err) {
1414
+ logger.warn({ err, userId }, 'Failed to read user QQ config');
1415
+ return null;
1416
+ }
1417
+ }
1418
+ export function saveUserQQConfig(userId, next) {
1419
+ const normalized = {
1420
+ appId: normalizeFeishuAppId(next.appId),
1421
+ appSecret: normalizeSecret(next.appSecret, 'appSecret'),
1422
+ enabled: next.enabled,
1423
+ updatedAt: new Date().toISOString(),
1424
+ };
1425
+ const payload = {
1426
+ version: 1,
1427
+ appId: normalized.appId,
1428
+ enabled: normalized.enabled,
1429
+ updatedAt: normalized.updatedAt || new Date().toISOString(),
1430
+ secret: encryptChannelSecret({
1431
+ appSecret: normalized.appSecret,
1432
+ }),
1433
+ };
1434
+ const dir = userImDir(userId);
1435
+ fs.mkdirSync(dir, { recursive: true });
1436
+ const filePath = path.join(dir, 'qq.json');
1437
+ const tmp = `${filePath}.tmp`;
1438
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
1439
+ fs.renameSync(tmp, filePath);
1440
+ return normalized;
1441
+ }
1442
+ export function getUserWeChatConfig(userId) {
1443
+ const filePath = path.join(userImDir(userId), 'wechat.json');
1444
+ try {
1445
+ if (!fs.existsSync(filePath))
1446
+ return null;
1447
+ const content = fs.readFileSync(filePath, 'utf-8');
1448
+ const parsed = JSON.parse(content);
1449
+ if (parsed.version !== 1)
1450
+ return null;
1451
+ const stored = parsed;
1452
+ const secret = decryptChannelSecret(stored.secret);
1453
+ return {
1454
+ botToken: secret.botToken,
1455
+ ilinkBotId: (stored.ilinkBotId ?? '').trim(),
1456
+ baseUrl: stored.baseUrl,
1457
+ cdnBaseUrl: stored.cdnBaseUrl,
1458
+ getUpdatesBuf: stored.getUpdatesBuf,
1459
+ bypassProxy: stored.bypassProxy ?? true, // 默认直连
1460
+ enabled: stored.enabled,
1461
+ updatedAt: stored.updatedAt || null,
1462
+ };
1463
+ }
1464
+ catch (err) {
1465
+ logger.warn({ err, userId }, 'Failed to read user WeChat config');
1466
+ return null;
1467
+ }
1468
+ }
1469
+ export function saveUserWeChatConfig(userId, next) {
1470
+ const normalized = {
1471
+ botToken: normalizeSecret(next.botToken, 'botToken'),
1472
+ ilinkBotId: (next.ilinkBotId ?? '').trim(),
1473
+ baseUrl: next.baseUrl?.trim() || undefined,
1474
+ cdnBaseUrl: next.cdnBaseUrl?.trim() || undefined,
1475
+ getUpdatesBuf: next.getUpdatesBuf,
1476
+ bypassProxy: next.bypassProxy ?? true,
1477
+ enabled: next.enabled,
1478
+ updatedAt: new Date().toISOString(),
1479
+ };
1480
+ const payload = {
1481
+ version: 1,
1482
+ ilinkBotId: normalized.ilinkBotId,
1483
+ baseUrl: normalized.baseUrl,
1484
+ cdnBaseUrl: normalized.cdnBaseUrl,
1485
+ getUpdatesBuf: normalized.getUpdatesBuf,
1486
+ bypassProxy: normalized.bypassProxy,
1487
+ enabled: normalized.enabled,
1488
+ updatedAt: normalized.updatedAt || new Date().toISOString(),
1489
+ secret: encryptChannelSecret({
1490
+ botToken: normalized.botToken,
1491
+ }),
1492
+ };
1493
+ const dir = userImDir(userId);
1494
+ fs.mkdirSync(dir, { recursive: true });
1495
+ const filePath = path.join(dir, 'wechat.json');
1496
+ const tmp = `${filePath}.tmp`;
1497
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
1498
+ fs.renameSync(tmp, filePath);
1499
+ return normalized;
1500
+ }
1501
+ // ========== DingTalk User IM Config ==========
1502
+ export function getUserDingTalkConfig(userId) {
1503
+ const filePath = path.join(userImDir(userId), 'dingtalk.json');
1504
+ try {
1505
+ if (!fs.existsSync(filePath))
1506
+ return null;
1507
+ const content = fs.readFileSync(filePath, 'utf-8');
1508
+ const parsed = JSON.parse(content);
1509
+ if (parsed.version !== 1)
1510
+ return null;
1511
+ const stored = parsed;
1512
+ const secret = decryptChannelSecret(stored.secret);
1513
+ return {
1514
+ clientId: (stored.clientId ?? '').trim(),
1515
+ clientSecret: secret.clientSecret,
1516
+ enabled: stored.enabled,
1517
+ updatedAt: stored.updatedAt || null,
1518
+ };
1519
+ }
1520
+ catch (err) {
1521
+ logger.warn({ err, userId }, 'Failed to read user DingTalk config');
1522
+ return null;
1523
+ }
1524
+ }
1525
+ export function saveUserDingTalkConfig(userId, next) {
1526
+ const normalized = {
1527
+ clientId: (next.clientId ?? '').trim(),
1528
+ clientSecret: normalizeSecret(next.clientSecret, 'clientSecret'),
1529
+ enabled: next.enabled,
1530
+ updatedAt: new Date().toISOString(),
1531
+ };
1532
+ const payload = {
1533
+ version: 1,
1534
+ clientId: normalized.clientId,
1535
+ enabled: normalized.enabled,
1536
+ updatedAt: normalized.updatedAt || new Date().toISOString(),
1537
+ secret: encryptChannelSecret({
1538
+ clientSecret: normalized.clientSecret,
1539
+ }),
1540
+ };
1541
+ const dir = userImDir(userId);
1542
+ fs.mkdirSync(dir, { recursive: true });
1543
+ const filePath = path.join(dir, 'dingtalk.json');
1544
+ const tmp = `${filePath}.tmp`;
1545
+ fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
1546
+ fs.renameSync(tmp, filePath);
1547
+ return normalized;
1548
+ }
1549
+ // ─── System settings (plain JSON, no encryption) ─────────────────
1550
+ const SYSTEM_SETTINGS_FILE = path.join(CLAUDE_CONFIG_DIR, 'system-settings.json');
1551
+ const DEFAULT_SYSTEM_SETTINGS = {
1552
+ containerTimeout: 1800000,
1553
+ idleTimeout: 1800000,
1554
+ containerMaxOutputSize: 10485760,
1555
+ maxConcurrentContainers: 20,
1556
+ maxConcurrentHostProcesses: 5,
1557
+ maxLoginAttempts: 5,
1558
+ loginLockoutMinutes: 15,
1559
+ maxConcurrentScripts: 10,
1560
+ scriptTimeout: 60000,
1561
+ skillAutoSyncEnabled: false,
1562
+ skillAutoSyncIntervalMinutes: 10,
1563
+ billingEnabled: false,
1564
+ billingMode: 'wallet_first',
1565
+ billingMinStartBalanceUsd: 0.01,
1566
+ billingCurrency: 'USD',
1567
+ billingCurrencyRate: 1,
1568
+ };
1569
+ function parseIntEnv(envVar, fallback) {
1570
+ if (!envVar)
1571
+ return fallback;
1572
+ const parsed = parseInt(envVar, 10);
1573
+ return Number.isFinite(parsed) ? parsed : fallback;
1574
+ }
1575
+ function parseFloatEnv(envVar, fallback) {
1576
+ if (!envVar)
1577
+ return fallback;
1578
+ const parsed = parseFloat(envVar);
1579
+ return Number.isFinite(parsed) ? parsed : fallback;
1580
+ }
1581
+ // In-memory cache: avoid synchronous file I/O on hot paths (stdout data handler, queue capacity check)
1582
+ let _settingsCache = null;
1583
+ let _settingsMtimeMs = 0;
1584
+ function readSystemSettingsFromFile() {
1585
+ if (!fs.existsSync(SYSTEM_SETTINGS_FILE))
1586
+ return null;
1587
+ const raw = JSON.parse(fs.readFileSync(SYSTEM_SETTINGS_FILE, 'utf-8'));
1588
+ return {
1589
+ containerTimeout: typeof raw.containerTimeout === 'number' && raw.containerTimeout > 0
1590
+ ? raw.containerTimeout
1591
+ : DEFAULT_SYSTEM_SETTINGS.containerTimeout,
1592
+ idleTimeout: typeof raw.idleTimeout === 'number' && raw.idleTimeout > 0
1593
+ ? raw.idleTimeout
1594
+ : DEFAULT_SYSTEM_SETTINGS.idleTimeout,
1595
+ containerMaxOutputSize: typeof raw.containerMaxOutputSize === 'number' &&
1596
+ raw.containerMaxOutputSize > 0
1597
+ ? raw.containerMaxOutputSize
1598
+ : DEFAULT_SYSTEM_SETTINGS.containerMaxOutputSize,
1599
+ maxConcurrentContainers: typeof raw.maxConcurrentContainers === 'number' &&
1600
+ raw.maxConcurrentContainers > 0
1601
+ ? raw.maxConcurrentContainers
1602
+ : DEFAULT_SYSTEM_SETTINGS.maxConcurrentContainers,
1603
+ maxConcurrentHostProcesses: typeof raw.maxConcurrentHostProcesses === 'number' &&
1604
+ raw.maxConcurrentHostProcesses > 0
1605
+ ? raw.maxConcurrentHostProcesses
1606
+ : DEFAULT_SYSTEM_SETTINGS.maxConcurrentHostProcesses,
1607
+ maxLoginAttempts: typeof raw.maxLoginAttempts === 'number' && raw.maxLoginAttempts > 0
1608
+ ? raw.maxLoginAttempts
1609
+ : DEFAULT_SYSTEM_SETTINGS.maxLoginAttempts,
1610
+ loginLockoutMinutes: typeof raw.loginLockoutMinutes === 'number' && raw.loginLockoutMinutes > 0
1611
+ ? raw.loginLockoutMinutes
1612
+ : DEFAULT_SYSTEM_SETTINGS.loginLockoutMinutes,
1613
+ maxConcurrentScripts: typeof raw.maxConcurrentScripts === 'number' &&
1614
+ raw.maxConcurrentScripts > 0
1615
+ ? raw.maxConcurrentScripts
1616
+ : DEFAULT_SYSTEM_SETTINGS.maxConcurrentScripts,
1617
+ scriptTimeout: typeof raw.scriptTimeout === 'number' && raw.scriptTimeout > 0
1618
+ ? raw.scriptTimeout
1619
+ : DEFAULT_SYSTEM_SETTINGS.scriptTimeout,
1620
+ skillAutoSyncEnabled: typeof raw.skillAutoSyncEnabled === 'boolean'
1621
+ ? raw.skillAutoSyncEnabled
1622
+ : DEFAULT_SYSTEM_SETTINGS.skillAutoSyncEnabled,
1623
+ skillAutoSyncIntervalMinutes: typeof raw.skillAutoSyncIntervalMinutes === 'number' &&
1624
+ raw.skillAutoSyncIntervalMinutes >= 1
1625
+ ? raw.skillAutoSyncIntervalMinutes
1626
+ : DEFAULT_SYSTEM_SETTINGS.skillAutoSyncIntervalMinutes,
1627
+ billingEnabled: typeof raw.billingEnabled === 'boolean'
1628
+ ? raw.billingEnabled
1629
+ : DEFAULT_SYSTEM_SETTINGS.billingEnabled,
1630
+ billingMode: 'wallet_first',
1631
+ billingMinStartBalanceUsd: typeof raw.billingMinStartBalanceUsd === 'number' &&
1632
+ raw.billingMinStartBalanceUsd >= 0
1633
+ ? raw.billingMinStartBalanceUsd
1634
+ : DEFAULT_SYSTEM_SETTINGS.billingMinStartBalanceUsd,
1635
+ billingCurrency: typeof raw.billingCurrency === 'string' && raw.billingCurrency
1636
+ ? raw.billingCurrency
1637
+ : DEFAULT_SYSTEM_SETTINGS.billingCurrency,
1638
+ billingCurrencyRate: typeof raw.billingCurrencyRate === 'number' && raw.billingCurrencyRate > 0
1639
+ ? raw.billingCurrencyRate
1640
+ : DEFAULT_SYSTEM_SETTINGS.billingCurrencyRate,
1641
+ };
1642
+ }
1643
+ function buildEnvFallbackSettings() {
1644
+ return {
1645
+ containerTimeout: parseIntEnv(process.env.CONTAINER_TIMEOUT, DEFAULT_SYSTEM_SETTINGS.containerTimeout),
1646
+ idleTimeout: parseIntEnv(process.env.IDLE_TIMEOUT, DEFAULT_SYSTEM_SETTINGS.idleTimeout),
1647
+ containerMaxOutputSize: parseIntEnv(process.env.CONTAINER_MAX_OUTPUT_SIZE, DEFAULT_SYSTEM_SETTINGS.containerMaxOutputSize),
1648
+ maxConcurrentContainers: parseIntEnv(process.env.MAX_CONCURRENT_CONTAINERS, DEFAULT_SYSTEM_SETTINGS.maxConcurrentContainers),
1649
+ maxConcurrentHostProcesses: parseIntEnv(process.env.MAX_CONCURRENT_HOST_PROCESSES, DEFAULT_SYSTEM_SETTINGS.maxConcurrentHostProcesses),
1650
+ maxLoginAttempts: parseIntEnv(process.env.MAX_LOGIN_ATTEMPTS, DEFAULT_SYSTEM_SETTINGS.maxLoginAttempts),
1651
+ loginLockoutMinutes: parseIntEnv(process.env.LOGIN_LOCKOUT_MINUTES, DEFAULT_SYSTEM_SETTINGS.loginLockoutMinutes),
1652
+ maxConcurrentScripts: parseIntEnv(process.env.MAX_CONCURRENT_SCRIPTS, DEFAULT_SYSTEM_SETTINGS.maxConcurrentScripts),
1653
+ scriptTimeout: parseIntEnv(process.env.SCRIPT_TIMEOUT, DEFAULT_SYSTEM_SETTINGS.scriptTimeout),
1654
+ skillAutoSyncEnabled: process.env.SKILL_AUTO_SYNC_ENABLED === 'true' ||
1655
+ DEFAULT_SYSTEM_SETTINGS.skillAutoSyncEnabled,
1656
+ skillAutoSyncIntervalMinutes: parseIntEnv(process.env.SKILL_AUTO_SYNC_INTERVAL_MINUTES, DEFAULT_SYSTEM_SETTINGS.skillAutoSyncIntervalMinutes),
1657
+ billingEnabled: process.env.BILLING_ENABLED === 'true' ||
1658
+ DEFAULT_SYSTEM_SETTINGS.billingEnabled,
1659
+ billingMode: 'wallet_first',
1660
+ billingMinStartBalanceUsd: parseFloatEnv(process.env.BILLING_MIN_START_BALANCE_USD, DEFAULT_SYSTEM_SETTINGS.billingMinStartBalanceUsd),
1661
+ billingCurrency: process.env.BILLING_CURRENCY || DEFAULT_SYSTEM_SETTINGS.billingCurrency,
1662
+ billingCurrencyRate: parseFloatEnv(process.env.BILLING_CURRENCY_RATE, DEFAULT_SYSTEM_SETTINGS.billingCurrencyRate),
1663
+ };
1664
+ }
1665
+ export function getSystemSettings() {
1666
+ // Fast path: return cached value if file hasn't changed (single stat)
1667
+ if (_settingsCache) {
1668
+ try {
1669
+ const mtimeMs = fs.statSync(SYSTEM_SETTINGS_FILE).mtimeMs;
1670
+ if (mtimeMs === _settingsMtimeMs)
1671
+ return _settingsCache;
1672
+ }
1673
+ catch {
1674
+ return _settingsCache; // file gone or stat failed — cached value is still valid
1675
+ }
1676
+ }
1677
+ // 1. Try reading from file
1678
+ try {
1679
+ const settings = readSystemSettingsFromFile();
1680
+ if (settings) {
1681
+ _settingsCache = settings;
1682
+ try {
1683
+ _settingsMtimeMs = fs.statSync(SYSTEM_SETTINGS_FILE).mtimeMs;
1684
+ }
1685
+ catch {
1686
+ /* ignore */
1687
+ }
1688
+ return settings;
1689
+ }
1690
+ }
1691
+ catch (err) {
1692
+ if (err.code !== 'ENOENT') {
1693
+ logger.warn({ err }, 'Failed to read system settings, falling back to env/defaults');
1694
+ }
1695
+ }
1696
+ // 2. Fall back to env vars, then hardcoded defaults
1697
+ const settings = buildEnvFallbackSettings();
1698
+ _settingsCache = settings;
1699
+ _settingsMtimeMs = 0; // no file — will re-check on next call
1700
+ return settings;
1701
+ }
1702
+ export function saveSystemSettings(partial) {
1703
+ const existing = getSystemSettings();
1704
+ const merged = { ...existing, ...partial };
1705
+ // Range validation
1706
+ if (merged.containerTimeout < 60000)
1707
+ merged.containerTimeout = 60000; // min 1 min
1708
+ if (merged.containerTimeout > 86400000)
1709
+ merged.containerTimeout = 86400000; // max 24 hours
1710
+ if (merged.idleTimeout < 60000)
1711
+ merged.idleTimeout = 60000;
1712
+ if (merged.idleTimeout > 86400000)
1713
+ merged.idleTimeout = 86400000;
1714
+ if (merged.containerMaxOutputSize < 1048576)
1715
+ merged.containerMaxOutputSize = 1048576; // min 1MB
1716
+ if (merged.containerMaxOutputSize > 104857600)
1717
+ merged.containerMaxOutputSize = 104857600; // max 100MB
1718
+ if (merged.maxConcurrentContainers < 1)
1719
+ merged.maxConcurrentContainers = 1;
1720
+ if (merged.maxConcurrentContainers > 100)
1721
+ merged.maxConcurrentContainers = 100;
1722
+ if (merged.maxConcurrentHostProcesses < 1)
1723
+ merged.maxConcurrentHostProcesses = 1;
1724
+ if (merged.maxConcurrentHostProcesses > 50)
1725
+ merged.maxConcurrentHostProcesses = 50;
1726
+ if (merged.maxLoginAttempts < 1)
1727
+ merged.maxLoginAttempts = 1;
1728
+ if (merged.maxLoginAttempts > 100)
1729
+ merged.maxLoginAttempts = 100;
1730
+ if (merged.loginLockoutMinutes < 1)
1731
+ merged.loginLockoutMinutes = 1;
1732
+ if (merged.loginLockoutMinutes > 1440)
1733
+ merged.loginLockoutMinutes = 1440; // max 24 hours
1734
+ if (merged.maxConcurrentScripts < 1)
1735
+ merged.maxConcurrentScripts = 1;
1736
+ if (merged.maxConcurrentScripts > 50)
1737
+ merged.maxConcurrentScripts = 50;
1738
+ if (merged.scriptTimeout < 5000)
1739
+ merged.scriptTimeout = 5000; // min 5s
1740
+ if (merged.scriptTimeout > 600000)
1741
+ merged.scriptTimeout = 600000; // max 10 min
1742
+ if (merged.skillAutoSyncIntervalMinutes < 1)
1743
+ merged.skillAutoSyncIntervalMinutes = 1;
1744
+ if (merged.skillAutoSyncIntervalMinutes > 1440)
1745
+ merged.skillAutoSyncIntervalMinutes = 1440; // max 24h
1746
+ merged.billingMode = 'wallet_first';
1747
+ if (merged.billingMinStartBalanceUsd < 0)
1748
+ merged.billingMinStartBalanceUsd =
1749
+ DEFAULT_SYSTEM_SETTINGS.billingMinStartBalanceUsd;
1750
+ if (merged.billingMinStartBalanceUsd > 1000000)
1751
+ merged.billingMinStartBalanceUsd = 1000000;
1752
+ fs.mkdirSync(CLAUDE_CONFIG_DIR, { recursive: true });
1753
+ const tmp = `${SYSTEM_SETTINGS_FILE}.tmp`;
1754
+ fs.writeFileSync(tmp, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
1755
+ fs.renameSync(tmp, SYSTEM_SETTINGS_FILE);
1756
+ // Update in-memory cache immediately
1757
+ _settingsCache = merged;
1758
+ try {
1759
+ _settingsMtimeMs = fs.statSync(SYSTEM_SETTINGS_FILE).mtimeMs;
1760
+ }
1761
+ catch {
1762
+ /* ignore */
1763
+ }
1764
+ return merged;
1765
+ }
1766
+ /**
1767
+ * 解析 OAuth usage bucket 对象
1768
+ * 运行时类型守卫,验证 API 响应结构
1769
+ */
1770
+ export function parseOAuthUsageBucket(v) {
1771
+ if (!v || typeof v !== 'object')
1772
+ return null;
1773
+ const obj = v;
1774
+ if (typeof obj.utilization !== 'number' || typeof obj.resets_at !== 'string')
1775
+ return null;
1776
+ return { utilization: obj.utilization, resets_at: obj.resets_at };
1777
+ }