libre-webui 0.16.0 → 0.17.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 (167) hide show
  1. package/README.md +237 -149
  2. package/backend/dist/db.d.ts.map +1 -1
  3. package/backend/dist/db.js +5 -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 +2 -0
  7. package/backend/dist/index.js.map +1 -1
  8. package/backend/dist/routes/agentCli.d.ts +3 -0
  9. package/backend/dist/routes/agentCli.d.ts.map +1 -0
  10. package/backend/dist/routes/agentCli.js +33 -0
  11. package/backend/dist/routes/agentCli.js.map +1 -0
  12. package/backend/dist/routes/chat.d.ts.map +1 -1
  13. package/backend/dist/routes/chat.js +27 -0
  14. package/backend/dist/routes/chat.js.map +1 -1
  15. package/backend/dist/routes/plugins.d.ts.map +1 -1
  16. package/backend/dist/routes/plugins.js +10 -3
  17. package/backend/dist/routes/plugins.js.map +1 -1
  18. package/backend/dist/routes/work.d.ts.map +1 -1
  19. package/backend/dist/routes/work.js +25 -3
  20. package/backend/dist/routes/work.js.map +1 -1
  21. package/backend/dist/services/agentCliService.d.ts +30 -0
  22. package/backend/dist/services/agentCliService.d.ts.map +1 -0
  23. package/backend/dist/services/agentCliService.js +328 -0
  24. package/backend/dist/services/agentCliService.js.map +1 -0
  25. package/backend/dist/services/chatGenerationService.d.ts.map +1 -1
  26. package/backend/dist/services/chatGenerationService.js +19 -1
  27. package/backend/dist/services/chatGenerationService.js.map +1 -1
  28. package/backend/dist/services/pluginService.d.ts +42 -1
  29. package/backend/dist/services/pluginService.d.ts.map +1 -1
  30. package/backend/dist/services/pluginService.js +143 -12
  31. package/backend/dist/services/pluginService.js.map +1 -1
  32. package/backend/dist/services/workAgentGuidance.js +1 -1
  33. package/backend/dist/services/workAgentGuidance.js.map +1 -1
  34. package/backend/dist/services/workAgentService.d.ts.map +1 -1
  35. package/backend/dist/services/workAgentService.js +37 -0
  36. package/backend/dist/services/workAgentService.js.map +1 -1
  37. package/backend/dist/services/workHostWorkspaceService.d.ts +22 -0
  38. package/backend/dist/services/workHostWorkspaceService.d.ts.map +1 -0
  39. package/backend/dist/services/workHostWorkspaceService.js +136 -0
  40. package/backend/dist/services/workHostWorkspaceService.js.map +1 -0
  41. package/backend/dist/services/workRuntimeService.d.ts +61 -3
  42. package/backend/dist/services/workRuntimeService.d.ts.map +1 -1
  43. package/backend/dist/services/workRuntimeService.js +311 -17
  44. package/backend/dist/services/workRuntimeService.js.map +1 -1
  45. package/backend/dist/services/workTaskService.d.ts +3 -3
  46. package/backend/dist/services/workTaskService.d.ts.map +1 -1
  47. package/backend/dist/services/workTaskService.js +10 -6
  48. package/backend/dist/services/workTaskService.js.map +1 -1
  49. package/backend/dist/services/workTerminalService.d.ts +46 -0
  50. package/backend/dist/services/workTerminalService.d.ts.map +1 -0
  51. package/backend/dist/services/workTerminalService.js +241 -0
  52. package/backend/dist/services/workTerminalService.js.map +1 -0
  53. package/backend/dist/types/index.d.ts +1 -1
  54. package/backend/dist/types/index.d.ts.map +1 -1
  55. package/backend/dist/types/work.d.ts +22 -0
  56. package/backend/dist/types/work.d.ts.map +1 -1
  57. package/backend/dist/utils/chatProviderSelection.d.ts.map +1 -1
  58. package/backend/dist/utils/chatProviderSelection.js +7 -5
  59. package/backend/dist/utils/chatProviderSelection.js.map +1 -1
  60. package/backend/dist/websocketServer.d.ts.map +1 -1
  61. package/backend/dist/websocketServer.js +51 -14
  62. package/backend/dist/websocketServer.js.map +1 -1
  63. package/backend/dist/workTerminalServer.d.ts +17 -0
  64. package/backend/dist/workTerminalServer.d.ts.map +1 -0
  65. package/backend/dist/workTerminalServer.js +224 -0
  66. package/backend/dist/workTerminalServer.js.map +1 -0
  67. package/backend/package.json +1 -1
  68. package/frontend/dist/assets/index-BqI0kezz.css +1 -0
  69. package/frontend/dist/assets/index-CRC6g6bL.js +3 -0
  70. package/frontend/dist/assets/xterm-CmMoOwwu.css +1 -0
  71. package/frontend/dist/index.html +8 -6
  72. package/frontend/dist/js/ArtifactContainer-Cdy-973y.js +1 -0
  73. package/frontend/dist/js/{ArtifactDemoPage-DBVPHFxk.js → ArtifactDemoPage-B4qsvUnF.js} +1 -1
  74. package/frontend/dist/js/ArtifactSlideOutPanel-CmKgQ4eD.js +1 -0
  75. package/frontend/dist/js/{Button-DBk-AREB.js → Button-CRwzOaDZ.js} +1 -1
  76. package/frontend/dist/js/ChatPage-CYT69oBR.js +16 -0
  77. package/frontend/dist/js/FirstTimeSetup-C8LdLP5J.js +1 -0
  78. package/frontend/dist/js/GalleryPage-Bm89pnS8.js +1 -0
  79. package/frontend/dist/js/HomePage-CeCYnMUG.js +1 -0
  80. package/frontend/dist/js/{LibreClawPage-qGr1Jy8p.js → LibreClawPage-DGMEV6dm.js} +1 -1
  81. package/frontend/dist/js/{ModelManagerModals-DltRprHU.js → ModelManagerModals-DMoEav1l.js} +1 -1
  82. package/frontend/dist/js/ModelSelector-DXPdGdQ_.js +1 -0
  83. package/frontend/dist/js/ModelsPage-D-FIy2x-.js +2 -0
  84. package/frontend/dist/js/PersonasPage-CIrEVFo5.js +13 -0
  85. package/frontend/dist/js/RichMessageContent-BJ49VN9j.js +3 -0
  86. package/frontend/dist/js/SettingsModal-CxtOXPY7.js +1 -0
  87. package/frontend/dist/js/UserManagementPage-BfyYGGFQ.js +1 -0
  88. package/frontend/dist/js/WorkPage-C7bW6Hxx.js +4 -0
  89. package/frontend/dist/js/addon-fit-Cj0_dfHF.js +1 -0
  90. package/frontend/dist/js/api-DCD41-l9.js +19 -0
  91. package/frontend/dist/js/appStore-Bw-8Exsx.js +2 -0
  92. package/frontend/dist/js/ar-BAHlIvy0.js +2 -0
  93. package/frontend/dist/js/authStore-mOYH_AXI.js +4 -0
  94. package/frontend/dist/js/bn-BxquDVbd.js +2 -0
  95. package/frontend/dist/js/{cs-6UKhKj0A.js → cs-C4DoKzRm.js} +2 -2
  96. package/frontend/dist/js/da-CFslpGTw.js +2 -0
  97. package/frontend/dist/js/de-ChudoRh2.js +2 -0
  98. package/frontend/dist/js/es-B36fefE-.js +2 -0
  99. package/frontend/dist/js/{estree-PDLT672k.js → estree-0vwU39qX.js} +1 -1
  100. package/frontend/dist/js/fr-BSVeTM5W.js +2 -0
  101. package/frontend/dist/js/hi-BGoFbl5V.js +2 -0
  102. package/frontend/dist/js/{html-CkhF9wDQ.js → html-CApr2Bvi.js} +1 -1
  103. package/frontend/dist/js/id-BfYs2p_j.js +2 -0
  104. package/frontend/dist/js/{is-BWSbAIoq.js → is-DZ1MS-5A.js} +2 -2
  105. package/frontend/dist/js/it-CXtrPCG1.js +2 -0
  106. package/frontend/dist/js/ja-FPJ_NK_y.js +2 -0
  107. package/frontend/dist/js/ko-DVwD4ZZF.js +2 -0
  108. package/frontend/dist/js/{markdown-B1iNUbse.js → markdown-C78Qc6xu.js} +1 -1
  109. package/frontend/dist/js/ms-PKPP0RX3.js +2 -0
  110. package/frontend/dist/js/nl-DWM5HJZ8.js +2 -0
  111. package/frontend/dist/js/pl-B9va8Tuf.js +2 -0
  112. package/frontend/dist/js/pt-P0IQi-Zr.js +2 -0
  113. package/frontend/dist/js/ru-Ca2bxYnc.js +2 -0
  114. package/frontend/dist/js/{standalone-CCd6rkf-.js → standalone-DmLfI4Qj.js} +1 -1
  115. package/frontend/dist/js/sv-DI4RDwyZ.js +2 -0
  116. package/frontend/dist/js/th-DN0bDS9d.js +2 -0
  117. package/frontend/dist/js/tr-ChqMv5Q4.js +2 -0
  118. package/frontend/dist/js/{typescript-DKJ1uM5L.js → typescript-Bcq62agC.js} +1 -1
  119. package/frontend/dist/js/{ui-vendor-CAFEDSaO.js → ui-vendor-DohPFHDW.js} +1 -1
  120. package/frontend/dist/js/uk-Cr51h_XQ.js +2 -0
  121. package/frontend/dist/js/useBaseQuery-BAOlhjar.js +1 -0
  122. package/frontend/dist/js/useQuery-CcRqxveh.js +1 -0
  123. package/frontend/dist/js/utils-DmZD5uA3.js +1 -0
  124. package/frontend/dist/js/{vi-82JkwFsQ.js → vi-D4OhoVMH.js} +2 -2
  125. package/frontend/dist/js/work-DVgm81bJ.js +1 -0
  126. package/frontend/dist/js/xterm-DKl1QLiw.js +7 -0
  127. package/frontend/dist/js/zh-Bvau7hlR.js +2 -0
  128. package/package.json +3 -3
  129. package/frontend/dist/assets/index-CKV9Lnj8.js +0 -5
  130. package/frontend/dist/assets/index-Dme2Ozol.css +0 -1
  131. package/frontend/dist/js/ArtifactContainer-B0uVR8Z9.js +0 -1
  132. package/frontend/dist/js/ArtifactSlideOutPanel-Cg5kAwv9.js +0 -1
  133. package/frontend/dist/js/ChatPage-CPHlIZqU.js +0 -16
  134. package/frontend/dist/js/FirstTimeSetup-DksL5arK.js +0 -1
  135. package/frontend/dist/js/GalleryPage-1mdUGo_N.js +0 -1
  136. package/frontend/dist/js/ModelSelector-CUnwRmNU.js +0 -1
  137. package/frontend/dist/js/ModelsPage-DMYVg3LT.js +0 -2
  138. package/frontend/dist/js/PersonasPage-Ct-KKW-j.js +0 -13
  139. package/frontend/dist/js/RichMessageContent-CGBAcUES.js +0 -3
  140. package/frontend/dist/js/SettingsModal-BVj3gFCS.js +0 -1
  141. package/frontend/dist/js/UserManagementPage-D3tLGtTu.js +0 -1
  142. package/frontend/dist/js/WorkPage-DhTjy-kf.js +0 -2
  143. package/frontend/dist/js/api-YWoSobKv.js +0 -19
  144. package/frontend/dist/js/ar-KceI0PfT.js +0 -2
  145. package/frontend/dist/js/bn-DsKo2dzS.js +0 -2
  146. package/frontend/dist/js/da-CJgZ3YuL.js +0 -2
  147. package/frontend/dist/js/de-BZAmOuCT.js +0 -2
  148. package/frontend/dist/js/es-CD4u7no9.js +0 -2
  149. package/frontend/dist/js/fr-Cou2jOS3.js +0 -2
  150. package/frontend/dist/js/hi-ZxkbNCyB.js +0 -2
  151. package/frontend/dist/js/id-DT9CqaWQ.js +0 -2
  152. package/frontend/dist/js/it-CdNoTXm3.js +0 -2
  153. package/frontend/dist/js/ja-CIuQg0xv.js +0 -2
  154. package/frontend/dist/js/ko-BWRUjDOH.js +0 -2
  155. package/frontend/dist/js/ms-gJstIj2a.js +0 -2
  156. package/frontend/dist/js/nl-CydjVNUw.js +0 -2
  157. package/frontend/dist/js/pl-DPN9mAa5.js +0 -2
  158. package/frontend/dist/js/pt-MRXGqIjQ.js +0 -2
  159. package/frontend/dist/js/ru-CvQ9GkJF.js +0 -2
  160. package/frontend/dist/js/sv-BxS2fvhU.js +0 -2
  161. package/frontend/dist/js/th-DMxyn6e4.js +0 -2
  162. package/frontend/dist/js/tr-BTPldUaA.js +0 -2
  163. package/frontend/dist/js/uk-BGxZOKgp.js +0 -2
  164. package/frontend/dist/js/useBaseQuery-MuQj7vA5.js +0 -1
  165. package/frontend/dist/js/useQuery-CYGPkxWR.js +0 -1
  166. package/frontend/dist/js/utils-pTFqSV8c.js +0 -1
  167. package/frontend/dist/js/zh-Bxxfwci3.js +0 -2
