libre-webui 0.19.6 → 0.21.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 (230) hide show
  1. package/README.md +32 -22
  2. package/backend/dist/db.d.ts.map +1 -1
  3. package/backend/dist/db.js +31 -0
  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 +14 -10
  7. package/backend/dist/index.js.map +1 -1
  8. package/backend/dist/routes/chat.d.ts.map +1 -1
  9. package/backend/dist/routes/chat.js +97 -10
  10. package/backend/dist/routes/chat.js.map +1 -1
  11. package/backend/dist/routes/libreClaw.d.ts.map +1 -1
  12. package/backend/dist/routes/libreClaw.js +41 -0
  13. package/backend/dist/routes/libreClaw.js.map +1 -1
  14. package/backend/dist/routes/ollama.d.ts.map +1 -1
  15. package/backend/dist/routes/ollama.js +52 -2
  16. package/backend/dist/routes/ollama.js.map +1 -1
  17. package/backend/dist/routes/search.d.ts +3 -0
  18. package/backend/dist/routes/search.d.ts.map +1 -0
  19. package/backend/dist/routes/search.js +143 -0
  20. package/backend/dist/routes/search.js.map +1 -0
  21. package/backend/dist/routes/work.d.ts.map +1 -1
  22. package/backend/dist/routes/work.js +138 -4
  23. package/backend/dist/routes/work.js.map +1 -1
  24. package/backend/dist/services/agentAccessService.d.ts +6 -0
  25. package/backend/dist/services/agentAccessService.d.ts.map +1 -0
  26. package/backend/dist/services/agentAccessService.js +58 -0
  27. package/backend/dist/services/agentAccessService.js.map +1 -0
  28. package/backend/dist/services/agentCliService.d.ts.map +1 -1
  29. package/backend/dist/services/agentCliService.js +2 -1
  30. package/backend/dist/services/agentCliService.js.map +1 -1
  31. package/backend/dist/services/authService.d.ts +1 -0
  32. package/backend/dist/services/authService.d.ts.map +1 -1
  33. package/backend/dist/services/authService.js +2 -0
  34. package/backend/dist/services/authService.js.map +1 -1
  35. package/backend/dist/services/documentService.d.ts +2 -0
  36. package/backend/dist/services/documentService.d.ts.map +1 -1
  37. package/backend/dist/services/documentService.js +51 -23
  38. package/backend/dist/services/documentService.js.map +1 -1
  39. package/backend/dist/services/modelAccessService.d.ts +12 -0
  40. package/backend/dist/services/modelAccessService.d.ts.map +1 -0
  41. package/backend/dist/services/modelAccessService.js +66 -0
  42. package/backend/dist/services/modelAccessService.js.map +1 -0
  43. package/backend/dist/services/systemDiagnosticsService.d.ts +4 -3
  44. package/backend/dist/services/systemDiagnosticsService.d.ts.map +1 -1
  45. package/backend/dist/services/systemDiagnosticsService.js +35 -34
  46. package/backend/dist/services/systemDiagnosticsService.js.map +1 -1
  47. package/backend/dist/services/webSearchService.d.ts +64 -0
  48. package/backend/dist/services/webSearchService.d.ts.map +1 -0
  49. package/backend/dist/services/webSearchService.js +230 -0
  50. package/backend/dist/services/webSearchService.js.map +1 -0
  51. package/backend/dist/services/workAccessService.d.ts +15 -0
  52. package/backend/dist/services/workAccessService.d.ts.map +1 -0
  53. package/backend/dist/services/workAccessService.js +67 -0
  54. package/backend/dist/services/workAccessService.js.map +1 -0
  55. package/backend/dist/services/workAdminService.d.ts +67 -0
  56. package/backend/dist/services/workAdminService.d.ts.map +1 -0
  57. package/backend/dist/services/workAdminService.js +92 -0
  58. package/backend/dist/services/workAdminService.js.map +1 -0
  59. package/backend/dist/services/workAgentGuidance.d.ts +1 -0
  60. package/backend/dist/services/workAgentGuidance.d.ts.map +1 -1
  61. package/backend/dist/services/workAgentGuidance.js +3 -0
  62. package/backend/dist/services/workAgentGuidance.js.map +1 -1
  63. package/backend/dist/services/workAgentService.d.ts +6 -0
  64. package/backend/dist/services/workAgentService.d.ts.map +1 -1
  65. package/backend/dist/services/workAgentService.js +199 -11
  66. package/backend/dist/services/workAgentService.js.map +1 -1
  67. package/backend/dist/services/workKubernetesDriver.d.ts +74 -0
  68. package/backend/dist/services/workKubernetesDriver.d.ts.map +1 -0
  69. package/backend/dist/services/workKubernetesDriver.js +659 -0
  70. package/backend/dist/services/workKubernetesDriver.js.map +1 -0
  71. package/backend/dist/services/workPolicyService.d.ts +58 -0
  72. package/backend/dist/services/workPolicyService.d.ts.map +1 -0
  73. package/backend/dist/services/workPolicyService.js +299 -0
  74. package/backend/dist/services/workPolicyService.js.map +1 -0
  75. package/backend/dist/services/workPreviewProxyService.d.ts +7 -1
  76. package/backend/dist/services/workPreviewProxyService.d.ts.map +1 -1
  77. package/backend/dist/services/workPreviewProxyService.js +36 -8
  78. package/backend/dist/services/workPreviewProxyService.js.map +1 -1
  79. package/backend/dist/services/workRuntimeDriver.d.ts +166 -0
  80. package/backend/dist/services/workRuntimeDriver.d.ts.map +1 -0
  81. package/backend/dist/services/workRuntimeDriver.js +737 -0
  82. package/backend/dist/services/workRuntimeDriver.js.map +1 -0
  83. package/backend/dist/services/workRuntimeService.d.ts +60 -51
  84. package/backend/dist/services/workRuntimeService.d.ts.map +1 -1
  85. package/backend/dist/services/workRuntimeService.js +310 -658
  86. package/backend/dist/services/workRuntimeService.js.map +1 -1
  87. package/backend/dist/services/workRuntimeShared.d.ts +83 -0
  88. package/backend/dist/services/workRuntimeShared.d.ts.map +1 -0
  89. package/backend/dist/services/workRuntimeShared.js +136 -0
  90. package/backend/dist/services/workRuntimeShared.js.map +1 -0
  91. package/backend/dist/services/workTaskService.d.ts +6 -1
  92. package/backend/dist/services/workTaskService.d.ts.map +1 -1
  93. package/backend/dist/services/workTaskService.js +18 -3
  94. package/backend/dist/services/workTaskService.js.map +1 -1
  95. package/backend/dist/services/workTerminalService.d.ts +2 -22
  96. package/backend/dist/services/workTerminalService.d.ts.map +1 -1
  97. package/backend/dist/services/workTerminalService.js +10 -127
  98. package/backend/dist/services/workTerminalService.js.map +1 -1
  99. package/backend/dist/storage.d.ts.map +1 -1
  100. package/backend/dist/storage.js +3 -3
  101. package/backend/dist/storage.js.map +1 -1
  102. package/backend/dist/storageMappers.d.ts +1 -0
  103. package/backend/dist/storageMappers.d.ts.map +1 -1
  104. package/backend/dist/storageMappers.js +1 -0
  105. package/backend/dist/storageMappers.js.map +1 -1
  106. package/backend/dist/types/index.d.ts +1 -0
  107. package/backend/dist/types/index.d.ts.map +1 -1
  108. package/backend/dist/types/index.js.map +1 -1
  109. package/backend/dist/types/work.d.ts +4 -0
  110. package/backend/dist/types/work.d.ts.map +1 -1
  111. package/backend/dist/utils/chatDocumentContext.d.ts +7 -0
  112. package/backend/dist/utils/chatDocumentContext.d.ts.map +1 -1
  113. package/backend/dist/utils/chatDocumentContext.js +38 -30
  114. package/backend/dist/utils/chatDocumentContext.js.map +1 -1
  115. package/backend/dist/utils/dockerEndpoint.d.ts +37 -0
  116. package/backend/dist/utils/dockerEndpoint.d.ts.map +1 -0
  117. package/backend/dist/utils/dockerEndpoint.js +72 -0
  118. package/backend/dist/utils/dockerEndpoint.js.map +1 -0
  119. package/backend/dist/websocketServer.d.ts.map +1 -1
  120. package/backend/dist/websocketServer.js +79 -15
  121. package/backend/dist/websocketServer.js.map +1 -1
  122. package/backend/dist/workTerminalServer.d.ts.map +1 -1
  123. package/backend/dist/workTerminalServer.js +6 -5
  124. package/backend/dist/workTerminalServer.js.map +1 -1
  125. package/backend/package.json +2 -1
  126. package/frontend/dist/artifact-runtime/.build-fingerprint +1 -1
  127. package/frontend/dist/assets/index-0wfi-EpC.js +40 -0
  128. package/frontend/dist/assets/index-D7m1zWWg.css +1 -0
  129. package/frontend/dist/index.html +12 -11
  130. package/frontend/dist/js/ArtifactContainer-DPRPcuES.js +1 -0
  131. package/frontend/dist/js/{ArtifactDemoPage-Big00RwH.js → ArtifactDemoPage-DzwPlz9J.js} +1 -1
  132. package/frontend/dist/js/{ArtifactSandboxFrame-C_S4j076.js → ArtifactSandboxFrame-CKi4mSTH.js} +2 -2
  133. package/frontend/dist/js/{ArtifactSlideOutPanel-B4-D3HkB.js → ArtifactSlideOutPanel-BskRCzP0.js} +1 -1
  134. package/frontend/dist/js/ChatPage-DRutCyJ5.js +16 -0
  135. package/frontend/dist/js/FirstTimeSetup-DU50YWrE.js +1 -0
  136. package/frontend/dist/js/GalleryPage-DxXIUesf.js +1 -0
  137. package/frontend/dist/js/HomePage-Bew9kMKx.js +1 -0
  138. package/frontend/dist/js/{LibreClawPage-B14Go5Mn.js → LibreClawPage-Cy9y3YlF.js} +1 -1
  139. package/frontend/dist/js/{ModelManagerModals-D9j9RECi.js → ModelManagerModals-D-NxeVvV.js} +1 -1
  140. package/frontend/dist/js/ModelSelector-DXDcDmDR.js +1 -0
  141. package/frontend/dist/js/ModelsPage-Bj2reiF9.js +2 -0
  142. package/frontend/dist/js/{NotesPage-BwEMW-Z3.js → NotesPage-CJKEbj3J.js} +1 -1
  143. package/frontend/dist/js/{PersonasPage-e-CbTxP3.js → PersonasPage-bgWF9S7b.js} +2 -2
  144. package/frontend/dist/js/{PluginUsagePage-CvZMJJwv.js → PluginUsagePage-CRkD1Tz6.js} +1 -1
  145. package/frontend/dist/js/{RichMessageContent-DNl3_CIw.js → RichMessageContent-DeNjCiyN.js} +1 -1
  146. package/frontend/dist/js/SettingsModal-BUUlkZcv.js +1 -0
  147. package/frontend/dist/js/SettingsToggle-bZ5W4CCf.js +1 -0
  148. package/frontend/dist/js/SystemPage-DcO51urv.js +1 -0
  149. package/frontend/dist/js/UserManagementPage-DLiYw6xG.js +1 -0
  150. package/frontend/dist/js/WorkPage-MMQ0Y0U5.js +5 -0
  151. package/frontend/dist/js/api-CZURQtuc.js +14 -0
  152. package/frontend/dist/js/{appStore-BpoXhleX.js → appStore-sGaWMSDr.js} +2 -2
  153. package/frontend/dist/js/ar-Bm27TCMQ.js +2 -0
  154. package/frontend/dist/js/authStore-uaj0hswV.js +4 -0
  155. package/frontend/dist/js/bn-C7CHC42H.js +2 -0
  156. package/frontend/dist/js/{chatStore-CWqfsOjc.js → chatStore-tRx0Ulq0.js} +2 -2
  157. package/frontend/dist/js/{config-CuXlcWP1.js → config-D-jxRG52.js} +1 -1
  158. package/frontend/dist/js/cs-zGEtwQn_.js +2 -0
  159. package/frontend/dist/js/da-B-0sHNTu.js +2 -0
  160. package/frontend/dist/js/de-UZNE6t2h.js +2 -0
  161. package/frontend/dist/js/es-CPLnFYIw.js +2 -0
  162. package/frontend/dist/js/{estree-BuWOCMW0.js → estree-CRuerC6a.js} +1 -1
  163. package/frontend/dist/js/fr-Br1o0WVz.js +2 -0
  164. package/frontend/dist/js/hi-BcEvNEpb.js +2 -0
  165. package/frontend/dist/js/{html-D7tW7UvW.js → html-BYKlz3Jp.js} +1 -1
  166. package/frontend/dist/js/i18n-DJTg6xWw.js +2 -0
  167. package/frontend/dist/js/id-C30L2DoT.js +2 -0
  168. package/frontend/dist/js/is-BAEhfi5Z.js +2 -0
  169. package/frontend/dist/js/it-HWnfQXZY.js +2 -0
  170. package/frontend/dist/js/ja-Dvux1mon.js +2 -0
  171. package/frontend/dist/js/ko-agDQ__KP.js +2 -0
  172. package/frontend/dist/js/{markdown-CBSxWEtK.js → markdown-DFy8bILU.js} +1 -1
  173. package/frontend/dist/js/{messageCodeStyles-BlTi46wI.js → messageCodeStyles-_gGirbe-.js} +2 -2
  174. package/frontend/dist/js/ms-aNRa2QX7.js +2 -0
  175. package/frontend/dist/js/nl-BtkiDUn-.js +2 -0
  176. package/frontend/dist/js/pl-SEO0lBnX.js +2 -0
  177. package/frontend/dist/js/pt-BYtjmvq1.js +2 -0
  178. package/frontend/dist/js/ru-B-ynj2hU.js +2 -0
  179. package/frontend/dist/js/{standalone-CaXys7q3.js → standalone-jOqWoOmp.js} +1 -1
  180. package/frontend/dist/js/sv-CliKR15U.js +2 -0
  181. package/frontend/dist/js/th-DJQyDr9J.js +2 -0
  182. package/frontend/dist/js/tr-BBGKZ1la.js +2 -0
  183. package/frontend/dist/js/{typescript-DQ5zi5zo.js → typescript-TwGXpgeF.js} +1 -1
  184. package/frontend/dist/js/{ui-DFQiHo_k.js → ui-DSFUl2vT.js} +2 -2
  185. package/frontend/dist/js/{ui-vendor-D_pYT79f.js → ui-vendor-xUzKqAXI.js} +1 -1
  186. package/frontend/dist/js/uk-C1GcFWxE.js +2 -0
  187. package/frontend/dist/js/vi-CIxgICOQ.js +2 -0
  188. package/frontend/dist/js/workApi-kClHFVkJ.js +6 -0
  189. package/frontend/dist/js/zh-Cm7vsQ9s.js +2 -0
  190. package/package.json +4 -3
  191. package/frontend/dist/assets/index-Cd0_AoYV.js +0 -23
  192. package/frontend/dist/assets/index-DtkGuqvC.css +0 -1
  193. package/frontend/dist/js/ArtifactContainer-Dy_1KKWg.js +0 -1
  194. package/frontend/dist/js/ChatPage-BkFd1iac.js +0 -16
  195. package/frontend/dist/js/FirstTimeSetup-C6YxrwR7.js +0 -1
  196. package/frontend/dist/js/GalleryPage-BtcPs7SO.js +0 -1
  197. package/frontend/dist/js/HomePage-5PhpAt14.js +0 -1
  198. package/frontend/dist/js/ModelSelector-C1wN91tl.js +0 -1
  199. package/frontend/dist/js/ModelsPage-Cc3L0ur8.js +0 -2
  200. package/frontend/dist/js/SettingsModal-C8KJJEud.js +0 -1
  201. package/frontend/dist/js/SystemPage-8kEAS71H.js +0 -1
  202. package/frontend/dist/js/UserManagementPage-BSrZ0ucu.js +0 -1
  203. package/frontend/dist/js/WorkPage-spf92PVN.js +0 -5
  204. package/frontend/dist/js/api-MySpmAK8.js +0 -19
  205. package/frontend/dist/js/ar-ClMsdoTH.js +0 -2
  206. package/frontend/dist/js/authStore-DmrydYuS.js +0 -3
  207. package/frontend/dist/js/bn-LyPol_AY.js +0 -2
  208. package/frontend/dist/js/cs-D98fZaKF.js +0 -2
  209. package/frontend/dist/js/da-M7ND0GQR.js +0 -2
  210. package/frontend/dist/js/de-BV0q1kzb.js +0 -2
  211. package/frontend/dist/js/es-C7FvsVoN.js +0 -2
  212. package/frontend/dist/js/fr-5SloN0ok.js +0 -2
  213. package/frontend/dist/js/hi-C6VeRgeR.js +0 -2
  214. package/frontend/dist/js/i18n-CLd1FZPx.js +0 -2
  215. package/frontend/dist/js/id-CkVp7Z1Z.js +0 -2
  216. package/frontend/dist/js/is-DqxnBX4W.js +0 -2
  217. package/frontend/dist/js/it-CiW4_O5V.js +0 -2
  218. package/frontend/dist/js/ja-BZ3Sdund.js +0 -2
  219. package/frontend/dist/js/ko-CS7LAhfJ.js +0 -2
  220. package/frontend/dist/js/ms-URvcYqgz.js +0 -2
  221. package/frontend/dist/js/nl-CQJpW7wK.js +0 -2
  222. package/frontend/dist/js/pl-DDMgPMAz.js +0 -2
  223. package/frontend/dist/js/pt-JCqqmGn_.js +0 -2
  224. package/frontend/dist/js/ru-C_dwMbiK.js +0 -2
  225. package/frontend/dist/js/sv-CW3c9NGx.js +0 -2
  226. package/frontend/dist/js/th-9ARgClBr.js +0 -2
  227. package/frontend/dist/js/tr-DNY5_DAg.js +0 -2
  228. package/frontend/dist/js/uk-XDh4_7IL.js +0 -2
  229. package/frontend/dist/js/vi-BaoJBXnt.js +0 -2
  230. package/frontend/dist/js/zh-B202vYJt.js +0 -2
