@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.
Files changed (67) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +471 -0
  3. package/dist/config/constants.d.ts +24 -0
  4. package/dist/config/constants.d.ts.map +1 -0
  5. package/dist/config/constants.js +70 -0
  6. package/dist/config/constants.js.map +1 -0
  7. package/dist/core/bash-manager.d.ts +56 -0
  8. package/dist/core/bash-manager.d.ts.map +1 -0
  9. package/dist/core/bash-manager.js +106 -0
  10. package/dist/core/bash-manager.js.map +1 -0
  11. package/dist/core/pyodide-manager.d.ts +125 -0
  12. package/dist/core/pyodide-manager.d.ts.map +1 -0
  13. package/dist/core/pyodide-manager.js +669 -0
  14. package/dist/core/pyodide-manager.js.map +1 -0
  15. package/dist/core/pyodide-worker.d.ts +9 -0
  16. package/dist/core/pyodide-worker.d.ts.map +1 -0
  17. package/dist/core/pyodide-worker.js +295 -0
  18. package/dist/core/pyodide-worker.js.map +1 -0
  19. package/dist/core/secure-fs.d.ts +101 -0
  20. package/dist/core/secure-fs.d.ts.map +1 -0
  21. package/dist/core/secure-fs.js +279 -0
  22. package/dist/core/secure-fs.js.map +1 -0
  23. package/dist/integration.test.d.ts +10 -0
  24. package/dist/integration.test.d.ts.map +1 -0
  25. package/dist/integration.test.js +439 -0
  26. package/dist/integration.test.js.map +1 -0
  27. package/dist/resources/index.d.ts +12 -0
  28. package/dist/resources/index.d.ts.map +1 -0
  29. package/dist/resources/index.js +13 -0
  30. package/dist/resources/index.js.map +1 -0
  31. package/dist/resources/workspace.d.ts +12 -0
  32. package/dist/resources/workspace.d.ts.map +1 -0
  33. package/dist/resources/workspace.js +105 -0
  34. package/dist/resources/workspace.js.map +1 -0
  35. package/dist/server.d.ts +17 -0
  36. package/dist/server.d.ts.map +1 -0
  37. package/dist/server.js +51 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/tools/bash-execution.d.ts +13 -0
  40. package/dist/tools/bash-execution.d.ts.map +1 -0
  41. package/dist/tools/bash-execution.js +135 -0
  42. package/dist/tools/bash-execution.js.map +1 -0
  43. package/dist/tools/filesystem.d.ts +12 -0
  44. package/dist/tools/filesystem.d.ts.map +1 -0
  45. package/dist/tools/filesystem.js +104 -0
  46. package/dist/tools/filesystem.js.map +1 -0
  47. package/dist/tools/index.d.ts +13 -0
  48. package/dist/tools/index.d.ts.map +1 -0
  49. package/dist/tools/index.js +17 -0
  50. package/dist/tools/index.js.map +1 -0
  51. package/dist/tools/python-execution.d.ts +12 -0
  52. package/dist/tools/python-execution.d.ts.map +1 -0
  53. package/dist/tools/python-execution.js +77 -0
  54. package/dist/tools/python-execution.js.map +1 -0
  55. package/dist/types/index.d.ts +64 -0
  56. package/dist/types/index.d.ts.map +1 -0
  57. package/dist/types/index.js +2 -0
  58. package/dist/types/index.js.map +1 -0
  59. package/dist/utils/async-lock.d.ts +35 -0
  60. package/dist/utils/async-lock.d.ts.map +1 -0
  61. package/dist/utils/async-lock.js +57 -0
  62. package/dist/utils/async-lock.js.map +1 -0
  63. package/dist/utils/index.d.ts +5 -0
  64. package/dist/utils/index.d.ts.map +1 -0
  65. package/dist/utils/index.js +5 -0
  66. package/dist/utils/index.js.map +1 -0
  67. 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