@@ -31,10 +31,15 @@ export const WORK_RUNTIME_DEFAULTS = {
31
31
  cpuLimit: '2',
32
32
  pidsLimit: 256,
33
33
  previewPort: 4173,
34
+ previewBind: '127.0.0.1',
35
+ networkName: 'libre-webui-work',
34
36
  };
37
+ // Two runtimes per administrator so a second task does not have to wait for
38
+ // the first, three per instance to bound the worst case at three containers'
39
+ // resource caps. Both remain tunable through WORK_MAX_ACTIVE_RUNTIMES_*.
35
40
  export const WORK_RUNTIME_ADMISSION_DEFAULTS = {
36
- maxActiveRuntimesGlobal: 2,
37
- maxActiveRuntimesPerUser: 1,
41
+ maxActiveRuntimesGlobal: 3,
42
+ maxActiveRuntimesPerUser: 2,
38
43
  };
39
44
  const config = {
40
45
  image: process.env.WORK_RUNTIME_IMAGE || WORK_RUNTIME_DEFAULTS.image,
@@ -46,18 +51,37 @@ const config = {
46
51
  cpuLimit: process.env.WORK_CPU_LIMIT || WORK_RUNTIME_DEFAULTS.cpuLimit,
47
52
  pidsLimit: positiveInteger(process.env.WORK_PIDS_LIMIT, WORK_RUNTIME_DEFAULTS.pidsLimit),
48
53
  previewPort: positiveInteger(process.env.WORK_PREVIEW_PORT, WORK_RUNTIME_DEFAULTS.previewPort),
54
+ // Interface the daemon publishes a task preview on. Loopback keeps a preview
55
+ // private to the Docker host, which is correct when the browser runs there.
56
+ previewBind: process.env.WORK_PREVIEW_BIND || WORK_RUNTIME_DEFAULTS.previewBind,
57
+ // Host advertised in the preview URL. It differs from the bind address when
58
+ // the browser reaches the Docker host by another name.
59
+ previewHost: process.env.WORK_PREVIEW_HOST ||
60
+ process.env.WORK_PREVIEW_BIND ||
61
+ WORK_RUNTIME_DEFAULTS.previewBind,
49
62
  maxActiveRuntimesGlobal: positiveInteger(process.env.WORK_MAX_ACTIVE_RUNTIMES_GLOBAL, WORK_RUNTIME_ADMISSION_DEFAULTS.maxActiveRuntimesGlobal),
50
63
  maxActiveRuntimesPerUser: positiveInteger(process.env.WORK_MAX_ACTIVE_RUNTIMES_PER_USER, WORK_RUNTIME_ADMISSION_DEFAULTS.maxActiveRuntimesPerUser),
64
+ // Dedicated bridge network for networked Work tasks. It is created with
65
+ // inter-container communication disabled, so a sandbox cannot reach other
66
+ // Work sandboxes or the deployment's own containers on the default bridge.
67
+ networkName: process.env.WORK_NETWORK_NAME || WORK_RUNTIME_DEFAULTS.networkName,
68
+ // Optional resolvers forced onto every networked sandbox. Pointing this at
69
+ // a filtering resolver is the supported egress-policy hook.
70
+ dnsServers: parseDnsServers(process.env.WORK_RUNTIME_DNS),
51
71
  };
52
72
  const PREVIEW_READY_TIMEOUT_MS = 15000;
53
73
  const PREVIEW_POLL_INTERVAL_MS = 250;
54
74
  const runtimePolicyFingerprint = createHash('sha256')
55
75
  .update(JSON.stringify({
56
- version: 1,
76
+ version: 2,
57
77
  memoryLimit: config.memoryLimit,
58
78
  cpuLimit: config.cpuLimit,
59
79
  pidsLimit: config.pidsLimit,
60
80
  previewPort: config.previewPort,
81
+ previewBind: config.previewBind,
82
+ networkName: config.networkName,
83
+ dnsServers: config.dnsServers,
84
+ memorySwapPinned: true,
61
85
  }))
62
86
  .digest('hex');
63
87
  export class WorkRuntimeError extends Error {
@@ -76,14 +100,18 @@ export class WorkRuntimeService {
76
100
  maxRounds: config.maxAgentRounds,
77
101
  commandTimeoutMs: config.commandTimeoutMs,
78
102
  maxOutputChars: config.maxOutputChars,
103
+ maxActiveRuntimesGlobal: config.maxActiveRuntimesGlobal,
104
+ maxActiveRuntimesPerUser: config.maxActiveRuntimesPerUser,
79
105
  };
80
106
  this.lifecycleTails = new Map();
81
107
  this.preparations = new Map();
82
108
  this.retiringTasks = new Set();
83
109
  this.networkPolicies = new Map();
84
110
  this.activeCommands = new Set();
111
+ this.lastDockerUnavailableReason = null;
85
112
  this.runtimeLeases = new Map();
86
113
  this.previewLeaseReleases = new Map();
114
+ this.terminalHolds = new Map();
87
115
  this.recoveryTasks = new Map();
88
116
  this.shuttingDown = false;
89
117
  }
@@ -106,12 +134,36 @@ export class WorkRuntimeService {
106
134
  await this.docker(['info', '--format', '{{.ServerVersion}}'], {
107
135
  timeoutMs: 5000,
108
136
  });
137
+ this.lastDockerUnavailableReason = null;
109
138
  return true;
110
139
  }
111
- catch {
140
+ catch (error) {
141
+ this.lastDockerUnavailableReason = describeDockerUnavailable(error, config.dockerCommand);
112
142
  return false;
113
143
  }
114
144
  }
145
+ /**
146
+ * Why the last availability probe failed. "Docker is not available" cannot be
147
+ * acted on: a containerized deployment fails for a missing CLI, an unreadable
148
+ * socket, or a stopped daemon, and each needs a different change.
149
+ */
150
+ get dockerUnavailableReason() {
151
+ return this.lastDockerUnavailableReason;
152
+ }
153
+ /**
154
+ * Live runtime occupancy, so the interface can say "1 of 2 runtimes in use"
155
+ * instead of surprising the administrator with a 429 at submit time.
156
+ */
157
+ activeRuntimeCounts(userId) {
158
+ let user = 0;
159
+ if (userId) {
160
+ for (const lease of this.runtimeLeases.values()) {
161
+ if (lease.userId === userId)
162
+ user += 1;
163
+ }
164
+ }
165
+ return { global: this.runtimeLeases.size, user };
166
+ }
115
167
  async beginRecovery(tasks) {
116
168
  for (const task of tasks) {
117
169
  this.recoveryTasks.set(task.id, task);
@@ -206,6 +258,46 @@ export class WorkRuntimeService {
206
258
  throw error;
207
259
  }
208
260
  }
261
+ /**
262
+ * Hold the task container up for an interactive terminal session. The hold
263
+ * goes through the same admission (runtime lease), policy verification, and
264
+ * container preparation as every other runtime operation, and keeps the
265
+ * idle-stop path from tearing the container down while a shell is attached.
266
+ */
267
+ async acquireTerminalHold(task) {
268
+ if (this.activeCommands.has(task.id)) {
269
+ 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');
270
+ }
271
+ const releaseLease = await this.prepare(task);
272
+ this.terminalHolds.set(task.id, (this.terminalHolds.get(task.id) ?? 0) + 1);
273
+ let released = false;
274
+ return async () => {
275
+ if (released)
276
+ return;
277
+ released = true;
278
+ const remaining = (this.terminalHolds.get(task.id) ?? 1) - 1;
279
+ if (remaining <= 0) {
280
+ this.terminalHolds.delete(task.id);
281
+ }
282
+ else {
283
+ this.terminalHolds.set(task.id, remaining);
284
+ }
285
+ try {
286
+ if (remaining <= 0 && !this.shuttingDown) {
287
+ await this.withLifecycleLock(task.id, () => this.stopContainerIfIdleWithLock(task));
288
+ }
289
+ }
290
+ catch (error) {
291
+ logger.warn(`Could not idle Work container ${task.containerName} after terminal session:`, error);
292
+ }
293
+ finally {
294
+ releaseLease();
295
+ }
296
+ };
297
+ }
298
+ terminalSessionCount(taskId) {
299
+ return this.terminalHolds.get(taskId) ?? 0;
300
+ }
209
301
  beginShutdown() {
210
302
  this.shuttingDown = true;
211
303
  if (this.recoveryTimer) {
@@ -302,8 +394,39 @@ export class WorkRuntimeService {
302
394
  this.assertTaskIsActive(task);
303
395
  }
304
396
  }
397
+ async ensureWorkNetwork(task) {
398
+ if (!task.networkEnabled)
399
+ return;
400
+ const inspect = await this.docker([
401
+ 'network',
402
+ 'inspect',
403
+ '--format',
404
+ '{{index .Labels "ai.libre-webui.managed"}} {{index .Options "com.docker.network.bridge.enable_icc"}}',
405
+ config.networkName,
406
+ ], { acceptFailure: true });
407
+ if (inspect.exitCode === 0) {
408
+ const [managed, icc] = inspect.stdout.trim().split(' ');
409
+ if (managed !== 'true' || icc !== 'false') {
410
+ 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');
411
+ }
412
+ return;
413
+ }
414
+ const created = await this.docker([
415
+ 'network',
416
+ 'create',
417
+ '--label',
418
+ 'ai.libre-webui.managed=true',
419
+ '--opt',
420
+ 'com.docker.network.bridge.enable_icc=false',
421
+ config.networkName,
422
+ ], { acceptFailure: true });
423
+ if (created.exitCode !== 0 && !/already exists/i.test(created.stderr)) {
424
+ throw new WorkRuntimeError(`Could not create the Work sandbox network "${config.networkName}": ${created.stderr.trim()}`, 503, 'WORK_NETWORK_UNAVAILABLE');
425
+ }
426
+ }
305
427
  async ensureContainer(task) {
306
428
  this.assertRuntimeLease(task);
429
+ await this.ensureWorkNetwork(task);
307
430
  if (await this.containerExists(task.containerName)) {
308
431
  await this.assertManagedContainer(task);
309
432
  if (!(await this.containerMatchesTaskPolicy(task))) {
@@ -518,6 +641,10 @@ export class WorkRuntimeService {
518
641
  async stopContainerIfIdleWithLock(task) {
519
642
  if (this.activeCommands.has(task.id))
520
643
  return;
644
+ // An attached terminal session owns the running container exactly like a
645
+ // ready preview does.
646
+ if ((this.terminalHolds.get(task.id) ?? 0) > 0)
647
+ return;
521
648
  try {
522
649
  if ((await this.previewProcessCheckWithLock(task)) === 'ready' &&
523
650
  this.previewLeaseReleases.has(task.id)) {
@@ -579,6 +706,59 @@ export class WorkRuntimeService {
579
706
  };
580
707
  });
581
708
  }
709
+ /**
710
+ * Delete a workspace file, symlink, or directory. Directories require an
711
+ * explicit recursive flag, and the workspace root is never deletable. Runs
712
+ * through the helper container, so it also works while a preview holds the
713
+ * task container.
714
+ */
715
+ async deletePath(task, requestedPath, recursive = false) {
716
+ const workspacePath = validateWorkspacePath(requestedPath, false);
717
+ return this.withWorkspaceHelperContainer(task, async () => {
718
+ const result = await this.exec(task, [
719
+ 'node',
720
+ '-e',
721
+ DELETE_PATH_SCRIPT,
722
+ '--',
723
+ workspacePath,
724
+ recursive ? 'recursive' : '',
725
+ ], { maxOutputChars: 10000, acceptFailure: true });
726
+ if (result.exitCode === 21) {
727
+ throw new WorkRuntimeError(`No file or directory exists at ${workspacePath}.`, 404, 'WORK_FILE_NOT_FOUND');
728
+ }
729
+ if (result.exitCode === 22) {
730
+ throw new WorkRuntimeError(`${workspacePath} is a directory. Pass recursive: true to delete it with its contents.`, 409, 'WORK_DELETE_REQUIRES_RECURSIVE');
731
+ }
732
+ if (result.exitCode !== 0) {
733
+ throw new WorkRuntimeError(result.stderr.trim() || 'Could not delete the workspace path.', 503, 'WORK_FILE_DELETE_FAILED');
734
+ }
735
+ const payload = parseJsonOutput(result.stdout);
736
+ return { path: workspacePath, type: payload.type };
737
+ });
738
+ }
739
+ /**
740
+ * Move or rename a workspace file or directory. Destination parents are
741
+ * created inside the workspace, an existing destination is never
742
+ * overwritten, and like deletePath this works while a preview is running.
743
+ */
744
+ async movePath(task, requestedFrom, requestedTo) {
745
+ const fromPath = validateWorkspacePath(requestedFrom, false);
746
+ const toPath = validateWorkspacePath(requestedTo, false);
747
+ return this.withWorkspaceHelperContainer(task, async () => {
748
+ const result = await this.exec(task, ['node', '-e', MOVE_PATH_SCRIPT, '--', fromPath, toPath], { maxOutputChars: 10000, acceptFailure: true });
749
+ if (result.exitCode === 21) {
750
+ throw new WorkRuntimeError(`No file or directory exists at ${fromPath}.`, 404, 'WORK_FILE_NOT_FOUND');
751
+ }
752
+ if (result.exitCode === 24) {
753
+ throw new WorkRuntimeError(`${toPath} already exists. Delete it first or choose another destination.`, 409, 'WORK_DESTINATION_EXISTS');
754
+ }
755
+ if (result.exitCode !== 0) {
756
+ throw new WorkRuntimeError(result.stderr.trim() || 'Could not move the workspace path.', 503, 'WORK_FILE_MOVE_FAILED');
757
+ }
758
+ parseJsonOutput(result.stdout);
759
+ return { from: fromPath, to: toPath };
760
+ });
761
+ }
582
762
  async searchFiles(task, query, requestedPath = '.') {
583
763
  const workspacePath = validateWorkspacePath(requestedPath);
584
764
  if (!query.trim() || query.length > 500) {
@@ -832,7 +1012,7 @@ export class WorkRuntimeService {
832
1012
  if (!publishedPort) {
833
1013
  throw new WorkRuntimeError('Docker did not publish the preview port.', 503, 'WORK_PREVIEW_PORT_UNAVAILABLE');
834
1014
  }
835
- return `http://127.0.0.1:${publishedPort}`;
1015
+ return `http://${formatPreviewHost(config.previewHost)}:${publishedPort}`;
836
1016
  }
837
1017
  catch (error) {
838
1018
  try {
@@ -1072,7 +1252,7 @@ export class WorkRuntimeService {
1072
1252
  ? inspected.Mounts.map(objectRecord)
1073
1253
  : [];
1074
1254
  const workspaceMount = mounts.find(mount => mount.Destination === '/workspace');
1075
- const expectedNetwork = task.networkEnabled ? 'bridge' : 'none';
1255
+ const expectedNetwork = task.networkEnabled ? config.networkName : 'none';
1076
1256
  const portBindings = objectRecord(hostConfig.PortBindings);
1077
1257
  const previewBindings = Array.isArray(portBindings[`${config.previewPort}/tcp`])
1078
1258
  ? portBindings[`${config.previewPort}/tcp`].map(objectRecord)
@@ -1080,7 +1260,7 @@ export class WorkRuntimeService {
1080
1260
  const portPolicyMatches = task.networkEnabled
1081
1261
  ? Object.keys(portBindings).length === 1 &&
1082
1262
  previewBindings.length === 1 &&
1083
- previewBindings[0]?.HostIp === '127.0.0.1'
1263
+ previewBindings[0]?.HostIp === config.previewBind
1084
1264
  : Object.keys(portBindings).length === 0;
1085
1265
  return (labels['ai.libre-webui.managed'] === 'true' &&
1086
1266
  labels['ai.libre-webui.task'] === task.id &&
@@ -1097,6 +1277,7 @@ export class WorkRuntimeService {
1097
1277
  securityOpt.includes('no-new-privileges') &&
1098
1278
  Number(hostConfig.PidsLimit) === config.pidsLimit &&
1099
1279
  Number(hostConfig.Memory) > 0 &&
1280
+ Number(hostConfig.MemorySwap) === Number(hostConfig.Memory) &&
1100
1281
  Number(hostConfig.NanoCpus) > 0 &&
1101
1282
  hostConfig.NetworkMode === expectedNetwork &&
1102
1283
  portPolicyMatches &&
@@ -1196,6 +1377,31 @@ export class WorkRuntimeService {
1196
1377
  }
1197
1378
  }
1198
1379
  }
1380
+ /** Bracket a bare IPv6 literal so it can carry a port in a URL. */
1381
+ export function formatPreviewHost(host) {
1382
+ return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
1383
+ }
1384
+ /**
1385
+ * Turn a failed `docker info` into the change an operator has to make. These
1386
+ * are the three ways a containerized Libre WebUI fails to reach the daemon:
1387
+ * the image has no CLI, the bind-mounted socket is owned by a group the
1388
+ * backend user is not in, or no daemon is listening.
1389
+ */
1390
+ export function describeDockerUnavailable(error, dockerCommand = config.dockerCommand) {
1391
+ const message = error instanceof Error ? error.message : String(error);
1392
+ if (/ENOENT/.test(message)) {
1393
+ return `The "${dockerCommand}" CLI is not installed in the Libre WebUI runtime. Run an image that ships the Docker CLI and mount the host Docker socket, or point WORK_DOCKER_COMMAND at the CLI path.`;
1394
+ }
1395
+ if (/EACCES/.test(message) || /permission denied/i.test(message)) {
1396
+ return 'The Docker socket is mounted but the Libre WebUI user cannot open it. Add the backend user to the group that owns /var/run/docker.sock (Compose: group_add with the socket GID).';
1397
+ }
1398
+ if (/cannot connect to the docker daemon/i.test(message) ||
1399
+ /is the docker daemon running/i.test(message) ||
1400
+ /no such file or directory/i.test(message)) {
1401
+ return 'No Docker daemon is reachable. Start Docker, or mount the host socket into the Libre WebUI container with -v /var/run/docker.sock:/var/run/docker.sock.';
1402
+ }
1403
+ return message;
1404
+ }
1199
1405
  export function buildWorkContainerRunArgs(task, image = config.image) {
1200
1406
  const args = [
1201
1407
  'run',
@@ -1228,15 +1434,25 @@ export function buildWorkContainerRunArgs(task, image = config.image) {
1228
1434
  String(config.pidsLimit),
1229
1435
  '--memory',
1230
1436
  config.memoryLimit,
1437
+ // Same value as --memory: the memory cap cannot be sidestepped via swap.
1438
+ '--memory-swap',
1439
+ config.memoryLimit,
1231
1440
  '--cpus',
1232
1441
  config.cpuLimit,
1233
1442
  '--network',
1234
- task.networkEnabled ? 'bridge' : 'none',
1443
+ task.networkEnabled ? config.networkName : 'none',
1235
1444
  ];
1236
1445
  if (task.networkEnabled) {
1237
- args.push('--publish', `127.0.0.1::${config.previewPort}`);
1446
+ args.push('--publish', `${config.previewBind}::${config.previewPort}`);
1447
+ for (const server of config.dnsServers) {
1448
+ args.push('--dns', server);
1449
+ }
1238
1450
  }
1239
- args.push('--mount', `type=volume,src=${task.volumeName},dst=/workspace,volume-nocopy`, image, 'tail', '-f', '/dev/null');
1451
+ args.push('--mount', task.hostPath
1452
+ ? // Host-folder workspaces are opt-in per deployment and validated
1453
+ // against an allowlist before ever reaching this point.
1454
+ `type=bind,src=${task.hostPath},dst=/workspace`
1455
+ : `type=volume,src=${task.volumeName},dst=/workspace,volume-nocopy`, image, 'tail', '-f', '/dev/null');
1240
1456
  return args;
1241
1457
  }
1242
1458
  export function validateWorkspacePath(input, allowRoot = true) {
@@ -1256,14 +1472,19 @@ export function validateWorkspacePath(input, allowRoot = true) {
1256
1472
  }
1257
1473
  return canonical;
1258
1474
  }
1259
- export function parsePublishedPort(output, _containerPort = config.previewPort) {
1475
+ export function parsePublishedPort(output, _containerPort = config.previewPort, bindAddress = config.previewBind) {
1476
+ // Only a binding on the interface this deployment asked for counts. The
1477
+ // default stays loopback, so a stray wildcard binding is still rejected.
1478
+ const allowed = new Set(bindAddress === WORK_RUNTIME_DEFAULTS.previewBind
1479
+ ? ['127.0.0.1', '[::1]']
1480
+ : [bindAddress, formatPreviewHost(bindAddress)]);
1260
1481
  for (const line of output.trim().split(/\r?\n/)) {
1261
- const match = line.match(/^(?:127\.0\.0\.1|\[::1\]):(\d+)$/);
1262
- if (match) {
1263
- const port = Number(match[1]);
1264
- if (Number.isInteger(port) && port > 0 && port <= 65535)
1265
- return port;
1266
- }
1482
+ const match = line.match(/^(.*):(\d+)$/);
1483
+ if (!match || !allowed.has(match[1]))
1484
+ continue;
1485
+ const port = Number(match[2]);
1486
+ if (Number.isInteger(port) && port > 0 && port <= 65535)
1487
+ return port;
1267
1488
  }
1268
1489
  return undefined;
1269
1490
  }
@@ -1392,6 +1613,21 @@ function positiveInteger(value, fallback) {
1392
1613
  const parsed = Number(value);
1393
1614
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
1394
1615
  }
1616
+ export function parseDnsServers(value) {
1617
+ if (!value)
1618
+ return [];
1619
+ return value
1620
+ .split(',')
1621
+ .map(server => server.trim())
1622
+ .filter(server => {
1623
+ if (!server)
1624
+ return false;
1625
+ if (/^[0-9a-fA-F:.]+$/.test(server))
1626
+ return true;
1627
+ logger.warn(`Ignoring WORK_RUNTIME_DNS entry "${server}": not an IPv4/IPv6 address.`);
1628
+ return false;
1629
+ });
1630
+ }
1395
1631
  function objectRecord(value) {
1396
1632
  return value && typeof value === 'object'
1397
1633
  ? value
@@ -1827,6 +2063,64 @@ process.stdin.on('end', () => {
1827
2063
  console.log(JSON.stringify({size:stat.size, updatedAt:stat.mtimeMs}));
1828
2064
  });
1829
2065
  `;
2066
+ const DELETE_PATH_SCRIPT = `${COMMON_PATH_GUARD}
2067
+ if (target === rootReal) throw new Error('Refusing to delete the workspace root');
2068
+ const parentReal = fs.realpathSync(path.dirname(target));
2069
+ if (!inside(parentReal)) throw new Error('Path escapes workspace');
2070
+ const safeTarget = path.join(parentReal, path.basename(target));
2071
+ let stat;
2072
+ try { stat = fs.lstatSync(safeTarget); } catch { process.exit(21); }
2073
+ const recursive = process.argv[2] === 'recursive';
2074
+ let type;
2075
+ if (stat.isSymbolicLink()) {
2076
+ type = 'symlink';
2077
+ fs.unlinkSync(safeTarget);
2078
+ } else if (stat.isDirectory()) {
2079
+ if (!recursive) process.exit(22);
2080
+ type = 'directory';
2081
+ fs.rmSync(safeTarget, {recursive: true});
2082
+ } else {
2083
+ type = 'file';
2084
+ fs.unlinkSync(safeTarget);
2085
+ }
2086
+ process.stdout.write(JSON.stringify({type}));
2087
+ `;
2088
+ const MOVE_PATH_SCRIPT = `${COMMON_PATH_GUARD}
2089
+ const destRel = process.argv[2] || '';
2090
+ if (!destRel) throw new Error('Destination path is required');
2091
+ const destTarget = path.resolve(root, destRel);
2092
+ if (!inside(destTarget)) throw new Error('Path escapes workspace');
2093
+ if (target === rootReal || destTarget === rootReal) {
2094
+ throw new Error('Refusing to move the workspace root');
2095
+ }
2096
+ const sourceParentReal = fs.realpathSync(path.dirname(target));
2097
+ if (!inside(sourceParentReal)) throw new Error('Path escapes workspace');
2098
+ const safeSource = path.join(sourceParentReal, path.basename(target));
2099
+ let sourceStat;
2100
+ try { sourceStat = fs.lstatSync(safeSource); } catch { process.exit(21); }
2101
+ const destParent = path.dirname(destTarget);
2102
+ const destParts = path.relative(root, destParent).split(path.sep).filter(Boolean);
2103
+ let destParentReal = rootReal;
2104
+ for (const part of destParts) {
2105
+ const next = path.join(destParentReal, part);
2106
+ if (!fs.existsSync(next)) fs.mkdirSync(next, {mode:0o755});
2107
+ if (fs.lstatSync(next).isSymbolicLink()) throw new Error('Refusing to traverse symlink');
2108
+ destParentReal = fs.realpathSync(next);
2109
+ if (!inside(destParentReal) || !fs.statSync(destParentReal).isDirectory()) {
2110
+ throw new Error('Path escapes workspace');
2111
+ }
2112
+ }
2113
+ const safeDest = path.join(destParentReal, path.basename(destTarget));
2114
+ let destTaken = false;
2115
+ try { fs.lstatSync(safeDest); destTaken = true; } catch {}
2116
+ if (destTaken) process.exit(24);
2117
+ if (safeSource === safeDest) throw new Error('Source and destination are the same path');
2118
+ if (sourceStat.isDirectory() && safeDest.startsWith(safeSource + path.sep)) {
2119
+ throw new Error('Cannot move a directory inside itself');
2120
+ }
2121
+ fs.renameSync(safeSource, safeDest);
2122
+ process.stdout.write(JSON.stringify({moved: true}));
2123
+ `;
1830
2124
  const SEARCH_FILES_SCRIPT = `${COMMON_PATH_GUARD}
1831
2125
  const query = String(process.argv[2] || '').toLowerCase();
1832
2126
  const targetReal = fs.realpathSync(target);