libre-webui 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/README.md +232 -154
  2. package/backend/dist/db.d.ts.map +1 -1
  3. package/backend/dist/db.js +155 -6
  4. package/backend/dist/db.js.map +1 -1
  5. package/backend/dist/index.d.ts.map +1 -1
  6. package/backend/dist/index.js +17 -10
  7. package/backend/dist/index.js.map +1 -1
  8. package/backend/dist/middleware/auth.d.ts.map +1 -1
  9. package/backend/dist/middleware/auth.js +35 -3
  10. package/backend/dist/middleware/auth.js.map +1 -1
  11. package/backend/dist/models/userModel.d.ts +23 -0
  12. package/backend/dist/models/userModel.d.ts.map +1 -1
  13. package/backend/dist/models/userModel.js +89 -24
  14. package/backend/dist/models/userModel.js.map +1 -1
  15. package/backend/dist/routes/agentCli.d.ts +3 -0
  16. package/backend/dist/routes/agentCli.d.ts.map +1 -0
  17. package/backend/dist/routes/agentCli.js +35 -0
  18. package/backend/dist/routes/agentCli.js.map +1 -0
  19. package/backend/dist/routes/auth.d.ts.map +1 -1
  20. package/backend/dist/routes/auth.js +46 -33
  21. package/backend/dist/routes/auth.js.map +1 -1
  22. package/backend/dist/routes/chat.d.ts.map +1 -1
  23. package/backend/dist/routes/chat.js +30 -1
  24. package/backend/dist/routes/chat.js.map +1 -1
  25. package/backend/dist/routes/documents.d.ts.map +1 -1
  26. package/backend/dist/routes/documents.js +49 -41
  27. package/backend/dist/routes/documents.js.map +1 -1
  28. package/backend/dist/routes/embeddings.js +2 -2
  29. package/backend/dist/routes/embeddings.js.map +1 -1
  30. package/backend/dist/routes/huggingfaceHub.d.ts.map +1 -1
  31. package/backend/dist/routes/huggingfaceHub.js +3 -1
  32. package/backend/dist/routes/huggingfaceHub.js.map +1 -1
  33. package/backend/dist/routes/imageGen.d.ts.map +1 -1
  34. package/backend/dist/routes/imageGen.js +2 -0
  35. package/backend/dist/routes/imageGen.js.map +1 -1
  36. package/backend/dist/routes/media.d.ts +3 -0
  37. package/backend/dist/routes/media.d.ts.map +1 -0
  38. package/backend/dist/routes/media.js +429 -0
  39. package/backend/dist/routes/media.js.map +1 -0
  40. package/backend/dist/routes/ollama.d.ts.map +1 -1
  41. package/backend/dist/routes/ollama.js +15 -83
  42. package/backend/dist/routes/ollama.js.map +1 -1
  43. package/backend/dist/routes/personas.d.ts.map +1 -1
  44. package/backend/dist/routes/personas.js +2 -0
  45. package/backend/dist/routes/personas.js.map +1 -1
  46. package/backend/dist/routes/plugins.d.ts.map +1 -1
  47. package/backend/dist/routes/plugins.js +28 -0
  48. package/backend/dist/routes/plugins.js.map +1 -1
  49. package/backend/dist/routes/systemDiagnostics.d.ts +3 -0
  50. package/backend/dist/routes/systemDiagnostics.d.ts.map +1 -0
  51. package/backend/dist/routes/systemDiagnostics.js +49 -0
  52. package/backend/dist/routes/systemDiagnostics.js.map +1 -0
  53. package/backend/dist/routes/tts.d.ts.map +1 -1
  54. package/backend/dist/routes/tts.js +4 -0
  55. package/backend/dist/routes/tts.js.map +1 -1
  56. package/backend/dist/routes/users.d.ts.map +1 -1
  57. package/backend/dist/routes/users.js +58 -0
  58. package/backend/dist/routes/users.js.map +1 -1
  59. package/backend/dist/routes/work.d.ts.map +1 -1
  60. package/backend/dist/routes/work.js +107 -2
  61. package/backend/dist/routes/work.js.map +1 -1
  62. package/backend/dist/services/agentCliService.d.ts +30 -0
  63. package/backend/dist/services/agentCliService.d.ts.map +1 -0
  64. package/backend/dist/services/agentCliService.js +328 -0
  65. package/backend/dist/services/agentCliService.js.map +1 -0
  66. package/backend/dist/services/authService.d.ts +15 -9
  67. package/backend/dist/services/authService.d.ts.map +1 -1
  68. package/backend/dist/services/authService.js +34 -29
  69. package/backend/dist/services/authService.js.map +1 -1
  70. package/backend/dist/services/chatGenerationService.d.ts.map +1 -1
  71. package/backend/dist/services/chatGenerationService.js +19 -1
  72. package/backend/dist/services/chatGenerationService.js.map +1 -1
  73. package/backend/dist/services/chatRequestService.d.ts +8 -2
  74. package/backend/dist/services/chatRequestService.d.ts.map +1 -1
  75. package/backend/dist/services/chatRequestService.js +30 -3
  76. package/backend/dist/services/chatRequestService.js.map +1 -1
  77. package/backend/dist/services/documentService.d.ts +11 -10
  78. package/backend/dist/services/documentService.d.ts.map +1 -1
  79. package/backend/dist/services/documentService.js +62 -54
  80. package/backend/dist/services/documentService.js.map +1 -1
  81. package/backend/dist/services/encryptionService.d.ts.map +1 -1
  82. package/backend/dist/services/encryptionService.js +18 -8
  83. package/backend/dist/services/encryptionService.js.map +1 -1
  84. package/backend/dist/services/galleryService.d.ts +23 -1
  85. package/backend/dist/services/galleryService.d.ts.map +1 -1
  86. package/backend/dist/services/galleryService.js +125 -8
  87. package/backend/dist/services/galleryService.js.map +1 -1
  88. package/backend/dist/services/mediaGenerationJobService.d.ts +25 -0
  89. package/backend/dist/services/mediaGenerationJobService.d.ts.map +1 -0
  90. package/backend/dist/services/mediaGenerationJobService.js +82 -0
  91. package/backend/dist/services/mediaGenerationJobService.js.map +1 -0
  92. package/backend/dist/services/pluginAudioGenerationService.d.ts +34 -0
  93. package/backend/dist/services/pluginAudioGenerationService.d.ts.map +1 -0
  94. package/backend/dist/services/pluginAudioGenerationService.js +211 -0
  95. package/backend/dist/services/pluginAudioGenerationService.js.map +1 -0
  96. package/backend/dist/services/pluginCapabilityRegistryService.d.ts.map +1 -1
  97. package/backend/dist/services/pluginCapabilityRegistryService.js +6 -0
  98. package/backend/dist/services/pluginCapabilityRegistryService.js.map +1 -1
  99. package/backend/dist/services/pluginEmbeddingService.d.ts +2 -0
  100. package/backend/dist/services/pluginEmbeddingService.d.ts.map +1 -1
  101. package/backend/dist/services/pluginEmbeddingService.js +66 -35
  102. package/backend/dist/services/pluginEmbeddingService.js.map +1 -1
  103. package/backend/dist/services/pluginImageGenerationService.d.ts +2 -0
  104. package/backend/dist/services/pluginImageGenerationService.d.ts.map +1 -1
  105. package/backend/dist/services/pluginImageGenerationService.js +63 -38
  106. package/backend/dist/services/pluginImageGenerationService.js.map +1 -1
  107. package/backend/dist/services/pluginService.d.ts +38 -1
  108. package/backend/dist/services/pluginService.d.ts.map +1 -1
  109. package/backend/dist/services/pluginService.js +390 -69
  110. package/backend/dist/services/pluginService.js.map +1 -1
  111. package/backend/dist/services/pluginTTSService.d.ts +2 -0
  112. package/backend/dist/services/pluginTTSService.d.ts.map +1 -1
  113. package/backend/dist/services/pluginTTSService.js +27 -2
  114. package/backend/dist/services/pluginTTSService.js.map +1 -1
  115. package/backend/dist/services/pluginUsageService.d.ts +82 -0
  116. package/backend/dist/services/pluginUsageService.d.ts.map +1 -0
  117. package/backend/dist/services/pluginUsageService.js +238 -0
  118. package/backend/dist/services/pluginUsageService.js.map +1 -0
  119. package/backend/dist/services/pluginVideoGenerationService.d.ts +45 -0
  120. package/backend/dist/services/pluginVideoGenerationService.d.ts.map +1 -0
  121. package/backend/dist/services/pluginVideoGenerationService.js +198 -0
  122. package/backend/dist/services/pluginVideoGenerationService.js.map +1 -0
  123. package/backend/dist/services/preferencesService.d.ts.map +1 -1
  124. package/backend/dist/services/preferencesService.js +15 -1
  125. package/backend/dist/services/preferencesService.js.map +1 -1
  126. package/backend/dist/services/registrationPolicy.d.ts +5 -0
  127. package/backend/dist/services/registrationPolicy.d.ts.map +1 -0
  128. package/backend/dist/services/registrationPolicy.js +21 -0
  129. package/backend/dist/services/registrationPolicy.js.map +1 -0
  130. package/backend/dist/services/simpleGitHubOAuth.d.ts.map +1 -1
  131. package/backend/dist/services/simpleGitHubOAuth.js +8 -12
  132. package/backend/dist/services/simpleGitHubOAuth.js.map +1 -1
  133. package/backend/dist/services/simpleHuggingFaceOAuth.d.ts.map +1 -1
  134. package/backend/dist/services/simpleHuggingFaceOAuth.js +7 -12
  135. package/backend/dist/services/simpleHuggingFaceOAuth.js.map +1 -1
  136. package/backend/dist/services/systemDiagnosticsService.d.ts +117 -0
  137. package/backend/dist/services/systemDiagnosticsService.d.ts.map +1 -0
  138. package/backend/dist/services/systemDiagnosticsService.js +326 -0
  139. package/backend/dist/services/systemDiagnosticsService.js.map +1 -0
  140. package/backend/dist/services/turnstileService.d.ts +2 -1
  141. package/backend/dist/services/turnstileService.d.ts.map +1 -1
  142. package/backend/dist/services/turnstileService.js +28 -2
  143. package/backend/dist/services/turnstileService.js.map +1 -1
  144. package/backend/dist/services/workAgentGuidance.js +1 -1
  145. package/backend/dist/services/workAgentGuidance.js.map +1 -1
  146. package/backend/dist/services/workAgentService.d.ts.map +1 -1
  147. package/backend/dist/services/workAgentService.js +37 -0
  148. package/backend/dist/services/workAgentService.js.map +1 -1
  149. package/backend/dist/services/workHostWorkspaceService.d.ts +22 -0
  150. package/backend/dist/services/workHostWorkspaceService.d.ts.map +1 -0
  151. package/backend/dist/services/workHostWorkspaceService.js +136 -0
  152. package/backend/dist/services/workHostWorkspaceService.js.map +1 -0
  153. package/backend/dist/services/workModelProviderService.d.ts +3 -0
  154. package/backend/dist/services/workModelProviderService.d.ts.map +1 -1
  155. package/backend/dist/services/workModelProviderService.js +37 -4
  156. package/backend/dist/services/workModelProviderService.js.map +1 -1
  157. package/backend/dist/services/workPreviewProxyService.d.ts +27 -0
  158. package/backend/dist/services/workPreviewProxyService.d.ts.map +1 -0
  159. package/backend/dist/services/workPreviewProxyService.js +514 -0
  160. package/backend/dist/services/workPreviewProxyService.js.map +1 -0
  161. package/backend/dist/services/workRuntimeService.d.ts +65 -3
  162. package/backend/dist/services/workRuntimeService.d.ts.map +1 -1
  163. package/backend/dist/services/workRuntimeService.js +536 -14
  164. package/backend/dist/services/workRuntimeService.js.map +1 -1
  165. package/backend/dist/services/workTaskService.d.ts +3 -3
  166. package/backend/dist/services/workTaskService.d.ts.map +1 -1
  167. package/backend/dist/services/workTaskService.js +10 -6
  168. package/backend/dist/services/workTaskService.js.map +1 -1
  169. package/backend/dist/services/workTerminalService.d.ts +46 -0
  170. package/backend/dist/services/workTerminalService.d.ts.map +1 -0
  171. package/backend/dist/services/workTerminalService.js +241 -0
  172. package/backend/dist/services/workTerminalService.js.map +1 -0
  173. package/backend/dist/storage.d.ts.map +1 -1
  174. package/backend/dist/storage.js +3 -3
  175. package/backend/dist/storage.js.map +1 -1
  176. package/backend/dist/types/index.d.ts +78 -2
  177. package/backend/dist/types/index.d.ts.map +1 -1
  178. package/backend/dist/types/index.js.map +1 -1
  179. package/backend/dist/types/work.d.ts +53 -0
  180. package/backend/dist/types/work.d.ts.map +1 -1
  181. package/backend/dist/utils/chatDocumentContext.d.ts +1 -1
  182. package/backend/dist/utils/chatDocumentContext.d.ts.map +1 -1
  183. package/backend/dist/utils/chatDocumentContext.js +4 -4
  184. package/backend/dist/utils/chatDocumentContext.js.map +1 -1
  185. package/backend/dist/utils/chatProviderSelection.d.ts.map +1 -1
  186. package/backend/dist/utils/chatProviderSelection.js +7 -5
  187. package/backend/dist/utils/chatProviderSelection.js.map +1 -1
  188. package/backend/dist/utils/pluginDefinitionTrust.js +1 -1
  189. package/backend/dist/utils/pluginValidation.d.ts +3 -2
  190. package/backend/dist/utils/pluginValidation.d.ts.map +1 -1
  191. package/backend/dist/utils/pluginValidation.js +30 -2
  192. package/backend/dist/utils/pluginValidation.js.map +1 -1
  193. package/backend/dist/utils/workGit.d.ts +14 -0
  194. package/backend/dist/utils/workGit.d.ts.map +1 -0
  195. package/backend/dist/utils/workGit.js +207 -0
  196. package/backend/dist/utils/workGit.js.map +1 -0
  197. package/backend/dist/websocketServer.d.ts +1 -0
  198. package/backend/dist/websocketServer.d.ts.map +1 -1
  199. package/backend/dist/websocketServer.js +156 -35
  200. package/backend/dist/websocketServer.js.map +1 -1
  201. package/backend/dist/workTerminalServer.d.ts +17 -0
  202. package/backend/dist/workTerminalServer.d.ts.map +1 -0
  203. package/backend/dist/workTerminalServer.js +226 -0
  204. package/backend/dist/workTerminalServer.js.map +1 -0
  205. package/backend/package.json +1 -1
  206. package/frontend/dist/assets/index-ChhWhug2.css +1 -0
  207. package/frontend/dist/assets/index-CmZntyR1.js +3 -0
  208. package/frontend/dist/assets/xterm-CmMoOwwu.css +1 -0
  209. package/frontend/dist/index.html +28 -6
  210. package/frontend/dist/js/ArtifactContainer-CBAbdoIJ.js +1 -0
  211. package/frontend/dist/js/{ArtifactDemoPage-CWDhGWrz.js → ArtifactDemoPage-BWN0MWCx.js} +1 -1
  212. package/frontend/dist/js/ArtifactSlideOutPanel-D-AvYkfk.js +1 -0
  213. package/frontend/dist/js/{Button-DBk-AREB.js → Button-CRwzOaDZ.js} +1 -1
  214. package/frontend/dist/js/ChatPage-w2FkF_fE.js +16 -0
  215. package/frontend/dist/js/FirstTimeSetup-CzBxl7UH.js +1 -0
  216. package/frontend/dist/js/GalleryPage-Cv8pniRW.js +1 -0
  217. package/frontend/dist/js/HomePage-Vyj4ndU4.js +1 -0
  218. package/frontend/dist/js/LibreClawPage-CYhLgQB-.js +1 -0
  219. package/frontend/dist/js/{ModelManagerModals-DltRprHU.js → ModelManagerModals-C2rwHd01.js} +1 -1
  220. package/frontend/dist/js/ModelSelector-D7kDjZV9.js +1 -0
  221. package/frontend/dist/js/ModelsPage-BnUddkrs.js +2 -0
  222. package/frontend/dist/js/{PersonasPage-C2pqDsWA.js → PersonasPage-BL5XlrEm.js} +2 -2
  223. package/frontend/dist/js/PluginUsagePage-BXqweCCs.js +1 -0
  224. package/frontend/dist/js/RichMessageContent-nAEaPTpH.js +3 -0
  225. package/frontend/dist/js/SettingsModal-DZYCI5-y.js +1 -0
  226. package/frontend/dist/js/SystemPage-jdHYfn9A.js +1 -0
  227. package/frontend/dist/js/UserManagementPage-BpOnt6os.js +1 -0
  228. package/frontend/dist/js/WorkPage-DAp1T1dl.js +5 -0
  229. package/frontend/dist/js/addon-fit-Cj0_dfHF.js +1 -0
  230. package/frontend/dist/js/api-BCqWXjZI.js +19 -0
  231. package/frontend/dist/js/appStore-CDM6V5ny.js +2 -0
  232. package/frontend/dist/js/ar-DS8gs8WK.js +2 -0
  233. package/frontend/dist/js/authStore-Bl5ofD_h.js +4 -0
  234. package/frontend/dist/js/bn-CrMPJwTR.js +2 -0
  235. package/frontend/dist/js/cs-BL116Ezu.js +2 -0
  236. package/frontend/dist/js/da-TDNaKzqy.js +2 -0
  237. package/frontend/dist/js/de-DkIqdAB5.js +2 -0
  238. package/frontend/dist/js/es-CHRXt6Fd.js +2 -0
  239. package/frontend/dist/js/{estree-BRklD62a.js → estree-BFya-bSu.js} +1 -1
  240. package/frontend/dist/js/fr-8715yUKq.js +2 -0
  241. package/frontend/dist/js/hi-C2O08SBU.js +2 -0
  242. package/frontend/dist/js/{html-BgGIAGmn.js → html-DJot0Wwf.js} +1 -1
  243. package/frontend/dist/js/id-Dq7UXUEV.js +2 -0
  244. package/frontend/dist/js/is-B3dbbhSl.js +2 -0
  245. package/frontend/dist/js/it-CLSuu2K7.js +2 -0
  246. package/frontend/dist/js/ja-CthrqyCR.js +2 -0
  247. package/frontend/dist/js/ko-BJvFDJ82.js +2 -0
  248. package/frontend/dist/js/{markdown-CM2nBJJi.js → markdown-CXwWsPYG.js} +1 -1
  249. package/frontend/dist/js/ms-QV0202Mj.js +2 -0
  250. package/frontend/dist/js/nl-Dob1m1pV.js +2 -0
  251. package/frontend/dist/js/pl-urfOKnYI.js +2 -0
  252. package/frontend/dist/js/pt-D52Xkpi0.js +2 -0
  253. package/frontend/dist/js/ru-CwnW9Xe2.js +2 -0
  254. package/frontend/dist/js/{standalone-CUBo-NGt.js → standalone-C0NjJpWr.js} +1 -1
  255. package/frontend/dist/js/sv-BNclj-Fo.js +2 -0
  256. package/frontend/dist/js/th-BlQ4PL57.js +2 -0
  257. package/frontend/dist/js/tr-B80aZ1px.js +2 -0
  258. package/frontend/dist/js/{typescript-Dcy9qxSB.js → typescript-d0TUM2-0.js} +1 -1
  259. package/frontend/dist/js/ui-vendor-ChfdHf5M.js +177 -0
  260. package/frontend/dist/js/uk-DZuuaI56.js +2 -0
  261. package/frontend/dist/js/utils-DmZD5uA3.js +1 -0
  262. package/frontend/dist/js/vi-Bg42fPfF.js +2 -0
  263. package/frontend/dist/js/work-DVgm81bJ.js +1 -0
  264. package/frontend/dist/js/xterm-DKl1QLiw.js +7 -0
  265. package/frontend/dist/js/zh-BSt87zs6.js +2 -0
  266. package/package.json +3 -3
  267. package/plugins/openrouter.json +89 -0
  268. package/backend/dist/services/systemSettingsService.d.ts +0 -12
  269. package/backend/dist/services/systemSettingsService.d.ts.map +0 -1
  270. package/backend/dist/services/systemSettingsService.js +0 -70
  271. package/backend/dist/services/systemSettingsService.js.map +0 -1
  272. package/frontend/dist/assets/index-BVgyQN3z.js +0 -5
  273. package/frontend/dist/assets/index-Dme2Ozol.css +0 -1
  274. package/frontend/dist/js/ArtifactContainer-CbMAYwvr.js +0 -1
  275. package/frontend/dist/js/ArtifactSlideOutPanel-CzUpDM59.js +0 -1
  276. package/frontend/dist/js/ChatPage-D0fdzy7a.js +0 -16
  277. package/frontend/dist/js/FirstTimeSetup-CfnOmy9w.js +0 -1
  278. package/frontend/dist/js/GalleryPage-CMkCur9v.js +0 -1
  279. package/frontend/dist/js/LibreClawPage-CHEfny1k.js +0 -1
  280. package/frontend/dist/js/ModelSelector-COnlNsVM.js +0 -1
  281. package/frontend/dist/js/ModelsPage-nxheiYJp.js +0 -2
  282. package/frontend/dist/js/RichMessageContent-Cz9y-bgl.js +0 -3
  283. package/frontend/dist/js/SettingsModal-B6Se68Hd.js +0 -1
  284. package/frontend/dist/js/UserManagementPage-DQeL-Twp.js +0 -1
  285. package/frontend/dist/js/WorkPage-AcZMp8OB.js +0 -2
  286. package/frontend/dist/js/api-Fi-lJlfu.js +0 -19
  287. package/frontend/dist/js/ar-KceI0PfT.js +0 -2
  288. package/frontend/dist/js/bn-DsKo2dzS.js +0 -2
  289. package/frontend/dist/js/cs-6UKhKj0A.js +0 -2
  290. package/frontend/dist/js/da-CJgZ3YuL.js +0 -2
  291. package/frontend/dist/js/de-BZAmOuCT.js +0 -2
  292. package/frontend/dist/js/es-CD4u7no9.js +0 -2
  293. package/frontend/dist/js/fr-Cou2jOS3.js +0 -2
  294. package/frontend/dist/js/hi-ZxkbNCyB.js +0 -2
  295. package/frontend/dist/js/id-DT9CqaWQ.js +0 -2
  296. package/frontend/dist/js/is-BWSbAIoq.js +0 -2
  297. package/frontend/dist/js/it-CdNoTXm3.js +0 -2
  298. package/frontend/dist/js/ja-CIuQg0xv.js +0 -2
  299. package/frontend/dist/js/ko-BWRUjDOH.js +0 -2
  300. package/frontend/dist/js/ms-gJstIj2a.js +0 -2
  301. package/frontend/dist/js/nl-CydjVNUw.js +0 -2
  302. package/frontend/dist/js/pl-DPN9mAa5.js +0 -2
  303. package/frontend/dist/js/pt-MRXGqIjQ.js +0 -2
  304. package/frontend/dist/js/ru-CvQ9GkJF.js +0 -2
  305. package/frontend/dist/js/sv-BxS2fvhU.js +0 -2
  306. package/frontend/dist/js/th-DMxyn6e4.js +0 -2
  307. package/frontend/dist/js/tr-BTPldUaA.js +0 -2
  308. package/frontend/dist/js/ui-vendor-CAFEDSaO.js +0 -177
  309. package/frontend/dist/js/uk-BGxZOKgp.js +0 -2
  310. package/frontend/dist/js/useBaseQuery-CFTidwqz.js +0 -1
  311. package/frontend/dist/js/useQuery-UsSBfWzs.js +0 -1
  312. package/frontend/dist/js/utils-pTFqSV8c.js +0 -1
  313. package/frontend/dist/js/vi-82JkwFsQ.js +0 -2
  314. package/frontend/dist/js/zh-Bxxfwci3.js +0 -2
