libre-webui 0.14.3 → 0.15.1
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 +117 -33
- package/backend/dist/db.d.ts.map +1 -1
- package/backend/dist/db.js +84 -0
- package/backend/dist/db.js.map +1 -1
- package/backend/dist/index.d.ts.map +1 -1
- package/backend/dist/index.js +99 -35
- package/backend/dist/index.js.map +1 -1
- package/backend/dist/middleware/auth.d.ts.map +1 -1
- package/backend/dist/middleware/auth.js +27 -2
- package/backend/dist/middleware/auth.js.map +1 -1
- package/backend/dist/routes/plugins.d.ts.map +1 -1
- package/backend/dist/routes/plugins.js +1 -1
- package/backend/dist/routes/plugins.js.map +1 -1
- package/backend/dist/routes/users.d.ts.map +1 -1
- package/backend/dist/routes/users.js +40 -6
- package/backend/dist/routes/users.js.map +1 -1
- package/backend/dist/routes/work.d.ts +3 -0
- package/backend/dist/routes/work.d.ts.map +1 -0
- package/backend/dist/routes/work.js +604 -0
- package/backend/dist/routes/work.js.map +1 -0
- package/backend/dist/services/ollamaService.d.ts +7 -2
- package/backend/dist/services/ollamaService.d.ts.map +1 -1
- package/backend/dist/services/ollamaService.js +8 -8
- package/backend/dist/services/ollamaService.js.map +1 -1
- package/backend/dist/services/pluginService.d.ts.map +1 -1
- package/backend/dist/services/pluginService.js +15 -15
- package/backend/dist/services/pluginService.js.map +1 -1
- package/backend/dist/services/preferencesService.d.ts.map +1 -1
- package/backend/dist/services/preferencesService.js +1 -0
- package/backend/dist/services/preferencesService.js.map +1 -1
- package/backend/dist/services/workAgentGuidance.d.ts +24 -0
- package/backend/dist/services/workAgentGuidance.d.ts.map +1 -0
- package/backend/dist/services/workAgentGuidance.js +131 -0
- package/backend/dist/services/workAgentGuidance.js.map +1 -0
- package/backend/dist/services/workAgentService.d.ts +33 -0
- package/backend/dist/services/workAgentService.d.ts.map +1 -0
- package/backend/dist/services/workAgentService.js +935 -0
- package/backend/dist/services/workAgentService.js.map +1 -0
- package/backend/dist/services/workEventService.d.ts +40 -0
- package/backend/dist/services/workEventService.d.ts.map +1 -0
- package/backend/dist/services/workEventService.js +324 -0
- package/backend/dist/services/workEventService.js.map +1 -0
- package/backend/dist/services/workModelProviderService.d.ts +51 -0
- package/backend/dist/services/workModelProviderService.d.ts.map +1 -0
- package/backend/dist/services/workModelProviderService.js +952 -0
- package/backend/dist/services/workModelProviderService.js.map +1 -0
- package/backend/dist/services/workRuntimeService.d.ts +143 -0
- package/backend/dist/services/workRuntimeService.d.ts.map +1 -0
- package/backend/dist/services/workRuntimeService.js +1860 -0
- package/backend/dist/services/workRuntimeService.js.map +1 -0
- package/backend/dist/services/workTaskService.d.ts +96 -0
- package/backend/dist/services/workTaskService.d.ts.map +1 -0
- package/backend/dist/services/workTaskService.js +718 -0
- package/backend/dist/services/workTaskService.js.map +1 -0
- package/backend/dist/types/index.d.ts +3 -0
- package/backend/dist/types/index.d.ts.map +1 -1
- package/backend/dist/types/index.js.map +1 -1
- package/backend/dist/types/work.d.ts +209 -0
- package/backend/dist/types/work.d.ts.map +1 -0
- package/backend/dist/types/work.js +18 -0
- package/backend/dist/types/work.js.map +1 -0
- package/backend/dist/utils/ollamaLibrary.d.ts.map +1 -1
- package/backend/dist/utils/ollamaLibrary.js +81 -8
- package/backend/dist/utils/ollamaLibrary.js.map +1 -1
- package/backend/dist/utils/pluginChatAdapter.d.ts +9 -0
- package/backend/dist/utils/pluginChatAdapter.d.ts.map +1 -1
- package/backend/dist/utils/pluginChatAdapter.js +32 -6
- package/backend/dist/utils/pluginChatAdapter.js.map +1 -1
- package/backend/dist/utils/pluginStreamAdapter.d.ts +12 -0
- package/backend/dist/utils/pluginStreamAdapter.d.ts.map +1 -1
- package/backend/dist/utils/pluginStreamAdapter.js +168 -19
- package/backend/dist/utils/pluginStreamAdapter.js.map +1 -1
- package/backend/dist/utils/pluginStreaming.d.ts +10 -0
- package/backend/dist/utils/pluginStreaming.d.ts.map +1 -1
- package/backend/dist/utils/pluginStreaming.js.map +1 -1
- package/backend/dist/utils/pluginValidation.d.ts +1 -0
- package/backend/dist/utils/pluginValidation.d.ts.map +1 -1
- package/backend/dist/utils/pluginValidation.js +3 -0
- package/backend/dist/utils/pluginValidation.js.map +1 -1
- package/backend/package.json +1 -1
- package/electron/main.js +39 -16
- package/frontend/dist/assets/index-DkShhNBf.js +5 -0
- package/frontend/dist/assets/index-bdSWBsAM.css +1 -0
- package/frontend/dist/index.html +5 -4
- package/frontend/dist/js/{ArtifactContainer-CuvIIoHC.js → ArtifactContainer-BHSBrlQB.js} +1 -1
- package/frontend/dist/js/{ArtifactDemoPage-DI0QzAee.js → ArtifactDemoPage-BHGJmJAT.js} +1 -1
- package/frontend/dist/js/ArtifactSlideOutPanel-s8vcE5Ea.js +1 -0
- package/frontend/dist/js/ChatPage-B6Hm_pdu.js +16 -0
- package/frontend/dist/js/FirstTimeSetup-BYWAVAdE.js +1 -0
- package/frontend/dist/js/GalleryPage-CH4f65q8.js +1 -0
- package/frontend/dist/js/LibreClawPage-CcH7M3mJ.js +1 -0
- package/frontend/dist/js/{ModelManagerModals-DfgizeSq.js → ModelManagerModals-DltRprHU.js} +1 -1
- package/frontend/dist/js/ModelSelector-T1KZtIde.js +1 -0
- package/frontend/dist/js/ModelsPage-C1bM_Yoc.js +2 -0
- package/frontend/dist/js/PersonasPage-T_PrhnPG.js +13 -0
- package/frontend/dist/js/RichMessageContent-BdU5vXz9.js +3 -0
- package/frontend/dist/js/SettingsModal-BVj-D1L4.js +1 -0
- package/frontend/dist/js/UserManagementPage-Pix71oRW.js +1 -0
- package/frontend/dist/js/WorkPage-DdOVGyj_.js +2 -0
- package/frontend/dist/js/api-Do6u0tMM.js +19 -0
- package/frontend/dist/js/{ar-UnnZB1Qy.js → ar-KceI0PfT.js} +2 -2
- package/frontend/dist/js/babel-BnLuavNv.js +16 -0
- package/frontend/dist/js/{bn-uZXaDyeV.js → bn-DsKo2dzS.js} +2 -2
- package/frontend/dist/js/{cs-CCXlnUEB.js → cs-6UKhKj0A.js} +2 -2
- package/frontend/dist/js/{da--BpVVGPp.js → da-CJgZ3YuL.js} +2 -2
- package/frontend/dist/js/{de-DZd9lsBc.js → de-BZAmOuCT.js} +2 -2
- package/frontend/dist/js/{es-Bm968oUX.js → es-CD4u7no9.js} +2 -2
- package/frontend/dist/js/estree-Bu_-Wi6O.js +44 -0
- package/frontend/dist/js/fr-Cou2jOS3.js +2 -0
- package/frontend/dist/js/{hi-C6kwkL7d.js → hi-ZxkbNCyB.js} +2 -2
- package/frontend/dist/js/html-Ca764Rus.js +25 -0
- package/frontend/dist/js/{id-CDfqnnXS.js → id-DT9CqaWQ.js} +2 -2
- package/frontend/dist/js/{is-Dnd_7Jqv.js → is-BWSbAIoq.js} +2 -2
- package/frontend/dist/js/{it-BM0j6_Sz.js → it-CdNoTXm3.js} +2 -2
- package/frontend/dist/js/{ja-xGblKDDf.js → ja-CIuQg0xv.js} +2 -2
- package/frontend/dist/js/{ko-CQ2d_Oj0.js → ko-BWRUjDOH.js} +2 -2
- package/frontend/dist/js/markdown-DaiQGPEs.js +70 -0
- package/frontend/dist/js/{ms-CFpRuv_a.js → ms-gJstIj2a.js} +2 -2
- package/frontend/dist/js/{nl-D6oTztmP.js → nl-CydjVNUw.js} +2 -2
- package/frontend/dist/js/{pl-ByuvABul.js → pl-DPN9mAa5.js} +2 -2
- package/frontend/dist/js/postcss-DSDkwgFE.js +62 -0
- package/frontend/dist/js/{pt-CNFV21T7.js → pt-MRXGqIjQ.js} +2 -2
- package/frontend/dist/js/router-vendor-BjDI9mRe.js +1 -0
- package/frontend/dist/js/{ru-CRsSOntz.js → ru-CvQ9GkJF.js} +2 -2
- package/frontend/dist/js/standalone-DWWnsbIr.js +29 -0
- package/frontend/dist/js/{sv-BRkwjyS5.js → sv-BxS2fvhU.js} +2 -2
- package/frontend/dist/js/{th-DigqsQEB.js → th-DMxyn6e4.js} +2 -2
- package/frontend/dist/js/{tr-B5aMUMJE.js → tr-BTPldUaA.js} +2 -2
- package/frontend/dist/js/typescript-CatjJeHm.js +22 -0
- package/frontend/dist/js/ui-vendor-CAFEDSaO.js +177 -0
- package/frontend/dist/js/{uk-BUYi3llT.js → uk-BGxZOKgp.js} +2 -2
- package/frontend/dist/js/useBaseQuery-CVNuhuID.js +1 -0
- package/frontend/dist/js/useQuery-CYhC_da4.js +1 -0
- package/frontend/dist/js/{vi-hQIcXi3m.js → vi-82JkwFsQ.js} +2 -2
- package/frontend/dist/js/yaml-mLy9chOK.js +154 -0
- package/frontend/dist/js/{zh-CUDZ3JDv.js → zh-Bxxfwci3.js} +2 -2
- package/package.json +4 -2
- package/plugins/kimi-code.json +6 -37
- package/plugins/mlx-lm.json +55 -0
- package/frontend/dist/assets/index-BKzwVaAM.js +0 -16
- package/frontend/dist/assets/index-BPvF-fpc.css +0 -1
- package/frontend/dist/js/ArtifactSlideOutPanel-BzBemzHy.js +0 -1
- package/frontend/dist/js/ChatPage-DqpXctC2.js +0 -16
- package/frontend/dist/js/FirstTimeSetup-DRWeCsVI.js +0 -1
- package/frontend/dist/js/GalleryPage-CgD8fVx0.js +0 -1
- package/frontend/dist/js/LibreClawPage-B_Yon2fQ.js +0 -1
- package/frontend/dist/js/ModelsPage-CAKQ7J-b.js +0 -2
- package/frontend/dist/js/PersonasPage-Dj6Bka4I.js +0 -13
- package/frontend/dist/js/RichMessageContent-DP7XoxIa.js +0 -3
- package/frontend/dist/js/SettingsModal-BFyYhZYE.js +0 -1
- package/frontend/dist/js/UserManagementPage-CGMnhZr1.js +0 -1
- package/frontend/dist/js/fr-RlC0rD8I.js +0 -2
- package/frontend/dist/js/router-vendor-Bbvzv1ZC.js +0 -1
- package/frontend/dist/js/ui-vendor-CurEAeCe.js +0 -177
- package/frontend/dist/js/useBaseQuery-B1on3xcz.js +0 -1
- package/frontend/dist/js/useQuery-CXvo2RBT.js +0 -1
|
@@ -0,0 +1,1860 @@
|
|
|
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 { createHash } from 'node:crypto';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { getDatabase } from '../db.js';
|
|
21
|
+
import { createLogger } from '../utils/logger.js';
|
|
22
|
+
const logger = createLogger('services:work-runtime');
|
|
23
|
+
const activeDockerProcesses = new Set();
|
|
24
|
+
export const WORK_RUNTIME_DEFAULTS = {
|
|
25
|
+
image: 'node:22.22-bookworm@sha256:2d178f2785b96dfbf62a416ca2e40f50e30150b4ff3320d706f0d96e90600eb3',
|
|
26
|
+
dockerCommand: 'docker',
|
|
27
|
+
commandTimeoutMs: 120000,
|
|
28
|
+
maxOutputChars: 50000,
|
|
29
|
+
maxAgentRounds: 48,
|
|
30
|
+
memoryLimit: '2g',
|
|
31
|
+
cpuLimit: '2',
|
|
32
|
+
pidsLimit: 256,
|
|
33
|
+
previewPort: 4173,
|
|
34
|
+
};
|
|
35
|
+
export const WORK_RUNTIME_ADMISSION_DEFAULTS = {
|
|
36
|
+
maxActiveRuntimesGlobal: 2,
|
|
37
|
+
maxActiveRuntimesPerUser: 1,
|
|
38
|
+
};
|
|
39
|
+
const config = {
|
|
40
|
+
image: process.env.WORK_RUNTIME_IMAGE || WORK_RUNTIME_DEFAULTS.image,
|
|
41
|
+
dockerCommand: process.env.WORK_DOCKER_COMMAND || WORK_RUNTIME_DEFAULTS.dockerCommand,
|
|
42
|
+
commandTimeoutMs: positiveInteger(process.env.WORK_COMMAND_TIMEOUT_MS, WORK_RUNTIME_DEFAULTS.commandTimeoutMs),
|
|
43
|
+
maxOutputChars: positiveInteger(process.env.WORK_MAX_OUTPUT_CHARS, WORK_RUNTIME_DEFAULTS.maxOutputChars),
|
|
44
|
+
maxAgentRounds: positiveInteger(process.env.WORK_MAX_AGENT_ROUNDS, WORK_RUNTIME_DEFAULTS.maxAgentRounds),
|
|
45
|
+
memoryLimit: process.env.WORK_MEMORY_LIMIT || WORK_RUNTIME_DEFAULTS.memoryLimit,
|
|
46
|
+
cpuLimit: process.env.WORK_CPU_LIMIT || WORK_RUNTIME_DEFAULTS.cpuLimit,
|
|
47
|
+
pidsLimit: positiveInteger(process.env.WORK_PIDS_LIMIT, WORK_RUNTIME_DEFAULTS.pidsLimit),
|
|
48
|
+
previewPort: positiveInteger(process.env.WORK_PREVIEW_PORT, WORK_RUNTIME_DEFAULTS.previewPort),
|
|
49
|
+
maxActiveRuntimesGlobal: positiveInteger(process.env.WORK_MAX_ACTIVE_RUNTIMES_GLOBAL, WORK_RUNTIME_ADMISSION_DEFAULTS.maxActiveRuntimesGlobal),
|
|
50
|
+
maxActiveRuntimesPerUser: positiveInteger(process.env.WORK_MAX_ACTIVE_RUNTIMES_PER_USER, WORK_RUNTIME_ADMISSION_DEFAULTS.maxActiveRuntimesPerUser),
|
|
51
|
+
};
|
|
52
|
+
const PREVIEW_READY_TIMEOUT_MS = 15000;
|
|
53
|
+
const PREVIEW_POLL_INTERVAL_MS = 250;
|
|
54
|
+
const runtimePolicyFingerprint = createHash('sha256')
|
|
55
|
+
.update(JSON.stringify({
|
|
56
|
+
version: 1,
|
|
57
|
+
memoryLimit: config.memoryLimit,
|
|
58
|
+
cpuLimit: config.cpuLimit,
|
|
59
|
+
pidsLimit: config.pidsLimit,
|
|
60
|
+
previewPort: config.previewPort,
|
|
61
|
+
}))
|
|
62
|
+
.digest('hex');
|
|
63
|
+
export class WorkRuntimeError extends Error {
|
|
64
|
+
constructor(message, status = 503, code = 'WORK_RUNTIME_UNAVAILABLE') {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = 'WorkRuntimeError';
|
|
67
|
+
this.status = status;
|
|
68
|
+
this.code = code;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export class WorkRuntimeService {
|
|
72
|
+
constructor() {
|
|
73
|
+
this.image = config.image;
|
|
74
|
+
this.previewPort = config.previewPort;
|
|
75
|
+
this.limits = {
|
|
76
|
+
maxRounds: config.maxAgentRounds,
|
|
77
|
+
commandTimeoutMs: config.commandTimeoutMs,
|
|
78
|
+
maxOutputChars: config.maxOutputChars,
|
|
79
|
+
};
|
|
80
|
+
this.lifecycleTails = new Map();
|
|
81
|
+
this.preparations = new Map();
|
|
82
|
+
this.retiringTasks = new Set();
|
|
83
|
+
this.networkPolicies = new Map();
|
|
84
|
+
this.activeCommands = new Set();
|
|
85
|
+
this.runtimeLeases = new Map();
|
|
86
|
+
this.previewLeaseReleases = new Map();
|
|
87
|
+
this.recoveryTasks = new Map();
|
|
88
|
+
this.shuttingDown = false;
|
|
89
|
+
}
|
|
90
|
+
get recoveryPending() {
|
|
91
|
+
return this.recoveryTasks.size > 0;
|
|
92
|
+
}
|
|
93
|
+
get recoveryPendingCount() {
|
|
94
|
+
return this.recoveryTasks.size;
|
|
95
|
+
}
|
|
96
|
+
assertAcceptingWork() {
|
|
97
|
+
if (this.shuttingDown) {
|
|
98
|
+
throw new WorkRuntimeError('The Work runtime is shutting down.', 503, 'WORK_RUNTIME_SHUTTING_DOWN');
|
|
99
|
+
}
|
|
100
|
+
if (this.recoveryPending) {
|
|
101
|
+
throw new WorkRuntimeError(`Work is temporarily unavailable while ${this.recoveryPendingCount} container cleanup(s) are retried.`, 503, 'WORK_RUNTIME_RECOVERING');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async isDockerAvailable() {
|
|
105
|
+
try {
|
|
106
|
+
await this.docker(['info', '--format', '{{.ServerVersion}}'], {
|
|
107
|
+
timeoutMs: 5000,
|
|
108
|
+
});
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async beginRecovery(tasks) {
|
|
116
|
+
for (const task of tasks) {
|
|
117
|
+
this.recoveryTasks.set(task.id, task);
|
|
118
|
+
}
|
|
119
|
+
const result = await this.sweepRecoveryTasks();
|
|
120
|
+
this.scheduleRecoverySweep();
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
acquireRuntimeLease(task) {
|
|
124
|
+
this.assertTaskIsActive(task);
|
|
125
|
+
const existing = this.runtimeLeases.get(task.id);
|
|
126
|
+
if (existing) {
|
|
127
|
+
if (existing.userId !== task.userId) {
|
|
128
|
+
throw new WorkRuntimeError('This Work runtime lease belongs to a different administrator.', 409, 'WORK_RUNTIME_LEASE_CONFLICT');
|
|
129
|
+
}
|
|
130
|
+
existing.holders += 1;
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const perUser = [...this.runtimeLeases.values()].filter(lease => lease.userId === task.userId).length;
|
|
134
|
+
if (perUser >= config.maxActiveRuntimesPerUser) {
|
|
135
|
+
throw new WorkRuntimeError(`This administrator already has ${config.maxActiveRuntimesPerUser} active Work runtime(s). Wait for another operation or preview to stop.`, 429, 'WORK_USER_RUNTIME_LIMIT');
|
|
136
|
+
}
|
|
137
|
+
if (this.runtimeLeases.size >= config.maxActiveRuntimesGlobal) {
|
|
138
|
+
throw new WorkRuntimeError(`This Libre WebUI instance already has ${config.maxActiveRuntimesGlobal} active Work runtime(s). Wait for another operation or preview to stop.`, 429, 'WORK_GLOBAL_RUNTIME_LIMIT');
|
|
139
|
+
}
|
|
140
|
+
this.runtimeLeases.set(task.id, { userId: task.userId, holders: 1 });
|
|
141
|
+
}
|
|
142
|
+
let released = false;
|
|
143
|
+
return () => {
|
|
144
|
+
if (released)
|
|
145
|
+
return;
|
|
146
|
+
released = true;
|
|
147
|
+
const lease = this.runtimeLeases.get(task.id);
|
|
148
|
+
if (!lease || lease.userId !== task.userId)
|
|
149
|
+
return;
|
|
150
|
+
lease.holders -= 1;
|
|
151
|
+
if (lease.holders <= 0) {
|
|
152
|
+
this.runtimeLeases.delete(task.id);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
assertRuntimeLease(task) {
|
|
157
|
+
const lease = this.runtimeLeases.get(task.id);
|
|
158
|
+
if (!lease || lease.userId !== task.userId || lease.holders < 1) {
|
|
159
|
+
throw new WorkRuntimeError('Work container acquisition requires a runtime capacity lease.', 503, 'WORK_RUNTIME_LEASE_REQUIRED');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
releasePreviewLease(taskId) {
|
|
163
|
+
if (!this.previewLeaseReleases.has(taskId))
|
|
164
|
+
return;
|
|
165
|
+
const release = this.previewLeaseReleases.get(taskId);
|
|
166
|
+
this.previewLeaseReleases.delete(taskId);
|
|
167
|
+
if (typeof release === 'function') {
|
|
168
|
+
release();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async prepare(task, signal) {
|
|
172
|
+
const releaseLease = this.acquireRuntimeLease(task);
|
|
173
|
+
this.assertTaskIsActive(task);
|
|
174
|
+
const active = this.preparations.get(task.id);
|
|
175
|
+
if (active) {
|
|
176
|
+
try {
|
|
177
|
+
await waitForAbortSignal(active, signal);
|
|
178
|
+
return releaseLease;
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
releaseLease();
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
let tracked;
|
|
186
|
+
tracked = (async () => {
|
|
187
|
+
await waitForAbortSignal(this.ensureImage(), signal);
|
|
188
|
+
this.assertTaskIsActive(task);
|
|
189
|
+
await this.withLifecycleLock(task.id, async () => {
|
|
190
|
+
this.assertTaskIsActive(task);
|
|
191
|
+
await this.prepareWithLock(task);
|
|
192
|
+
this.assertTaskIsActive(task);
|
|
193
|
+
});
|
|
194
|
+
})().finally(() => {
|
|
195
|
+
if (this.preparations.get(task.id) === tracked) {
|
|
196
|
+
this.preparations.delete(task.id);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
this.preparations.set(task.id, tracked);
|
|
200
|
+
try {
|
|
201
|
+
await tracked;
|
|
202
|
+
return releaseLease;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
releaseLease();
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
beginShutdown() {
|
|
210
|
+
this.shuttingDown = true;
|
|
211
|
+
if (this.recoveryTimer) {
|
|
212
|
+
clearTimeout(this.recoveryTimer);
|
|
213
|
+
this.recoveryTimer = undefined;
|
|
214
|
+
}
|
|
215
|
+
for (const child of activeDockerProcesses) {
|
|
216
|
+
child.kill('SIGKILL');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
beginTaskSuspension(taskId) {
|
|
220
|
+
if (this.retiringTasks.has(taskId)) {
|
|
221
|
+
throw new WorkRuntimeError('This Work task is already being suspended or deleted.', 409, 'WORK_TASK_REMOVING');
|
|
222
|
+
}
|
|
223
|
+
this.retiringTasks.add(taskId);
|
|
224
|
+
}
|
|
225
|
+
releaseTaskSuspension(taskId) {
|
|
226
|
+
this.retiringTasks.delete(taskId);
|
|
227
|
+
}
|
|
228
|
+
async sweepRecoveryTasks() {
|
|
229
|
+
if (this.recoveryTasks.size === 0) {
|
|
230
|
+
return { stopped: 0, failed: 0 };
|
|
231
|
+
}
|
|
232
|
+
if (this.shuttingDown || !(await this.isDockerAvailable())) {
|
|
233
|
+
return { stopped: 0, failed: this.recoveryTasks.size };
|
|
234
|
+
}
|
|
235
|
+
const tasks = [...this.recoveryTasks.values()];
|
|
236
|
+
const results = await Promise.allSettled(tasks.map(task => this.stopContainer(task)));
|
|
237
|
+
let stopped = 0;
|
|
238
|
+
results.forEach((result, index) => {
|
|
239
|
+
if (result.status === 'fulfilled') {
|
|
240
|
+
this.recoveryTasks.delete(tasks[index].id);
|
|
241
|
+
stopped += 1;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
logger.warn(`Could not stop Work container ${tasks[index].containerName} during recovery:`, result.reason);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
if (this.recoveryTasks.size === 0) {
|
|
248
|
+
logger.info('Work startup container recovery completed.');
|
|
249
|
+
}
|
|
250
|
+
return { stopped, failed: this.recoveryTasks.size };
|
|
251
|
+
}
|
|
252
|
+
scheduleRecoverySweep() {
|
|
253
|
+
if (this.shuttingDown ||
|
|
254
|
+
this.recoveryTasks.size === 0 ||
|
|
255
|
+
this.recoveryTimer) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
this.recoveryTimer = setTimeout(() => {
|
|
259
|
+
this.recoveryTimer = undefined;
|
|
260
|
+
void this.sweepRecoveryTasks()
|
|
261
|
+
.catch(error => {
|
|
262
|
+
logger.warn('Work startup container recovery retry failed:', error);
|
|
263
|
+
})
|
|
264
|
+
.finally(() => {
|
|
265
|
+
this.scheduleRecoverySweep();
|
|
266
|
+
});
|
|
267
|
+
}, 10000);
|
|
268
|
+
this.recoveryTimer.unref();
|
|
269
|
+
}
|
|
270
|
+
queueFailedCleanup(task, error) {
|
|
271
|
+
this.recoveryTasks.set(task.id, task);
|
|
272
|
+
logger.warn(`Work is fail-closed until container ${task.containerName} can be stopped:`, error);
|
|
273
|
+
this.scheduleRecoverySweep();
|
|
274
|
+
}
|
|
275
|
+
completeRecoveryTask(taskId) {
|
|
276
|
+
const removed = this.recoveryTasks.delete(taskId);
|
|
277
|
+
if (removed && this.recoveryTasks.size === 0) {
|
|
278
|
+
logger.info('Pending Work container cleanup completed.');
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
markPreviewStopped(taskId) {
|
|
282
|
+
try {
|
|
283
|
+
getDatabase()
|
|
284
|
+
.prepare(`UPDATE work_tasks
|
|
285
|
+
SET preview_status = 'stopped', preview_url = NULL, updated_at = ?
|
|
286
|
+
WHERE id = ?`)
|
|
287
|
+
.run(Date.now(), taskId);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
logger.warn(`Could not persist stopped preview state for Work task ${taskId}:`, error);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
async prepareWithLock(task) {
|
|
294
|
+
this.assertRuntimeLease(task);
|
|
295
|
+
this.assertCurrentNetworkPolicy(task);
|
|
296
|
+
this.assertTaskIsActive(task);
|
|
297
|
+
await this.ensureVolume(task);
|
|
298
|
+
this.assertTaskIsActive(task);
|
|
299
|
+
await this.ensureContainer(task);
|
|
300
|
+
if (this.shuttingDown) {
|
|
301
|
+
await this.stopContainerWithLock(task);
|
|
302
|
+
this.assertTaskIsActive(task);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async ensureContainer(task) {
|
|
306
|
+
this.assertRuntimeLease(task);
|
|
307
|
+
if (await this.containerExists(task.containerName)) {
|
|
308
|
+
await this.assertManagedContainer(task);
|
|
309
|
+
if (!(await this.containerMatchesTaskPolicy(task))) {
|
|
310
|
+
logger.warn(`Recreating Work container ${task.containerName} because its isolation policy is stale.`);
|
|
311
|
+
await this.docker(['rm', '-f', task.containerName]);
|
|
312
|
+
await this.docker(buildWorkContainerRunArgs(task, this.image));
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const state = await this.docker([
|
|
316
|
+
'inspect',
|
|
317
|
+
'--format',
|
|
318
|
+
'{{.State.Running}}',
|
|
319
|
+
task.containerName,
|
|
320
|
+
]);
|
|
321
|
+
if (state.stdout.trim() !== 'true') {
|
|
322
|
+
await this.docker(['start', task.containerName]);
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
await this.docker(buildWorkContainerRunArgs(task, this.image));
|
|
327
|
+
}
|
|
328
|
+
async recreateContainer(task) {
|
|
329
|
+
const releaseLease = this.acquireRuntimeLease(task);
|
|
330
|
+
try {
|
|
331
|
+
await this.ensureImage();
|
|
332
|
+
this.assertTaskIsActive(task);
|
|
333
|
+
await this.withLifecycleLock(task.id, async () => {
|
|
334
|
+
this.assertTaskIsActive(task);
|
|
335
|
+
await this.recreateContainerWithLock(task);
|
|
336
|
+
await this.stopContainerWithLock(task);
|
|
337
|
+
this.networkPolicies.set(task.id, task.networkEnabled);
|
|
338
|
+
});
|
|
339
|
+
this.releasePreviewLease(task.id);
|
|
340
|
+
this.markPreviewStopped(task.id);
|
|
341
|
+
}
|
|
342
|
+
finally {
|
|
343
|
+
releaseLease();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async changeNetworkPolicy(before, desired, commit) {
|
|
347
|
+
this.assertTaskIsActive(before);
|
|
348
|
+
if (desired.networkEnabled === before.networkEnabled) {
|
|
349
|
+
return this.withLifecycleLock(before.id, async () => {
|
|
350
|
+
this.assertTaskIsActive(before);
|
|
351
|
+
this.assertCurrentNetworkPolicy(before);
|
|
352
|
+
const result = commit();
|
|
353
|
+
this.networkPolicies.set(desired.id, desired.networkEnabled);
|
|
354
|
+
return result;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
const releaseLease = this.acquireRuntimeLease(before);
|
|
358
|
+
let previewStopped = false;
|
|
359
|
+
try {
|
|
360
|
+
await this.ensureImage();
|
|
361
|
+
this.assertTaskIsActive(before);
|
|
362
|
+
return await this.withLifecycleLock(before.id, async () => {
|
|
363
|
+
this.assertTaskIsActive(before);
|
|
364
|
+
this.assertCurrentNetworkPolicy(before);
|
|
365
|
+
try {
|
|
366
|
+
await this.recreateContainerWithLock(desired);
|
|
367
|
+
await this.stopContainerWithLock(desired);
|
|
368
|
+
previewStopped = true;
|
|
369
|
+
const result = commit();
|
|
370
|
+
this.networkPolicies.set(desired.id, desired.networkEnabled);
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
try {
|
|
375
|
+
await this.recreateContainerWithLock(before);
|
|
376
|
+
await this.stopContainerWithLock(before);
|
|
377
|
+
previewStopped = true;
|
|
378
|
+
this.networkPolicies.set(before.id, before.networkEnabled);
|
|
379
|
+
}
|
|
380
|
+
catch (rollbackError) {
|
|
381
|
+
logger.error(`Could not restore prior network policy for Work task ${before.id}:`, rollbackError);
|
|
382
|
+
}
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
if (previewStopped) {
|
|
389
|
+
this.releasePreviewLease(before.id);
|
|
390
|
+
this.markPreviewStopped(before.id);
|
|
391
|
+
}
|
|
392
|
+
releaseLease();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async recreateContainerWithLock(task) {
|
|
396
|
+
this.assertRuntimeLease(task);
|
|
397
|
+
this.assertTaskIsActive(task);
|
|
398
|
+
await this.ensureVolume(task);
|
|
399
|
+
this.assertTaskIsActive(task);
|
|
400
|
+
if (await this.containerExists(task.containerName)) {
|
|
401
|
+
await this.assertManagedContainer(task);
|
|
402
|
+
await this.docker(['rm', '-f', task.containerName]);
|
|
403
|
+
}
|
|
404
|
+
await this.ensureContainer(task);
|
|
405
|
+
if (this.shuttingDown) {
|
|
406
|
+
await this.stopContainerWithLock(task);
|
|
407
|
+
this.assertTaskIsActive(task);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async stopContainer(task) {
|
|
411
|
+
await this.withLifecycleLock(task.id, () => this.stopContainerWithLock(task));
|
|
412
|
+
this.releasePreviewLease(task.id);
|
|
413
|
+
this.markPreviewStopped(task.id);
|
|
414
|
+
this.completeRecoveryTask(task.id);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Interrupt an already-running helper, command, or preview without waiting
|
|
418
|
+
* behind its lifecycle queue. Callers must first suspend the task, then
|
|
419
|
+
* follow this with the regular serialized stop to catch a container that
|
|
420
|
+
* was still being created when this interrupt ran.
|
|
421
|
+
*/
|
|
422
|
+
async interruptContainer(task) {
|
|
423
|
+
await this.stopContainerWithLock(task);
|
|
424
|
+
this.releasePreviewLease(task.id);
|
|
425
|
+
this.markPreviewStopped(task.id);
|
|
426
|
+
this.completeRecoveryTask(task.id);
|
|
427
|
+
}
|
|
428
|
+
async stopContainerWithLock(task) {
|
|
429
|
+
try {
|
|
430
|
+
if (!(await this.containerExists(task.containerName)))
|
|
431
|
+
return;
|
|
432
|
+
await this.assertManagedContainer(task);
|
|
433
|
+
await this.docker(['stop', '--time', '1', task.containerName], {
|
|
434
|
+
timeoutMs: 10000,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
this.queueFailedCleanup(task, error);
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
async removeTask(task, allowRevokedOwner = false) {
|
|
443
|
+
if (allowRevokedOwner) {
|
|
444
|
+
this.assertTaskStillOwned(task);
|
|
445
|
+
}
|
|
446
|
+
else {
|
|
447
|
+
this.assertTaskOwnerIsAdmin(task);
|
|
448
|
+
}
|
|
449
|
+
this.retiringTasks.add(task.id);
|
|
450
|
+
try {
|
|
451
|
+
await this.withLifecycleLock(task.id, async () => {
|
|
452
|
+
const [hasContainer, hasVolume] = await Promise.all([
|
|
453
|
+
this.containerExists(task.containerName),
|
|
454
|
+
this.volumeExists(task.volumeName),
|
|
455
|
+
]);
|
|
456
|
+
// Validate every destructive target before removing either one, so a
|
|
457
|
+
// conflicting unmanaged Docker resource cannot cause partial cleanup.
|
|
458
|
+
if (hasContainer) {
|
|
459
|
+
await this.assertManagedContainer(task);
|
|
460
|
+
}
|
|
461
|
+
if (hasVolume) {
|
|
462
|
+
await this.assertManagedVolume(task);
|
|
463
|
+
}
|
|
464
|
+
if (hasContainer) {
|
|
465
|
+
await this.docker(['rm', '-f', task.containerName], {
|
|
466
|
+
timeoutMs: 15000,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
if (hasVolume) {
|
|
470
|
+
await this.docker(['volume', 'rm', task.volumeName], {
|
|
471
|
+
timeoutMs: 15000,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
this.retiringTasks.delete(task.id);
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
finalizeTaskRemoval(taskId) {
|
|
482
|
+
this.retiringTasks.delete(taskId);
|
|
483
|
+
this.networkPolicies.delete(taskId);
|
|
484
|
+
this.preparations.delete(taskId);
|
|
485
|
+
this.activeCommands.delete(taskId);
|
|
486
|
+
this.releasePreviewLease(taskId);
|
|
487
|
+
this.runtimeLeases.delete(taskId);
|
|
488
|
+
}
|
|
489
|
+
async listFiles(task, requestedPath = '.') {
|
|
490
|
+
const workspacePath = validateWorkspacePath(requestedPath);
|
|
491
|
+
return this.withWorkspaceHelperContainer(task, async () => {
|
|
492
|
+
const result = await this.exec(task, ['node', '-e', LIST_FILES_SCRIPT, '--', workspacePath], { maxOutputChars: 2000000 });
|
|
493
|
+
const payload = parseJsonOutput(result.stdout);
|
|
494
|
+
return { path: workspacePath, entries: payload.entries };
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
async withWorkspaceHelperContainer(task, operation) {
|
|
498
|
+
const releaseLease = this.acquireRuntimeLease(task);
|
|
499
|
+
try {
|
|
500
|
+
await this.ensureImage();
|
|
501
|
+
this.assertTaskIsActive(task);
|
|
502
|
+
return await this.withLifecycleLock(task.id, async () => {
|
|
503
|
+
this.assertTaskIsActive(task);
|
|
504
|
+
await this.prepareWithLock(task);
|
|
505
|
+
this.assertTaskIsActive(task);
|
|
506
|
+
try {
|
|
507
|
+
return await operation();
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
await this.stopContainerIfIdleWithLock(task);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
finally {
|
|
515
|
+
releaseLease();
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
async stopContainerIfIdleWithLock(task) {
|
|
519
|
+
if (this.activeCommands.has(task.id))
|
|
520
|
+
return;
|
|
521
|
+
try {
|
|
522
|
+
if ((await this.previewProcessCheckWithLock(task)) === 'ready' &&
|
|
523
|
+
this.previewLeaseReleases.has(task.id)) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
catch (error) {
|
|
528
|
+
logger.warn(`Could not verify preview state before idling Work container ${task.containerName}; stopping it:`, error);
|
|
529
|
+
}
|
|
530
|
+
await this.stopContainerWithLock(task);
|
|
531
|
+
this.releasePreviewLease(task.id);
|
|
532
|
+
this.markPreviewStopped(task.id);
|
|
533
|
+
this.completeRecoveryTask(task.id);
|
|
534
|
+
}
|
|
535
|
+
async readFile(task, requestedPath) {
|
|
536
|
+
const workspacePath = validateWorkspacePath(requestedPath, false);
|
|
537
|
+
return this.withWorkspaceHelperContainer(task, async () => {
|
|
538
|
+
const result = await this.exec(task, ['node', '-e', READ_FILE_SCRIPT, '--', workspacePath],
|
|
539
|
+
// JSON may escape each input byte as a six-character Unicode sequence.
|
|
540
|
+
{ maxOutputChars: 12100000 });
|
|
541
|
+
const payload = parseJsonOutput(result.stdout);
|
|
542
|
+
return {
|
|
543
|
+
path: workspacePath,
|
|
544
|
+
...payload,
|
|
545
|
+
modifiedAt: payload.updatedAt,
|
|
546
|
+
};
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
async writeFile(task, requestedPath, content, expectedUpdatedAt) {
|
|
550
|
+
const workspacePath = validateWorkspacePath(requestedPath, false);
|
|
551
|
+
if (Buffer.byteLength(content, 'utf8') > 2000000) {
|
|
552
|
+
throw new WorkRuntimeError('File content exceeds the 2,000,000 byte limit.', 413, 'WORK_FILE_TOO_LARGE');
|
|
553
|
+
}
|
|
554
|
+
if (expectedUpdatedAt !== undefined &&
|
|
555
|
+
(!Number.isFinite(expectedUpdatedAt) || expectedUpdatedAt < 0)) {
|
|
556
|
+
throw new WorkRuntimeError('expectedUpdatedAt must be a non-negative number.', 400, 'WORK_INVALID_FILE_VERSION');
|
|
557
|
+
}
|
|
558
|
+
return this.withWorkspaceHelperContainer(task, async () => {
|
|
559
|
+
const result = await this.exec(task, [
|
|
560
|
+
'node',
|
|
561
|
+
'-e',
|
|
562
|
+
WRITE_FILE_SCRIPT,
|
|
563
|
+
'--',
|
|
564
|
+
workspacePath,
|
|
565
|
+
expectedUpdatedAt === undefined ? '' : String(expectedUpdatedAt),
|
|
566
|
+
], { input: content, maxOutputChars: 10000, acceptFailure: true });
|
|
567
|
+
if (result.exitCode === 23) {
|
|
568
|
+
throw new WorkRuntimeError('The file changed since it was opened. Reload it before saving.', 409, 'WORK_FILE_CONFLICT');
|
|
569
|
+
}
|
|
570
|
+
if (result.exitCode !== 0) {
|
|
571
|
+
throw new WorkRuntimeError(result.stderr.trim() || 'Could not write workspace file.', 503, 'WORK_FILE_WRITE_FAILED');
|
|
572
|
+
}
|
|
573
|
+
const payload = parseJsonOutput(result.stdout);
|
|
574
|
+
return {
|
|
575
|
+
path: workspacePath,
|
|
576
|
+
content,
|
|
577
|
+
...payload,
|
|
578
|
+
modifiedAt: payload.updatedAt,
|
|
579
|
+
};
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
async searchFiles(task, query, requestedPath = '.') {
|
|
583
|
+
const workspacePath = validateWorkspacePath(requestedPath);
|
|
584
|
+
if (!query.trim() || query.length > 500) {
|
|
585
|
+
throw new WorkRuntimeError('Search query must contain 1 to 500 characters.', 400, 'WORK_INVALID_SEARCH');
|
|
586
|
+
}
|
|
587
|
+
return this.withWorkspaceHelperContainer(task, () => this.exec(task, ['node', '-e', SEARCH_FILES_SCRIPT, '--', workspacePath, query], { maxOutputChars: config.maxOutputChars }));
|
|
588
|
+
}
|
|
589
|
+
async runCommand(task, command, timeoutMs = config.commandTimeoutMs) {
|
|
590
|
+
if (!command.trim() || command.length > 20000) {
|
|
591
|
+
throw new WorkRuntimeError('Command must contain 1 to 20,000 characters.', 400, 'WORK_INVALID_COMMAND');
|
|
592
|
+
}
|
|
593
|
+
const boundedTimeout = Math.min(Math.max(timeoutMs, 1000), 600000);
|
|
594
|
+
const releaseLease = this.acquireRuntimeLease(task);
|
|
595
|
+
let commandRegistered = false;
|
|
596
|
+
try {
|
|
597
|
+
await this.ensureImage();
|
|
598
|
+
this.assertTaskIsActive(task);
|
|
599
|
+
await this.withLifecycleLock(task.id, async () => {
|
|
600
|
+
this.assertTaskIsActive(task);
|
|
601
|
+
try {
|
|
602
|
+
await this.prepareWithLock(task);
|
|
603
|
+
this.assertTaskIsActive(task);
|
|
604
|
+
if (this.activeCommands.has(task.id)) {
|
|
605
|
+
throw new WorkRuntimeError('This Work task already has an active command.', 409, 'WORK_COMMAND_ACTIVE');
|
|
606
|
+
}
|
|
607
|
+
const previewState = await this.previewProcessCheckWithLock(task);
|
|
608
|
+
if (previewState === 'ready' || previewState === 'alive') {
|
|
609
|
+
throw new WorkRuntimeError('Stop the Work preview before running another command.', 409, 'WORK_COMMAND_REQUIRES_STOPPED_PREVIEW');
|
|
610
|
+
}
|
|
611
|
+
this.activeCommands.add(task.id);
|
|
612
|
+
commandRegistered = true;
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
await this.stopContainerIfIdleWithLock(task);
|
|
616
|
+
throw error;
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
return await this.exec(task, [
|
|
620
|
+
'timeout',
|
|
621
|
+
'--signal=TERM',
|
|
622
|
+
'--kill-after=3s',
|
|
623
|
+
`${Math.ceil(boundedTimeout / 1000)}s`,
|
|
624
|
+
'/bin/bash',
|
|
625
|
+
'-c',
|
|
626
|
+
MANAGED_COMMAND_SCRIPT,
|
|
627
|
+
'--',
|
|
628
|
+
command,
|
|
629
|
+
], {
|
|
630
|
+
timeoutMs: boundedTimeout + 5000,
|
|
631
|
+
maxOutputChars: config.maxOutputChars,
|
|
632
|
+
acceptFailure: true,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
finally {
|
|
636
|
+
try {
|
|
637
|
+
if (commandRegistered) {
|
|
638
|
+
// This also kills intentionally detached descendants that escaped
|
|
639
|
+
// the managed process group. The named volume remains durable.
|
|
640
|
+
await this.stopContainer(task);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
finally {
|
|
644
|
+
this.activeCommands.delete(task.id);
|
|
645
|
+
releaseLease();
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async startPreview(task, command, hooks = {}) {
|
|
650
|
+
if (!task.networkEnabled) {
|
|
651
|
+
throw new WorkRuntimeError('Preview requires network access. Enable network access for this Work task first.', 409, 'WORK_PREVIEW_REQUIRES_NETWORK');
|
|
652
|
+
}
|
|
653
|
+
const previewCommand = command?.trim();
|
|
654
|
+
if (previewCommand && previewCommand.length > 20000) {
|
|
655
|
+
throw new WorkRuntimeError('Preview command is too long.', 400, 'WORK_INVALID_COMMAND');
|
|
656
|
+
}
|
|
657
|
+
const releaseLease = this.acquireRuntimeLease(task);
|
|
658
|
+
let leaseRetained = false;
|
|
659
|
+
try {
|
|
660
|
+
await this.ensureImage();
|
|
661
|
+
this.assertTaskIsActive(task);
|
|
662
|
+
const url = await this.withLifecycleLock(task.id, async () => {
|
|
663
|
+
this.assertTaskIsActive(task);
|
|
664
|
+
this.assertCurrentNetworkPolicy(task);
|
|
665
|
+
if (this.activeCommands.has(task.id)) {
|
|
666
|
+
throw new WorkRuntimeError('Wait for the active command to finish before starting a preview.', 409, 'WORK_PREVIEW_COMMAND_ACTIVE');
|
|
667
|
+
}
|
|
668
|
+
hooks.onStarting?.();
|
|
669
|
+
try {
|
|
670
|
+
await this.prepareWithLock(task);
|
|
671
|
+
this.assertTaskIsActive(task);
|
|
672
|
+
const previewLaunch = previewCommand
|
|
673
|
+
? {
|
|
674
|
+
kind: 'shell',
|
|
675
|
+
workdir: '/workspace',
|
|
676
|
+
command: previewCommand,
|
|
677
|
+
}
|
|
678
|
+
: await this.detectPreviewLaunch(task);
|
|
679
|
+
const previewUrl = await this.startPreviewPrepared(task, previewLaunch);
|
|
680
|
+
this.assertTaskIsActive(task);
|
|
681
|
+
hooks.onRunning?.(previewUrl);
|
|
682
|
+
return previewUrl;
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
hooks.onFailed?.();
|
|
686
|
+
this.releasePreviewLease(task.id);
|
|
687
|
+
throw error;
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
if (this.previewLeaseReleases.has(task.id)) {
|
|
691
|
+
releaseLease();
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
this.previewLeaseReleases.set(task.id, releaseLease);
|
|
695
|
+
}
|
|
696
|
+
leaseRetained = true;
|
|
697
|
+
return url;
|
|
698
|
+
}
|
|
699
|
+
finally {
|
|
700
|
+
if (!leaseRetained) {
|
|
701
|
+
releaseLease();
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
async detectPreviewLaunch(task) {
|
|
706
|
+
try {
|
|
707
|
+
const result = await this.docker([
|
|
708
|
+
'exec',
|
|
709
|
+
'--user',
|
|
710
|
+
'1000:1000',
|
|
711
|
+
'--workdir',
|
|
712
|
+
'/workspace',
|
|
713
|
+
task.containerName,
|
|
714
|
+
'node',
|
|
715
|
+
'-e',
|
|
716
|
+
PREVIEW_TARGET_SCRIPT,
|
|
717
|
+
'--',
|
|
718
|
+
'/workspace',
|
|
719
|
+
], {
|
|
720
|
+
acceptFailure: true,
|
|
721
|
+
timeoutMs: 5000,
|
|
722
|
+
maxOutputChars: 10000,
|
|
723
|
+
});
|
|
724
|
+
if (result.exitCode !== 0) {
|
|
725
|
+
throw new WorkRuntimeError(`Could not inspect the workspace for a previewable app.${result.stderr.trim() ? `\n${result.stderr.trim()}` : ''}`, 422, 'WORK_PREVIEW_DETECTION_FAILED');
|
|
726
|
+
}
|
|
727
|
+
const detection = parseJsonOutput(result.stdout);
|
|
728
|
+
if (!detection ||
|
|
729
|
+
!['npm', 'static', 'none', 'ambiguous'].includes(detection.kind)) {
|
|
730
|
+
throw new WorkRuntimeError('Workspace preview detection returned an invalid response.', 500, 'WORK_HELPER_INVALID_RESPONSE');
|
|
731
|
+
}
|
|
732
|
+
if (detection.kind === 'none') {
|
|
733
|
+
throw new WorkRuntimeError('No previewable app was found. Add an index.html file or a package.json dev script, or enter a custom start command.', 422, 'WORK_PREVIEW_NOT_FOUND');
|
|
734
|
+
}
|
|
735
|
+
if (detection.kind === 'ambiguous') {
|
|
736
|
+
const candidates = (detection.candidates || []).slice(0, 8).join(', ');
|
|
737
|
+
throw new WorkRuntimeError(`More than one previewable app was found${candidates ? `: ${candidates}` : ''}. Custom commands run from /workspace, so select the intended app with a command such as "cd <app-directory> && npm run dev".`, 422, 'WORK_PREVIEW_AMBIGUOUS');
|
|
738
|
+
}
|
|
739
|
+
if (!isSafePreviewWorkdir(detection.workdir)) {
|
|
740
|
+
throw new WorkRuntimeError('Workspace preview detection returned an unsafe working directory.', 500, 'WORK_HELPER_INVALID_RESPONSE');
|
|
741
|
+
}
|
|
742
|
+
if (detection.kind === 'static') {
|
|
743
|
+
return { kind: 'static', workdir: detection.workdir };
|
|
744
|
+
}
|
|
745
|
+
if (detection.runner !== undefined &&
|
|
746
|
+
detection.runner !== 'standard' &&
|
|
747
|
+
detection.runner !== 'next') {
|
|
748
|
+
throw new WorkRuntimeError('Workspace preview detection returned an unsupported npm runner.', 500, 'WORK_HELPER_INVALID_RESPONSE');
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
kind: 'shell',
|
|
752
|
+
workdir: detection.workdir,
|
|
753
|
+
command: detection.runner === 'next'
|
|
754
|
+
? `npm run dev -- --hostname 0.0.0.0 --port ${config.previewPort}`
|
|
755
|
+
: `npm run dev -- --host 0.0.0.0 --port ${config.previewPort}`,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
try {
|
|
760
|
+
await this.stopPreviewPrepared(task);
|
|
761
|
+
}
|
|
762
|
+
catch (cleanupError) {
|
|
763
|
+
logger.warn(`Could not clean up failed preview detection for Work task ${task.id}:`, cleanupError);
|
|
764
|
+
}
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
async startPreviewPrepared(task, previewLaunch) {
|
|
769
|
+
try {
|
|
770
|
+
const launch = await this.docker([
|
|
771
|
+
'exec',
|
|
772
|
+
'--user',
|
|
773
|
+
'1000:1000',
|
|
774
|
+
'--workdir',
|
|
775
|
+
previewLaunch.workdir,
|
|
776
|
+
task.containerName,
|
|
777
|
+
'/bin/bash',
|
|
778
|
+
'-lc',
|
|
779
|
+
`if [ -s /tmp/libre-work-preview.pid ] &&
|
|
780
|
+
kill -0 "$(cat /tmp/libre-work-preview.pid)" 2>/dev/null; then
|
|
781
|
+
exit 17
|
|
782
|
+
fi
|
|
783
|
+
if [ "$1" = "static" ]; then
|
|
784
|
+
nohup setsid node -e "$2" -- "$3" \
|
|
785
|
+
> /tmp/libre-work-preview.log 2>&1 < /dev/null &
|
|
786
|
+
elif [ "$1" = "shell" ]; then
|
|
787
|
+
nohup setsid /bin/bash -lc "$2" \
|
|
788
|
+
> /tmp/libre-work-preview.log 2>&1 < /dev/null &
|
|
789
|
+
else
|
|
790
|
+
exit 18
|
|
791
|
+
fi
|
|
792
|
+
echo $! > /tmp/libre-work-preview.pid`,
|
|
793
|
+
'--',
|
|
794
|
+
previewLaunch.kind,
|
|
795
|
+
previewLaunch.kind === 'static'
|
|
796
|
+
? STATIC_PREVIEW_SERVER_SCRIPT
|
|
797
|
+
: previewLaunch.command,
|
|
798
|
+
String(config.previewPort),
|
|
799
|
+
], { acceptFailure: true, timeoutMs: 5000 });
|
|
800
|
+
if (launch.exitCode !== 0 && launch.exitCode !== 17) {
|
|
801
|
+
throw await this.previewStartError(task, 'Preview process could not be launched.', launch.stderr);
|
|
802
|
+
}
|
|
803
|
+
const deadline = Date.now() + PREVIEW_READY_TIMEOUT_MS;
|
|
804
|
+
while (true) {
|
|
805
|
+
const readiness = await this.docker([
|
|
806
|
+
'exec',
|
|
807
|
+
'--user',
|
|
808
|
+
'1000:1000',
|
|
809
|
+
task.containerName,
|
|
810
|
+
'node',
|
|
811
|
+
'-e',
|
|
812
|
+
PREVIEW_READY_SCRIPT,
|
|
813
|
+
'--',
|
|
814
|
+
String(config.previewPort),
|
|
815
|
+
], { acceptFailure: true, timeoutMs: 2000, maxOutputChars: 2000 });
|
|
816
|
+
if (readiness.exitCode === 0)
|
|
817
|
+
break;
|
|
818
|
+
if (readiness.exitCode !== 3) {
|
|
819
|
+
throw await this.previewStartError(task, 'Preview command exited before becoming ready.', readiness.stderr);
|
|
820
|
+
}
|
|
821
|
+
if (Date.now() >= deadline) {
|
|
822
|
+
throw await this.previewStartError(task, `Preview did not listen on port ${config.previewPort} within ${PREVIEW_READY_TIMEOUT_MS / 1000} seconds.`);
|
|
823
|
+
}
|
|
824
|
+
await new Promise(resolve => setTimeout(resolve, PREVIEW_POLL_INTERVAL_MS));
|
|
825
|
+
}
|
|
826
|
+
const portResult = await this.docker([
|
|
827
|
+
'port',
|
|
828
|
+
task.containerName,
|
|
829
|
+
`${config.previewPort}/tcp`,
|
|
830
|
+
]);
|
|
831
|
+
const publishedPort = parsePublishedPort(portResult.stdout, config.previewPort);
|
|
832
|
+
if (!publishedPort) {
|
|
833
|
+
throw new WorkRuntimeError('Docker did not publish the preview port.', 503, 'WORK_PREVIEW_PORT_UNAVAILABLE');
|
|
834
|
+
}
|
|
835
|
+
return `http://127.0.0.1:${publishedPort}`;
|
|
836
|
+
}
|
|
837
|
+
catch (error) {
|
|
838
|
+
try {
|
|
839
|
+
await this.stopPreviewPrepared(task);
|
|
840
|
+
}
|
|
841
|
+
catch (cleanupError) {
|
|
842
|
+
logger.warn(`Could not clean up failed preview for Work task ${task.id}:`, cleanupError);
|
|
843
|
+
}
|
|
844
|
+
throw error;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
async previewStartError(task, reason, detail = '') {
|
|
848
|
+
const log = await this.docker([
|
|
849
|
+
'exec',
|
|
850
|
+
'--user',
|
|
851
|
+
'1000:1000',
|
|
852
|
+
task.containerName,
|
|
853
|
+
'/bin/bash',
|
|
854
|
+
'-lc',
|
|
855
|
+
'tail -c 4000 /tmp/libre-work-preview.log 2>/dev/null || true',
|
|
856
|
+
], { acceptFailure: true, timeoutMs: 5000, maxOutputChars: 4000 });
|
|
857
|
+
const diagnostic = [detail.trim(), log.stdout.trim()]
|
|
858
|
+
.filter(Boolean)
|
|
859
|
+
.join('\n');
|
|
860
|
+
return new WorkRuntimeError(`${reason}${diagnostic ? `\n${diagnostic}` : ''}`, 422, 'WORK_PREVIEW_START_FAILED');
|
|
861
|
+
}
|
|
862
|
+
async stopPreview(task, hooks = {}) {
|
|
863
|
+
let stopped = false;
|
|
864
|
+
try {
|
|
865
|
+
await this.withLifecycleLock(task.id, async () => {
|
|
866
|
+
this.assertCurrentNetworkPolicy(task);
|
|
867
|
+
if (this.activeCommands.has(task.id)) {
|
|
868
|
+
throw new WorkRuntimeError('Wait for the active command to finish before stopping a preview.', 409, 'WORK_PREVIEW_COMMAND_ACTIVE');
|
|
869
|
+
}
|
|
870
|
+
await this.stopPreviewPrepared(task);
|
|
871
|
+
stopped = true;
|
|
872
|
+
hooks.onStopped?.();
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
finally {
|
|
876
|
+
if (stopped) {
|
|
877
|
+
this.releasePreviewLease(task.id);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
async isPreviewRunning(task) {
|
|
882
|
+
return this.withLifecycleLock(task.id, async () => {
|
|
883
|
+
this.assertCurrentNetworkPolicy(task);
|
|
884
|
+
// An ordinary command owns this running container and will stop it when
|
|
885
|
+
// it finishes. Preview reconciliation must not interrupt that command.
|
|
886
|
+
if (this.activeCommands.has(task.id))
|
|
887
|
+
return false;
|
|
888
|
+
const state = await this.previewProcessCheckWithLock(task);
|
|
889
|
+
if (state === 'ready' && this.previewLeaseReleases.has(task.id)) {
|
|
890
|
+
return true;
|
|
891
|
+
}
|
|
892
|
+
if (state === 'dead' || state === 'alive') {
|
|
893
|
+
await this.stopPreviewPrepared(task);
|
|
894
|
+
}
|
|
895
|
+
if (state === 'ready') {
|
|
896
|
+
await this.stopPreviewPrepared(task);
|
|
897
|
+
}
|
|
898
|
+
this.releasePreviewLease(task.id);
|
|
899
|
+
return false;
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
async previewProcessCheckWithLock(task) {
|
|
903
|
+
if (!(await this.containerExists(task.containerName)))
|
|
904
|
+
return 'absent';
|
|
905
|
+
await this.assertManagedContainer(task);
|
|
906
|
+
if (!(await this.containerIsRunning(task.containerName)))
|
|
907
|
+
return 'absent';
|
|
908
|
+
const readiness = await this.docker([
|
|
909
|
+
'exec',
|
|
910
|
+
'--user',
|
|
911
|
+
'1000:1000',
|
|
912
|
+
task.containerName,
|
|
913
|
+
'node',
|
|
914
|
+
'-e',
|
|
915
|
+
PREVIEW_READY_SCRIPT,
|
|
916
|
+
'--',
|
|
917
|
+
String(config.previewPort),
|
|
918
|
+
], { acceptFailure: true, timeoutMs: 2000, maxOutputChars: 2000 });
|
|
919
|
+
if (readiness.exitCode === 0)
|
|
920
|
+
return 'ready';
|
|
921
|
+
if (readiness.exitCode === 3)
|
|
922
|
+
return 'alive';
|
|
923
|
+
if (readiness.exitCode === 2)
|
|
924
|
+
return 'dead';
|
|
925
|
+
throw new WorkRuntimeError(readiness.stderr.trim() || 'Could not inspect the Work preview.', 503, 'WORK_PREVIEW_INSPECT_FAILED');
|
|
926
|
+
}
|
|
927
|
+
async stopPreviewPrepared(task) {
|
|
928
|
+
// Stop the container, not only the recorded process group. A custom
|
|
929
|
+
// preview command can intentionally double-fork or create a new session;
|
|
930
|
+
// Docker's container boundary guarantees those descendants are gone.
|
|
931
|
+
// The named /workspace volume remains persistent across the restart.
|
|
932
|
+
await this.stopContainerWithLock(task);
|
|
933
|
+
}
|
|
934
|
+
async ensureImage() {
|
|
935
|
+
if (!this.imagePreparation) {
|
|
936
|
+
this.imagePreparation = (async () => {
|
|
937
|
+
const inspected = await this.docker(['image', 'inspect', this.image], {
|
|
938
|
+
timeoutMs: 10000,
|
|
939
|
+
acceptFailure: true,
|
|
940
|
+
});
|
|
941
|
+
if (inspected.exitCode === 0)
|
|
942
|
+
return;
|
|
943
|
+
logger.info(`Pulling Work runtime image ${this.image}`);
|
|
944
|
+
await this.docker(['pull', this.image], { timeoutMs: 900000 });
|
|
945
|
+
})().catch(error => {
|
|
946
|
+
this.imagePreparation = undefined;
|
|
947
|
+
throw error;
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
await this.imagePreparation;
|
|
951
|
+
}
|
|
952
|
+
async initializeVolume(task) {
|
|
953
|
+
await this.docker([
|
|
954
|
+
'run',
|
|
955
|
+
'--rm',
|
|
956
|
+
'--network',
|
|
957
|
+
'none',
|
|
958
|
+
'--read-only',
|
|
959
|
+
'--tmpfs',
|
|
960
|
+
'/tmp:rw,noexec,nosuid,size=64m',
|
|
961
|
+
'--cap-drop',
|
|
962
|
+
'ALL',
|
|
963
|
+
'--cap-add',
|
|
964
|
+
'CHOWN',
|
|
965
|
+
'--mount',
|
|
966
|
+
`type=volume,src=${task.volumeName},dst=/workspace`,
|
|
967
|
+
this.image,
|
|
968
|
+
'chown',
|
|
969
|
+
'-R',
|
|
970
|
+
'1000:1000',
|
|
971
|
+
'/workspace',
|
|
972
|
+
]);
|
|
973
|
+
}
|
|
974
|
+
async ensureVolume(task) {
|
|
975
|
+
if (await this.volumeExists(task.volumeName)) {
|
|
976
|
+
await this.assertManagedVolume(task);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
await this.docker([
|
|
980
|
+
'volume',
|
|
981
|
+
'create',
|
|
982
|
+
'--label',
|
|
983
|
+
'ai.libre-webui.managed=true',
|
|
984
|
+
'--label',
|
|
985
|
+
`ai.libre-webui.task=${task.id}`,
|
|
986
|
+
task.volumeName,
|
|
987
|
+
]);
|
|
988
|
+
// Docker returns an existing volume from `volume create` if another
|
|
989
|
+
// process wins the name race, so prove ownership before mounting it.
|
|
990
|
+
await this.assertManagedVolume(task);
|
|
991
|
+
try {
|
|
992
|
+
await this.initializeVolume(task);
|
|
993
|
+
}
|
|
994
|
+
catch (error) {
|
|
995
|
+
await this.docker(['volume', 'rm', task.volumeName], {
|
|
996
|
+
acceptFailure: true,
|
|
997
|
+
});
|
|
998
|
+
throw error;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
async volumeExists(name) {
|
|
1002
|
+
const result = await this.docker(['volume', 'inspect', name], {
|
|
1003
|
+
timeoutMs: 5000,
|
|
1004
|
+
acceptFailure: true,
|
|
1005
|
+
});
|
|
1006
|
+
if (result.exitCode === 0)
|
|
1007
|
+
return true;
|
|
1008
|
+
if (/no such volume/i.test(`${result.stderr}\n${result.stdout}`)) {
|
|
1009
|
+
return false;
|
|
1010
|
+
}
|
|
1011
|
+
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');
|
|
1012
|
+
}
|
|
1013
|
+
async containerExists(name) {
|
|
1014
|
+
const result = await this.docker(['container', 'inspect', name], {
|
|
1015
|
+
timeoutMs: 5000,
|
|
1016
|
+
acceptFailure: true,
|
|
1017
|
+
});
|
|
1018
|
+
if (result.exitCode === 0)
|
|
1019
|
+
return true;
|
|
1020
|
+
if (/no such (?:container|object)/i.test(`${result.stderr}\n${result.stdout}`)) {
|
|
1021
|
+
return false;
|
|
1022
|
+
}
|
|
1023
|
+
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');
|
|
1024
|
+
}
|
|
1025
|
+
async containerIsRunning(name) {
|
|
1026
|
+
const result = await this.docker([
|
|
1027
|
+
'inspect',
|
|
1028
|
+
'--format',
|
|
1029
|
+
'{{.State.Running}}',
|
|
1030
|
+
name,
|
|
1031
|
+
]);
|
|
1032
|
+
const state = result.stdout.trim();
|
|
1033
|
+
if (state === 'true')
|
|
1034
|
+
return true;
|
|
1035
|
+
if (state === 'false')
|
|
1036
|
+
return false;
|
|
1037
|
+
throw new WorkRuntimeError(`Docker returned an invalid state for Work container "${name}".`, 503, 'WORK_DOCKER_INSPECT_FAILED');
|
|
1038
|
+
}
|
|
1039
|
+
async assertManagedContainer(task) {
|
|
1040
|
+
const result = await this.docker([
|
|
1041
|
+
'inspect',
|
|
1042
|
+
'--format',
|
|
1043
|
+
'{{ index .Config.Labels "ai.libre-webui.task" }}',
|
|
1044
|
+
task.containerName,
|
|
1045
|
+
]);
|
|
1046
|
+
if (result.stdout.trim() !== task.id) {
|
|
1047
|
+
throw new WorkRuntimeError(`Refusing to operate unmanaged container "${task.containerName}".`, 409, 'WORK_CONTAINER_NAME_CONFLICT');
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
async containerMatchesTaskPolicy(task) {
|
|
1051
|
+
const result = await this.docker([
|
|
1052
|
+
'inspect',
|
|
1053
|
+
'--format',
|
|
1054
|
+
'{{json .}}',
|
|
1055
|
+
task.containerName,
|
|
1056
|
+
]);
|
|
1057
|
+
let inspected;
|
|
1058
|
+
try {
|
|
1059
|
+
inspected = JSON.parse(result.stdout);
|
|
1060
|
+
}
|
|
1061
|
+
catch {
|
|
1062
|
+
return false;
|
|
1063
|
+
}
|
|
1064
|
+
const containerConfig = objectRecord(inspected.Config);
|
|
1065
|
+
const hostConfig = objectRecord(inspected.HostConfig);
|
|
1066
|
+
const labels = objectRecord(containerConfig.Labels);
|
|
1067
|
+
const env = stringArray(containerConfig.Env);
|
|
1068
|
+
const capDrop = stringArray(hostConfig.CapDrop);
|
|
1069
|
+
const securityOpt = stringArray(hostConfig.SecurityOpt);
|
|
1070
|
+
const command = stringArray(containerConfig.Cmd);
|
|
1071
|
+
const mounts = Array.isArray(inspected.Mounts)
|
|
1072
|
+
? inspected.Mounts.map(objectRecord)
|
|
1073
|
+
: [];
|
|
1074
|
+
const workspaceMount = mounts.find(mount => mount.Destination === '/workspace');
|
|
1075
|
+
const expectedNetwork = task.networkEnabled ? 'bridge' : 'none';
|
|
1076
|
+
const portBindings = objectRecord(hostConfig.PortBindings);
|
|
1077
|
+
const previewBindings = Array.isArray(portBindings[`${config.previewPort}/tcp`])
|
|
1078
|
+
? portBindings[`${config.previewPort}/tcp`].map(objectRecord)
|
|
1079
|
+
: [];
|
|
1080
|
+
const portPolicyMatches = task.networkEnabled
|
|
1081
|
+
? Object.keys(portBindings).length === 1 &&
|
|
1082
|
+
previewBindings.length === 1 &&
|
|
1083
|
+
previewBindings[0]?.HostIp === '127.0.0.1'
|
|
1084
|
+
: Object.keys(portBindings).length === 0;
|
|
1085
|
+
return (labels['ai.libre-webui.managed'] === 'true' &&
|
|
1086
|
+
labels['ai.libre-webui.task'] === task.id &&
|
|
1087
|
+
labels['ai.libre-webui.policy'] === runtimePolicyFingerprint &&
|
|
1088
|
+
containerConfig.Image === this.image &&
|
|
1089
|
+
containerConfig.User === '1000:1000' &&
|
|
1090
|
+
containerConfig.WorkingDir === '/workspace' &&
|
|
1091
|
+
command.join('\0') === ['tail', '-f', '/dev/null'].join('\0') &&
|
|
1092
|
+
env.includes('HOME=/tmp') &&
|
|
1093
|
+
env.includes('NPM_CONFIG_CACHE=/tmp/npm-cache') &&
|
|
1094
|
+
hostConfig.ReadonlyRootfs === true &&
|
|
1095
|
+
hostConfig.Privileged === false &&
|
|
1096
|
+
capDrop.includes('ALL') &&
|
|
1097
|
+
securityOpt.includes('no-new-privileges') &&
|
|
1098
|
+
Number(hostConfig.PidsLimit) === config.pidsLimit &&
|
|
1099
|
+
Number(hostConfig.Memory) > 0 &&
|
|
1100
|
+
Number(hostConfig.NanoCpus) > 0 &&
|
|
1101
|
+
hostConfig.NetworkMode === expectedNetwork &&
|
|
1102
|
+
portPolicyMatches &&
|
|
1103
|
+
workspaceMount?.Type === 'volume' &&
|
|
1104
|
+
workspaceMount.Name === task.volumeName &&
|
|
1105
|
+
workspaceMount.RW === true);
|
|
1106
|
+
}
|
|
1107
|
+
async assertManagedVolume(task) {
|
|
1108
|
+
const result = await this.docker([
|
|
1109
|
+
'volume',
|
|
1110
|
+
'inspect',
|
|
1111
|
+
'--format',
|
|
1112
|
+
'{{ index .Labels "ai.libre-webui.task" }}',
|
|
1113
|
+
task.volumeName,
|
|
1114
|
+
]);
|
|
1115
|
+
if (result.stdout.trim() !== task.id) {
|
|
1116
|
+
throw new WorkRuntimeError(`Refusing to operate unmanaged volume "${task.volumeName}".`, 409, 'WORK_VOLUME_NAME_CONFLICT');
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
assertTaskIsActive(task) {
|
|
1120
|
+
this.assertAcceptingWork();
|
|
1121
|
+
if (this.retiringTasks.has(task.id)) {
|
|
1122
|
+
throw new WorkRuntimeError('This Work task is being deleted.', 409, 'WORK_TASK_REMOVING');
|
|
1123
|
+
}
|
|
1124
|
+
this.assertTaskOwnerIsAdmin(task);
|
|
1125
|
+
}
|
|
1126
|
+
assertTaskOwnerIsAdmin(task) {
|
|
1127
|
+
const access = getDatabase()
|
|
1128
|
+
.prepare(`SELECT users.role
|
|
1129
|
+
FROM work_tasks
|
|
1130
|
+
JOIN users ON users.id = work_tasks.user_id
|
|
1131
|
+
WHERE work_tasks.id = ? AND work_tasks.user_id = ?`)
|
|
1132
|
+
.get(task.id, task.userId);
|
|
1133
|
+
if (!access) {
|
|
1134
|
+
throw new WorkRuntimeError('This Work task no longer exists.', 409, 'WORK_TASK_REMOVING');
|
|
1135
|
+
}
|
|
1136
|
+
if (access.role !== 'admin') {
|
|
1137
|
+
throw new WorkRuntimeError('Administrator access to this Work task was revoked.', 403, 'WORK_ACCESS_REVOKED');
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
assertTaskStillOwned(task) {
|
|
1141
|
+
const persisted = getDatabase()
|
|
1142
|
+
.prepare(`SELECT 1
|
|
1143
|
+
FROM work_tasks
|
|
1144
|
+
WHERE id = ? AND user_id = ?
|
|
1145
|
+
AND volume_name = ? AND container_name = ?`)
|
|
1146
|
+
.get(task.id, task.userId, task.volumeName, task.containerName);
|
|
1147
|
+
if (!persisted) {
|
|
1148
|
+
throw new WorkRuntimeError('This Work task no longer exists or its managed resources changed.', 409, 'WORK_TASK_REMOVING');
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
assertCurrentNetworkPolicy(task) {
|
|
1152
|
+
const current = this.networkPolicies.get(task.id);
|
|
1153
|
+
if (current === undefined) {
|
|
1154
|
+
this.networkPolicies.set(task.id, task.networkEnabled);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
if (current !== task.networkEnabled) {
|
|
1158
|
+
throw new WorkRuntimeError('This Work task view is stale; reload it before accessing the workspace.', 409, 'WORK_STALE_TASK_POLICY');
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
async withLifecycleLock(taskId, operation) {
|
|
1162
|
+
const previous = this.lifecycleTails.get(taskId) || Promise.resolve();
|
|
1163
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
1164
|
+
const tail = current.then(() => undefined, () => undefined);
|
|
1165
|
+
this.lifecycleTails.set(taskId, tail);
|
|
1166
|
+
try {
|
|
1167
|
+
return await current;
|
|
1168
|
+
}
|
|
1169
|
+
finally {
|
|
1170
|
+
if (this.lifecycleTails.get(taskId) === tail) {
|
|
1171
|
+
this.lifecycleTails.delete(taskId);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
async exec(task, command, options = {}) {
|
|
1176
|
+
const args = [
|
|
1177
|
+
'exec',
|
|
1178
|
+
...(options.input !== undefined ? ['--interactive'] : []),
|
|
1179
|
+
'--user',
|
|
1180
|
+
'1000:1000',
|
|
1181
|
+
'--workdir',
|
|
1182
|
+
'/workspace',
|
|
1183
|
+
task.containerName,
|
|
1184
|
+
...command,
|
|
1185
|
+
];
|
|
1186
|
+
return this.docker(args, options);
|
|
1187
|
+
}
|
|
1188
|
+
async docker(args, options = {}) {
|
|
1189
|
+
try {
|
|
1190
|
+
return await runProcess(config.dockerCommand, args, options);
|
|
1191
|
+
}
|
|
1192
|
+
catch (error) {
|
|
1193
|
+
if (error instanceof WorkRuntimeError)
|
|
1194
|
+
throw error;
|
|
1195
|
+
throw new WorkRuntimeError(error instanceof Error ? error.message : 'Docker command failed.');
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
export function buildWorkContainerRunArgs(task, image = config.image) {
|
|
1200
|
+
const args = [
|
|
1201
|
+
'run',
|
|
1202
|
+
'--detach',
|
|
1203
|
+
'--name',
|
|
1204
|
+
task.containerName,
|
|
1205
|
+
'--init',
|
|
1206
|
+
'--label',
|
|
1207
|
+
'ai.libre-webui.managed=true',
|
|
1208
|
+
'--label',
|
|
1209
|
+
`ai.libre-webui.task=${task.id}`,
|
|
1210
|
+
'--label',
|
|
1211
|
+
`ai.libre-webui.policy=${runtimePolicyFingerprint}`,
|
|
1212
|
+
'--user',
|
|
1213
|
+
'1000:1000',
|
|
1214
|
+
'--workdir',
|
|
1215
|
+
'/workspace',
|
|
1216
|
+
'--read-only',
|
|
1217
|
+
'--tmpfs',
|
|
1218
|
+
'/tmp:rw,nosuid,size=512m',
|
|
1219
|
+
'--env',
|
|
1220
|
+
'HOME=/tmp',
|
|
1221
|
+
'--env',
|
|
1222
|
+
'NPM_CONFIG_CACHE=/tmp/npm-cache',
|
|
1223
|
+
'--cap-drop',
|
|
1224
|
+
'ALL',
|
|
1225
|
+
'--security-opt',
|
|
1226
|
+
'no-new-privileges',
|
|
1227
|
+
'--pids-limit',
|
|
1228
|
+
String(config.pidsLimit),
|
|
1229
|
+
'--memory',
|
|
1230
|
+
config.memoryLimit,
|
|
1231
|
+
'--cpus',
|
|
1232
|
+
config.cpuLimit,
|
|
1233
|
+
'--network',
|
|
1234
|
+
task.networkEnabled ? 'bridge' : 'none',
|
|
1235
|
+
];
|
|
1236
|
+
if (task.networkEnabled) {
|
|
1237
|
+
args.push('--publish', `127.0.0.1::${config.previewPort}`);
|
|
1238
|
+
}
|
|
1239
|
+
args.push('--mount', `type=volume,src=${task.volumeName},dst=/workspace,volume-nocopy`, image, 'tail', '-f', '/dev/null');
|
|
1240
|
+
return args;
|
|
1241
|
+
}
|
|
1242
|
+
export function validateWorkspacePath(input, allowRoot = true) {
|
|
1243
|
+
const value = String(input || '.').trim();
|
|
1244
|
+
if (!value ||
|
|
1245
|
+
value.includes('\0') ||
|
|
1246
|
+
value.includes('\\') ||
|
|
1247
|
+
value.length > 1024 ||
|
|
1248
|
+
path.posix.isAbsolute(value) ||
|
|
1249
|
+
value.split('/').includes('..')) {
|
|
1250
|
+
throw new WorkRuntimeError('Path must be a relative path inside /workspace.', 400, 'WORK_INVALID_PATH');
|
|
1251
|
+
}
|
|
1252
|
+
const normalized = path.posix.normalize(value);
|
|
1253
|
+
const canonical = normalized.replace(/^(?:\.\/)+/, '') || '.';
|
|
1254
|
+
if ((!allowRoot && canonical === '.') || canonical.startsWith('../')) {
|
|
1255
|
+
throw new WorkRuntimeError('Path must identify an item inside /workspace.', 400, 'WORK_INVALID_PATH');
|
|
1256
|
+
}
|
|
1257
|
+
return canonical;
|
|
1258
|
+
}
|
|
1259
|
+
export function parsePublishedPort(output, _containerPort = config.previewPort) {
|
|
1260
|
+
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
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return undefined;
|
|
1269
|
+
}
|
|
1270
|
+
async function waitForAbortSignal(promise, signal) {
|
|
1271
|
+
if (!signal)
|
|
1272
|
+
return promise;
|
|
1273
|
+
const cancelled = () => new WorkRuntimeError('Work runtime preparation was cancelled.', 409, 'WORK_RUN_CANCELLED');
|
|
1274
|
+
if (signal.aborted)
|
|
1275
|
+
throw cancelled();
|
|
1276
|
+
return new Promise((resolve, reject) => {
|
|
1277
|
+
const onAbort = () => {
|
|
1278
|
+
signal.removeEventListener('abort', onAbort);
|
|
1279
|
+
reject(cancelled());
|
|
1280
|
+
};
|
|
1281
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1282
|
+
promise.then(value => {
|
|
1283
|
+
signal.removeEventListener('abort', onAbort);
|
|
1284
|
+
resolve(value);
|
|
1285
|
+
}, error => {
|
|
1286
|
+
signal.removeEventListener('abort', onAbort);
|
|
1287
|
+
reject(error);
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
async function runProcess(command, args, options) {
|
|
1292
|
+
const timeoutMs = options.timeoutMs ?? config.commandTimeoutMs;
|
|
1293
|
+
const maxOutputChars = options.maxOutputChars ?? config.maxOutputChars;
|
|
1294
|
+
return new Promise((resolve, reject) => {
|
|
1295
|
+
const child = spawn(command, args, {
|
|
1296
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1297
|
+
env: process.env,
|
|
1298
|
+
});
|
|
1299
|
+
activeDockerProcesses.add(child);
|
|
1300
|
+
let stdout = '';
|
|
1301
|
+
let stderr = '';
|
|
1302
|
+
let truncated = false;
|
|
1303
|
+
let settled = false;
|
|
1304
|
+
let stdinError;
|
|
1305
|
+
let timer;
|
|
1306
|
+
const claimSettlement = () => {
|
|
1307
|
+
if (settled)
|
|
1308
|
+
return false;
|
|
1309
|
+
settled = true;
|
|
1310
|
+
if (timer)
|
|
1311
|
+
clearTimeout(timer);
|
|
1312
|
+
return true;
|
|
1313
|
+
};
|
|
1314
|
+
const append = (current, chunk) => {
|
|
1315
|
+
const next = current + chunk.toString('utf8');
|
|
1316
|
+
if (next.length <= maxOutputChars)
|
|
1317
|
+
return next;
|
|
1318
|
+
truncated = true;
|
|
1319
|
+
const half = Math.floor(maxOutputChars / 2);
|
|
1320
|
+
return `${next.slice(0, half)}\n... output truncated ...\n${next.slice(-half)}`;
|
|
1321
|
+
};
|
|
1322
|
+
child.stdout.on('data', chunk => {
|
|
1323
|
+
stdout = append(stdout, chunk);
|
|
1324
|
+
});
|
|
1325
|
+
child.stderr.on('data', chunk => {
|
|
1326
|
+
stderr = append(stderr, chunk);
|
|
1327
|
+
});
|
|
1328
|
+
child.stdin.on('error', error => {
|
|
1329
|
+
// A helper may reject the request and close stdin before a large input
|
|
1330
|
+
// has finished writing. EPIPE is then expected; the process exit carries
|
|
1331
|
+
// the useful error. Other stdin failures are reported when it closes.
|
|
1332
|
+
if (error.code !== 'EPIPE') {
|
|
1333
|
+
stdinError = error;
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
child.on('error', error => {
|
|
1337
|
+
activeDockerProcesses.delete(child);
|
|
1338
|
+
if (!claimSettlement())
|
|
1339
|
+
return;
|
|
1340
|
+
reject(new WorkRuntimeError(`Could not start ${command}: ${error.message}`, 503, 'WORK_DOCKER_UNAVAILABLE'));
|
|
1341
|
+
});
|
|
1342
|
+
child.on('close', (code, signal) => {
|
|
1343
|
+
activeDockerProcesses.delete(child);
|
|
1344
|
+
const result = {
|
|
1345
|
+
exitCode: code ?? -1,
|
|
1346
|
+
stdout,
|
|
1347
|
+
stderr,
|
|
1348
|
+
truncated,
|
|
1349
|
+
signal: signal || undefined,
|
|
1350
|
+
};
|
|
1351
|
+
if (stdinError && !options.acceptFailure) {
|
|
1352
|
+
if (!claimSettlement())
|
|
1353
|
+
return;
|
|
1354
|
+
reject(new WorkRuntimeError(`Could not write command input: ${stdinError.message}`, 503, 'WORK_DOCKER_STDIN_FAILED'));
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
if (result.exitCode !== 0 && !options.acceptFailure) {
|
|
1358
|
+
if (!claimSettlement())
|
|
1359
|
+
return;
|
|
1360
|
+
reject(new WorkRuntimeError(stderr.trim() ||
|
|
1361
|
+
stdout.trim() ||
|
|
1362
|
+
`${command} exited with code ${result.exitCode}.`, 503, 'WORK_DOCKER_COMMAND_FAILED'));
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (claimSettlement())
|
|
1366
|
+
resolve(result);
|
|
1367
|
+
});
|
|
1368
|
+
timer = setTimeout(() => {
|
|
1369
|
+
child.kill('SIGKILL');
|
|
1370
|
+
if (!claimSettlement())
|
|
1371
|
+
return;
|
|
1372
|
+
reject(new WorkRuntimeError(`Command timed out after ${timeoutMs}ms.`, 504, 'WORK_COMMAND_TIMEOUT'));
|
|
1373
|
+
}, timeoutMs);
|
|
1374
|
+
timer.unref();
|
|
1375
|
+
if (options.input !== undefined) {
|
|
1376
|
+
child.stdin.end(options.input);
|
|
1377
|
+
}
|
|
1378
|
+
else {
|
|
1379
|
+
child.stdin.end();
|
|
1380
|
+
}
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
function parseJsonOutput(output) {
|
|
1384
|
+
try {
|
|
1385
|
+
return JSON.parse(output.trim());
|
|
1386
|
+
}
|
|
1387
|
+
catch {
|
|
1388
|
+
throw new WorkRuntimeError('Workspace helper returned an invalid response.', 500, 'WORK_HELPER_INVALID_RESPONSE');
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
function positiveInteger(value, fallback) {
|
|
1392
|
+
const parsed = Number(value);
|
|
1393
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
1394
|
+
}
|
|
1395
|
+
function objectRecord(value) {
|
|
1396
|
+
return value && typeof value === 'object'
|
|
1397
|
+
? value
|
|
1398
|
+
: {};
|
|
1399
|
+
}
|
|
1400
|
+
function stringArray(value) {
|
|
1401
|
+
return Array.isArray(value)
|
|
1402
|
+
? value.filter((item) => typeof item === 'string')
|
|
1403
|
+
: [];
|
|
1404
|
+
}
|
|
1405
|
+
function isSafePreviewWorkdir(value) {
|
|
1406
|
+
if (typeof value !== 'string' ||
|
|
1407
|
+
value.length > 4096 ||
|
|
1408
|
+
value.includes('\0')) {
|
|
1409
|
+
return false;
|
|
1410
|
+
}
|
|
1411
|
+
return (path.posix.normalize(value) === value &&
|
|
1412
|
+
(value === '/workspace' || value.startsWith('/workspace/')));
|
|
1413
|
+
}
|
|
1414
|
+
const MANAGED_COMMAND_SCRIPT = String.raw `
|
|
1415
|
+
setsid /bin/bash -lc "$1" &
|
|
1416
|
+
command_pid=$!
|
|
1417
|
+
cleanup() {
|
|
1418
|
+
trap - EXIT INT TERM
|
|
1419
|
+
kill -TERM -- "-$command_pid" 2>/dev/null || true
|
|
1420
|
+
for _ in 1 2 3 4 5 6 7 8 9 10; do
|
|
1421
|
+
kill -0 -- "-$command_pid" 2>/dev/null || return
|
|
1422
|
+
sleep 0.1
|
|
1423
|
+
done
|
|
1424
|
+
kill -KILL -- "-$command_pid" 2>/dev/null || true
|
|
1425
|
+
}
|
|
1426
|
+
trap cleanup EXIT INT TERM
|
|
1427
|
+
wait "$command_pid"
|
|
1428
|
+
status=$?
|
|
1429
|
+
exit "$status"
|
|
1430
|
+
`;
|
|
1431
|
+
const COMMON_PATH_GUARD = String.raw `
|
|
1432
|
+
const fs = require('node:fs');
|
|
1433
|
+
const path = require('node:path');
|
|
1434
|
+
const root = '/workspace';
|
|
1435
|
+
const rootReal = fs.realpathSync(root);
|
|
1436
|
+
const inside = candidate => candidate === rootReal || candidate.startsWith(rootReal + path.sep);
|
|
1437
|
+
const rel = process.argv[1] || '.';
|
|
1438
|
+
const target = path.resolve(root, rel);
|
|
1439
|
+
if (!inside(target)) throw new Error('Path escapes workspace');
|
|
1440
|
+
`;
|
|
1441
|
+
const PREVIEW_READY_SCRIPT = String.raw `
|
|
1442
|
+
const fs = require('node:fs');
|
|
1443
|
+
const net = require('node:net');
|
|
1444
|
+
const port = Number(process.argv[1]);
|
|
1445
|
+
let pid;
|
|
1446
|
+
try {
|
|
1447
|
+
pid = Number(fs.readFileSync('/tmp/libre-work-preview.pid', 'utf8').trim());
|
|
1448
|
+
if (!Number.isInteger(pid) || pid <= 0) process.exit(2);
|
|
1449
|
+
process.kill(pid, 0);
|
|
1450
|
+
} catch {
|
|
1451
|
+
process.exit(2);
|
|
1452
|
+
}
|
|
1453
|
+
let settled = false;
|
|
1454
|
+
const socket = net.createConnection({host:'127.0.0.1', port});
|
|
1455
|
+
const finish = code => {
|
|
1456
|
+
if (settled) return;
|
|
1457
|
+
settled = true;
|
|
1458
|
+
socket.destroy();
|
|
1459
|
+
process.exit(code);
|
|
1460
|
+
};
|
|
1461
|
+
socket.setTimeout(750, () => finish(3));
|
|
1462
|
+
socket.once('connect', () => finish(0));
|
|
1463
|
+
socket.once('error', () => finish(3));
|
|
1464
|
+
`;
|
|
1465
|
+
export const PREVIEW_TARGET_SCRIPT = String.raw `
|
|
1466
|
+
const fs = require('node:fs');
|
|
1467
|
+
const path = require('node:path');
|
|
1468
|
+
const root = fs.realpathSync(process.argv[1] || '/workspace');
|
|
1469
|
+
const ignored = new Set([
|
|
1470
|
+
'.git',
|
|
1471
|
+
'.next',
|
|
1472
|
+
'.nuxt',
|
|
1473
|
+
'.output',
|
|
1474
|
+
'.svelte-kit',
|
|
1475
|
+
'build',
|
|
1476
|
+
'coverage',
|
|
1477
|
+
'dist',
|
|
1478
|
+
'node_modules',
|
|
1479
|
+
'out',
|
|
1480
|
+
'target'
|
|
1481
|
+
]);
|
|
1482
|
+
const candidates = [];
|
|
1483
|
+
const queue = [{directory:root, depth:0}];
|
|
1484
|
+
let cursor = 0;
|
|
1485
|
+
while (cursor < queue.length && cursor < 500) {
|
|
1486
|
+
const {directory, depth} = queue[cursor];
|
|
1487
|
+
cursor += 1;
|
|
1488
|
+
let entries;
|
|
1489
|
+
try {
|
|
1490
|
+
entries = fs.readdirSync(directory, {withFileTypes:true})
|
|
1491
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
1492
|
+
} catch {
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
const packageEntry = entries.find(
|
|
1496
|
+
entry => entry.isFile() && entry.name === 'package.json'
|
|
1497
|
+
);
|
|
1498
|
+
if (packageEntry) {
|
|
1499
|
+
try {
|
|
1500
|
+
const manifestPath = path.join(directory, packageEntry.name);
|
|
1501
|
+
if (fs.statSync(manifestPath).size > 1000000) {
|
|
1502
|
+
throw new Error('Manifest is too large');
|
|
1503
|
+
}
|
|
1504
|
+
const manifest = JSON.parse(
|
|
1505
|
+
fs.readFileSync(manifestPath, 'utf8')
|
|
1506
|
+
);
|
|
1507
|
+
if (
|
|
1508
|
+
manifest &&
|
|
1509
|
+
manifest.scripts &&
|
|
1510
|
+
typeof manifest.scripts.dev === 'string' &&
|
|
1511
|
+
manifest.scripts.dev.trim()
|
|
1512
|
+
) {
|
|
1513
|
+
const dependencies = {
|
|
1514
|
+
...(manifest.dependencies || {}),
|
|
1515
|
+
...(manifest.devDependencies || {})
|
|
1516
|
+
};
|
|
1517
|
+
const runner =
|
|
1518
|
+
typeof dependencies.next === 'string' ||
|
|
1519
|
+
/(^|\\s)next(?:\\s|$)/.test(manifest.scripts.dev)
|
|
1520
|
+
? 'next'
|
|
1521
|
+
: 'standard';
|
|
1522
|
+
candidates.push({
|
|
1523
|
+
kind:'npm',
|
|
1524
|
+
workdir:directory,
|
|
1525
|
+
depth,
|
|
1526
|
+
rank:0,
|
|
1527
|
+
runner
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
} catch {}
|
|
1531
|
+
}
|
|
1532
|
+
if (entries.some(entry => entry.isFile() && entry.name === 'index.html')) {
|
|
1533
|
+
candidates.push({kind:'static', workdir:directory, depth, rank:1});
|
|
1534
|
+
}
|
|
1535
|
+
for (const entry of entries) {
|
|
1536
|
+
if (
|
|
1537
|
+
!entry.isDirectory() ||
|
|
1538
|
+
ignored.has(entry.name) ||
|
|
1539
|
+
entry.name.startsWith('.')
|
|
1540
|
+
) {
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
if (depth < 4 && queue.length < 1500) {
|
|
1544
|
+
queue.push({
|
|
1545
|
+
directory:path.join(directory, entry.name),
|
|
1546
|
+
depth:depth + 1
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
if (candidates.length === 0) {
|
|
1552
|
+
process.stdout.write(JSON.stringify({kind:'none'}));
|
|
1553
|
+
} else {
|
|
1554
|
+
const rootNpm = candidates.filter(
|
|
1555
|
+
candidate => candidate.depth === 0 && candidate.rank === 0
|
|
1556
|
+
);
|
|
1557
|
+
const rootStatic = candidates.filter(
|
|
1558
|
+
candidate => candidate.depth === 0 && candidate.rank === 1
|
|
1559
|
+
);
|
|
1560
|
+
const nestedNpm = candidates.filter(
|
|
1561
|
+
candidate => candidate.depth > 0 && candidate.rank === 0
|
|
1562
|
+
);
|
|
1563
|
+
const nestedStatic = candidates.filter(
|
|
1564
|
+
candidate => candidate.depth > 0 && candidate.rank === 1
|
|
1565
|
+
);
|
|
1566
|
+
const pool =
|
|
1567
|
+
rootNpm.length > 0
|
|
1568
|
+
? rootNpm
|
|
1569
|
+
: rootStatic.length > 0
|
|
1570
|
+
? rootStatic
|
|
1571
|
+
: nestedNpm.length > 0
|
|
1572
|
+
? nestedNpm
|
|
1573
|
+
: nestedStatic;
|
|
1574
|
+
pool.sort(
|
|
1575
|
+
(left, right) =>
|
|
1576
|
+
left.depth - right.depth ||
|
|
1577
|
+
left.workdir.localeCompare(right.workdir)
|
|
1578
|
+
);
|
|
1579
|
+
const best = pool[0];
|
|
1580
|
+
const competing = pool.filter(candidate => candidate.depth === best.depth);
|
|
1581
|
+
if (competing.length > 1) {
|
|
1582
|
+
process.stdout.write(JSON.stringify({
|
|
1583
|
+
kind:'ambiguous',
|
|
1584
|
+
candidates:competing.map(candidate =>
|
|
1585
|
+
path.relative(root, candidate.workdir) || '.'
|
|
1586
|
+
)
|
|
1587
|
+
}));
|
|
1588
|
+
} else {
|
|
1589
|
+
process.stdout.write(JSON.stringify({
|
|
1590
|
+
kind:best.kind,
|
|
1591
|
+
workdir:best.workdir,
|
|
1592
|
+
...(best.runner ? {runner:best.runner} : {})
|
|
1593
|
+
}));
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
`;
|
|
1597
|
+
export const STATIC_PREVIEW_SERVER_SCRIPT = String.raw `
|
|
1598
|
+
const fs = require('node:fs');
|
|
1599
|
+
const http = require('node:http');
|
|
1600
|
+
const path = require('node:path');
|
|
1601
|
+
const root = fs.realpathSync(process.cwd());
|
|
1602
|
+
const port = Number(process.argv[1]);
|
|
1603
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
1604
|
+
throw new Error('Invalid preview port');
|
|
1605
|
+
}
|
|
1606
|
+
const inside = candidate =>
|
|
1607
|
+
candidate === root || candidate.startsWith(root + path.sep);
|
|
1608
|
+
const mimeTypes = {
|
|
1609
|
+
'.avif':'image/avif',
|
|
1610
|
+
'.css':'text/css; charset=utf-8',
|
|
1611
|
+
'.gif':'image/gif',
|
|
1612
|
+
'.htm':'text/html; charset=utf-8',
|
|
1613
|
+
'.html':'text/html; charset=utf-8',
|
|
1614
|
+
'.ico':'image/x-icon',
|
|
1615
|
+
'.jpeg':'image/jpeg',
|
|
1616
|
+
'.jpg':'image/jpeg',
|
|
1617
|
+
'.js':'text/javascript; charset=utf-8',
|
|
1618
|
+
'.json':'application/json; charset=utf-8',
|
|
1619
|
+
'.mjs':'text/javascript; charset=utf-8',
|
|
1620
|
+
'.mp3':'audio/mpeg',
|
|
1621
|
+
'.mp4':'video/mp4',
|
|
1622
|
+
'.ogg':'audio/ogg',
|
|
1623
|
+
'.otf':'font/otf',
|
|
1624
|
+
'.png':'image/png',
|
|
1625
|
+
'.svg':'image/svg+xml',
|
|
1626
|
+
'.ttf':'font/ttf',
|
|
1627
|
+
'.txt':'text/plain; charset=utf-8',
|
|
1628
|
+
'.wasm':'application/wasm',
|
|
1629
|
+
'.webm':'video/webm',
|
|
1630
|
+
'.webp':'image/webp',
|
|
1631
|
+
'.woff':'font/woff',
|
|
1632
|
+
'.woff2':'font/woff2',
|
|
1633
|
+
'.xml':'application/xml; charset=utf-8'
|
|
1634
|
+
};
|
|
1635
|
+
const resolveFile = requestPath => {
|
|
1636
|
+
if (
|
|
1637
|
+
requestPath
|
|
1638
|
+
.split('/')
|
|
1639
|
+
.filter(Boolean)
|
|
1640
|
+
.some(segment => segment.startsWith('.'))
|
|
1641
|
+
) {
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
const lexical = path.resolve(root, '.' + requestPath);
|
|
1645
|
+
if (!inside(lexical)) return;
|
|
1646
|
+
let real;
|
|
1647
|
+
let stat;
|
|
1648
|
+
try {
|
|
1649
|
+
real = fs.realpathSync(lexical);
|
|
1650
|
+
if (!inside(real)) return;
|
|
1651
|
+
stat = fs.statSync(real);
|
|
1652
|
+
} catch {
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
if (stat.isDirectory()) {
|
|
1656
|
+
try {
|
|
1657
|
+
real = fs.realpathSync(path.join(real, 'index.html'));
|
|
1658
|
+
if (!inside(real)) return;
|
|
1659
|
+
stat = fs.statSync(real);
|
|
1660
|
+
} catch {
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
return stat.isFile() ? {file:real, stat} : undefined;
|
|
1665
|
+
};
|
|
1666
|
+
const sendText = (response, status, message) => {
|
|
1667
|
+
response.writeHead(status, {
|
|
1668
|
+
'Content-Type':'text/plain; charset=utf-8',
|
|
1669
|
+
'X-Content-Type-Options':'nosniff'
|
|
1670
|
+
});
|
|
1671
|
+
response.end(message);
|
|
1672
|
+
};
|
|
1673
|
+
const server = http.createServer((request, response) => {
|
|
1674
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
1675
|
+
response.setHeader('Allow', 'GET, HEAD');
|
|
1676
|
+
sendText(response, 405, 'Method not allowed');
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
let pathname;
|
|
1680
|
+
try {
|
|
1681
|
+
pathname = decodeURIComponent(
|
|
1682
|
+
new URL(request.url || '/', 'http://127.0.0.1').pathname
|
|
1683
|
+
);
|
|
1684
|
+
} catch {
|
|
1685
|
+
sendText(response, 400, 'Invalid URL');
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
let resolved = resolveFile(pathname);
|
|
1689
|
+
if (
|
|
1690
|
+
!resolved &&
|
|
1691
|
+
request.headers.accept &&
|
|
1692
|
+
request.headers.accept.includes('text/html') &&
|
|
1693
|
+
!path.posix.extname(pathname)
|
|
1694
|
+
) {
|
|
1695
|
+
resolved = resolveFile('/index.html');
|
|
1696
|
+
}
|
|
1697
|
+
if (!resolved) {
|
|
1698
|
+
sendText(response, 404, 'Not found');
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
response.writeHead(200, {
|
|
1702
|
+
'Content-Type':
|
|
1703
|
+
mimeTypes[path.extname(resolved.file).toLowerCase()] ||
|
|
1704
|
+
'application/octet-stream',
|
|
1705
|
+
'Content-Length':String(resolved.stat.size),
|
|
1706
|
+
'Cache-Control':'no-store',
|
|
1707
|
+
'X-Content-Type-Options':'nosniff'
|
|
1708
|
+
});
|
|
1709
|
+
if (request.method === 'HEAD') {
|
|
1710
|
+
response.end();
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1713
|
+
const stream = fs.createReadStream(resolved.file);
|
|
1714
|
+
stream.on('error', () => response.destroy());
|
|
1715
|
+
stream.pipe(response);
|
|
1716
|
+
});
|
|
1717
|
+
server.on('error', error => {
|
|
1718
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
1719
|
+
process.exit(1);
|
|
1720
|
+
});
|
|
1721
|
+
server.listen(port, '0.0.0.0', () => {
|
|
1722
|
+
console.log('Static preview listening on 0.0.0.0:' + port);
|
|
1723
|
+
});
|
|
1724
|
+
`;
|
|
1725
|
+
const LIST_FILES_SCRIPT = `${COMMON_PATH_GUARD}
|
|
1726
|
+
const targetReal = fs.realpathSync(target);
|
|
1727
|
+
if (!inside(targetReal)) throw new Error('Path escapes workspace');
|
|
1728
|
+
const entries = fs.readdirSync(targetReal, {withFileTypes:true}).slice(0, 1000).map(entry => {
|
|
1729
|
+
const full = path.join(targetReal, entry.name);
|
|
1730
|
+
const stat = fs.lstatSync(full);
|
|
1731
|
+
const itemPath = rel === '.' ? entry.name : path.posix.join(rel, entry.name);
|
|
1732
|
+
return {
|
|
1733
|
+
name: entry.name,
|
|
1734
|
+
path: itemPath,
|
|
1735
|
+
type: entry.isDirectory() ? 'directory' : 'file',
|
|
1736
|
+
size: stat.size,
|
|
1737
|
+
updatedAt: stat.mtimeMs,
|
|
1738
|
+
modifiedAt: stat.mtimeMs
|
|
1739
|
+
};
|
|
1740
|
+
});
|
|
1741
|
+
console.log(JSON.stringify({entries}));
|
|
1742
|
+
`;
|
|
1743
|
+
const READ_FILE_SCRIPT = `${COMMON_PATH_GUARD}
|
|
1744
|
+
const targetReal = fs.realpathSync(target);
|
|
1745
|
+
if (!inside(targetReal)) throw new Error('Path escapes workspace');
|
|
1746
|
+
const stat = fs.statSync(targetReal);
|
|
1747
|
+
if (!stat.isFile()) throw new Error('Path is not a file');
|
|
1748
|
+
if (stat.size > 2000000) throw new Error('File exceeds 2 MB limit');
|
|
1749
|
+
console.log(JSON.stringify({
|
|
1750
|
+
content: fs.readFileSync(targetReal, 'utf8'),
|
|
1751
|
+
size: stat.size,
|
|
1752
|
+
updatedAt: stat.mtimeMs
|
|
1753
|
+
}));
|
|
1754
|
+
`;
|
|
1755
|
+
const WRITE_FILE_SCRIPT = `${COMMON_PATH_GUARD}
|
|
1756
|
+
const parent = path.dirname(target);
|
|
1757
|
+
const parentParts = path.relative(root, parent).split(path.sep).filter(Boolean);
|
|
1758
|
+
let parentReal = rootReal;
|
|
1759
|
+
for (const part of parentParts) {
|
|
1760
|
+
const next = path.join(parentReal, part);
|
|
1761
|
+
if (!fs.existsSync(next)) fs.mkdirSync(next, {mode:0o755});
|
|
1762
|
+
if (fs.lstatSync(next).isSymbolicLink()) throw new Error('Refusing to traverse symlink');
|
|
1763
|
+
parentReal = fs.realpathSync(next);
|
|
1764
|
+
if (!inside(parentReal) || !fs.statSync(parentReal).isDirectory()) {
|
|
1765
|
+
throw new Error('Path escapes workspace');
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
const safeTarget = path.join(parentReal, path.basename(target));
|
|
1769
|
+
const targetExists = fs.existsSync(safeTarget);
|
|
1770
|
+
if (targetExists && fs.lstatSync(safeTarget).isSymbolicLink()) throw new Error('Refusing to write through symlink');
|
|
1771
|
+
const targetMode = targetExists ? fs.statSync(safeTarget).mode & 0o777 : 0o644;
|
|
1772
|
+
const expectedText = process.argv[2] || '';
|
|
1773
|
+
let expectedStat;
|
|
1774
|
+
if (expectedText) {
|
|
1775
|
+
if (!targetExists) process.exit(23);
|
|
1776
|
+
const expected = Number(expectedText);
|
|
1777
|
+
expectedStat = fs.statSync(safeTarget);
|
|
1778
|
+
if (!Number.isFinite(expected) || expectedStat.mtimeMs !== expected) process.exit(23);
|
|
1779
|
+
}
|
|
1780
|
+
let content = '';
|
|
1781
|
+
process.stdin.setEncoding('utf8');
|
|
1782
|
+
process.stdin.on('data', chunk => content += chunk);
|
|
1783
|
+
process.stdin.on('end', () => {
|
|
1784
|
+
const temporary = path.join(
|
|
1785
|
+
parentReal,
|
|
1786
|
+
'.' + path.basename(safeTarget) + '.libre-tmp-' + process.pid + '-' + Date.now()
|
|
1787
|
+
);
|
|
1788
|
+
const exitWithConflict = () => {
|
|
1789
|
+
try { fs.unlinkSync(temporary); } catch {}
|
|
1790
|
+
process.exit(23);
|
|
1791
|
+
};
|
|
1792
|
+
let descriptor;
|
|
1793
|
+
try {
|
|
1794
|
+
descriptor = fs.openSync(
|
|
1795
|
+
temporary,
|
|
1796
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY,
|
|
1797
|
+
targetMode
|
|
1798
|
+
);
|
|
1799
|
+
fs.writeFileSync(descriptor, content, {encoding:'utf8'});
|
|
1800
|
+
fs.fsyncSync(descriptor);
|
|
1801
|
+
fs.closeSync(descriptor);
|
|
1802
|
+
descriptor = undefined;
|
|
1803
|
+
if (expectedStat) {
|
|
1804
|
+
if (
|
|
1805
|
+
!fs.existsSync(safeTarget) ||
|
|
1806
|
+
fs.lstatSync(safeTarget).isSymbolicLink()
|
|
1807
|
+
) {
|
|
1808
|
+
exitWithConflict();
|
|
1809
|
+
}
|
|
1810
|
+
const currentStat = fs.statSync(safeTarget);
|
|
1811
|
+
if (
|
|
1812
|
+
currentStat.dev !== expectedStat.dev ||
|
|
1813
|
+
currentStat.ino !== expectedStat.ino ||
|
|
1814
|
+
currentStat.size !== expectedStat.size ||
|
|
1815
|
+
currentStat.mtimeMs !== expectedStat.mtimeMs ||
|
|
1816
|
+
currentStat.ctimeMs !== expectedStat.ctimeMs
|
|
1817
|
+
) {
|
|
1818
|
+
exitWithConflict();
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
fs.renameSync(temporary, safeTarget);
|
|
1822
|
+
} finally {
|
|
1823
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
1824
|
+
try { fs.unlinkSync(temporary); } catch {}
|
|
1825
|
+
}
|
|
1826
|
+
const stat = fs.statSync(safeTarget);
|
|
1827
|
+
console.log(JSON.stringify({size:stat.size, updatedAt:stat.mtimeMs}));
|
|
1828
|
+
});
|
|
1829
|
+
`;
|
|
1830
|
+
const SEARCH_FILES_SCRIPT = `${COMMON_PATH_GUARD}
|
|
1831
|
+
const query = String(process.argv[2] || '').toLowerCase();
|
|
1832
|
+
const targetReal = fs.realpathSync(target);
|
|
1833
|
+
if (!inside(targetReal)) throw new Error('Path escapes workspace');
|
|
1834
|
+
const ignored = new Set(['node_modules','.git','dist','build','.next']);
|
|
1835
|
+
const results = [];
|
|
1836
|
+
const walk = directory => {
|
|
1837
|
+
if (results.length >= 100) return;
|
|
1838
|
+
for (const entry of fs.readdirSync(directory, {withFileTypes:true})) {
|
|
1839
|
+
if (results.length >= 100 || ignored.has(entry.name)) continue;
|
|
1840
|
+
const full = path.join(directory, entry.name);
|
|
1841
|
+
if (entry.isDirectory()) { walk(full); continue; }
|
|
1842
|
+
if (!entry.isFile()) continue;
|
|
1843
|
+
const stat = fs.statSync(full);
|
|
1844
|
+
if (stat.size > 1000000) continue;
|
|
1845
|
+
let text;
|
|
1846
|
+
try { text = fs.readFileSync(full, 'utf8'); } catch { continue; }
|
|
1847
|
+
const lines = text.split(/\\r?\\n/);
|
|
1848
|
+
lines.forEach((line, index) => {
|
|
1849
|
+
if (results.length < 100 && line.toLowerCase().includes(query)) {
|
|
1850
|
+
results.push(path.relative(rootReal, full) + ':' + (index + 1) + ':' + line.slice(0, 500));
|
|
1851
|
+
}
|
|
1852
|
+
});
|
|
1853
|
+
}
|
|
1854
|
+
};
|
|
1855
|
+
walk(targetReal);
|
|
1856
|
+
process.stdout.write(results.join('\\n'));
|
|
1857
|
+
`;
|
|
1858
|
+
export const workRuntimeService = new WorkRuntimeService();
|
|
1859
|
+
export default workRuntimeService;
|
|
1860
|
+
//# sourceMappingURL=workRuntimeService.js.map
|