libre-webui 0.19.6 → 0.20.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 +2 -2
- package/backend/dist/db.d.ts.map +1 -1
- package/backend/dist/db.js +29 -0
- package/backend/dist/db.js.map +1 -1
- package/backend/dist/index.d.ts.map +1 -1
- package/backend/dist/index.js +12 -10
- package/backend/dist/index.js.map +1 -1
- package/backend/dist/routes/work.d.ts.map +1 -1
- package/backend/dist/routes/work.js +138 -4
- package/backend/dist/routes/work.js.map +1 -1
- package/backend/dist/services/systemDiagnosticsService.d.ts +4 -3
- package/backend/dist/services/systemDiagnosticsService.d.ts.map +1 -1
- package/backend/dist/services/systemDiagnosticsService.js +35 -34
- package/backend/dist/services/systemDiagnosticsService.js.map +1 -1
- package/backend/dist/services/workAccessService.d.ts +15 -0
- package/backend/dist/services/workAccessService.d.ts.map +1 -0
- package/backend/dist/services/workAccessService.js +67 -0
- package/backend/dist/services/workAccessService.js.map +1 -0
- package/backend/dist/services/workAdminService.d.ts +67 -0
- package/backend/dist/services/workAdminService.d.ts.map +1 -0
- package/backend/dist/services/workAdminService.js +92 -0
- package/backend/dist/services/workAdminService.js.map +1 -0
- package/backend/dist/services/workKubernetesDriver.d.ts +74 -0
- package/backend/dist/services/workKubernetesDriver.d.ts.map +1 -0
- package/backend/dist/services/workKubernetesDriver.js +659 -0
- package/backend/dist/services/workKubernetesDriver.js.map +1 -0
- package/backend/dist/services/workPolicyService.d.ts +58 -0
- package/backend/dist/services/workPolicyService.d.ts.map +1 -0
- package/backend/dist/services/workPolicyService.js +249 -0
- package/backend/dist/services/workPolicyService.js.map +1 -0
- package/backend/dist/services/workPreviewProxyService.d.ts +7 -1
- package/backend/dist/services/workPreviewProxyService.d.ts.map +1 -1
- package/backend/dist/services/workPreviewProxyService.js +36 -8
- package/backend/dist/services/workPreviewProxyService.js.map +1 -1
- package/backend/dist/services/workRuntimeDriver.d.ts +166 -0
- package/backend/dist/services/workRuntimeDriver.d.ts.map +1 -0
- package/backend/dist/services/workRuntimeDriver.js +737 -0
- package/backend/dist/services/workRuntimeDriver.js.map +1 -0
- package/backend/dist/services/workRuntimeService.d.ts +60 -51
- package/backend/dist/services/workRuntimeService.d.ts.map +1 -1
- package/backend/dist/services/workRuntimeService.js +310 -658
- package/backend/dist/services/workRuntimeService.js.map +1 -1
- package/backend/dist/services/workRuntimeShared.d.ts +83 -0
- package/backend/dist/services/workRuntimeShared.d.ts.map +1 -0
- package/backend/dist/services/workRuntimeShared.js +136 -0
- package/backend/dist/services/workRuntimeShared.js.map +1 -0
- package/backend/dist/services/workTaskService.d.ts +6 -1
- package/backend/dist/services/workTaskService.d.ts.map +1 -1
- package/backend/dist/services/workTaskService.js +18 -3
- package/backend/dist/services/workTaskService.js.map +1 -1
- package/backend/dist/services/workTerminalService.d.ts +2 -22
- package/backend/dist/services/workTerminalService.d.ts.map +1 -1
- package/backend/dist/services/workTerminalService.js +10 -127
- package/backend/dist/services/workTerminalService.js.map +1 -1
- package/backend/dist/types/work.d.ts +4 -0
- package/backend/dist/types/work.d.ts.map +1 -1
- package/backend/dist/utils/dockerEndpoint.d.ts +37 -0
- package/backend/dist/utils/dockerEndpoint.d.ts.map +1 -0
- package/backend/dist/utils/dockerEndpoint.js +72 -0
- package/backend/dist/utils/dockerEndpoint.js.map +1 -0
- package/backend/dist/workTerminalServer.d.ts.map +1 -1
- package/backend/dist/workTerminalServer.js +6 -5
- package/backend/dist/workTerminalServer.js.map +1 -1
- package/backend/package.json +2 -1
- package/frontend/dist/artifact-runtime/.build-fingerprint +1 -1
- package/frontend/dist/assets/index-Z4-oLWDC.js +2 -0
- package/frontend/dist/assets/index-tBUyDbHh.css +1 -0
- package/frontend/dist/index.html +11 -10
- package/frontend/dist/js/{ArtifactContainer-Dy_1KKWg.js → ArtifactContainer-DQRF4rh-.js} +1 -1
- package/frontend/dist/js/{ArtifactDemoPage-Big00RwH.js → ArtifactDemoPage-BOlB1lS-.js} +1 -1
- package/frontend/dist/js/{ArtifactSandboxFrame-C_S4j076.js → ArtifactSandboxFrame-DqU5GDrI.js} +2 -2
- package/frontend/dist/js/{ArtifactSlideOutPanel-B4-D3HkB.js → ArtifactSlideOutPanel-B0I-hCux.js} +1 -1
- package/frontend/dist/js/ChatPage-DyV0_oCt.js +16 -0
- package/frontend/dist/js/{FirstTimeSetup-C6YxrwR7.js → FirstTimeSetup-ButTmjp0.js} +1 -1
- package/frontend/dist/js/{GalleryPage-BtcPs7SO.js → GalleryPage-CaT63b-s.js} +1 -1
- package/frontend/dist/js/HomePage-BWyg3nHu.js +1 -0
- package/frontend/dist/js/{LibreClawPage-B14Go5Mn.js → LibreClawPage-DUd3tjQE.js} +1 -1
- package/frontend/dist/js/ModelSelector-COFcJnMJ.js +1 -0
- package/frontend/dist/js/{ModelsPage-Cc3L0ur8.js → ModelsPage-BRbTEXNL.js} +1 -1
- package/frontend/dist/js/{NotesPage-BwEMW-Z3.js → NotesPage-CTutDN50.js} +1 -1
- package/frontend/dist/js/{PersonasPage-e-CbTxP3.js → PersonasPage-C6iTMI3N.js} +2 -2
- package/frontend/dist/js/{PluginUsagePage-CvZMJJwv.js → PluginUsagePage-BQQ0-_PP.js} +1 -1
- package/frontend/dist/js/{RichMessageContent-DNl3_CIw.js → RichMessageContent-DPqeqxkQ.js} +1 -1
- package/frontend/dist/js/SettingsModal-C_1k75Kv.js +1 -0
- package/frontend/dist/js/SettingsToggle-bZ5W4CCf.js +1 -0
- package/frontend/dist/js/SystemPage-CUecyRPh.js +1 -0
- package/frontend/dist/js/UserManagementPage-CZ4KMOY9.js +1 -0
- package/frontend/dist/js/WorkPage-BqDcfXV6.js +5 -0
- package/frontend/dist/js/api-iCpOEijB.js +14 -0
- package/frontend/dist/js/{appStore-BpoXhleX.js → appStore-DTd6dWg2.js} +2 -2
- package/frontend/dist/js/{ar-ClMsdoTH.js → ar-C8YV99Y-.js} +1 -1
- package/frontend/dist/js/authStore-CuK4HUVh.js +4 -0
- package/frontend/dist/js/{bn-LyPol_AY.js → bn-C9dcls7_.js} +1 -1
- package/frontend/dist/js/chatStore-VNT0eHRB.js +2 -0
- package/frontend/dist/js/{config-CuXlcWP1.js → config--Xp9rZtJ.js} +1 -1
- package/frontend/dist/js/{cs-D98fZaKF.js → cs-DtL8VeeX.js} +1 -1
- package/frontend/dist/js/{da-M7ND0GQR.js → da-U3aNn1aF.js} +1 -1
- package/frontend/dist/js/{de-BV0q1kzb.js → de-DzuyeVpX.js} +1 -1
- package/frontend/dist/js/{es-C7FvsVoN.js → es-OiU8plii.js} +1 -1
- package/frontend/dist/js/{estree-BuWOCMW0.js → estree-BAQkciRX.js} +1 -1
- package/frontend/dist/js/{fr-5SloN0ok.js → fr-DDa3R8bs.js} +1 -1
- package/frontend/dist/js/{hi-C6VeRgeR.js → hi-D8Y0hl34.js} +1 -1
- package/frontend/dist/js/{html-D7tW7UvW.js → html-DxA-Bcvi.js} +1 -1
- package/frontend/dist/js/{i18n-CLd1FZPx.js → i18n-BqWNFeNo.js} +1 -1
- package/frontend/dist/js/{id-CkVp7Z1Z.js → id-BwcWtkBv.js} +1 -1
- package/frontend/dist/js/{is-DqxnBX4W.js → is-DzfQHQih.js} +1 -1
- package/frontend/dist/js/{it-CiW4_O5V.js → it-C5VU3_Je.js} +1 -1
- package/frontend/dist/js/{ja-BZ3Sdund.js → ja-DLhOhxvf.js} +1 -1
- package/frontend/dist/js/{ko-CS7LAhfJ.js → ko-CCKiyQD6.js} +1 -1
- package/frontend/dist/js/{markdown-CBSxWEtK.js → markdown-IsZpJ5ke.js} +1 -1
- package/frontend/dist/js/{messageCodeStyles-BlTi46wI.js → messageCodeStyles-CsV3zwyK.js} +1 -1
- package/frontend/dist/js/{ms-URvcYqgz.js → ms-CXXkPvWw.js} +1 -1
- package/frontend/dist/js/{nl-CQJpW7wK.js → nl-CA_e0Epl.js} +1 -1
- package/frontend/dist/js/{pl-DDMgPMAz.js → pl-BG32wdpx.js} +1 -1
- package/frontend/dist/js/{pt-JCqqmGn_.js → pt-BzRGWQXO.js} +1 -1
- package/frontend/dist/js/{ru-C_dwMbiK.js → ru-DBjn_Q-Q.js} +1 -1
- package/frontend/dist/js/{standalone-CaXys7q3.js → standalone-CK3b5Ktq.js} +1 -1
- package/frontend/dist/js/{sv-CW3c9NGx.js → sv-Hz_iRGdF.js} +1 -1
- package/frontend/dist/js/{th-9ARgClBr.js → th-MsyXlGH9.js} +1 -1
- package/frontend/dist/js/{tr-DNY5_DAg.js → tr-Cofmy_ef.js} +1 -1
- package/frontend/dist/js/{typescript-DQ5zi5zo.js → typescript-BMYmVohw.js} +1 -1
- package/frontend/dist/js/{ui-DFQiHo_k.js → ui-C2kNYwfG.js} +2 -2
- package/frontend/dist/js/{uk-XDh4_7IL.js → uk-B2o05LCR.js} +1 -1
- package/frontend/dist/js/{vi-BaoJBXnt.js → vi-CZh8uJgV.js} +1 -1
- package/frontend/dist/js/workApi-C33ZrPws.js +6 -0
- package/frontend/dist/js/{zh-B202vYJt.js → zh-DhKXi8ZA.js} +1 -1
- package/package.json +4 -3
- package/frontend/dist/assets/index-Cd0_AoYV.js +0 -23
- package/frontend/dist/assets/index-DtkGuqvC.css +0 -1
- package/frontend/dist/js/ChatPage-BkFd1iac.js +0 -16
- package/frontend/dist/js/HomePage-5PhpAt14.js +0 -1
- package/frontend/dist/js/ModelSelector-C1wN91tl.js +0 -1
- package/frontend/dist/js/SettingsModal-C8KJJEud.js +0 -1
- package/frontend/dist/js/SystemPage-8kEAS71H.js +0 -1
- package/frontend/dist/js/UserManagementPage-BSrZ0ucu.js +0 -1
- package/frontend/dist/js/WorkPage-spf92PVN.js +0 -5
- package/frontend/dist/js/api-MySpmAK8.js +0 -19
- package/frontend/dist/js/authStore-DmrydYuS.js +0 -3
- package/frontend/dist/js/chatStore-CWqfsOjc.js +0 -2
|
@@ -0,0 +1,737 @@
|
|
|
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 { spawn } from 'node:child_process';
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import { dockerEndpointRequestOptions, resolveDockerEndpoint, } from '../utils/dockerEndpoint.js';
|
|
20
|
+
import { createLogger } from '../utils/logger.js';
|
|
21
|
+
import workPolicyService from './workPolicyService.js';
|
|
22
|
+
import { WorkRuntimeError, WORK_RUNTIME_DEFAULTS, computePolicyFingerprint, defaultRuntimePolicy, workRuntimeConfig as config, } from './workRuntimeShared.js';
|
|
23
|
+
const logger = createLogger('services:work-runtime');
|
|
24
|
+
const activeDockerProcesses = new Set();
|
|
25
|
+
export class DockerWorkRuntimeDriver {
|
|
26
|
+
constructor() {
|
|
27
|
+
this.image = config.image;
|
|
28
|
+
/**
|
|
29
|
+
* The terminal talks to the Docker Engine API directly because a TTY exec
|
|
30
|
+
* needs a hijacked bidirectional stream, which the docker CLI only offers
|
|
31
|
+
* to an interactive controlling terminal. The connection is the resolved
|
|
32
|
+
* Docker endpoint: the Unix socket, or a plain-HTTP tcp:// endpoint such
|
|
33
|
+
* as a socket proxy holding the socket on the app's behalf — the hijacked
|
|
34
|
+
* stream survives an HTTP-aware proxy because it rides a standard
|
|
35
|
+
* Connection: Upgrade tunnel. Endpoints this client cannot speak to
|
|
36
|
+
* (ssh://, TLS-verified tcp) make the terminal report itself unavailable
|
|
37
|
+
* instead of guessing.
|
|
38
|
+
*/
|
|
39
|
+
this.terminalEndpoint = resolveDockerEndpoint(process.env.WORK_DOCKER_SOCKET, process.env.DOCKER_HOST, process.env.DOCKER_TLS_VERIFY);
|
|
40
|
+
}
|
|
41
|
+
async probe() {
|
|
42
|
+
try {
|
|
43
|
+
await this.docker(['info', '--format', '{{.ServerVersion}}'], {
|
|
44
|
+
timeoutMs: 5000,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
throw new WorkRuntimeError(describeDockerUnavailable(error, config.dockerCommand), 503, 'WORK_DOCKER_UNAVAILABLE');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
shutdown() {
|
|
52
|
+
for (const child of activeDockerProcesses) {
|
|
53
|
+
child.kill('SIGKILL');
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async ensureImage(image = config.image) {
|
|
57
|
+
const inspected = await this.docker(['image', 'inspect', image], {
|
|
58
|
+
timeoutMs: 10000,
|
|
59
|
+
acceptFailure: true,
|
|
60
|
+
});
|
|
61
|
+
if (inspected.exitCode === 0)
|
|
62
|
+
return;
|
|
63
|
+
logger.info(`Pulling Work runtime image ${image}`);
|
|
64
|
+
await this.docker(['pull', image], { timeoutMs: 900000 });
|
|
65
|
+
}
|
|
66
|
+
async ensureWorkspace(task) {
|
|
67
|
+
if (await this.volumeExists(task.volumeName)) {
|
|
68
|
+
await this.assertManagedVolume(task);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
await this.docker([
|
|
72
|
+
'volume',
|
|
73
|
+
'create',
|
|
74
|
+
'--label',
|
|
75
|
+
'ai.libre-webui.managed=true',
|
|
76
|
+
'--label',
|
|
77
|
+
`ai.libre-webui.task=${task.id}`,
|
|
78
|
+
task.volumeName,
|
|
79
|
+
]);
|
|
80
|
+
// Docker returns an existing volume from `volume create` if another
|
|
81
|
+
// process wins the name race, so prove ownership before mounting it.
|
|
82
|
+
await this.assertManagedVolume(task);
|
|
83
|
+
try {
|
|
84
|
+
await this.initializeVolume(task, workPolicyService.resolve(task.policyId).image);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
await this.docker(['volume', 'rm', task.volumeName], {
|
|
88
|
+
acceptFailure: true,
|
|
89
|
+
});
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async ensureRuntime(task) {
|
|
94
|
+
const policy = workPolicyService.resolve(task.policyId);
|
|
95
|
+
await this.ensureWorkNetwork(task);
|
|
96
|
+
if (await this.containerExists(task.containerName)) {
|
|
97
|
+
await this.assertManagedContainer(task);
|
|
98
|
+
if (!(await this.containerMatchesTaskPolicy(task, policy))) {
|
|
99
|
+
logger.warn(`Recreating Work container ${task.containerName} because its isolation policy is stale.`);
|
|
100
|
+
await this.docker(['rm', '-f', task.containerName]);
|
|
101
|
+
await this.docker(buildWorkContainerRunArgs(task, policy));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const state = await this.docker([
|
|
105
|
+
'inspect',
|
|
106
|
+
'--format',
|
|
107
|
+
'{{.State.Running}}',
|
|
108
|
+
task.containerName,
|
|
109
|
+
]);
|
|
110
|
+
if (state.stdout.trim() !== 'true') {
|
|
111
|
+
await this.docker(['start', task.containerName]);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
await this.docker(buildWorkContainerRunArgs(task, policy));
|
|
116
|
+
}
|
|
117
|
+
async runtimeState(task) {
|
|
118
|
+
if (!(await this.containerExists(task.containerName)))
|
|
119
|
+
return 'absent';
|
|
120
|
+
await this.assertManagedContainer(task);
|
|
121
|
+
return (await this.containerIsRunning(task.containerName))
|
|
122
|
+
? 'running'
|
|
123
|
+
: 'stopped';
|
|
124
|
+
}
|
|
125
|
+
async stopRuntime(task) {
|
|
126
|
+
if (!(await this.containerExists(task.containerName)))
|
|
127
|
+
return;
|
|
128
|
+
await this.assertManagedContainer(task);
|
|
129
|
+
await this.docker(['stop', '--time', '1', task.containerName], {
|
|
130
|
+
timeoutMs: 10000,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async removeRuntime(task) {
|
|
134
|
+
if (!(await this.containerExists(task.containerName)))
|
|
135
|
+
return;
|
|
136
|
+
await this.assertManagedContainer(task);
|
|
137
|
+
await this.docker(['rm', '-f', task.containerName]);
|
|
138
|
+
}
|
|
139
|
+
async removeTaskResources(task) {
|
|
140
|
+
const [hasContainer, hasVolume] = await Promise.all([
|
|
141
|
+
this.containerExists(task.containerName),
|
|
142
|
+
this.volumeExists(task.volumeName),
|
|
143
|
+
]);
|
|
144
|
+
// Validate every destructive target before removing either one, so a
|
|
145
|
+
// conflicting unmanaged Docker resource cannot cause partial cleanup.
|
|
146
|
+
if (hasContainer) {
|
|
147
|
+
await this.assertManagedContainer(task);
|
|
148
|
+
}
|
|
149
|
+
if (hasVolume) {
|
|
150
|
+
await this.assertManagedVolume(task);
|
|
151
|
+
}
|
|
152
|
+
if (hasContainer) {
|
|
153
|
+
await this.docker(['rm', '-f', task.containerName], {
|
|
154
|
+
timeoutMs: 15000,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (hasVolume) {
|
|
158
|
+
await this.docker(['volume', 'rm', task.volumeName], {
|
|
159
|
+
timeoutMs: 15000,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async exec(task, command, options = {}) {
|
|
164
|
+
const args = [
|
|
165
|
+
'exec',
|
|
166
|
+
...(options.input !== undefined ? ['--interactive'] : []),
|
|
167
|
+
'--user',
|
|
168
|
+
'1000:1000',
|
|
169
|
+
'--workdir',
|
|
170
|
+
options.workdir ?? '/workspace',
|
|
171
|
+
task.containerName,
|
|
172
|
+
...command,
|
|
173
|
+
];
|
|
174
|
+
return this.docker(args, options);
|
|
175
|
+
}
|
|
176
|
+
async previewEndpoint(task) {
|
|
177
|
+
const portResult = await this.docker([
|
|
178
|
+
'port',
|
|
179
|
+
task.containerName,
|
|
180
|
+
`${config.previewPort}/tcp`,
|
|
181
|
+
]);
|
|
182
|
+
const port = parsePublishedPort(portResult.stdout, config.previewPort);
|
|
183
|
+
// No host: the proxy targets its configured upstream (publish) interface.
|
|
184
|
+
return port === undefined ? undefined : { port };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Every container the Work runtime has ever created, in one daemon query,
|
|
188
|
+
* identified by the managed label stamped at `docker run` time.
|
|
189
|
+
*/
|
|
190
|
+
async listManaged() {
|
|
191
|
+
const result = await this.docker([
|
|
192
|
+
'ps',
|
|
193
|
+
'--all',
|
|
194
|
+
'--no-trunc',
|
|
195
|
+
'--filter',
|
|
196
|
+
'label=ai.libre-webui.managed=true',
|
|
197
|
+
'--format',
|
|
198
|
+
'{{.Names}}\t{{.State}}\t{{.Label "ai.libre-webui.task"}}',
|
|
199
|
+
], { timeoutMs: 15000 });
|
|
200
|
+
return parseManagedContainerList(result.stdout);
|
|
201
|
+
}
|
|
202
|
+
async removeOrphan(name) {
|
|
203
|
+
await this.docker(['rm', '--force', name], { timeoutMs: 15000 });
|
|
204
|
+
}
|
|
205
|
+
terminalUnavailableReason() {
|
|
206
|
+
if (!this.terminalEndpoint) {
|
|
207
|
+
return 'The Work terminal needs a reachable Docker Engine API. Set WORK_DOCKER_SOCKET to the Unix socket path, or point DOCKER_HOST at a plain-HTTP tcp:// endpoint such as a socket proxy (DOCKER_HOST currently names an endpoint this client cannot speak to).';
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
async openTerminal(containerName) {
|
|
212
|
+
const created = await this.terminalApi('POST', `/containers/${encodeURIComponent(containerName)}/exec`, buildExecCreatePayload());
|
|
213
|
+
const execId = parseExecId(created);
|
|
214
|
+
const stream = await this.startExecStream(execId);
|
|
215
|
+
return {
|
|
216
|
+
stream,
|
|
217
|
+
resize: async (cols, rows) => {
|
|
218
|
+
await this.terminalApi('POST', `/exec/${encodeURIComponent(execId)}/resize?w=${cols}&h=${rows}`);
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Transport options for the resolved endpoint. A Unix socket needs an
|
|
224
|
+
* explicit Host header (there is no authority in the request target); a
|
|
225
|
+
* tcp endpoint gets the real host:port so an HTTP-aware proxy can route
|
|
226
|
+
* on it.
|
|
227
|
+
*/
|
|
228
|
+
terminalTransport() {
|
|
229
|
+
const endpoint = this.terminalEndpoint;
|
|
230
|
+
if (!endpoint) {
|
|
231
|
+
throw new WorkRuntimeError(this.terminalUnavailableReason() ?? 'The Work terminal is unavailable.', 503, 'WORK_TERMINAL_UNAVAILABLE');
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
options: dockerEndpointRequestOptions(endpoint),
|
|
235
|
+
hostHeader: endpoint.kind === 'unix' ? { Host: 'docker' } : {},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
terminalApi(method, path, payload) {
|
|
239
|
+
return new Promise((resolve, reject) => {
|
|
240
|
+
const body = payload === undefined ? undefined : JSON.stringify(payload);
|
|
241
|
+
const { options, hostHeader } = this.terminalTransport();
|
|
242
|
+
const request = http.request({
|
|
243
|
+
...options,
|
|
244
|
+
method,
|
|
245
|
+
path,
|
|
246
|
+
headers: {
|
|
247
|
+
...hostHeader,
|
|
248
|
+
'Content-Type': 'application/json',
|
|
249
|
+
'Content-Length': body ? Buffer.byteLength(body) : 0,
|
|
250
|
+
},
|
|
251
|
+
}, response => {
|
|
252
|
+
const chunks = [];
|
|
253
|
+
response.on('data', chunk => chunks.push(chunk));
|
|
254
|
+
response.on('end', () => {
|
|
255
|
+
resolve({
|
|
256
|
+
status: response.statusCode ?? 0,
|
|
257
|
+
body: Buffer.concat(chunks).toString('utf8'),
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
request.setTimeout(10000, () => {
|
|
262
|
+
request.destroy(new Error('Docker Engine API request timed out.'));
|
|
263
|
+
});
|
|
264
|
+
request.on('error', error => {
|
|
265
|
+
reject(new WorkRuntimeError(`Could not reach the Docker Engine socket for the terminal: ${error.message}`, 503, 'WORK_TERMINAL_UNAVAILABLE'));
|
|
266
|
+
});
|
|
267
|
+
if (body)
|
|
268
|
+
request.write(body);
|
|
269
|
+
request.end();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
startExecStream(execId) {
|
|
273
|
+
return new Promise((resolve, reject) => {
|
|
274
|
+
const body = JSON.stringify({ Detach: false, Tty: true });
|
|
275
|
+
const { options, hostHeader } = this.terminalTransport();
|
|
276
|
+
const request = http.request({
|
|
277
|
+
...options,
|
|
278
|
+
method: 'POST',
|
|
279
|
+
path: `/exec/${encodeURIComponent(execId)}/start`,
|
|
280
|
+
headers: {
|
|
281
|
+
...hostHeader,
|
|
282
|
+
'Content-Type': 'application/json',
|
|
283
|
+
'Content-Length': Buffer.byteLength(body),
|
|
284
|
+
Connection: 'Upgrade',
|
|
285
|
+
Upgrade: 'tcp',
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
request.on('upgrade', (_response, socket, head) => {
|
|
289
|
+
// With Tty:true the hijacked stream is raw terminal bytes in both
|
|
290
|
+
// directions; no stream-multiplexing frames to parse.
|
|
291
|
+
if (head.length > 0)
|
|
292
|
+
socket.unshift(head);
|
|
293
|
+
resolve(socket);
|
|
294
|
+
});
|
|
295
|
+
request.on('response', response => {
|
|
296
|
+
const chunks = [];
|
|
297
|
+
response.on('data', chunk => chunks.push(chunk));
|
|
298
|
+
response.on('end', () => {
|
|
299
|
+
reject(new WorkRuntimeError(`The Docker Engine refused the terminal stream (HTTP ${response.statusCode}): ${Buffer.concat(chunks).toString('utf8').trim()}`, 503, 'WORK_TERMINAL_UNAVAILABLE'));
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
request.on('error', error => {
|
|
303
|
+
reject(new WorkRuntimeError(`Could not open the terminal stream: ${error.message}`, 503, 'WORK_TERMINAL_UNAVAILABLE'));
|
|
304
|
+
});
|
|
305
|
+
request.write(body);
|
|
306
|
+
request.end();
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
async ensureWorkNetwork(task) {
|
|
310
|
+
if (!task.networkEnabled)
|
|
311
|
+
return;
|
|
312
|
+
const inspect = await this.docker([
|
|
313
|
+
'network',
|
|
314
|
+
'inspect',
|
|
315
|
+
'--format',
|
|
316
|
+
'{{index .Labels "ai.libre-webui.managed"}} {{index .Options "com.docker.network.bridge.enable_icc"}}',
|
|
317
|
+
config.networkName,
|
|
318
|
+
], { acceptFailure: true });
|
|
319
|
+
if (inspect.exitCode === 0) {
|
|
320
|
+
const [managed, icc] = inspect.stdout.trim().split(' ');
|
|
321
|
+
if (managed !== 'true' || icc !== 'false') {
|
|
322
|
+
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');
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const created = await this.docker([
|
|
327
|
+
'network',
|
|
328
|
+
'create',
|
|
329
|
+
'--label',
|
|
330
|
+
'ai.libre-webui.managed=true',
|
|
331
|
+
'--opt',
|
|
332
|
+
'com.docker.network.bridge.enable_icc=false',
|
|
333
|
+
config.networkName,
|
|
334
|
+
], { acceptFailure: true });
|
|
335
|
+
if (created.exitCode !== 0 && !/already exists/i.test(created.stderr)) {
|
|
336
|
+
throw new WorkRuntimeError(`Could not create the Work sandbox network "${config.networkName}": ${created.stderr.trim()}`, 503, 'WORK_NETWORK_UNAVAILABLE');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
async initializeVolume(task, image = this.image) {
|
|
340
|
+
await this.docker([
|
|
341
|
+
'run',
|
|
342
|
+
'--rm',
|
|
343
|
+
'--network',
|
|
344
|
+
'none',
|
|
345
|
+
'--read-only',
|
|
346
|
+
'--tmpfs',
|
|
347
|
+
'/tmp:rw,noexec,nosuid,size=64m',
|
|
348
|
+
'--cap-drop',
|
|
349
|
+
'ALL',
|
|
350
|
+
'--cap-add',
|
|
351
|
+
'CHOWN',
|
|
352
|
+
'--mount',
|
|
353
|
+
`type=volume,src=${task.volumeName},dst=/workspace`,
|
|
354
|
+
image,
|
|
355
|
+
'chown',
|
|
356
|
+
'-R',
|
|
357
|
+
'1000:1000',
|
|
358
|
+
'/workspace',
|
|
359
|
+
]);
|
|
360
|
+
}
|
|
361
|
+
async volumeExists(name) {
|
|
362
|
+
const result = await this.docker(['volume', 'inspect', name], {
|
|
363
|
+
timeoutMs: 5000,
|
|
364
|
+
acceptFailure: true,
|
|
365
|
+
});
|
|
366
|
+
if (result.exitCode === 0)
|
|
367
|
+
return true;
|
|
368
|
+
if (/no such volume/i.test(`${result.stderr}\n${result.stdout}`)) {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
throw new WorkRuntimeError(`Could not inspect Work volume "${name}": ${result.stderr.trim() || result.stdout.trim() || `Docker exited with code ${result.exitCode}.`}`, 503, 'WORK_DOCKER_INSPECT_FAILED');
|
|
372
|
+
}
|
|
373
|
+
async containerExists(name) {
|
|
374
|
+
const result = await this.docker(['container', 'inspect', name], {
|
|
375
|
+
timeoutMs: 5000,
|
|
376
|
+
acceptFailure: true,
|
|
377
|
+
});
|
|
378
|
+
if (result.exitCode === 0)
|
|
379
|
+
return true;
|
|
380
|
+
if (/no such (?:container|object)/i.test(`${result.stderr}\n${result.stdout}`)) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
throw new WorkRuntimeError(`Could not inspect Work container "${name}": ${result.stderr.trim() || result.stdout.trim() || `Docker exited with code ${result.exitCode}.`}`, 503, 'WORK_DOCKER_INSPECT_FAILED');
|
|
384
|
+
}
|
|
385
|
+
async containerIsRunning(name) {
|
|
386
|
+
const result = await this.docker([
|
|
387
|
+
'inspect',
|
|
388
|
+
'--format',
|
|
389
|
+
'{{.State.Running}}',
|
|
390
|
+
name,
|
|
391
|
+
]);
|
|
392
|
+
const state = result.stdout.trim();
|
|
393
|
+
if (state === 'true')
|
|
394
|
+
return true;
|
|
395
|
+
if (state === 'false')
|
|
396
|
+
return false;
|
|
397
|
+
throw new WorkRuntimeError(`Docker returned an invalid state for Work container "${name}".`, 503, 'WORK_DOCKER_INSPECT_FAILED');
|
|
398
|
+
}
|
|
399
|
+
async assertManagedContainer(task) {
|
|
400
|
+
const result = await this.docker([
|
|
401
|
+
'inspect',
|
|
402
|
+
'--format',
|
|
403
|
+
'{{ index .Config.Labels "ai.libre-webui.task" }}',
|
|
404
|
+
task.containerName,
|
|
405
|
+
]);
|
|
406
|
+
if (result.stdout.trim() !== task.id) {
|
|
407
|
+
throw new WorkRuntimeError(`Refusing to operate unmanaged container "${task.containerName}".`, 409, 'WORK_CONTAINER_NAME_CONFLICT');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async containerMatchesTaskPolicy(task, policy = defaultRuntimePolicy) {
|
|
411
|
+
const result = await this.docker([
|
|
412
|
+
'inspect',
|
|
413
|
+
'--format',
|
|
414
|
+
'{{json .}}',
|
|
415
|
+
task.containerName,
|
|
416
|
+
]);
|
|
417
|
+
let inspected;
|
|
418
|
+
try {
|
|
419
|
+
inspected = JSON.parse(result.stdout);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
const containerConfig = objectRecord(inspected.Config);
|
|
425
|
+
const hostConfig = objectRecord(inspected.HostConfig);
|
|
426
|
+
const labels = objectRecord(containerConfig.Labels);
|
|
427
|
+
const env = stringArray(containerConfig.Env);
|
|
428
|
+
const capDrop = stringArray(hostConfig.CapDrop);
|
|
429
|
+
const securityOpt = stringArray(hostConfig.SecurityOpt);
|
|
430
|
+
const command = stringArray(containerConfig.Cmd);
|
|
431
|
+
const mounts = Array.isArray(inspected.Mounts)
|
|
432
|
+
? inspected.Mounts.map(objectRecord)
|
|
433
|
+
: [];
|
|
434
|
+
const workspaceMount = mounts.find(mount => mount.Destination === '/workspace');
|
|
435
|
+
const expectedNetwork = task.networkEnabled ? config.networkName : 'none';
|
|
436
|
+
const portBindings = objectRecord(hostConfig.PortBindings);
|
|
437
|
+
const previewBindings = Array.isArray(portBindings[`${config.previewPort}/tcp`])
|
|
438
|
+
? portBindings[`${config.previewPort}/tcp`].map(objectRecord)
|
|
439
|
+
: [];
|
|
440
|
+
const portPolicyMatches = task.networkEnabled
|
|
441
|
+
? Object.keys(portBindings).length === 1 &&
|
|
442
|
+
previewBindings.length === 1 &&
|
|
443
|
+
previewBindings[0]?.HostIp === config.previewBind
|
|
444
|
+
: Object.keys(portBindings).length === 0;
|
|
445
|
+
return (labels['ai.libre-webui.managed'] === 'true' &&
|
|
446
|
+
labels['ai.libre-webui.task'] === task.id &&
|
|
447
|
+
labels['ai.libre-webui.policy'] === computePolicyFingerprint(policy) &&
|
|
448
|
+
containerConfig.Image === policy.image &&
|
|
449
|
+
containerConfig.User === '1000:1000' &&
|
|
450
|
+
containerConfig.WorkingDir === '/workspace' &&
|
|
451
|
+
command.join('\0') === ['tail', '-f', '/dev/null'].join('\0') &&
|
|
452
|
+
env.includes('HOME=/tmp') &&
|
|
453
|
+
env.includes('NPM_CONFIG_CACHE=/tmp/npm-cache') &&
|
|
454
|
+
hostConfig.ReadonlyRootfs === true &&
|
|
455
|
+
hostConfig.Privileged === false &&
|
|
456
|
+
capDrop.includes('ALL') &&
|
|
457
|
+
securityOpt.includes('no-new-privileges') &&
|
|
458
|
+
Number(hostConfig.PidsLimit) === policy.pidsLimit &&
|
|
459
|
+
Number(hostConfig.Memory) > 0 &&
|
|
460
|
+
Number(hostConfig.MemorySwap) === Number(hostConfig.Memory) &&
|
|
461
|
+
Number(hostConfig.NanoCpus) > 0 &&
|
|
462
|
+
hostConfig.NetworkMode === expectedNetwork &&
|
|
463
|
+
portPolicyMatches &&
|
|
464
|
+
workspaceMount?.Type === 'volume' &&
|
|
465
|
+
workspaceMount.Name === task.volumeName &&
|
|
466
|
+
workspaceMount.RW === true);
|
|
467
|
+
}
|
|
468
|
+
async assertManagedVolume(task) {
|
|
469
|
+
const result = await this.docker([
|
|
470
|
+
'volume',
|
|
471
|
+
'inspect',
|
|
472
|
+
'--format',
|
|
473
|
+
'{{ index .Labels "ai.libre-webui.task" }}',
|
|
474
|
+
task.volumeName,
|
|
475
|
+
]);
|
|
476
|
+
if (result.stdout.trim() !== task.id) {
|
|
477
|
+
throw new WorkRuntimeError(`Refusing to operate unmanaged volume "${task.volumeName}".`, 409, 'WORK_VOLUME_NAME_CONFLICT');
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
async docker(args, options = {}) {
|
|
481
|
+
try {
|
|
482
|
+
return await runProcess(config.dockerCommand, args, options);
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
if (error instanceof WorkRuntimeError)
|
|
486
|
+
throw error;
|
|
487
|
+
throw new WorkRuntimeError(error instanceof Error ? error.message : 'Docker command failed.');
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
export function buildExecCreatePayload(shell = ['/bin/bash', '-l']) {
|
|
492
|
+
// Mirrors the container policy: the shell runs as the same unprivileged
|
|
493
|
+
// user, in the workspace, inside the already-hardened container. Nothing
|
|
494
|
+
// about the sandbox weakens because a human is typing instead of the model.
|
|
495
|
+
return {
|
|
496
|
+
AttachStdin: true,
|
|
497
|
+
AttachStdout: true,
|
|
498
|
+
AttachStderr: true,
|
|
499
|
+
Tty: true,
|
|
500
|
+
User: '1000:1000',
|
|
501
|
+
WorkingDir: '/workspace',
|
|
502
|
+
Env: ['TERM=xterm-256color'],
|
|
503
|
+
Cmd: [...shell],
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function parseExecId(response) {
|
|
507
|
+
if (response.status < 200 || response.status >= 300) {
|
|
508
|
+
throw new WorkRuntimeError(`The Docker Engine rejected the terminal exec (HTTP ${response.status}): ${response.body.trim()}`, 503, 'WORK_TERMINAL_UNAVAILABLE');
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
const parsed = JSON.parse(response.body);
|
|
512
|
+
if (typeof parsed.Id === 'string' && parsed.Id)
|
|
513
|
+
return parsed.Id;
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
// Fall through to the error below.
|
|
517
|
+
}
|
|
518
|
+
throw new WorkRuntimeError('The Docker Engine returned an unexpected exec-create response.', 503, 'WORK_TERMINAL_UNAVAILABLE');
|
|
519
|
+
}
|
|
520
|
+
/** Parse `docker ps` lines of `name\tstate\ttask-label` into containers. */
|
|
521
|
+
export function parseManagedContainerList(stdout) {
|
|
522
|
+
const containers = [];
|
|
523
|
+
for (const line of stdout.split('\n')) {
|
|
524
|
+
if (!line.trim())
|
|
525
|
+
continue;
|
|
526
|
+
const [name = '', state = '', taskId = ''] = line
|
|
527
|
+
.split('\t')
|
|
528
|
+
.map(field => field.trim());
|
|
529
|
+
if (!name || !state)
|
|
530
|
+
continue;
|
|
531
|
+
containers.push({
|
|
532
|
+
name,
|
|
533
|
+
taskId,
|
|
534
|
+
// paused and restarting containers still hold execution state; only
|
|
535
|
+
// created/exited/dead containers are safely at rest.
|
|
536
|
+
running: state === 'running' || state === 'restarting' || state === 'paused',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
return containers;
|
|
540
|
+
}
|
|
541
|
+
/** Bracket a bare IPv6 literal so it can carry a port in a URL. */
|
|
542
|
+
export function formatPreviewHost(host) {
|
|
543
|
+
return host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Turn a failed `docker info` into the change an operator has to make. These
|
|
547
|
+
* are the three ways a containerized Libre WebUI fails to reach the daemon:
|
|
548
|
+
* the image has no CLI, the bind-mounted socket is owned by a group the
|
|
549
|
+
* backend user is not in, or no daemon is listening.
|
|
550
|
+
*/
|
|
551
|
+
export function describeDockerUnavailable(error, dockerCommand = config.dockerCommand) {
|
|
552
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
553
|
+
if (/ENOENT/.test(message)) {
|
|
554
|
+
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.`;
|
|
555
|
+
}
|
|
556
|
+
if (/EACCES/.test(message) || /permission denied/i.test(message)) {
|
|
557
|
+
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).';
|
|
558
|
+
}
|
|
559
|
+
if (/cannot connect to the docker daemon/i.test(message) ||
|
|
560
|
+
/is the docker daemon running/i.test(message) ||
|
|
561
|
+
/no such file or directory/i.test(message)) {
|
|
562
|
+
return 'No Docker daemon is reachable. Start Docker, mount the host socket into the Libre WebUI container with -v /var/run/docker.sock:/var/run/docker.sock, or point DOCKER_HOST at a reachable Docker API endpoint such as a socket proxy.';
|
|
563
|
+
}
|
|
564
|
+
return message;
|
|
565
|
+
}
|
|
566
|
+
export function buildWorkContainerRunArgs(task, policy = defaultRuntimePolicy) {
|
|
567
|
+
const args = [
|
|
568
|
+
'run',
|
|
569
|
+
'--detach',
|
|
570
|
+
'--name',
|
|
571
|
+
task.containerName,
|
|
572
|
+
'--init',
|
|
573
|
+
'--label',
|
|
574
|
+
'ai.libre-webui.managed=true',
|
|
575
|
+
'--label',
|
|
576
|
+
`ai.libre-webui.task=${task.id}`,
|
|
577
|
+
'--label',
|
|
578
|
+
`ai.libre-webui.policy=${computePolicyFingerprint(policy)}`,
|
|
579
|
+
'--user',
|
|
580
|
+
'1000:1000',
|
|
581
|
+
'--workdir',
|
|
582
|
+
'/workspace',
|
|
583
|
+
'--read-only',
|
|
584
|
+
'--tmpfs',
|
|
585
|
+
'/tmp:rw,nosuid,size=512m',
|
|
586
|
+
'--env',
|
|
587
|
+
'HOME=/tmp',
|
|
588
|
+
'--env',
|
|
589
|
+
'NPM_CONFIG_CACHE=/tmp/npm-cache',
|
|
590
|
+
'--cap-drop',
|
|
591
|
+
'ALL',
|
|
592
|
+
'--security-opt',
|
|
593
|
+
'no-new-privileges',
|
|
594
|
+
'--pids-limit',
|
|
595
|
+
String(policy.pidsLimit),
|
|
596
|
+
'--memory',
|
|
597
|
+
policy.memoryLimit,
|
|
598
|
+
// Same value as --memory: the memory cap cannot be sidestepped via swap.
|
|
599
|
+
'--memory-swap',
|
|
600
|
+
policy.memoryLimit,
|
|
601
|
+
'--cpus',
|
|
602
|
+
policy.cpuLimit,
|
|
603
|
+
'--network',
|
|
604
|
+
task.networkEnabled ? config.networkName : 'none',
|
|
605
|
+
];
|
|
606
|
+
if (task.networkEnabled) {
|
|
607
|
+
args.push('--publish', `${config.previewBind}::${config.previewPort}`);
|
|
608
|
+
for (const server of config.dnsServers) {
|
|
609
|
+
args.push('--dns', server);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
args.push('--mount', task.hostPath
|
|
613
|
+
? // Host-folder workspaces are opt-in per deployment and validated
|
|
614
|
+
// against an allowlist before ever reaching this point.
|
|
615
|
+
`type=bind,src=${task.hostPath},dst=/workspace`
|
|
616
|
+
: `type=volume,src=${task.volumeName},dst=/workspace,volume-nocopy`, policy.image, 'tail', '-f', '/dev/null');
|
|
617
|
+
return args;
|
|
618
|
+
}
|
|
619
|
+
export function parsePublishedPort(output, _containerPort = config.previewPort, bindAddress = config.previewBind) {
|
|
620
|
+
// Only a binding on the interface this deployment asked for counts. The
|
|
621
|
+
// default stays loopback, so a stray wildcard binding is still rejected.
|
|
622
|
+
const allowed = new Set(bindAddress === WORK_RUNTIME_DEFAULTS.previewBind
|
|
623
|
+
? ['127.0.0.1', '[::1]']
|
|
624
|
+
: [bindAddress, formatPreviewHost(bindAddress)]);
|
|
625
|
+
for (const line of output.trim().split(/\r?\n/)) {
|
|
626
|
+
const match = line.match(/^(.*):(\d+)$/);
|
|
627
|
+
if (!match || !allowed.has(match[1]))
|
|
628
|
+
continue;
|
|
629
|
+
const port = Number(match[2]);
|
|
630
|
+
if (Number.isInteger(port) && port > 0 && port <= 65535)
|
|
631
|
+
return port;
|
|
632
|
+
}
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
function objectRecord(value) {
|
|
636
|
+
return value && typeof value === 'object'
|
|
637
|
+
? value
|
|
638
|
+
: {};
|
|
639
|
+
}
|
|
640
|
+
function stringArray(value) {
|
|
641
|
+
return Array.isArray(value)
|
|
642
|
+
? value.filter((item) => typeof item === 'string')
|
|
643
|
+
: [];
|
|
644
|
+
}
|
|
645
|
+
async function runProcess(command, args, options) {
|
|
646
|
+
const timeoutMs = options.timeoutMs ?? config.commandTimeoutMs;
|
|
647
|
+
const maxOutputChars = options.maxOutputChars ?? config.maxOutputChars;
|
|
648
|
+
return new Promise((resolve, reject) => {
|
|
649
|
+
const child = spawn(command, args, {
|
|
650
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
651
|
+
env: process.env,
|
|
652
|
+
});
|
|
653
|
+
activeDockerProcesses.add(child);
|
|
654
|
+
let stdout = '';
|
|
655
|
+
let stderr = '';
|
|
656
|
+
let truncated = false;
|
|
657
|
+
let settled = false;
|
|
658
|
+
let stdinError;
|
|
659
|
+
let timer;
|
|
660
|
+
const claimSettlement = () => {
|
|
661
|
+
if (settled)
|
|
662
|
+
return false;
|
|
663
|
+
settled = true;
|
|
664
|
+
if (timer)
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
return true;
|
|
667
|
+
};
|
|
668
|
+
const append = (current, chunk) => {
|
|
669
|
+
const next = current + chunk.toString('utf8');
|
|
670
|
+
if (next.length <= maxOutputChars)
|
|
671
|
+
return next;
|
|
672
|
+
truncated = true;
|
|
673
|
+
const half = Math.floor(maxOutputChars / 2);
|
|
674
|
+
return `${next.slice(0, half)}\n... output truncated ...\n${next.slice(-half)}`;
|
|
675
|
+
};
|
|
676
|
+
child.stdout.on('data', chunk => {
|
|
677
|
+
stdout = append(stdout, chunk);
|
|
678
|
+
});
|
|
679
|
+
child.stderr.on('data', chunk => {
|
|
680
|
+
stderr = append(stderr, chunk);
|
|
681
|
+
});
|
|
682
|
+
child.stdin.on('error', error => {
|
|
683
|
+
// A helper may reject the request and close stdin before a large input
|
|
684
|
+
// has finished writing. EPIPE is then expected; the process exit carries
|
|
685
|
+
// the useful error. Other stdin failures are reported when it closes.
|
|
686
|
+
if (error.code !== 'EPIPE') {
|
|
687
|
+
stdinError = error;
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
child.on('error', error => {
|
|
691
|
+
activeDockerProcesses.delete(child);
|
|
692
|
+
if (!claimSettlement())
|
|
693
|
+
return;
|
|
694
|
+
reject(new WorkRuntimeError(`Could not start ${command}: ${error.message}`, 503, 'WORK_DOCKER_UNAVAILABLE'));
|
|
695
|
+
});
|
|
696
|
+
child.on('close', (code, signal) => {
|
|
697
|
+
activeDockerProcesses.delete(child);
|
|
698
|
+
const result = {
|
|
699
|
+
exitCode: code ?? -1,
|
|
700
|
+
stdout,
|
|
701
|
+
stderr,
|
|
702
|
+
truncated,
|
|
703
|
+
signal: signal || undefined,
|
|
704
|
+
};
|
|
705
|
+
if (stdinError && !options.acceptFailure) {
|
|
706
|
+
if (!claimSettlement())
|
|
707
|
+
return;
|
|
708
|
+
reject(new WorkRuntimeError(`Could not write command input: ${stdinError.message}`, 503, 'WORK_DOCKER_STDIN_FAILED'));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (result.exitCode !== 0 && !options.acceptFailure) {
|
|
712
|
+
if (!claimSettlement())
|
|
713
|
+
return;
|
|
714
|
+
reject(new WorkRuntimeError(stderr.trim() ||
|
|
715
|
+
stdout.trim() ||
|
|
716
|
+
`${command} exited with code ${result.exitCode}.`, 503, 'WORK_DOCKER_COMMAND_FAILED'));
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
if (claimSettlement())
|
|
720
|
+
resolve(result);
|
|
721
|
+
});
|
|
722
|
+
timer = setTimeout(() => {
|
|
723
|
+
child.kill('SIGKILL');
|
|
724
|
+
if (!claimSettlement())
|
|
725
|
+
return;
|
|
726
|
+
reject(new WorkRuntimeError(`Command timed out after ${timeoutMs}ms.`, 504, 'WORK_COMMAND_TIMEOUT'));
|
|
727
|
+
}, timeoutMs);
|
|
728
|
+
timer.unref();
|
|
729
|
+
if (options.input !== undefined) {
|
|
730
|
+
child.stdin.end(options.input);
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
child.stdin.end();
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
//# sourceMappingURL=workRuntimeDriver.js.map
|