@@ -19,6 +19,8 @@ import { createHash } from 'node:crypto';
19
19
  import path from 'node:path';
20
20
  import { getDatabase } from '../db.js';
21
21
  import { createLogger } from '../utils/logger.js';
22
+ import { buildWorkGitCommand, parseWorkGitBranches, parseWorkGitLog, parseWorkGitStatus, validateWorkGitBranchName, validateWorkGitRepositoryPaths, } from '../utils/workGit.js';
23
+ import workPreviewProxyService from './workPreviewProxyService.js';
22
24
  const logger = createLogger('services:work-runtime');
23
25
  const activeDockerProcesses = new Set();
24
26
  export const WORK_RUNTIME_DEFAULTS = {
@@ -32,10 +34,14 @@ export const WORK_RUNTIME_DEFAULTS = {
32
34
  pidsLimit: 256,
33
35
  previewPort: 4173,
34
36
  previewBind: '127.0.0.1',
37
+ networkName: 'libre-webui-work',
35
38
  };
39
+ // Two runtimes per administrator so a second task does not have to wait for
40
+ // the first, three per instance to bound the worst case at three containers'
41
+ // resource caps. Both remain tunable through WORK_MAX_ACTIVE_RUNTIMES_*.
36
42
  export const WORK_RUNTIME_ADMISSION_DEFAULTS = {
37
- maxActiveRuntimesGlobal: 2,
38
- maxActiveRuntimesPerUser: 1,
43
+ maxActiveRuntimesGlobal: 3,
44
+ maxActiveRuntimesPerUser: 2,
39
45
  };
40
46
  const config = {
41
47
  image: process.env.WORK_RUNTIME_IMAGE || WORK_RUNTIME_DEFAULTS.image,
@@ -50,24 +56,29 @@ const config = {
50
56
  // Interface the daemon publishes a task preview on. Loopback keeps a preview
51
57
  // private to the Docker host, which is correct when the browser runs there.
52
58
  previewBind: process.env.WORK_PREVIEW_BIND || WORK_RUNTIME_DEFAULTS.previewBind,
53
- // Host advertised in the preview URL. It differs from the bind address when
54
- // the browser reaches the Docker host by another name.
55
- previewHost: process.env.WORK_PREVIEW_HOST ||
56
- process.env.WORK_PREVIEW_BIND ||
57
- WORK_RUNTIME_DEFAULTS.previewBind,
58
59
  maxActiveRuntimesGlobal: positiveInteger(process.env.WORK_MAX_ACTIVE_RUNTIMES_GLOBAL, WORK_RUNTIME_ADMISSION_DEFAULTS.maxActiveRuntimesGlobal),
59
60
  maxActiveRuntimesPerUser: positiveInteger(process.env.WORK_MAX_ACTIVE_RUNTIMES_PER_USER, WORK_RUNTIME_ADMISSION_DEFAULTS.maxActiveRuntimesPerUser),
61
+ // Dedicated bridge network for networked Work tasks. It is created with
62
+ // inter-container communication disabled, so a sandbox cannot reach other
63
+ // Work sandboxes or the deployment's own containers on the default bridge.
64
+ networkName: process.env.WORK_NETWORK_NAME || WORK_RUNTIME_DEFAULTS.networkName,
65
+ // Optional resolvers forced onto every networked sandbox. Pointing this at
66
+ // a filtering resolver is the supported egress-policy hook.
67
+ dnsServers: parseDnsServers(process.env.WORK_RUNTIME_DNS),
60
68
  };
61
69
  const PREVIEW_READY_TIMEOUT_MS = 15000;
62
70
  const PREVIEW_POLL_INTERVAL_MS = 250;
63
71
  const runtimePolicyFingerprint = createHash('sha256')
64
72
  .update(JSON.stringify({
65
- version: 1,
73
+ version: 2,
66
74
  memoryLimit: config.memoryLimit,
67
75
  cpuLimit: config.cpuLimit,
68
76
  pidsLimit: config.pidsLimit,
69
77
  previewPort: config.previewPort,
70
78
  previewBind: config.previewBind,
79
+ networkName: config.networkName,
80
+ dnsServers: config.dnsServers,
81
+ memorySwapPinned: true,
71
82
  }))
72
83
  .digest('hex');
73
84
  export class WorkRuntimeError extends Error {
@@ -86,6 +97,8 @@ export class WorkRuntimeService {
86
97
  maxRounds: config.maxAgentRounds,
87
98
  commandTimeoutMs: config.commandTimeoutMs,
88
99
  maxOutputChars: config.maxOutputChars,
100
+ maxActiveRuntimesGlobal: config.maxActiveRuntimesGlobal,
101
+ maxActiveRuntimesPerUser: config.maxActiveRuntimesPerUser,
89
102
  };
90
103
  this.lifecycleTails = new Map();
91
104
  this.preparations = new Map();
@@ -95,6 +108,7 @@ export class WorkRuntimeService {
95
108
  this.lastDockerUnavailableReason = null;
96
109
  this.runtimeLeases = new Map();
97
110
  this.previewLeaseReleases = new Map();
111
+ this.terminalHolds = new Map();
98
112
  this.recoveryTasks = new Map();
99
113
  this.shuttingDown = false;
100
114
  }
@@ -133,6 +147,20 @@ export class WorkRuntimeService {
133
147
  get dockerUnavailableReason() {
134
148
  return this.lastDockerUnavailableReason;
135
149
  }
150
+ /**
151
+ * Live runtime occupancy, so the interface can say "1 of 2 runtimes in use"
152
+ * instead of surprising the administrator with a 429 at submit time.
153
+ */
154
+ activeRuntimeCounts(userId) {
155
+ let user = 0;
156
+ if (userId) {
157
+ for (const lease of this.runtimeLeases.values()) {
158
+ if (lease.userId === userId)
159
+ user += 1;
160
+ }
161
+ }
162
+ return { global: this.runtimeLeases.size, user };
163
+ }
136
164
  async beginRecovery(tasks) {
137
165
  for (const task of tasks) {
138
166
  this.recoveryTasks.set(task.id, task);
@@ -227,6 +255,46 @@ export class WorkRuntimeService {
227
255
  throw error;
228
256
  }
229
257
  }
258
+ /**
259
+ * Hold the task container up for an interactive terminal session. The hold
260
+ * goes through the same admission (runtime lease), policy verification, and
261
+ * container preparation as every other runtime operation, and keeps the
262
+ * idle-stop path from tearing the container down while a shell is attached.
263
+ */
264
+ async acquireTerminalHold(task) {
265
+ if (this.activeCommands.has(task.id)) {
266
+ throw new WorkRuntimeError('A Work command is running in this container. Wait for it to finish, then reconnect the terminal.', 409, 'WORK_TERMINAL_COMMAND_ACTIVE');
267
+ }
268
+ const releaseLease = await this.prepare(task);
269
+ this.terminalHolds.set(task.id, (this.terminalHolds.get(task.id) ?? 0) + 1);
270
+ let released = false;
271
+ return async () => {
272
+ if (released)
273
+ return;
274
+ released = true;
275
+ const remaining = (this.terminalHolds.get(task.id) ?? 1) - 1;
276
+ if (remaining <= 0) {
277
+ this.terminalHolds.delete(task.id);
278
+ }
279
+ else {
280
+ this.terminalHolds.set(task.id, remaining);
281
+ }
282
+ try {
283
+ if (remaining <= 0 && !this.shuttingDown) {
284
+ await this.withLifecycleLock(task.id, () => this.stopContainerIfIdleWithLock(task));
285
+ }
286
+ }
287
+ catch (error) {
288
+ logger.warn(`Could not idle Work container ${task.containerName} after terminal session:`, error);
289
+ }
290
+ finally {
291
+ releaseLease();
292
+ }
293
+ };
294
+ }
295
+ terminalSessionCount(taskId) {
296
+ return this.terminalHolds.get(taskId) ?? 0;
297
+ }
230
298
  beginShutdown() {
231
299
  this.shuttingDown = true;
232
300
  if (this.recoveryTimer) {
@@ -323,8 +391,39 @@ export class WorkRuntimeService {
323
391
  this.assertTaskIsActive(task);
324
392
  }
325
393
  }
394
+ async ensureWorkNetwork(task) {
395
+ if (!task.networkEnabled)
396
+ return;
397
+ const inspect = await this.docker([
398
+ 'network',
399
+ 'inspect',
400
+ '--format',
401
+ '{{index .Labels "ai.libre-webui.managed"}} {{index .Options "com.docker.network.bridge.enable_icc"}}',
402
+ config.networkName,
403
+ ], { acceptFailure: true });
404
+ if (inspect.exitCode === 0) {
405
+ const [managed, icc] = inspect.stdout.trim().split(' ');
406
+ if (managed !== 'true' || icc !== 'false') {
407
+ throw new WorkRuntimeError(`Docker network "${config.networkName}" exists but is not the managed Work sandbox network (label ai.libre-webui.managed=true with inter-container communication disabled). Remove it or point WORK_NETWORK_NAME at an unused name.`, 503, 'WORK_NETWORK_CONFLICT');
408
+ }
409
+ return;
410
+ }
411
+ const created = await this.docker([
412
+ 'network',
413
+ 'create',
414
+ '--label',
415
+ 'ai.libre-webui.managed=true',
416
+ '--opt',
417
+ 'com.docker.network.bridge.enable_icc=false',
418
+ config.networkName,
419
+ ], { acceptFailure: true });
420
+ if (created.exitCode !== 0 && !/already exists/i.test(created.stderr)) {
421
+ throw new WorkRuntimeError(`Could not create the Work sandbox network "${config.networkName}": ${created.stderr.trim()}`, 503, 'WORK_NETWORK_UNAVAILABLE');
422
+ }
423
+ }
326
424
  async ensureContainer(task) {
327
425
  this.assertRuntimeLease(task);
426
+ await this.ensureWorkNetwork(task);
328
427
  if (await this.containerExists(task.containerName)) {
329
428
  await this.assertManagedContainer(task);
330
429
  if (!(await this.containerMatchesTaskPolicy(task))) {
@@ -539,6 +638,10 @@ export class WorkRuntimeService {
539
638
  async stopContainerIfIdleWithLock(task) {
540
639
  if (this.activeCommands.has(task.id))
541
640
  return;
641
+ // An attached terminal session owns the running container exactly like a
642
+ // ready preview does.
643
+ if ((this.terminalHolds.get(task.id) ?? 0) > 0)
644
+ return;
542
645
  try {
543
646
  if ((await this.previewProcessCheckWithLock(task)) === 'ready' &&
544
647
  this.previewLeaseReleases.has(task.id)) {
@@ -558,7 +661,11 @@ export class WorkRuntimeService {
558
661
  return this.withWorkspaceHelperContainer(task, async () => {
559
662
  const result = await this.exec(task, ['node', '-e', READ_FILE_SCRIPT, '--', workspacePath],
560
663
  // JSON may escape each input byte as a six-character Unicode sequence.
561
- { maxOutputChars: 12100000 });
664
+ { maxOutputChars: 12100000, acceptFailure: true });
665
+ if (result.exitCode === 24) {
666
+ throw new WorkRuntimeError('This file is not valid UTF-8 and cannot be opened in the text editor.', 415, 'WORK_FILE_NOT_UTF8');
667
+ }
668
+ this.assertSuccessfulHelperCommand(result, 'The workspace file could not be read.', 'WORK_FILE_READ_FAILED');
562
669
  const payload = parseJsonOutput(result.stdout);
563
670
  return {
564
671
  path: workspacePath,
@@ -567,6 +674,114 @@ export class WorkRuntimeService {
567
674
  };
568
675
  });
569
676
  }
677
+ async getGitStatus(task) {
678
+ return this.withWorkspaceHelperContainer(task, () => this.loadGitStatus(task));
679
+ }
680
+ async getGitDiff(task, requestedPath) {
681
+ const workspacePath = requestedPath
682
+ ? validateWorkspacePath(requestedPath, false)
683
+ : undefined;
684
+ return this.withWorkspaceHelperContainer(task, async () => {
685
+ const status = await this.loadGitStatus(task);
686
+ if (!status.initialized) {
687
+ throw new WorkRuntimeError('Initialize Git before requesting a diff.', 409, 'WORK_GIT_NOT_INITIALIZED');
688
+ }
689
+ const revisionArgs = status.head ? ['HEAD'] : ['--cached'];
690
+ const result = await this.execGit(task, [
691
+ 'diff',
692
+ ...revisionArgs,
693
+ '--no-ext-diff',
694
+ '--no-textconv',
695
+ '--',
696
+ ...(workspacePath ? [workspacePath] : []),
697
+ ], { maxOutputChars: 600000 });
698
+ return {
699
+ ...(workspacePath ? { path: workspacePath } : {}),
700
+ patch: result.stdout,
701
+ truncated: result.truncated,
702
+ };
703
+ });
704
+ }
705
+ async initializeGit(task) {
706
+ return this.withGitMutation(task, async () => {
707
+ const current = await this.loadGitStatus(task);
708
+ if (current.initialized)
709
+ return current;
710
+ await this.requireGitSuccess(task, ['init', '--initial-branch=main', '--template=', '.'], 'Git could not initialize this workspace.');
711
+ return this.loadGitStatus(task);
712
+ });
713
+ }
714
+ async stageGitPaths(task, requestedPaths) {
715
+ if (requestedPaths.length < 1 || requestedPaths.length > 200) {
716
+ throw new WorkRuntimeError('Choose between 1 and 200 workspace paths to stage.', 400, 'WORK_GIT_INVALID_PATHS');
717
+ }
718
+ const workspacePaths = [
719
+ ...new Set(requestedPaths.map(value => validateWorkspacePath(value, false))),
720
+ ];
721
+ return this.withGitMutation(task, async () => {
722
+ await this.requireGitRepository(task);
723
+ await this.assertNoExecutableGitFilters(task);
724
+ await this.requireGitSuccess(task, ['add', '--all', '--', ...workspacePaths], 'Git could not stage the selected paths.');
725
+ return this.loadGitStatus(task);
726
+ });
727
+ }
728
+ async commitGit(task, message, identity) {
729
+ const commitMessage = message.trim();
730
+ if (!commitMessage ||
731
+ commitMessage.length > 4000 ||
732
+ commitMessage.includes('\0')) {
733
+ throw new WorkRuntimeError('Commit message must contain between 1 and 4,000 characters.', 400, 'WORK_GIT_INVALID_COMMIT_MESSAGE');
734
+ }
735
+ const name = validateGitIdentity(identity.name, 'name');
736
+ const email = validateGitIdentity(identity.email, 'email');
737
+ return this.withGitMutation(task, async () => {
738
+ await this.requireGitRepository(task);
739
+ await this.requireGitSuccess(task, [
740
+ '-c',
741
+ `user.name=${name}`,
742
+ '-c',
743
+ `user.email=${email}`,
744
+ 'commit',
745
+ '--no-verify',
746
+ '-m',
747
+ commitMessage,
748
+ ], 'Git could not create the commit.');
749
+ return this.loadGitStatus(task);
750
+ });
751
+ }
752
+ async createGitBranch(task, requestedName) {
753
+ const branchName = validateGitBranchInput(requestedName);
754
+ return this.withGitMutation(task, async () => {
755
+ const status = await this.loadGitStatus(task);
756
+ if (!status.initialized) {
757
+ throw new WorkRuntimeError('Initialize Git before creating a branch.', 409, 'WORK_GIT_NOT_INITIALIZED');
758
+ }
759
+ if (!status.head) {
760
+ throw new WorkRuntimeError('Create the first commit before creating another branch.', 409, 'WORK_GIT_UNBORN_BRANCH');
761
+ }
762
+ await this.requireValidGitBranch(task, branchName);
763
+ await this.requireGitSuccess(task, ['branch', branchName], 'Git could not create the branch.');
764
+ return this.loadGitStatus(task);
765
+ });
766
+ }
767
+ async switchGitBranch(task, requestedName) {
768
+ const branchName = validateGitBranchInput(requestedName);
769
+ return this.withGitMutation(task, async () => {
770
+ const status = await this.loadGitStatus(task);
771
+ if (!status.initialized) {
772
+ throw new WorkRuntimeError('Initialize Git before switching branches.', 409, 'WORK_GIT_NOT_INITIALIZED');
773
+ }
774
+ if (status.changes.length > 0) {
775
+ throw new WorkRuntimeError('Commit or discard workspace changes before switching branches.', 409, 'WORK_GIT_DIRTY_WORKTREE');
776
+ }
777
+ if (!status.branches.includes(branchName)) {
778
+ throw new WorkRuntimeError('Only an existing local branch can be selected.', 400, 'WORK_GIT_UNKNOWN_BRANCH');
779
+ }
780
+ await this.assertNoExecutableGitFilters(task);
781
+ await this.requireGitSuccess(task, ['switch', '--no-guess', branchName], 'Git could not switch branches.');
782
+ return this.loadGitStatus(task);
783
+ });
784
+ }
570
785
  async writeFile(task, requestedPath, content, expectedUpdatedAt) {
571
786
  const workspacePath = validateWorkspacePath(requestedPath, false);
572
787
  if (Buffer.byteLength(content, 'utf8') > 2000000) {
@@ -600,6 +815,59 @@ export class WorkRuntimeService {
600
815
  };
601
816
  });
602
817
  }
818
+ /**
819
+ * Delete a workspace file, symlink, or directory. Directories require an
820
+ * explicit recursive flag, and the workspace root is never deletable. Runs
821
+ * through the helper container, so it also works while a preview holds the
822
+ * task container.
823
+ */
824
+ async deletePath(task, requestedPath, recursive = false) {
825
+ const workspacePath = validateWorkspacePath(requestedPath, false);
826
+ return this.withWorkspaceHelperContainer(task, async () => {
827
+ const result = await this.exec(task, [
828
+ 'node',
829
+ '-e',
830
+ DELETE_PATH_SCRIPT,
831
+ '--',
832
+ workspacePath,
833
+ recursive ? 'recursive' : '',
834
+ ], { maxOutputChars: 10000, acceptFailure: true });
835
+ if (result.exitCode === 21) {
836
+ throw new WorkRuntimeError(`No file or directory exists at ${workspacePath}.`, 404, 'WORK_FILE_NOT_FOUND');
837
+ }
838
+ if (result.exitCode === 22) {
839
+ throw new WorkRuntimeError(`${workspacePath} is a directory. Pass recursive: true to delete it with its contents.`, 409, 'WORK_DELETE_REQUIRES_RECURSIVE');
840
+ }
841
+ if (result.exitCode !== 0) {
842
+ throw new WorkRuntimeError(result.stderr.trim() || 'Could not delete the workspace path.', 503, 'WORK_FILE_DELETE_FAILED');
843
+ }
844
+ const payload = parseJsonOutput(result.stdout);
845
+ return { path: workspacePath, type: payload.type };
846
+ });
847
+ }
848
+ /**
849
+ * Move or rename a workspace file or directory. Destination parents are
850
+ * created inside the workspace, an existing destination is never
851
+ * overwritten, and like deletePath this works while a preview is running.
852
+ */
853
+ async movePath(task, requestedFrom, requestedTo) {
854
+ const fromPath = validateWorkspacePath(requestedFrom, false);
855
+ const toPath = validateWorkspacePath(requestedTo, false);
856
+ return this.withWorkspaceHelperContainer(task, async () => {
857
+ const result = await this.exec(task, ['node', '-e', MOVE_PATH_SCRIPT, '--', fromPath, toPath], { maxOutputChars: 10000, acceptFailure: true });
858
+ if (result.exitCode === 21) {
859
+ throw new WorkRuntimeError(`No file or directory exists at ${fromPath}.`, 404, 'WORK_FILE_NOT_FOUND');
860
+ }
861
+ if (result.exitCode === 24) {
862
+ throw new WorkRuntimeError(`${toPath} already exists. Delete it first or choose another destination.`, 409, 'WORK_DESTINATION_EXISTS');
863
+ }
864
+ if (result.exitCode !== 0) {
865
+ throw new WorkRuntimeError(result.stderr.trim() || 'Could not move the workspace path.', 503, 'WORK_FILE_MOVE_FAILED');
866
+ }
867
+ parseJsonOutput(result.stdout);
868
+ return { from: fromPath, to: toPath };
869
+ });
870
+ }
603
871
  async searchFiles(task, query, requestedPath = '.') {
604
872
  const workspacePath = validateWorkspacePath(requestedPath);
605
873
  if (!query.trim() || query.length > 500) {
@@ -853,7 +1121,7 @@ export class WorkRuntimeService {
853
1121
  if (!publishedPort) {
854
1122
  throw new WorkRuntimeError('Docker did not publish the preview port.', 503, 'WORK_PREVIEW_PORT_UNAVAILABLE');
855
1123
  }
856
- return `http://${formatPreviewHost(config.previewHost)}:${publishedPort}`;
1124
+ return workPreviewProxyService.createPreviewUrl(task.id, publishedPort);
857
1125
  }
858
1126
  catch (error) {
859
1127
  try {
@@ -1093,7 +1361,7 @@ export class WorkRuntimeService {
1093
1361
  ? inspected.Mounts.map(objectRecord)
1094
1362
  : [];
1095
1363
  const workspaceMount = mounts.find(mount => mount.Destination === '/workspace');
1096
- const expectedNetwork = task.networkEnabled ? 'bridge' : 'none';
1364
+ const expectedNetwork = task.networkEnabled ? config.networkName : 'none';
1097
1365
  const portBindings = objectRecord(hostConfig.PortBindings);
1098
1366
  const previewBindings = Array.isArray(portBindings[`${config.previewPort}/tcp`])
1099
1367
  ? portBindings[`${config.previewPort}/tcp`].map(objectRecord)
@@ -1118,6 +1386,7 @@ export class WorkRuntimeService {
1118
1386
  securityOpt.includes('no-new-privileges') &&
1119
1387
  Number(hostConfig.PidsLimit) === config.pidsLimit &&
1120
1388
  Number(hostConfig.Memory) > 0 &&
1389
+ Number(hostConfig.MemorySwap) === Number(hostConfig.Memory) &&
1121
1390
  Number(hostConfig.NanoCpus) > 0 &&
1122
1391
  hostConfig.NetworkMode === expectedNetwork &&
1123
1392
  portPolicyMatches &&
@@ -1193,6 +1462,136 @@ export class WorkRuntimeService {
1193
1462
  }
1194
1463
  }
1195
1464
  }
1465
+ async withGitMutation(task, operation) {
1466
+ this.assertGitMutationIdle(task.id);
1467
+ return this.withWorkspaceHelperContainer(task, async () => {
1468
+ this.assertGitMutationIdle(task.id);
1469
+ return operation();
1470
+ });
1471
+ }
1472
+ assertGitMutationIdle(taskId) {
1473
+ if (this.activeCommands.has(taskId) ||
1474
+ (this.terminalHolds.get(taskId) ?? 0) > 0 ||
1475
+ this.previewLeaseReleases.has(taskId)) {
1476
+ throw new WorkRuntimeError('Stop the active Work run, terminal, and preview before changing Git state.', 409, 'WORK_GIT_RUNTIME_BUSY');
1477
+ }
1478
+ }
1479
+ async loadGitStatus(task) {
1480
+ if (!(await this.probeGitRepository(task))) {
1481
+ return {
1482
+ initialized: false,
1483
+ detached: false,
1484
+ ahead: 0,
1485
+ behind: 0,
1486
+ changes: [],
1487
+ branches: [],
1488
+ commits: [],
1489
+ };
1490
+ }
1491
+ const statusResult = await this.execGit(task, ['status', '--porcelain=v2', '--branch', '-z', '--untracked-files=all'], { maxOutputChars: 2000000 });
1492
+ if (statusResult.truncated) {
1493
+ throw new WorkRuntimeError('Git status is too large to display safely.', 413, 'WORK_GIT_STATUS_TOO_LARGE');
1494
+ }
1495
+ const status = parseWorkGitStatus(statusResult.stdout);
1496
+ const branchesResult = await this.execGit(task, ['for-each-ref', '--format=%(refname:short)', 'refs/heads'], { maxOutputChars: 100000 });
1497
+ if (branchesResult.truncated) {
1498
+ throw new WorkRuntimeError('The local branch list is too large to display safely.', 413, 'WORK_GIT_BRANCHES_TOO_LARGE');
1499
+ }
1500
+ status.branches = parseWorkGitBranches(branchesResult.stdout);
1501
+ if (status.branch && !status.branches.includes(status.branch)) {
1502
+ status.branches.unshift(status.branch);
1503
+ }
1504
+ if (status.head) {
1505
+ const logResult = await this.execGit(task, ['log', '-n', '20', '--format=format:%H%x00%h%x00%an%x00%aI%x00%s%x00'], { maxOutputChars: 200000 });
1506
+ if (logResult.truncated) {
1507
+ throw new WorkRuntimeError('Git history is too large to display safely.', 413, 'WORK_GIT_HISTORY_TOO_LARGE');
1508
+ }
1509
+ status.commits = parseWorkGitLog(logResult.stdout);
1510
+ }
1511
+ return status;
1512
+ }
1513
+ async probeGitRepository(task) {
1514
+ const result = await this.execGit(task, [
1515
+ 'rev-parse',
1516
+ '--path-format=absolute',
1517
+ '--show-toplevel',
1518
+ '--git-dir',
1519
+ '--git-common-dir',
1520
+ ], { acceptFailure: true, maxOutputChars: 20000 });
1521
+ if (result.exitCode === 127) {
1522
+ throw new WorkRuntimeError('Git is not installed in the configured Work runtime image.', 503, 'WORK_GIT_UNAVAILABLE');
1523
+ }
1524
+ if (result.exitCode !== 0)
1525
+ return false;
1526
+ try {
1527
+ validateWorkGitRepositoryPaths(result.stdout);
1528
+ }
1529
+ catch (error) {
1530
+ throw new WorkRuntimeError(error instanceof Error
1531
+ ? error.message
1532
+ : 'Git repository layout is unsafe.', 409, 'WORK_GIT_UNSAFE_REPOSITORY');
1533
+ }
1534
+ const [, gitDirectory, commonDirectory] = result.stdout
1535
+ .trimEnd()
1536
+ .split('\n');
1537
+ const realPathCheck = await this.exec(task, [
1538
+ 'node',
1539
+ '-e',
1540
+ VALIDATE_GIT_REPOSITORY_PATHS_SCRIPT,
1541
+ '--',
1542
+ gitDirectory,
1543
+ commonDirectory,
1544
+ ], { acceptFailure: true, maxOutputChars: 10000 });
1545
+ if (realPathCheck.exitCode !== 0) {
1546
+ throw new WorkRuntimeError('Git metadata must resolve inside /workspace.', 409, 'WORK_GIT_UNSAFE_REPOSITORY');
1547
+ }
1548
+ return true;
1549
+ }
1550
+ async requireGitRepository(task) {
1551
+ if (await this.probeGitRepository(task))
1552
+ return;
1553
+ throw new WorkRuntimeError('Initialize Git before changing repository state.', 409, 'WORK_GIT_NOT_INITIALIZED');
1554
+ }
1555
+ async assertNoExecutableGitFilters(task) {
1556
+ const result = await this.execGit(task, [
1557
+ 'config',
1558
+ '--includes',
1559
+ '--get-regexp',
1560
+ '^filter\\..*\\.(clean|smudge|process)$',
1561
+ ], { acceptFailure: true, maxOutputChars: 20000 });
1562
+ if (result.exitCode > 1) {
1563
+ this.throwGitFailure(result, 'Git configuration could not be inspected.');
1564
+ }
1565
+ if (result.stdout.trim()) {
1566
+ throw new WorkRuntimeError('This repository configures executable Git filters. Remove them before using Git write actions in Work.', 409, 'WORK_GIT_EXECUTABLE_FILTER_BLOCKED');
1567
+ }
1568
+ }
1569
+ async requireValidGitBranch(task, branchName) {
1570
+ const result = await this.execGit(task, ['check-ref-format', '--branch', branchName], { acceptFailure: true, maxOutputChars: 10000 });
1571
+ if (result.exitCode !== 0) {
1572
+ throw new WorkRuntimeError('Branch name is invalid.', 400, 'WORK_GIT_INVALID_BRANCH');
1573
+ }
1574
+ }
1575
+ async requireGitSuccess(task, args, fallbackMessage) {
1576
+ const result = await this.execGit(task, args, {
1577
+ acceptFailure: true,
1578
+ maxOutputChars: 100000,
1579
+ });
1580
+ if (result.exitCode !== 0)
1581
+ this.throwGitFailure(result, fallbackMessage);
1582
+ return result;
1583
+ }
1584
+ throwGitFailure(result, fallbackMessage) {
1585
+ throw new WorkRuntimeError(result.stderr.trim() || result.stdout.trim() || fallbackMessage, 409, 'WORK_GIT_COMMAND_FAILED');
1586
+ }
1587
+ assertSuccessfulHelperCommand(result, fallbackMessage, code) {
1588
+ if (result.exitCode === 0)
1589
+ return;
1590
+ throw new WorkRuntimeError(result.stderr.trim() || result.stdout.trim() || fallbackMessage, 503, code);
1591
+ }
1592
+ async execGit(task, args, options = {}) {
1593
+ return this.exec(task, buildWorkGitCommand(args), options);
1594
+ }
1196
1595
  async exec(task, command, options = {}) {
1197
1596
  const args = [
1198
1597
  'exec',
@@ -1274,15 +1673,25 @@ export function buildWorkContainerRunArgs(task, image = config.image) {
1274
1673
  String(config.pidsLimit),
1275
1674
  '--memory',
1276
1675
  config.memoryLimit,
1676
+ // Same value as --memory: the memory cap cannot be sidestepped via swap.
1677
+ '--memory-swap',
1678
+ config.memoryLimit,
1277
1679
  '--cpus',
1278
1680
  config.cpuLimit,
1279
1681
  '--network',
1280
- task.networkEnabled ? 'bridge' : 'none',
1682
+ task.networkEnabled ? config.networkName : 'none',
1281
1683
  ];
1282
1684
  if (task.networkEnabled) {
1283
1685
  args.push('--publish', `${config.previewBind}::${config.previewPort}`);
1686
+ for (const server of config.dnsServers) {
1687
+ args.push('--dns', server);
1688
+ }
1284
1689
  }
1285
- args.push('--mount', `type=volume,src=${task.volumeName},dst=/workspace,volume-nocopy`, image, 'tail', '-f', '/dev/null');
1690
+ args.push('--mount', task.hostPath
1691
+ ? // Host-folder workspaces are opt-in per deployment and validated
1692
+ // against an allowlist before ever reaching this point.
1693
+ `type=bind,src=${task.hostPath},dst=/workspace`
1694
+ : `type=volume,src=${task.volumeName},dst=/workspace,volume-nocopy`, image, 'tail', '-f', '/dev/null');
1286
1695
  return args;
1287
1696
  }
1288
1697
  export function validateWorkspacePath(input, allowRoot = true) {
@@ -1302,6 +1711,25 @@ export function validateWorkspacePath(input, allowRoot = true) {
1302
1711
  }
1303
1712
  return canonical;
1304
1713
  }
1714
+ function validateGitBranchInput(input) {
1715
+ try {
1716
+ return validateWorkGitBranchName(input);
1717
+ }
1718
+ catch {
1719
+ throw new WorkRuntimeError('Branch name is invalid.', 400, 'WORK_GIT_INVALID_BRANCH');
1720
+ }
1721
+ }
1722
+ function validateGitIdentity(value, field) {
1723
+ const maximum = field === 'name' ? 200 : 320;
1724
+ const hasControlCharacter = [...value].some(character => {
1725
+ const code = character.charCodeAt(0);
1726
+ return code <= 31 || code === 127;
1727
+ });
1728
+ if (!value || value.length > maximum || hasControlCharacter) {
1729
+ throw new WorkRuntimeError(`Git ${field} is invalid.`, 400, 'WORK_GIT_INVALID_IDENTITY');
1730
+ }
1731
+ return value;
1732
+ }
1305
1733
  export function parsePublishedPort(output, _containerPort = config.previewPort, bindAddress = config.previewBind) {
1306
1734
  // Only a binding on the interface this deployment asked for counts. The
1307
1735
  // default stays loopback, so a stray wildcard binding is still rejected.
@@ -1443,6 +1871,21 @@ function positiveInteger(value, fallback) {
1443
1871
  const parsed = Number(value);
1444
1872
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
1445
1873
  }
1874
+ export function parseDnsServers(value) {
1875
+ if (!value)
1876
+ return [];
1877
+ return value
1878
+ .split(',')
1879
+ .map(server => server.trim())
1880
+ .filter(server => {
1881
+ if (!server)
1882
+ return false;
1883
+ if (/^[0-9a-fA-F:.]+$/.test(server))
1884
+ return true;
1885
+ logger.warn(`Ignoring WORK_RUNTIME_DNS entry "${server}": not an IPv4/IPv6 address.`);
1886
+ return false;
1887
+ });
1888
+ }
1446
1889
  function objectRecord(value) {
1447
1890
  return value && typeof value === 'object'
1448
1891
  ? value
@@ -1489,6 +1932,21 @@ const rel = process.argv[1] || '.';
1489
1932
  const target = path.resolve(root, rel);
1490
1933
  if (!inside(target)) throw new Error('Path escapes workspace');
1491
1934
  `;
1935
+ const VALIDATE_GIT_REPOSITORY_PATHS_SCRIPT = String.raw `
1936
+ const fs = require('node:fs');
1937
+ const path = require('node:path');
1938
+ const root = fs.realpathSync('/workspace');
1939
+ const inside = candidate => candidate === root || candidate.startsWith(root + path.sep);
1940
+ for (const candidate of process.argv.slice(1)) {
1941
+ let resolved;
1942
+ try {
1943
+ resolved = fs.realpathSync(candidate);
1944
+ } catch {
1945
+ process.exit(25);
1946
+ }
1947
+ if (!inside(resolved) || !fs.statSync(resolved).isDirectory()) process.exit(25);
1948
+ }
1949
+ `;
1492
1950
  const PREVIEW_READY_SCRIPT = String.raw `
1493
1951
  const fs = require('node:fs');
1494
1952
  const net = require('node:net');
@@ -1797,8 +2255,14 @@ if (!inside(targetReal)) throw new Error('Path escapes workspace');
1797
2255
  const stat = fs.statSync(targetReal);
1798
2256
  if (!stat.isFile()) throw new Error('Path is not a file');
1799
2257
  if (stat.size > 2000000) throw new Error('File exceeds 2 MB limit');
2258
+ let content;
2259
+ try {
2260
+ content = new TextDecoder('utf-8', {fatal:true}).decode(fs.readFileSync(targetReal));
2261
+ } catch {
2262
+ process.exit(24);
2263
+ }
1800
2264
  console.log(JSON.stringify({
1801
- content: fs.readFileSync(targetReal, 'utf8'),
2265
+ content,
1802
2266
  size: stat.size,
1803
2267
  updatedAt: stat.mtimeMs
1804
2268
  }));
@@ -1878,6 +2342,64 @@ process.stdin.on('end', () => {
1878
2342
  console.log(JSON.stringify({size:stat.size, updatedAt:stat.mtimeMs}));
1879
2343
  });
1880
2344
  `;
2345
+ const DELETE_PATH_SCRIPT = `${COMMON_PATH_GUARD}
2346
+ if (target === rootReal) throw new Error('Refusing to delete the workspace root');
2347
+ const parentReal = fs.realpathSync(path.dirname(target));
2348
+ if (!inside(parentReal)) throw new Error('Path escapes workspace');
2349
+ const safeTarget = path.join(parentReal, path.basename(target));
2350
+ let stat;
2351
+ try { stat = fs.lstatSync(safeTarget); } catch { process.exit(21); }
2352
+ const recursive = process.argv[2] === 'recursive';
2353
+ let type;
2354
+ if (stat.isSymbolicLink()) {
2355
+ type = 'symlink';
2356
+ fs.unlinkSync(safeTarget);
2357
+ } else if (stat.isDirectory()) {
2358
+ if (!recursive) process.exit(22);
2359
+ type = 'directory';
2360
+ fs.rmSync(safeTarget, {recursive: true});
2361
+ } else {
2362
+ type = 'file';
2363
+ fs.unlinkSync(safeTarget);
2364
+ }
2365
+ process.stdout.write(JSON.stringify({type}));
2366
+ `;
2367
+ const MOVE_PATH_SCRIPT = `${COMMON_PATH_GUARD}
2368
+ const destRel = process.argv[2] || '';
2369
+ if (!destRel) throw new Error('Destination path is required');
2370
+ const destTarget = path.resolve(root, destRel);
2371
+ if (!inside(destTarget)) throw new Error('Path escapes workspace');
2372
+ if (target === rootReal || destTarget === rootReal) {
2373
+ throw new Error('Refusing to move the workspace root');
2374
+ }
2375
+ const sourceParentReal = fs.realpathSync(path.dirname(target));
2376
+ if (!inside(sourceParentReal)) throw new Error('Path escapes workspace');
2377
+ const safeSource = path.join(sourceParentReal, path.basename(target));
2378
+ let sourceStat;
2379
+ try { sourceStat = fs.lstatSync(safeSource); } catch { process.exit(21); }
2380
+ const destParent = path.dirname(destTarget);
2381
+ const destParts = path.relative(root, destParent).split(path.sep).filter(Boolean);
2382
+ let destParentReal = rootReal;
2383
+ for (const part of destParts) {
2384
+ const next = path.join(destParentReal, part);
2385
+ if (!fs.existsSync(next)) fs.mkdirSync(next, {mode:0o755});
2386
+ if (fs.lstatSync(next).isSymbolicLink()) throw new Error('Refusing to traverse symlink');
2387
+ destParentReal = fs.realpathSync(next);
2388
+ if (!inside(destParentReal) || !fs.statSync(destParentReal).isDirectory()) {
2389
+ throw new Error('Path escapes workspace');
2390
+ }
2391
+ }
2392
+ const safeDest = path.join(destParentReal, path.basename(destTarget));
2393
+ let destTaken = false;
2394
+ try { fs.lstatSync(safeDest); destTaken = true; } catch {}
2395
+ if (destTaken) process.exit(24);
2396
+ if (safeSource === safeDest) throw new Error('Source and destination are the same path');
2397
+ if (sourceStat.isDirectory() && safeDest.startsWith(safeSource + path.sep)) {
2398
+ throw new Error('Cannot move a directory inside itself');
2399
+ }
2400
+ fs.renameSync(safeSource, safeDest);
2401
+ process.stdout.write(JSON.stringify({moved: true}));
2402
+ `;
1881
2403
  const SEARCH_FILES_SCRIPT = `${COMMON_PATH_GUARD}
1882
2404
  const query = String(process.argv[2] || '').toLowerCase();
1883
2405
  const targetReal = fs.realpathSync(target);