@@ -0,0 +1,659 @@
1
+ /*
2
+ * Libre WebUI
3
+ * Copyright (C) 2025 Kroonen AI, Inc.
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at:
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { Duplex, PassThrough, Readable, Writable } from 'node:stream';
18
+ import { createLogger } from '../utils/logger.js';
19
+ import workPolicyService from './workPolicyService.js';
20
+ import { WorkRuntimeError, computePolicyFingerprint, defaultRuntimePolicy, positiveInteger, workRuntimeConfig as config, } from './workRuntimeShared.js';
21
+ const logger = createLogger('services:work-runtime');
22
+ // Kubernetes-only knobs. Everything the Docker backend also honors (image,
23
+ // limits, timeouts, DNS) comes from the shared Work runtime config so one
24
+ // deployment story covers both backends.
25
+ export const WORK_KUBERNETES_DEFAULTS = {
26
+ namespace: 'libre-webui-work',
27
+ workspaceSize: '5Gi',
28
+ podReadyTimeoutMs: 900000,
29
+ podGoneTimeoutMs: 60000,
30
+ };
31
+ const k8sConfig = {
32
+ namespace: process.env.WORK_K8S_NAMESPACE || WORK_KUBERNETES_DEFAULTS.namespace,
33
+ storageClass: process.env.WORK_K8S_STORAGE_CLASS || undefined,
34
+ workspaceSize: process.env.WORK_K8S_WORKSPACE_SIZE ||
35
+ WORK_KUBERNETES_DEFAULTS.workspaceSize,
36
+ podReadyTimeoutMs: positiveInteger(process.env.WORK_K8S_POD_READY_TIMEOUT_MS, WORK_KUBERNETES_DEFAULTS.podReadyTimeoutMs),
37
+ podGoneTimeoutMs: positiveInteger(process.env.WORK_K8S_POD_GONE_TIMEOUT_MS, WORK_KUBERNETES_DEFAULTS.podGoneTimeoutMs),
38
+ };
39
+ const POD_POLL_INTERVAL_MS = 500;
40
+ const WORK_CONTAINER_NAME = 'work';
41
+ const MANAGED_LABEL = 'ai.libre-webui.managed';
42
+ const TASK_LABEL = 'ai.libre-webui.task';
43
+ const NETWORK_LABEL = 'ai.libre-webui.network';
44
+ const POLICY_ANNOTATION = 'ai.libre-webui.policy';
45
+ // Container states that mean the sandbox will never come up without operator
46
+ // action; waiting for the ready timeout would hide the actual problem.
47
+ const FATAL_WAITING_REASONS = new Set([
48
+ 'ErrImagePull',
49
+ 'ImagePullBackOff',
50
+ 'InvalidImageName',
51
+ 'CreateContainerConfigError',
52
+ 'CreateContainerError',
53
+ ]);
54
+ /**
55
+ * Work runtime driver backed by a Kubernetes namespace: one PVC per task
56
+ * workspace, one on-demand Pod per running sandbox, exec for everything that
57
+ * happens inside. No Docker daemon or socket is involved anywhere, and the
58
+ * backing ServiceAccount only needs pods/pods-exec/PVC rights in the sandbox
59
+ * namespace. Interactive terminals ride the exec subresource as a TTY
60
+ * WebSocket, and preview is served from the sandbox Pod IP through the
61
+ * signed proxy (the backend must run in-cluster to reach it).
62
+ */
63
+ export class KubernetesWorkRuntimeDriver {
64
+ constructor() {
65
+ this.image = config.image;
66
+ this.namespace = k8sConfig.namespace;
67
+ }
68
+ client() {
69
+ if (!this.clientState) {
70
+ this.clientState = (async () => {
71
+ // Loaded lazily so Docker-backed deployments never pay for the
72
+ // Kubernetes client at startup.
73
+ const lib = await import('@kubernetes/client-node');
74
+ const kubeConfig = new lib.KubeConfig();
75
+ kubeConfig.loadFromDefault();
76
+ return {
77
+ lib,
78
+ kubeConfig,
79
+ core: kubeConfig.makeApiClient(lib.CoreV1Api),
80
+ };
81
+ })().catch(error => {
82
+ this.clientState = undefined;
83
+ throw new WorkRuntimeError(describeKubernetesUnavailable(error, this.namespace), 503, 'WORK_KUBERNETES_UNAVAILABLE');
84
+ });
85
+ }
86
+ return this.clientState;
87
+ }
88
+ async probe() {
89
+ const { core } = await this.client();
90
+ try {
91
+ await core.listNamespacedPod({ namespace: this.namespace, limit: 1 });
92
+ }
93
+ catch (error) {
94
+ throw new WorkRuntimeError(describeKubernetesUnavailable(error, this.namespace), 503, 'WORK_KUBERNETES_UNAVAILABLE');
95
+ }
96
+ }
97
+ shutdown() {
98
+ // Exec WebSockets die with the process; nothing to kill explicitly.
99
+ }
100
+ async ensureImage() {
101
+ // The kubelet pulls images when a Pod is scheduled; pull problems are
102
+ // surfaced by ensureRuntime through the container waiting state.
103
+ }
104
+ async ensureWorkspace(task) {
105
+ assertNoHostWorkspace(task);
106
+ const { core } = await this.client();
107
+ const existing = await ignoreNotFound(core.readNamespacedPersistentVolumeClaim({
108
+ name: task.volumeName,
109
+ namespace: this.namespace,
110
+ }));
111
+ if (existing) {
112
+ assertOwnedWorkspace(existing.metadata?.labels, task);
113
+ return;
114
+ }
115
+ // fsGroup on the Pod makes the kubelet hand the volume to the sandbox
116
+ // user on mount; no chown init step is needed.
117
+ await core.createNamespacedPersistentVolumeClaim({
118
+ namespace: this.namespace,
119
+ body: buildWorkspaceClaimManifest(task, workPolicyService.resolve(task.policyId).workspaceSize),
120
+ });
121
+ }
122
+ async ensureRuntime(task) {
123
+ assertNoHostWorkspace(task);
124
+ const policy = workPolicyService.resolve(task.policyId);
125
+ const { core } = await this.client();
126
+ const existing = await this.readPod(task.containerName);
127
+ if (existing) {
128
+ assertOwnedRuntime(existing.metadata?.labels, task);
129
+ const phase = existing.status?.phase ?? '';
130
+ const fingerprint = existing.metadata?.annotations?.[POLICY_ANNOTATION];
131
+ const image = existing.spec?.containers?.[0]?.image;
132
+ const stale = fingerprint !== computePolicyFingerprint(policy) ||
133
+ image !== policy.image;
134
+ const finished = phase === 'Succeeded' || phase === 'Failed';
135
+ if (stale || finished) {
136
+ if (stale) {
137
+ logger.warn(`Recreating Work sandbox Pod ${task.containerName} because its isolation policy is stale.`);
138
+ }
139
+ await this.deletePodAndWait(task.containerName);
140
+ }
141
+ else {
142
+ await this.waitForPodRunning(task.containerName);
143
+ return;
144
+ }
145
+ }
146
+ await core.createNamespacedPod({
147
+ namespace: this.namespace,
148
+ body: buildWorkPodManifest(task, policy),
149
+ });
150
+ await this.waitForPodRunning(task.containerName);
151
+ }
152
+ async runtimeState(task) {
153
+ const pod = await this.readPod(task.containerName);
154
+ if (!pod)
155
+ return 'absent';
156
+ assertOwnedRuntime(pod.metadata?.labels, task);
157
+ return mapPodPhase(pod.status?.phase);
158
+ }
159
+ async stopRuntime(task) {
160
+ const pod = await this.readPod(task.containerName);
161
+ if (!pod)
162
+ return;
163
+ assertOwnedRuntime(pod.metadata?.labels, task);
164
+ // A sandbox at rest is simply no Pod: the durable state is the PVC, so
165
+ // stop and remove are the same operation on this backend.
166
+ await this.deletePodAndWait(task.containerName);
167
+ }
168
+ async removeRuntime(task) {
169
+ await this.stopRuntime(task);
170
+ }
171
+ async removeTaskResources(task) {
172
+ const { core } = await this.client();
173
+ const [pod, claim] = await Promise.all([
174
+ this.readPod(task.containerName),
175
+ ignoreNotFound(core.readNamespacedPersistentVolumeClaim({
176
+ name: task.volumeName,
177
+ namespace: this.namespace,
178
+ })),
179
+ ]);
180
+ // Validate every destructive target before removing either one, so a
181
+ // conflicting unmanaged resource cannot cause partial cleanup.
182
+ if (pod) {
183
+ assertOwnedRuntime(pod.metadata?.labels, task);
184
+ }
185
+ if (claim) {
186
+ assertOwnedWorkspace(claim.metadata?.labels, task);
187
+ }
188
+ if (pod) {
189
+ await this.deletePodAndWait(task.containerName);
190
+ }
191
+ if (claim) {
192
+ await ignoreNotFound(core.deleteNamespacedPersistentVolumeClaim({
193
+ name: task.volumeName,
194
+ namespace: this.namespace,
195
+ }));
196
+ }
197
+ }
198
+ async exec(task, command, options = {}) {
199
+ return this.execInPod(task.containerName, wrapCommandWithWorkdir(command, options.workdir), options);
200
+ }
201
+ async previewEndpoint(task) {
202
+ // The sandbox publishes nothing: the backend (running in-cluster)
203
+ // reaches the preview server directly on the Pod IP. The IP is valid
204
+ // exactly as long as the Pod, which is exactly as long as the preview.
205
+ const pod = await this.readPod(task.containerName);
206
+ if (!pod)
207
+ return undefined;
208
+ assertOwnedRuntime(pod.metadata?.labels, task);
209
+ const podIp = pod.status?.podIP;
210
+ if (!podIp)
211
+ return undefined;
212
+ return { host: podIp, port: config.previewPort };
213
+ }
214
+ async listManaged() {
215
+ const { core } = await this.client();
216
+ const pods = await core.listNamespacedPod({
217
+ namespace: this.namespace,
218
+ labelSelector: `${MANAGED_LABEL}=true`,
219
+ });
220
+ return (pods.items ?? []).map(pod => ({
221
+ name: pod.metadata?.name ?? '',
222
+ taskId: pod.metadata?.labels?.[TASK_LABEL] ?? '',
223
+ running: mapPodPhase(pod.status?.phase) === 'running',
224
+ }));
225
+ }
226
+ async removeOrphan(name) {
227
+ await this.deletePodAndWait(name);
228
+ }
229
+ async listWorkspaces() {
230
+ const { core } = await this.client();
231
+ const claims = await core.listNamespacedPersistentVolumeClaim({
232
+ namespace: this.namespace,
233
+ labelSelector: `${MANAGED_LABEL}=true`,
234
+ });
235
+ return (claims.items ?? []).map(claim => ({
236
+ name: claim.metadata?.name ?? '',
237
+ taskId: claim.metadata?.labels?.[TASK_LABEL] ?? '',
238
+ }));
239
+ }
240
+ terminalUnavailableReason() {
241
+ return null;
242
+ }
243
+ /**
244
+ * Interactive TTY over the Kubernetes exec subresource. The exec API has
245
+ * no env parameter, so TERM rides in via env(1); with tty=true the server
246
+ * merges stderr into stdout, and resize frames travel on the dedicated
247
+ * resize channel, driven by the client library watching our stdout-side
248
+ * stream for TTY-style resize events.
249
+ */
250
+ async openTerminal(containerName) {
251
+ const { lib, kubeConfig } = await this.client();
252
+ const stdinStream = new PassThrough();
253
+ const outputStream = new ResizableOutputStream();
254
+ const exec = new lib.Exec(kubeConfig);
255
+ let socket;
256
+ try {
257
+ socket = await exec.exec(this.namespace, containerName, WORK_CONTAINER_NAME, ['env', 'TERM=xterm-256color', '/bin/bash', '-l'], outputStream, null, stdinStream, true);
258
+ }
259
+ catch (error) {
260
+ throw new WorkRuntimeError(`Could not open the terminal stream: ${error instanceof Error ? error.message : String(error)}`, 503, 'WORK_TERMINAL_UNAVAILABLE');
261
+ }
262
+ const stream = new TerminalStreamBridge(stdinStream, outputStream);
263
+ socket.on('close', () => {
264
+ stream.destroy();
265
+ });
266
+ socket.on('error', () => {
267
+ stream.destroy();
268
+ });
269
+ stream.on('close', () => {
270
+ try {
271
+ socket.close();
272
+ }
273
+ catch {
274
+ // The socket may already be closed; nothing to clean up.
275
+ }
276
+ });
277
+ return {
278
+ stream,
279
+ resize: async (cols, rows) => {
280
+ outputStream.setSize(cols, rows);
281
+ },
282
+ };
283
+ }
284
+ async readPod(name) {
285
+ const { core } = await this.client();
286
+ return ignoreNotFound(core.readNamespacedPod({ name, namespace: this.namespace }));
287
+ }
288
+ async deletePodAndWait(name) {
289
+ const { core } = await this.client();
290
+ await ignoreNotFound(core.deleteNamespacedPod({
291
+ name,
292
+ namespace: this.namespace,
293
+ gracePeriodSeconds: 1,
294
+ }));
295
+ const deadline = Date.now() + k8sConfig.podGoneTimeoutMs;
296
+ while (await this.readPod(name)) {
297
+ if (Date.now() >= deadline) {
298
+ throw new WorkRuntimeError(`Work sandbox Pod "${name}" was not removed within ${k8sConfig.podGoneTimeoutMs}ms.`, 503, 'WORK_KUBERNETES_POD_STUCK');
299
+ }
300
+ await sleep(POD_POLL_INTERVAL_MS);
301
+ }
302
+ }
303
+ async waitForPodRunning(name) {
304
+ const deadline = Date.now() + k8sConfig.podReadyTimeoutMs;
305
+ while (true) {
306
+ const pod = await this.readPod(name);
307
+ if (!pod) {
308
+ throw new WorkRuntimeError(`Work sandbox Pod "${name}" disappeared while starting.`, 503, 'WORK_KUBERNETES_POD_STUCK');
309
+ }
310
+ const phase = pod.status?.phase ?? 'Pending';
311
+ if (phase === 'Running')
312
+ return;
313
+ const waiting = pod.status?.containerStatuses?.[0]?.state?.waiting;
314
+ if (phase === 'Failed' || phase === 'Succeeded') {
315
+ throw new WorkRuntimeError(`Work sandbox Pod "${name}" exited while starting (phase ${phase}).`, 503, 'WORK_KUBERNETES_POD_STUCK');
316
+ }
317
+ if (waiting?.reason && FATAL_WAITING_REASONS.has(waiting.reason)) {
318
+ throw new WorkRuntimeError(`Work sandbox Pod "${name}" cannot start: ${waiting.reason}${waiting.message ? ` — ${waiting.message}` : ''}`, 503, 'WORK_KUBERNETES_POD_STUCK');
319
+ }
320
+ if (Date.now() >= deadline) {
321
+ throw new WorkRuntimeError(`Work sandbox Pod "${name}" did not reach Running within ${k8sConfig.podReadyTimeoutMs}ms${waiting?.reason ? ` (${waiting.reason})` : ''}.`, 503, 'WORK_KUBERNETES_POD_STUCK');
322
+ }
323
+ await sleep(POD_POLL_INTERVAL_MS);
324
+ }
325
+ }
326
+ async execInPod(podName, command, options) {
327
+ const { lib, kubeConfig } = await this.client();
328
+ const timeoutMs = options.timeoutMs ?? config.commandTimeoutMs;
329
+ const maxOutputChars = options.maxOutputChars ?? config.maxOutputChars;
330
+ let stdout = '';
331
+ let stderr = '';
332
+ let truncated = false;
333
+ const append = (current, chunk) => {
334
+ const next = current + chunk.toString();
335
+ if (next.length <= maxOutputChars)
336
+ return next;
337
+ truncated = true;
338
+ const half = Math.floor(maxOutputChars / 2);
339
+ return `${next.slice(0, half)}\n... output truncated ...\n${next.slice(-half)}`;
340
+ };
341
+ const collector = (write) => new Writable({
342
+ write(chunk, _encoding, callback) {
343
+ write(chunk);
344
+ callback();
345
+ },
346
+ });
347
+ return new Promise((resolve, reject) => {
348
+ let settled = false;
349
+ let socket;
350
+ let status;
351
+ const claimSettlement = () => {
352
+ if (settled)
353
+ return false;
354
+ settled = true;
355
+ clearTimeout(timer);
356
+ return true;
357
+ };
358
+ // Armed before the connection attempt: a hung API-server connection
359
+ // must hit the same deadline as a hung command.
360
+ const timer = setTimeout(() => {
361
+ if (!claimSettlement())
362
+ return;
363
+ try {
364
+ socket?.close();
365
+ }
366
+ catch {
367
+ // The socket may already be closed; nothing to clean up.
368
+ }
369
+ reject(new WorkRuntimeError(`Command timed out after ${timeoutMs}ms.`, 504, 'WORK_COMMAND_TIMEOUT'));
370
+ }, timeoutMs);
371
+ timer.unref();
372
+ const exec = new lib.Exec(kubeConfig);
373
+ exec
374
+ .exec(this.namespace, podName, WORK_CONTAINER_NAME, command, collector(chunk => {
375
+ stdout = append(stdout, chunk);
376
+ }), collector(chunk => {
377
+ stderr = append(stderr, chunk);
378
+ }), options.input !== undefined ? Readable.from([options.input]) : null, false, result => {
379
+ status = result;
380
+ })
381
+ .then(connected => {
382
+ socket = connected;
383
+ if (settled) {
384
+ try {
385
+ connected.close();
386
+ }
387
+ catch {
388
+ // The socket may already be closed; nothing to clean up.
389
+ }
390
+ return;
391
+ }
392
+ connected.on('error', error => {
393
+ if (!claimSettlement())
394
+ return;
395
+ reject(new WorkRuntimeError(`The Work exec stream failed: ${error instanceof Error ? error.message : String(error)}`, 503, 'WORK_KUBERNETES_UNAVAILABLE'));
396
+ });
397
+ connected.on('close', () => {
398
+ if (!claimSettlement())
399
+ return;
400
+ const exitCode = statusToExitCode(status);
401
+ const result = {
402
+ exitCode,
403
+ stdout,
404
+ stderr,
405
+ truncated,
406
+ };
407
+ if (exitCode !== 0 && !options.acceptFailure) {
408
+ reject(new WorkRuntimeError(stderr.trim() ||
409
+ stdout.trim() ||
410
+ status?.message ||
411
+ `Command exited with code ${exitCode}.`, 503, 'WORK_COMMAND_FAILED'));
412
+ return;
413
+ }
414
+ resolve(result);
415
+ });
416
+ })
417
+ .catch(error => {
418
+ if (!claimSettlement())
419
+ return;
420
+ reject(new WorkRuntimeError(`Could not exec in Work sandbox Pod "${podName}": ${error instanceof Error ? error.message : String(error)}`, 503, 'WORK_KUBERNETES_UNAVAILABLE'));
421
+ });
422
+ });
423
+ }
424
+ }
425
+ export function buildWorkspaceClaimManifest(task, workspaceSize) {
426
+ return {
427
+ metadata: {
428
+ name: task.volumeName,
429
+ labels: {
430
+ [MANAGED_LABEL]: 'true',
431
+ [TASK_LABEL]: task.id,
432
+ },
433
+ },
434
+ spec: {
435
+ accessModes: ['ReadWriteOnce'],
436
+ resources: {
437
+ requests: { storage: workspaceSize ?? k8sConfig.workspaceSize },
438
+ },
439
+ ...(k8sConfig.storageClass
440
+ ? { storageClassName: k8sConfig.storageClass }
441
+ : {}),
442
+ },
443
+ };
444
+ }
445
+ export function buildWorkPodManifest(task, policy = defaultRuntimePolicy) {
446
+ assertNoHostWorkspace(task);
447
+ return {
448
+ metadata: {
449
+ name: task.containerName,
450
+ labels: {
451
+ [MANAGED_LABEL]: 'true',
452
+ [TASK_LABEL]: task.id,
453
+ // Network isolation is enforced by NetworkPolicy selecting on this
454
+ // label (plan phase 4); the label ships now so existing sandboxes
455
+ // are already selectable when those policies land.
456
+ [NETWORK_LABEL]: String(task.networkEnabled),
457
+ },
458
+ // The fingerprint is a 64-char sha256 hex digest — too long for a
459
+ // label value, so it rides as an annotation.
460
+ annotations: { [POLICY_ANNOTATION]: computePolicyFingerprint(policy) },
461
+ },
462
+ spec: {
463
+ restartPolicy: 'Never',
464
+ automountServiceAccountToken: false,
465
+ enableServiceLinks: false,
466
+ securityContext: {
467
+ runAsUser: 1000,
468
+ runAsGroup: 1000,
469
+ runAsNonRoot: true,
470
+ fsGroup: 1000,
471
+ fsGroupChangePolicy: 'OnRootMismatch',
472
+ seccompProfile: { type: 'RuntimeDefault' },
473
+ },
474
+ containers: [
475
+ {
476
+ name: WORK_CONTAINER_NAME,
477
+ image: policy.image,
478
+ command: ['tail', '-f', '/dev/null'],
479
+ workingDir: '/workspace',
480
+ env: [
481
+ { name: 'HOME', value: '/tmp' },
482
+ { name: 'NPM_CONFIG_CACHE', value: '/tmp/npm-cache' },
483
+ ],
484
+ securityContext: {
485
+ allowPrivilegeEscalation: false,
486
+ readOnlyRootFilesystem: true,
487
+ capabilities: { drop: ['ALL'] },
488
+ },
489
+ resources: {
490
+ limits: {
491
+ memory: dockerMemoryToKubernetes(policy.memoryLimit),
492
+ cpu: dockerCpusToKubernetes(policy.cpuLimit),
493
+ },
494
+ },
495
+ volumeMounts: [
496
+ { name: 'workspace', mountPath: '/workspace' },
497
+ { name: 'tmp', mountPath: '/tmp' },
498
+ ],
499
+ },
500
+ ],
501
+ volumes: [
502
+ {
503
+ name: 'workspace',
504
+ persistentVolumeClaim: { claimName: task.volumeName },
505
+ },
506
+ { name: 'tmp', emptyDir: { sizeLimit: '512Mi' } },
507
+ ],
508
+ ...(config.dnsServers.length > 0
509
+ ? {
510
+ dnsPolicy: 'None',
511
+ dnsConfig: { nameservers: config.dnsServers },
512
+ }
513
+ : {}),
514
+ },
515
+ };
516
+ }
517
+ /**
518
+ * Kubernetes exec always runs in the container's workingDir (/workspace for
519
+ * Work sandboxes); a different working directory needs a shell hop.
520
+ */
521
+ export function wrapCommandWithWorkdir(command, workdir) {
522
+ if (!workdir || workdir === '/workspace')
523
+ return command;
524
+ return ['/bin/sh', '-c', 'cd -- "$0" && exec "$@"', workdir, ...command];
525
+ }
526
+ /**
527
+ * Docker memory strings use binary multiples; so do Ki/Mi/Gi. Docker accepts
528
+ * both one- and two-letter suffixes (2g, 2gb, 2gib) for the same quantity.
529
+ */
530
+ export function dockerMemoryToKubernetes(value) {
531
+ const match = value.trim().match(/^(\d+(?:\.\d+)?)([bkmg])(?:i?b)?$/i);
532
+ if (!match)
533
+ return value;
534
+ const suffix = { b: '', k: 'Ki', m: 'Mi', g: 'Gi' }[match[2].toLowerCase()];
535
+ return `${match[1]}${suffix}`;
536
+ }
537
+ export function dockerCpusToKubernetes(value) {
538
+ const parsed = Number(value.trim());
539
+ if (!Number.isFinite(parsed) || parsed <= 0)
540
+ return value;
541
+ return Number.isInteger(parsed)
542
+ ? String(parsed)
543
+ : `${Math.round(parsed * 1000)}m`;
544
+ }
545
+ export function mapPodPhase(phase) {
546
+ // Pending Pods hold execution intent (scheduling, image pull, starting);
547
+ // treating them as running keeps stop/reconcile semantics conservative.
548
+ if (phase === 'Running' || phase === 'Pending' || phase === 'Unknown') {
549
+ return 'running';
550
+ }
551
+ if (phase === 'Succeeded' || phase === 'Failed')
552
+ return 'stopped';
553
+ return 'absent';
554
+ }
555
+ export function statusToExitCode(status) {
556
+ if (!status)
557
+ return -1;
558
+ if (status.status === 'Success')
559
+ return 0;
560
+ const cause = status.details?.causes?.find(item => item.reason === 'ExitCode');
561
+ const parsed = Number(cause?.message);
562
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : -1;
563
+ }
564
+ export function describeKubernetesUnavailable(error, namespace) {
565
+ const code = error?.code;
566
+ const message = error instanceof Error ? error.message : String(error);
567
+ if (code === 404) {
568
+ return `The Work sandbox namespace "${namespace}" does not exist. Create it (kubectl create namespace ${namespace}) or point WORK_K8S_NAMESPACE at an existing one.`;
569
+ }
570
+ if (code === 401 || code === 403) {
571
+ return `The Kubernetes credentials cannot manage Pods in namespace "${namespace}". Grant the backend ServiceAccount pods, pods/exec, and persistentvolumeclaims rights there.`;
572
+ }
573
+ if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH/.test(message)) {
574
+ return `No Kubernetes API server is reachable: ${message}. Check KUBECONFIG or run Libre WebUI inside the cluster.`;
575
+ }
576
+ if (/no active cluster|cannot load|ENOENT/i.test(message)) {
577
+ return `No Kubernetes configuration was found: ${message}. Provide a kubeconfig or run Libre WebUI inside the cluster.`;
578
+ }
579
+ return message;
580
+ }
581
+ function assertNoHostWorkspace(task) {
582
+ if (task.hostPath) {
583
+ throw new WorkRuntimeError('Host-folder workspaces are not supported on the Kubernetes Work backend.', 501, 'WORK_HOST_WORKSPACE_UNSUPPORTED');
584
+ }
585
+ }
586
+ function assertOwnedRuntime(labels, task) {
587
+ if (labels?.[TASK_LABEL] !== task.id) {
588
+ throw new WorkRuntimeError(`Refusing to operate unmanaged container "${task.containerName}".`, 409, 'WORK_CONTAINER_NAME_CONFLICT');
589
+ }
590
+ }
591
+ function assertOwnedWorkspace(labels, task) {
592
+ if (labels?.[TASK_LABEL] !== task.id) {
593
+ throw new WorkRuntimeError(`Refusing to operate unmanaged volume "${task.volumeName}".`, 409, 'WORK_VOLUME_NAME_CONFLICT');
594
+ }
595
+ }
596
+ async function ignoreNotFound(promise) {
597
+ try {
598
+ return await promise;
599
+ }
600
+ catch (error) {
601
+ if (error?.code === 404)
602
+ return undefined;
603
+ throw error;
604
+ }
605
+ }
606
+ function sleep(ms) {
607
+ return new Promise(resolve => setTimeout(resolve, ms));
608
+ }
609
+ /**
610
+ * Terminal output stream that also looks like a TTY to the Kubernetes
611
+ * client: exposing rows/columns and emitting 'resize' makes the client
612
+ * library open the resize channel and forward size updates to the server.
613
+ * That is undocumented @kubernetes/client-node behavior, so a client
614
+ * upgrade can silently break resize — the kind e2e suite asserts it works
615
+ * (`stty size` after a resize call); trust that check when bumping the
616
+ * dependency.
617
+ */
618
+ class ResizableOutputStream extends PassThrough {
619
+ constructor() {
620
+ super(...arguments);
621
+ this.rows = 24;
622
+ this.columns = 80;
623
+ }
624
+ setSize(columns, rows) {
625
+ this.columns = columns;
626
+ this.rows = rows;
627
+ this.emit('resize');
628
+ }
629
+ }
630
+ /**
631
+ * One bidirectional stream over the exec channel pair: writes become the
632
+ * shell's stdin, reads are the shell's merged TTY output — the same Duplex
633
+ * contract the Docker driver gets from its hijacked Engine-API socket.
634
+ */
635
+ class TerminalStreamBridge extends Duplex {
636
+ constructor(sink, source) {
637
+ super();
638
+ this.sink = sink;
639
+ this.source = source;
640
+ source.on('data', (chunk) => {
641
+ if (!this.push(chunk))
642
+ source.pause();
643
+ });
644
+ source.on('end', () => this.push(null));
645
+ source.on('error', error => this.destroy(error));
646
+ }
647
+ _read() {
648
+ this.source.resume();
649
+ }
650
+ _write(chunk, encoding, callback) {
651
+ this.sink.write(chunk, encoding, callback);
652
+ }
653
+ _destroy(error, callback) {
654
+ this.sink.destroy();
655
+ this.source.destroy();
656
+ callback(error);
657
+ }
658
+ }
659
+ //# sourceMappingURL=workKubernetesDriver.js.map