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.
- package/README.md +237 -149
- package/backend/dist/db.d.ts.map +1 -1
- package/backend/dist/db.js +5 -0
- package/backend/dist/db.js.map +1 -1
- package/backend/dist/index.d.ts.map +1 -1
- package/backend/dist/index.js +2 -0
- package/backend/dist/index.js.map +1 -1
- package/backend/dist/routes/agentCli.d.ts +3 -0
- package/backend/dist/routes/agentCli.d.ts.map +1 -0
- package/backend/dist/routes/agentCli.js +33 -0
- package/backend/dist/routes/agentCli.js.map +1 -0
- package/backend/dist/routes/chat.d.ts.map +1 -1
- package/backend/dist/routes/chat.js +27 -0
- package/backend/dist/routes/chat.js.map +1 -1
- package/backend/dist/routes/plugins.d.ts.map +1 -1
- package/backend/dist/routes/plugins.js +10 -3
- package/backend/dist/routes/plugins.js.map +1 -1
- package/backend/dist/routes/work.d.ts.map +1 -1
- package/backend/dist/routes/work.js +25 -3
- package/backend/dist/routes/work.js.map +1 -1
- package/backend/dist/services/agentCliService.d.ts +30 -0
- package/backend/dist/services/agentCliService.d.ts.map +1 -0
- package/backend/dist/services/agentCliService.js +328 -0
- package/backend/dist/services/agentCliService.js.map +1 -0
- package/backend/dist/services/chatGenerationService.d.ts.map +1 -1
- package/backend/dist/services/chatGenerationService.js +19 -1
- package/backend/dist/services/chatGenerationService.js.map +1 -1
- package/backend/dist/services/pluginService.d.ts +42 -1
- package/backend/dist/services/pluginService.d.ts.map +1 -1
- package/backend/dist/services/pluginService.js +143 -12
- package/backend/dist/services/pluginService.js.map +1 -1
- package/backend/dist/services/workAgentGuidance.js +1 -1
- package/backend/dist/services/workAgentGuidance.js.map +1 -1
- package/backend/dist/services/workAgentService.d.ts.map +1 -1
- package/backend/dist/services/workAgentService.js +37 -0
- package/backend/dist/services/workAgentService.js.map +1 -1
- package/backend/dist/services/workHostWorkspaceService.d.ts +22 -0
- package/backend/dist/services/workHostWorkspaceService.d.ts.map +1 -0
- package/backend/dist/services/workHostWorkspaceService.js +136 -0
- package/backend/dist/services/workHostWorkspaceService.js.map +1 -0
- package/backend/dist/services/workRuntimeService.d.ts +61 -3
- package/backend/dist/services/workRuntimeService.d.ts.map +1 -1
- package/backend/dist/services/workRuntimeService.js +311 -17
- package/backend/dist/services/workRuntimeService.js.map +1 -1
- package/backend/dist/services/workTaskService.d.ts +3 -3
- package/backend/dist/services/workTaskService.d.ts.map +1 -1
- package/backend/dist/services/workTaskService.js +10 -6
- package/backend/dist/services/workTaskService.js.map +1 -1
- package/backend/dist/services/workTerminalService.d.ts +46 -0
- package/backend/dist/services/workTerminalService.d.ts.map +1 -0
- package/backend/dist/services/workTerminalService.js +241 -0
- package/backend/dist/services/workTerminalService.js.map +1 -0
- package/backend/dist/types/index.d.ts +1 -1
- package/backend/dist/types/index.d.ts.map +1 -1
- package/backend/dist/types/work.d.ts +22 -0
- package/backend/dist/types/work.d.ts.map +1 -1
- package/backend/dist/utils/chatProviderSelection.d.ts.map +1 -1
- package/backend/dist/utils/chatProviderSelection.js +7 -5
- package/backend/dist/utils/chatProviderSelection.js.map +1 -1
- package/backend/dist/websocketServer.d.ts.map +1 -1
- package/backend/dist/websocketServer.js +51 -14
- package/backend/dist/websocketServer.js.map +1 -1
- package/backend/dist/workTerminalServer.d.ts +17 -0
- package/backend/dist/workTerminalServer.d.ts.map +1 -0
- package/backend/dist/workTerminalServer.js +224 -0
- package/backend/dist/workTerminalServer.js.map +1 -0
- package/backend/package.json +1 -1
- package/frontend/dist/assets/index-BqI0kezz.css +1 -0
- package/frontend/dist/assets/index-CRC6g6bL.js +3 -0
- package/frontend/dist/assets/xterm-CmMoOwwu.css +1 -0
- package/frontend/dist/index.html +8 -6
- package/frontend/dist/js/ArtifactContainer-Cdy-973y.js +1 -0
- package/frontend/dist/js/{ArtifactDemoPage-DBVPHFxk.js → ArtifactDemoPage-B4qsvUnF.js} +1 -1
- package/frontend/dist/js/ArtifactSlideOutPanel-CmKgQ4eD.js +1 -0
- package/frontend/dist/js/{Button-DBk-AREB.js → Button-CRwzOaDZ.js} +1 -1
- package/frontend/dist/js/ChatPage-CYT69oBR.js +16 -0
- package/frontend/dist/js/FirstTimeSetup-C8LdLP5J.js +1 -0
- package/frontend/dist/js/GalleryPage-Bm89pnS8.js +1 -0
- package/frontend/dist/js/HomePage-CeCYnMUG.js +1 -0
- package/frontend/dist/js/{LibreClawPage-qGr1Jy8p.js → LibreClawPage-DGMEV6dm.js} +1 -1
- package/frontend/dist/js/{ModelManagerModals-DltRprHU.js → ModelManagerModals-DMoEav1l.js} +1 -1
- package/frontend/dist/js/ModelSelector-DXPdGdQ_.js +1 -0
- package/frontend/dist/js/ModelsPage-D-FIy2x-.js +2 -0
- package/frontend/dist/js/PersonasPage-CIrEVFo5.js +13 -0
- package/frontend/dist/js/RichMessageContent-BJ49VN9j.js +3 -0
- package/frontend/dist/js/SettingsModal-CxtOXPY7.js +1 -0
- package/frontend/dist/js/UserManagementPage-BfyYGGFQ.js +1 -0
- package/frontend/dist/js/WorkPage-C7bW6Hxx.js +4 -0
- package/frontend/dist/js/addon-fit-Cj0_dfHF.js +1 -0
- package/frontend/dist/js/api-DCD41-l9.js +19 -0
- package/frontend/dist/js/appStore-Bw-8Exsx.js +2 -0
- package/frontend/dist/js/ar-BAHlIvy0.js +2 -0
- package/frontend/dist/js/authStore-mOYH_AXI.js +4 -0
- package/frontend/dist/js/bn-BxquDVbd.js +2 -0
- package/frontend/dist/js/{cs-6UKhKj0A.js → cs-C4DoKzRm.js} +2 -2
- package/frontend/dist/js/da-CFslpGTw.js +2 -0
- package/frontend/dist/js/de-ChudoRh2.js +2 -0
- package/frontend/dist/js/es-B36fefE-.js +2 -0
- package/frontend/dist/js/{estree-PDLT672k.js → estree-0vwU39qX.js} +1 -1
- package/frontend/dist/js/fr-BSVeTM5W.js +2 -0
- package/frontend/dist/js/hi-BGoFbl5V.js +2 -0
- package/frontend/dist/js/{html-CkhF9wDQ.js → html-CApr2Bvi.js} +1 -1
- package/frontend/dist/js/id-BfYs2p_j.js +2 -0
- package/frontend/dist/js/{is-BWSbAIoq.js → is-DZ1MS-5A.js} +2 -2
- package/frontend/dist/js/it-CXtrPCG1.js +2 -0
- package/frontend/dist/js/ja-FPJ_NK_y.js +2 -0
- package/frontend/dist/js/ko-DVwD4ZZF.js +2 -0
- package/frontend/dist/js/{markdown-B1iNUbse.js → markdown-C78Qc6xu.js} +1 -1
- package/frontend/dist/js/ms-PKPP0RX3.js +2 -0
- package/frontend/dist/js/nl-DWM5HJZ8.js +2 -0
- package/frontend/dist/js/pl-B9va8Tuf.js +2 -0
- package/frontend/dist/js/pt-P0IQi-Zr.js +2 -0
- package/frontend/dist/js/ru-Ca2bxYnc.js +2 -0
- package/frontend/dist/js/{standalone-CCd6rkf-.js → standalone-DmLfI4Qj.js} +1 -1
- package/frontend/dist/js/sv-DI4RDwyZ.js +2 -0
- package/frontend/dist/js/th-DN0bDS9d.js +2 -0
- package/frontend/dist/js/tr-ChqMv5Q4.js +2 -0
- package/frontend/dist/js/{typescript-DKJ1uM5L.js → typescript-Bcq62agC.js} +1 -1
- package/frontend/dist/js/{ui-vendor-CAFEDSaO.js → ui-vendor-DohPFHDW.js} +1 -1
- package/frontend/dist/js/uk-Cr51h_XQ.js +2 -0
- package/frontend/dist/js/useBaseQuery-BAOlhjar.js +1 -0
- package/frontend/dist/js/useQuery-CcRqxveh.js +1 -0
- package/frontend/dist/js/utils-DmZD5uA3.js +1 -0
- package/frontend/dist/js/{vi-82JkwFsQ.js → vi-D4OhoVMH.js} +2 -2
- package/frontend/dist/js/work-DVgm81bJ.js +1 -0
- package/frontend/dist/js/xterm-DKl1QLiw.js +7 -0
- package/frontend/dist/js/zh-Bvau7hlR.js +2 -0
- package/package.json +3 -3
- package/frontend/dist/assets/index-CKV9Lnj8.js +0 -5
- package/frontend/dist/assets/index-Dme2Ozol.css +0 -1
- package/frontend/dist/js/ArtifactContainer-B0uVR8Z9.js +0 -1
- package/frontend/dist/js/ArtifactSlideOutPanel-Cg5kAwv9.js +0 -1
- package/frontend/dist/js/ChatPage-CPHlIZqU.js +0 -16
- package/frontend/dist/js/FirstTimeSetup-DksL5arK.js +0 -1
- package/frontend/dist/js/GalleryPage-1mdUGo_N.js +0 -1
- package/frontend/dist/js/ModelSelector-CUnwRmNU.js +0 -1
- package/frontend/dist/js/ModelsPage-DMYVg3LT.js +0 -2
- package/frontend/dist/js/PersonasPage-Ct-KKW-j.js +0 -13
- package/frontend/dist/js/RichMessageContent-CGBAcUES.js +0 -3
- package/frontend/dist/js/SettingsModal-BVj3gFCS.js +0 -1
- package/frontend/dist/js/UserManagementPage-D3tLGtTu.js +0 -1
- package/frontend/dist/js/WorkPage-DhTjy-kf.js +0 -2
- package/frontend/dist/js/api-YWoSobKv.js +0 -19
- package/frontend/dist/js/ar-KceI0PfT.js +0 -2
- package/frontend/dist/js/bn-DsKo2dzS.js +0 -2
- package/frontend/dist/js/da-CJgZ3YuL.js +0 -2
- package/frontend/dist/js/de-BZAmOuCT.js +0 -2
- package/frontend/dist/js/es-CD4u7no9.js +0 -2
- package/frontend/dist/js/fr-Cou2jOS3.js +0 -2
- package/frontend/dist/js/hi-ZxkbNCyB.js +0 -2
- package/frontend/dist/js/id-DT9CqaWQ.js +0 -2
- package/frontend/dist/js/it-CdNoTXm3.js +0 -2
- package/frontend/dist/js/ja-CIuQg0xv.js +0 -2
- package/frontend/dist/js/ko-BWRUjDOH.js +0 -2
- package/frontend/dist/js/ms-gJstIj2a.js +0 -2
- package/frontend/dist/js/nl-CydjVNUw.js +0 -2
- package/frontend/dist/js/pl-DPN9mAa5.js +0 -2
- package/frontend/dist/js/pt-MRXGqIjQ.js +0 -2
- package/frontend/dist/js/ru-CvQ9GkJF.js +0 -2
- package/frontend/dist/js/sv-BxS2fvhU.js +0 -2
- package/frontend/dist/js/th-DMxyn6e4.js +0 -2
- package/frontend/dist/js/tr-BTPldUaA.js +0 -2
- package/frontend/dist/js/uk-BGxZOKgp.js +0 -2
- package/frontend/dist/js/useBaseQuery-MuQj7vA5.js +0 -1
- package/frontend/dist/js/useQuery-CYGPkxWR.js +0 -1
- package/frontend/dist/js/utils-pTFqSV8c.js +0 -1
- 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:
|
|
37
|
-
maxActiveRuntimesPerUser:
|
|
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:
|
|
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
|
|
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 ?
|
|
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 ===
|
|
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 ?
|
|
1443
|
+
task.networkEnabled ? config.networkName : 'none',
|
|
1235
1444
|
];
|
|
1236
1445
|
if (task.networkEnabled) {
|
|
1237
|
-
args.push('--publish',
|
|
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',
|
|
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(/^(
|
|
1262
|
-
if (match)
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
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);
|