@heimdall-ai/heimdall 0.1.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/LICENSE +190 -0
- package/README.md +471 -0
- package/dist/config/constants.d.ts +24 -0
- package/dist/config/constants.d.ts.map +1 -0
- package/dist/config/constants.js +70 -0
- package/dist/config/constants.js.map +1 -0
- package/dist/core/bash-manager.d.ts +56 -0
- package/dist/core/bash-manager.d.ts.map +1 -0
- package/dist/core/bash-manager.js +106 -0
- package/dist/core/bash-manager.js.map +1 -0
- package/dist/core/pyodide-manager.d.ts +125 -0
- package/dist/core/pyodide-manager.d.ts.map +1 -0
- package/dist/core/pyodide-manager.js +669 -0
- package/dist/core/pyodide-manager.js.map +1 -0
- package/dist/core/pyodide-worker.d.ts +9 -0
- package/dist/core/pyodide-worker.d.ts.map +1 -0
- package/dist/core/pyodide-worker.js +295 -0
- package/dist/core/pyodide-worker.js.map +1 -0
- package/dist/core/secure-fs.d.ts +101 -0
- package/dist/core/secure-fs.d.ts.map +1 -0
- package/dist/core/secure-fs.js +279 -0
- package/dist/core/secure-fs.js.map +1 -0
- package/dist/integration.test.d.ts +10 -0
- package/dist/integration.test.d.ts.map +1 -0
- package/dist/integration.test.js +439 -0
- package/dist/integration.test.js.map +1 -0
- package/dist/resources/index.d.ts +12 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +13 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/workspace.d.ts +12 -0
- package/dist/resources/workspace.d.ts.map +1 -0
- package/dist/resources/workspace.js +105 -0
- package/dist/resources/workspace.js.map +1 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +51 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/bash-execution.d.ts +13 -0
- package/dist/tools/bash-execution.d.ts.map +1 -0
- package/dist/tools/bash-execution.js +135 -0
- package/dist/tools/bash-execution.js.map +1 -0
- package/dist/tools/filesystem.d.ts +12 -0
- package/dist/tools/filesystem.d.ts.map +1 -0
- package/dist/tools/filesystem.js +104 -0
- package/dist/tools/filesystem.js.map +1 -0
- package/dist/tools/index.d.ts +13 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +17 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/python-execution.d.ts +12 -0
- package/dist/tools/python-execution.d.ts.map +1 -0
- package/dist/tools/python-execution.js +77 -0
- package/dist/tools/python-execution.js.map +1 -0
- package/dist/types/index.d.ts +64 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/async-lock.d.ts +35 -0
- package/dist/utils/async-lock.d.ts.map +1 -0
- package/dist/utils/async-lock.js +57 -0
- package/dist/utils/async-lock.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/package.json +61 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PyodideManager - Handles Python runtime lifecycle
|
|
3
|
+
*
|
|
4
|
+
* This class manages the Pyodide WebAssembly Python runtime, including:
|
|
5
|
+
* - Initialization and lifecycle management
|
|
6
|
+
* - Virtual filesystem operations
|
|
7
|
+
* - Host <-> Virtual filesystem synchronization
|
|
8
|
+
* - Python code execution (via worker thread for timeout support)
|
|
9
|
+
* - Package installation
|
|
10
|
+
*
|
|
11
|
+
* Code execution runs in a separate worker thread to enable true timeout
|
|
12
|
+
* enforcement. If Python code blocks indefinitely (infinite loops, etc.),
|
|
13
|
+
* the worker can be terminated to enforce the timeout.
|
|
14
|
+
*/
|
|
15
|
+
import { loadPyodide } from "pyodide";
|
|
16
|
+
import { Worker } from "worker_threads";
|
|
17
|
+
import * as fs from "fs";
|
|
18
|
+
import * as path from "path";
|
|
19
|
+
import { fileURLToPath } from "url";
|
|
20
|
+
import { WORKSPACE_DIR, VIRTUAL_WORKSPACE, MAX_FILE_SIZE, MAX_WORKSPACE_SIZE, PYTHON_EXECUTION_TIMEOUT_MS, } from "../config/constants.js";
|
|
21
|
+
import { AsyncLock } from "../utils/index.js";
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
23
|
+
const __dirname = path.dirname(__filename);
|
|
24
|
+
export class PyodideManager {
|
|
25
|
+
pyodide = null;
|
|
26
|
+
initialized = false;
|
|
27
|
+
initializationPromise = null;
|
|
28
|
+
// Worker thread for code execution with timeout support
|
|
29
|
+
worker = null;
|
|
30
|
+
workerReady = false;
|
|
31
|
+
workerInitPromise = null;
|
|
32
|
+
// Write lock to prevent TOCTOU race conditions during file operations
|
|
33
|
+
// This ensures workspace size checks and writes are atomic
|
|
34
|
+
writeLock = new AsyncLock();
|
|
35
|
+
/**
|
|
36
|
+
* Convert a virtual path to a host filesystem path.
|
|
37
|
+
*/
|
|
38
|
+
virtualToHostPath(virtualPath) {
|
|
39
|
+
if (virtualPath === VIRTUAL_WORKSPACE) {
|
|
40
|
+
return WORKSPACE_DIR;
|
|
41
|
+
}
|
|
42
|
+
if (virtualPath.startsWith(`${VIRTUAL_WORKSPACE}/`)) {
|
|
43
|
+
return path.join(WORKSPACE_DIR, virtualPath.slice(VIRTUAL_WORKSPACE.length + 1));
|
|
44
|
+
}
|
|
45
|
+
return path.join(WORKSPACE_DIR, virtualPath);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Validate and normalize a file path to prevent directory traversal attacks
|
|
49
|
+
* @param filePath - The file path to validate
|
|
50
|
+
* @returns The normalized virtual filesystem path
|
|
51
|
+
* @throws Error if path is invalid or attempts to escape workspace
|
|
52
|
+
*/
|
|
53
|
+
validatePath(filePath) {
|
|
54
|
+
// Convert to virtual path if not already absolute
|
|
55
|
+
const fullPath = filePath.startsWith("/") ? filePath : `${VIRTUAL_WORKSPACE}/${filePath}`;
|
|
56
|
+
// Normalize the path to resolve '..' and '.' segments
|
|
57
|
+
const normalized = path.posix.normalize(fullPath);
|
|
58
|
+
// Check if the normalized path is still within the workspace
|
|
59
|
+
if (!normalized.startsWith(VIRTUAL_WORKSPACE + "/") && normalized !== VIRTUAL_WORKSPACE) {
|
|
60
|
+
throw new Error(`Invalid path: Path traversal detected. Path must be within ${VIRTUAL_WORKSPACE}`);
|
|
61
|
+
}
|
|
62
|
+
// Additional check: reject paths containing '..' after normalization
|
|
63
|
+
// (should be caught above, but defense in depth)
|
|
64
|
+
if (normalized.includes("..")) {
|
|
65
|
+
throw new Error("Invalid path: Path contains '..' after normalization");
|
|
66
|
+
}
|
|
67
|
+
return normalized;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve symlinks and validate that the real path is within the workspace.
|
|
71
|
+
* This prevents symlink-based path traversal attacks where an attacker creates
|
|
72
|
+
* a symlink inside the workspace pointing to a location outside.
|
|
73
|
+
*
|
|
74
|
+
* @param hostPath - The host filesystem path to validate
|
|
75
|
+
* @throws Error if the resolved path escapes the workspace
|
|
76
|
+
*/
|
|
77
|
+
async validateHostPathWithSymlinkResolution(hostPath) {
|
|
78
|
+
try {
|
|
79
|
+
// Get the real path by resolving all symlinks
|
|
80
|
+
const realPath = await fs.promises.realpath(hostPath);
|
|
81
|
+
const realWorkspace = await fs.promises.realpath(WORKSPACE_DIR);
|
|
82
|
+
// Ensure the real path is within the workspace
|
|
83
|
+
if (!realPath.startsWith(realWorkspace + path.sep) && realPath !== realWorkspace) {
|
|
84
|
+
throw new Error("Invalid path: Symlink points outside workspace. This is a security violation.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
// If the file doesn't exist yet, we need to check parent directories for symlinks
|
|
89
|
+
if (e.code === "ENOENT") {
|
|
90
|
+
await this.validateParentPathForSymlinks(hostPath);
|
|
91
|
+
}
|
|
92
|
+
else if (e.message?.includes("security violation")) {
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
// Other errors (like EACCES) are acceptable - the operation will fail naturally
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* For files that don't exist yet, validate parent directories for symlinks.
|
|
100
|
+
* Walks up the path until we find an existing directory, then validates it.
|
|
101
|
+
*/
|
|
102
|
+
async validateParentPathForSymlinks(hostPath) {
|
|
103
|
+
let currentPath = path.dirname(hostPath);
|
|
104
|
+
const realWorkspace = await fs.promises.realpath(WORKSPACE_DIR);
|
|
105
|
+
while (currentPath !== path.dirname(currentPath)) {
|
|
106
|
+
// Stop at filesystem root
|
|
107
|
+
try {
|
|
108
|
+
const realPath = await fs.promises.realpath(currentPath);
|
|
109
|
+
// Check if this resolved path is within workspace
|
|
110
|
+
if (!realPath.startsWith(realWorkspace + path.sep) && realPath !== realWorkspace) {
|
|
111
|
+
throw new Error("Invalid path: Parent directory symlink points outside workspace. This is a security violation.");
|
|
112
|
+
}
|
|
113
|
+
// Found a valid existing parent, we're done
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
if (e.code === "ENOENT") {
|
|
118
|
+
// Keep walking up
|
|
119
|
+
currentPath = path.dirname(currentPath);
|
|
120
|
+
}
|
|
121
|
+
else if (e.message?.includes("security violation")) {
|
|
122
|
+
throw e;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
// Other error, bail
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Calculate total workspace size from host filesystem
|
|
133
|
+
* @returns Total size in bytes
|
|
134
|
+
*/
|
|
135
|
+
async getWorkspaceSize() {
|
|
136
|
+
try {
|
|
137
|
+
await fs.promises.access(WORKSPACE_DIR);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
let totalSize = 0;
|
|
143
|
+
const calculateSize = async (dirPath) => {
|
|
144
|
+
const items = await fs.promises.readdir(dirPath);
|
|
145
|
+
for (const item of items) {
|
|
146
|
+
const itemPath = path.join(dirPath, item);
|
|
147
|
+
const stat = await fs.promises.stat(itemPath);
|
|
148
|
+
if (stat.isDirectory()) {
|
|
149
|
+
await calculateSize(itemPath);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
totalSize += stat.size;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
await calculateSize(WORKSPACE_DIR);
|
|
157
|
+
return totalSize;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Validate that writing a file won't exceed workspace size limit
|
|
161
|
+
* @param fileSize - Size of file to be written
|
|
162
|
+
* @throws Error if workspace size limit would be exceeded
|
|
163
|
+
*/
|
|
164
|
+
async checkWorkspaceSize(fileSize) {
|
|
165
|
+
const currentSize = await this.getWorkspaceSize();
|
|
166
|
+
if (currentSize + fileSize > MAX_WORKSPACE_SIZE) {
|
|
167
|
+
throw new Error(`Workspace size limit exceeded. Current: ${(currentSize / 1024 / 1024).toFixed(2)}MB, ` +
|
|
168
|
+
`Limit: ${(MAX_WORKSPACE_SIZE / 1024 / 1024).toFixed(2)}MB`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async initialize() {
|
|
172
|
+
// Return existing instance if already initialized
|
|
173
|
+
if (this.pyodide && this.initialized) {
|
|
174
|
+
return this.pyodide;
|
|
175
|
+
}
|
|
176
|
+
// Use promise-based singleton to prevent concurrent initialization race conditions
|
|
177
|
+
if (this.initializationPromise) {
|
|
178
|
+
return this.initializationPromise;
|
|
179
|
+
}
|
|
180
|
+
this.initializationPromise = this.doInitialize();
|
|
181
|
+
try {
|
|
182
|
+
return await this.initializationPromise;
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
// Reset on failure so retry is possible
|
|
186
|
+
this.initializationPromise = null;
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async doInitialize() {
|
|
191
|
+
console.error("[Pyodide] Loading runtime...");
|
|
192
|
+
this.pyodide = await loadPyodide({
|
|
193
|
+
stdout: (text) => process.stdout.write(text),
|
|
194
|
+
stderr: (text) => process.stderr.write(text),
|
|
195
|
+
});
|
|
196
|
+
// Create workspace directory in virtual filesystem
|
|
197
|
+
this.pyodide.FS.mkdirTree(VIRTUAL_WORKSPACE);
|
|
198
|
+
// Load micropip for package installation with proper error handling
|
|
199
|
+
// Note: micropip loading may fail in some environments (restricted network, etc.)
|
|
200
|
+
// We'll handle this gracefully and lazy-load when actually needed
|
|
201
|
+
try {
|
|
202
|
+
await this.pyodide.loadPackage("micropip");
|
|
203
|
+
console.error("[Pyodide] micropip loaded successfully");
|
|
204
|
+
// Verify micropip is actually available
|
|
205
|
+
this.pyodide.pyimport("micropip");
|
|
206
|
+
console.error("[Pyodide] micropip verified");
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
console.error("[Pyodide] Warning: micropip not available (will be loaded on-demand):", error);
|
|
210
|
+
// Don't throw - we'll lazy-load micropip when package installation is requested
|
|
211
|
+
}
|
|
212
|
+
// Add workspace to Python path for imports
|
|
213
|
+
// Escape the path to prevent code injection vulnerabilities
|
|
214
|
+
const escapedWorkspacePath = VIRTUAL_WORKSPACE.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
215
|
+
this.pyodide.runPython(`
|
|
216
|
+
import sys
|
|
217
|
+
if '${escapedWorkspacePath}' not in sys.path:
|
|
218
|
+
sys.path.insert(0, '${escapedWorkspacePath}')
|
|
219
|
+
`);
|
|
220
|
+
// Sync host workspace to virtual FS
|
|
221
|
+
await this.syncHostToVirtual();
|
|
222
|
+
this.initialized = true;
|
|
223
|
+
console.error("[Pyodide] Runtime initialized successfully");
|
|
224
|
+
return this.pyodide;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Sync files from host filesystem to Pyodide virtual FS
|
|
228
|
+
*/
|
|
229
|
+
async syncHostToVirtual(hostPath = WORKSPACE_DIR, virtualPath = VIRTUAL_WORKSPACE) {
|
|
230
|
+
await this.syncHostPathToVirtual(hostPath, virtualPath);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Sync a specific host file or directory into the virtual FS.
|
|
234
|
+
*/
|
|
235
|
+
async syncHostPathToVirtual(hostPath, virtualPath) {
|
|
236
|
+
if (!this.pyodide)
|
|
237
|
+
return;
|
|
238
|
+
// Check if path exists asynchronously
|
|
239
|
+
try {
|
|
240
|
+
await fs.promises.access(hostPath);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const stat = await fs.promises.stat(hostPath);
|
|
246
|
+
if (!stat.isDirectory()) {
|
|
247
|
+
const parentDir = path.posix.dirname(virtualPath);
|
|
248
|
+
if (parentDir && parentDir !== "/" && !parentDir.includes("..")) {
|
|
249
|
+
try {
|
|
250
|
+
this.pyodide.FS.mkdirTree(parentDir);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
254
|
+
if (!errorMsg.includes("exists") && !errorMsg.includes("EEXIST")) {
|
|
255
|
+
console.error(`[Pyodide] Error creating directory ${parentDir}:`, error);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const content = await fs.promises.readFile(hostPath);
|
|
260
|
+
this.pyodide.FS.writeFile(virtualPath, content);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const items = await fs.promises.readdir(hostPath);
|
|
264
|
+
for (const item of items) {
|
|
265
|
+
const hostItemPath = path.join(hostPath, item);
|
|
266
|
+
const virtualItemPath = `${virtualPath}/${item}`;
|
|
267
|
+
const itemStat = await fs.promises.stat(hostItemPath);
|
|
268
|
+
if (itemStat.isDirectory()) {
|
|
269
|
+
try {
|
|
270
|
+
this.pyodide.FS.mkdirTree(virtualItemPath);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
// Only ignore if directory already exists, log other errors
|
|
274
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
275
|
+
if (!errorMsg.includes("exists") && !errorMsg.includes("EEXIST")) {
|
|
276
|
+
console.error(`[Pyodide] Error creating directory ${virtualItemPath}:`, error);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
await this.syncHostPathToVirtual(hostItemPath, virtualItemPath);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const content = await fs.promises.readFile(hostItemPath);
|
|
283
|
+
this.pyodide.FS.writeFile(virtualItemPath, content);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Sync files from Pyodide virtual FS to host filesystem
|
|
289
|
+
*/
|
|
290
|
+
async syncVirtualToHost(virtualPath = VIRTUAL_WORKSPACE, hostPath = WORKSPACE_DIR) {
|
|
291
|
+
await this.syncVirtualPathToHost(virtualPath, hostPath);
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Sync a specific virtual file or directory into the host filesystem.
|
|
295
|
+
* Includes symlink protection to prevent writing outside workspace.
|
|
296
|
+
*/
|
|
297
|
+
async syncVirtualPathToHost(virtualPath, hostPath) {
|
|
298
|
+
if (!this.pyodide)
|
|
299
|
+
return;
|
|
300
|
+
// SECURITY: Validate host path doesn't escape workspace via symlinks
|
|
301
|
+
await this.validateHostPathWithSymlinkResolution(hostPath);
|
|
302
|
+
let stat;
|
|
303
|
+
try {
|
|
304
|
+
stat = this.pyodide.FS.stat(virtualPath);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const isDir = this.pyodide.FS.isDir(stat.mode);
|
|
310
|
+
if (!isDir) {
|
|
311
|
+
const parentDir = path.dirname(hostPath);
|
|
312
|
+
await fs.promises.mkdir(parentDir, { recursive: true });
|
|
313
|
+
// Re-validate after mkdir in case it created through a symlink
|
|
314
|
+
await this.validateHostPathWithSymlinkResolution(hostPath);
|
|
315
|
+
const content = this.pyodide.FS.readFile(virtualPath);
|
|
316
|
+
await fs.promises.writeFile(hostPath, content);
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
await fs.promises.mkdir(hostPath, { recursive: true });
|
|
320
|
+
// Re-validate after mkdir
|
|
321
|
+
await this.validateHostPathWithSymlinkResolution(hostPath);
|
|
322
|
+
let items;
|
|
323
|
+
try {
|
|
324
|
+
items = this.pyodide.FS.readdir(virtualPath).filter((x) => x !== "." && x !== "..");
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
for (const item of items) {
|
|
330
|
+
const virtualItemPath = `${virtualPath}/${item}`;
|
|
331
|
+
const hostItemPath = path.join(hostPath, item);
|
|
332
|
+
const itemStat = this.pyodide.FS.stat(virtualItemPath);
|
|
333
|
+
const itemIsDir = this.pyodide.FS.isDir(itemStat.mode);
|
|
334
|
+
if (itemIsDir) {
|
|
335
|
+
await this.syncVirtualPathToHost(virtualItemPath, hostItemPath);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
// SECURITY: Validate each file path before writing
|
|
339
|
+
await this.validateHostPathWithSymlinkResolution(hostItemPath);
|
|
340
|
+
const content = this.pyodide.FS.readFile(virtualItemPath);
|
|
341
|
+
await fs.promises.writeFile(hostItemPath, content);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Initialize the worker thread for code execution
|
|
347
|
+
*/
|
|
348
|
+
async initializeWorker() {
|
|
349
|
+
if (this.workerReady && this.worker) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (this.workerInitPromise) {
|
|
353
|
+
return this.workerInitPromise;
|
|
354
|
+
}
|
|
355
|
+
this.workerInitPromise = this.doInitializeWorker();
|
|
356
|
+
try {
|
|
357
|
+
await this.workerInitPromise;
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
this.workerInitPromise = null;
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async doInitializeWorker() {
|
|
365
|
+
return new Promise((resolve, reject) => {
|
|
366
|
+
// Get the worker file path from the dist folder (built JavaScript)
|
|
367
|
+
// We use the compiled JS to avoid path resolution issues with tsx in workers
|
|
368
|
+
const distDir = path.resolve(__dirname, "..", "..", "dist", "core");
|
|
369
|
+
const workerJsPath = path.join(distDir, "pyodide-worker.js");
|
|
370
|
+
// Fallback to TypeScript source if dist doesn't exist (dev mode)
|
|
371
|
+
const srcDir = path.resolve(__dirname);
|
|
372
|
+
const workerTsPath = path.join(srcDir, "pyodide-worker.ts");
|
|
373
|
+
let workerPath;
|
|
374
|
+
let execArgv = [];
|
|
375
|
+
if (fs.existsSync(workerJsPath)) {
|
|
376
|
+
workerPath = workerJsPath;
|
|
377
|
+
}
|
|
378
|
+
else if (fs.existsSync(workerTsPath)) {
|
|
379
|
+
workerPath = workerTsPath;
|
|
380
|
+
execArgv = ["--import", "tsx"];
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
reject(new Error(`Worker file not found at ${workerJsPath} or ${workerTsPath}`));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
console.error(`[Heimdall] Starting worker thread from ${workerPath}...`);
|
|
387
|
+
this.worker = new Worker(workerPath, {
|
|
388
|
+
workerData: {
|
|
389
|
+
workspaceDir: WORKSPACE_DIR,
|
|
390
|
+
virtualWorkspace: VIRTUAL_WORKSPACE,
|
|
391
|
+
},
|
|
392
|
+
execArgv,
|
|
393
|
+
});
|
|
394
|
+
const initTimeout = setTimeout(() => {
|
|
395
|
+
this.terminateWorker();
|
|
396
|
+
reject(new Error("Worker initialization timed out"));
|
|
397
|
+
}, 60000); // 60 second timeout for initialization (Pyodide loading is slow)
|
|
398
|
+
this.worker.on("message", (message) => {
|
|
399
|
+
if (message.type === "ready") {
|
|
400
|
+
clearTimeout(initTimeout);
|
|
401
|
+
this.workerReady = true;
|
|
402
|
+
console.error("[Heimdall] Worker thread ready");
|
|
403
|
+
resolve();
|
|
404
|
+
}
|
|
405
|
+
else if (message.type === "error") {
|
|
406
|
+
clearTimeout(initTimeout);
|
|
407
|
+
reject(new Error(message.error));
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
this.worker.on("error", (error) => {
|
|
411
|
+
clearTimeout(initTimeout);
|
|
412
|
+
this.workerReady = false;
|
|
413
|
+
reject(error);
|
|
414
|
+
});
|
|
415
|
+
this.worker.on("exit", (code) => {
|
|
416
|
+
this.workerReady = false;
|
|
417
|
+
this.worker = null;
|
|
418
|
+
this.workerInitPromise = null;
|
|
419
|
+
if (code !== 0) {
|
|
420
|
+
console.error(`[Heimdall] Worker exited with code ${code}`);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Terminate the worker thread
|
|
427
|
+
*/
|
|
428
|
+
terminateWorker() {
|
|
429
|
+
if (this.worker) {
|
|
430
|
+
void this.worker.terminate();
|
|
431
|
+
this.worker = null;
|
|
432
|
+
this.workerReady = false;
|
|
433
|
+
this.workerInitPromise = null;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Execute Python code in the sandbox using a worker thread
|
|
438
|
+
*
|
|
439
|
+
* Network access is NOT available - Pyodide runs in WebAssembly which
|
|
440
|
+
* doesn't have network capabilities. This is by design for security.
|
|
441
|
+
*
|
|
442
|
+
* The code runs in a separate worker thread, enabling true timeout
|
|
443
|
+
* enforcement. If the code doesn't complete within the timeout,
|
|
444
|
+
* the worker is terminated.
|
|
445
|
+
*/
|
|
446
|
+
async executeCode(code, packages = []) {
|
|
447
|
+
// Initialize worker if needed
|
|
448
|
+
await this.initializeWorker();
|
|
449
|
+
if (!this.worker) {
|
|
450
|
+
return {
|
|
451
|
+
success: false,
|
|
452
|
+
stdout: "",
|
|
453
|
+
stderr: "",
|
|
454
|
+
result: null,
|
|
455
|
+
error: "Worker thread not available",
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
return new Promise((resolve) => {
|
|
459
|
+
let timeoutId = null;
|
|
460
|
+
let resolved = false;
|
|
461
|
+
const cleanup = () => {
|
|
462
|
+
if (timeoutId) {
|
|
463
|
+
clearTimeout(timeoutId);
|
|
464
|
+
timeoutId = null;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const handleMessage = (message) => {
|
|
468
|
+
if (resolved)
|
|
469
|
+
return;
|
|
470
|
+
if (message.type === "result") {
|
|
471
|
+
resolved = true;
|
|
472
|
+
cleanup();
|
|
473
|
+
this.worker?.off("message", handleMessage);
|
|
474
|
+
resolve({
|
|
475
|
+
success: message.success,
|
|
476
|
+
stdout: message.stdout,
|
|
477
|
+
stderr: message.stderr,
|
|
478
|
+
result: message.result,
|
|
479
|
+
error: message.error,
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
else if (message.type === "error") {
|
|
483
|
+
resolved = true;
|
|
484
|
+
cleanup();
|
|
485
|
+
this.worker?.off("message", handleMessage);
|
|
486
|
+
resolve({
|
|
487
|
+
success: false,
|
|
488
|
+
stdout: "",
|
|
489
|
+
stderr: "",
|
|
490
|
+
result: null,
|
|
491
|
+
error: message.error,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
// Worker is guaranteed to be non-null here due to the check above
|
|
496
|
+
const worker = this.worker;
|
|
497
|
+
worker.on("message", handleMessage);
|
|
498
|
+
// Set up timeout
|
|
499
|
+
if (PYTHON_EXECUTION_TIMEOUT_MS > 0) {
|
|
500
|
+
timeoutId = setTimeout(() => {
|
|
501
|
+
if (resolved)
|
|
502
|
+
return;
|
|
503
|
+
resolved = true;
|
|
504
|
+
console.error(`[Heimdall] Python execution timed out after ${PYTHON_EXECUTION_TIMEOUT_MS}ms, terminating worker`);
|
|
505
|
+
// Terminate the worker to stop the infinite loop
|
|
506
|
+
this.terminateWorker();
|
|
507
|
+
resolve({
|
|
508
|
+
success: false,
|
|
509
|
+
stdout: "",
|
|
510
|
+
stderr: "",
|
|
511
|
+
result: null,
|
|
512
|
+
error: `Execution timed out after ${PYTHON_EXECUTION_TIMEOUT_MS}ms`,
|
|
513
|
+
});
|
|
514
|
+
}, PYTHON_EXECUTION_TIMEOUT_MS);
|
|
515
|
+
}
|
|
516
|
+
// Send execute message to worker
|
|
517
|
+
worker.postMessage({
|
|
518
|
+
type: "execute",
|
|
519
|
+
code,
|
|
520
|
+
packages,
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Install packages via micropip
|
|
526
|
+
*/
|
|
527
|
+
async installPackages(packages) {
|
|
528
|
+
const py = await this.initialize();
|
|
529
|
+
const micropip = py.pyimport("micropip");
|
|
530
|
+
const results = [];
|
|
531
|
+
for (const pkg of packages) {
|
|
532
|
+
try {
|
|
533
|
+
await micropip.install(pkg);
|
|
534
|
+
results.push({ package: pkg, success: true, error: null });
|
|
535
|
+
}
|
|
536
|
+
catch (e) {
|
|
537
|
+
results.push({
|
|
538
|
+
package: pkg,
|
|
539
|
+
success: false,
|
|
540
|
+
error: e instanceof Error ? e.message : String(e),
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return results;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Read a file from the virtual filesystem
|
|
548
|
+
*/
|
|
549
|
+
async readFile(filePath) {
|
|
550
|
+
try {
|
|
551
|
+
const py = await this.initialize();
|
|
552
|
+
const fullPath = this.validatePath(filePath);
|
|
553
|
+
const hostPath = this.virtualToHostPath(fullPath);
|
|
554
|
+
// SECURITY: Validate host path doesn't escape workspace via symlinks
|
|
555
|
+
await this.validateHostPathWithSymlinkResolution(hostPath);
|
|
556
|
+
await this.syncHostPathToVirtual(hostPath, fullPath);
|
|
557
|
+
const content = py.FS.readFile(fullPath, { encoding: "utf8" });
|
|
558
|
+
return { success: true, content, error: null };
|
|
559
|
+
}
|
|
560
|
+
catch (e) {
|
|
561
|
+
return { success: false, content: null, error: e instanceof Error ? e.message : String(e) };
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Write a file to the virtual filesystem
|
|
566
|
+
*
|
|
567
|
+
* Uses a write lock to prevent TOCTOU race conditions where concurrent
|
|
568
|
+
* writes could bypass workspace size limits. The lock ensures that the
|
|
569
|
+
* size check and write operation are atomic.
|
|
570
|
+
*/
|
|
571
|
+
async writeFile(filePath, content) {
|
|
572
|
+
try {
|
|
573
|
+
const py = await this.initialize();
|
|
574
|
+
const fullPath = this.validatePath(filePath);
|
|
575
|
+
const hostPath = this.virtualToHostPath(fullPath);
|
|
576
|
+
// Validate file size (can be done outside lock - it's a constant check)
|
|
577
|
+
const fileSize = Buffer.byteLength(content, "utf8");
|
|
578
|
+
if (fileSize > MAX_FILE_SIZE) {
|
|
579
|
+
throw new Error(`File too large: ${(fileSize / 1024 / 1024).toFixed(2)}MB. ` +
|
|
580
|
+
`Maximum allowed: ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(2)}MB`);
|
|
581
|
+
}
|
|
582
|
+
// Acquire write lock to prevent TOCTOU race conditions
|
|
583
|
+
// This ensures workspace size check and write are atomic
|
|
584
|
+
return await this.writeLock.acquire("workspace", async () => {
|
|
585
|
+
// Check workspace size limit (inside lock to prevent race)
|
|
586
|
+
await this.checkWorkspaceSize(fileSize);
|
|
587
|
+
// Ensure parent directory exists
|
|
588
|
+
const parentDir = path.posix.dirname(fullPath);
|
|
589
|
+
if (parentDir && parentDir !== "/" && !parentDir.includes("..")) {
|
|
590
|
+
py.FS.mkdirTree(parentDir);
|
|
591
|
+
}
|
|
592
|
+
py.FS.writeFile(fullPath, content, { encoding: "utf8" });
|
|
593
|
+
await this.syncVirtualPathToHost(fullPath, hostPath);
|
|
594
|
+
return { success: true, error: null };
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
catch (e) {
|
|
598
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* List files in a directory
|
|
603
|
+
*/
|
|
604
|
+
async listFiles(dirPath = "") {
|
|
605
|
+
try {
|
|
606
|
+
const py = await this.initialize();
|
|
607
|
+
const fullPath = dirPath ? this.validatePath(dirPath) : VIRTUAL_WORKSPACE;
|
|
608
|
+
const hostPath = this.virtualToHostPath(fullPath);
|
|
609
|
+
// SECURITY: Validate host path doesn't escape workspace via symlinks
|
|
610
|
+
await this.validateHostPathWithSymlinkResolution(hostPath);
|
|
611
|
+
await this.syncHostPathToVirtual(hostPath, fullPath);
|
|
612
|
+
const items = py.FS.readdir(fullPath).filter((x) => x !== "." && x !== "..");
|
|
613
|
+
const files = items.map((item) => {
|
|
614
|
+
const itemPath = `${fullPath}/${item}`;
|
|
615
|
+
const stat = py.FS.stat(itemPath);
|
|
616
|
+
return {
|
|
617
|
+
name: item,
|
|
618
|
+
isDirectory: py.FS.isDir(stat.mode),
|
|
619
|
+
size: stat.size,
|
|
620
|
+
};
|
|
621
|
+
});
|
|
622
|
+
return { success: true, files, error: null };
|
|
623
|
+
}
|
|
624
|
+
catch (e) {
|
|
625
|
+
return { success: false, files: [], error: e instanceof Error ? e.message : String(e) };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Delete a file or directory
|
|
630
|
+
*/
|
|
631
|
+
async deleteFile(filePath) {
|
|
632
|
+
try {
|
|
633
|
+
const py = await this.initialize();
|
|
634
|
+
const fullPath = this.validatePath(filePath);
|
|
635
|
+
const hostPath = this.virtualToHostPath(fullPath);
|
|
636
|
+
await this.syncHostPathToVirtual(hostPath, fullPath);
|
|
637
|
+
// SECURITY: Validate the host path with symlink resolution
|
|
638
|
+
// This prevents deleting files outside workspace via symlinks
|
|
639
|
+
await this.validateHostPathWithSymlinkResolution(hostPath);
|
|
640
|
+
// Delete from virtual filesystem
|
|
641
|
+
const stat = py.FS.stat(fullPath);
|
|
642
|
+
if (py.FS.isDir(stat.mode)) {
|
|
643
|
+
py.FS.rmdir(fullPath);
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
py.FS.unlink(fullPath);
|
|
647
|
+
}
|
|
648
|
+
// Also delete from host filesystem
|
|
649
|
+
try {
|
|
650
|
+
await fs.promises.access(hostPath);
|
|
651
|
+
const hostStat = await fs.promises.stat(hostPath);
|
|
652
|
+
if (hostStat.isDirectory()) {
|
|
653
|
+
await fs.promises.rmdir(hostPath);
|
|
654
|
+
}
|
|
655
|
+
else {
|
|
656
|
+
await fs.promises.unlink(hostPath);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
catch {
|
|
660
|
+
// File doesn't exist on host, which is fine
|
|
661
|
+
}
|
|
662
|
+
return { success: true, error: null };
|
|
663
|
+
}
|
|
664
|
+
catch (e) {
|
|
665
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
//# sourceMappingURL=pyodide-manager.js.map
|