@secure-exec/core 0.1.1-rc.3 → 0.2.0-rc.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.
Files changed (102) hide show
  1. package/dist/esm-compiler.d.ts +5 -1
  2. package/dist/esm-compiler.js +5 -1
  3. package/dist/fs-helpers.d.ts +1 -1
  4. package/dist/generated/isolate-runtime.d.ts +15 -15
  5. package/dist/generated/isolate-runtime.js +15 -15
  6. package/dist/index.d.ts +24 -5
  7. package/dist/index.js +23 -3
  8. package/dist/isolate-runtime/apply-custom-global-policy.js +3 -3
  9. package/dist/isolate-runtime/apply-timing-mitigation-freeze.js +2 -2
  10. package/dist/isolate-runtime/apply-timing-mitigation-off.js +2 -2
  11. package/dist/isolate-runtime/bridge-attach.js +2 -2
  12. package/dist/isolate-runtime/bridge-initial-globals.js +145 -6
  13. package/dist/isolate-runtime/eval-script-result.js +1 -1
  14. package/dist/isolate-runtime/global-exposure-helpers.js +2 -2
  15. package/dist/isolate-runtime/init-commonjs-module-globals.js +2 -2
  16. package/dist/isolate-runtime/override-process-cwd.js +1 -1
  17. package/dist/isolate-runtime/override-process-env.js +1 -1
  18. package/dist/isolate-runtime/require-setup.js +1600 -338
  19. package/dist/isolate-runtime/set-commonjs-file-globals.js +2 -2
  20. package/dist/isolate-runtime/set-stdin-data.js +1 -1
  21. package/dist/isolate-runtime/setup-dynamic-import.js +47 -19
  22. package/dist/isolate-runtime/setup-fs-facade.js +62 -23
  23. package/dist/kernel/command-registry.d.ts +44 -0
  24. package/dist/kernel/command-registry.js +114 -0
  25. package/dist/kernel/device-layer.d.ts +12 -0
  26. package/dist/kernel/device-layer.js +262 -0
  27. package/dist/kernel/dns-cache.d.ts +29 -0
  28. package/dist/kernel/dns-cache.js +52 -0
  29. package/dist/kernel/fd-table.d.ts +84 -0
  30. package/dist/kernel/fd-table.js +278 -0
  31. package/dist/kernel/file-lock.d.ts +34 -0
  32. package/dist/kernel/file-lock.js +123 -0
  33. package/dist/kernel/host-adapter.d.ts +50 -0
  34. package/dist/kernel/host-adapter.js +8 -0
  35. package/dist/kernel/index.d.ts +36 -0
  36. package/dist/kernel/index.js +34 -0
  37. package/dist/kernel/inode-table.d.ts +43 -0
  38. package/dist/kernel/inode-table.js +85 -0
  39. package/dist/kernel/kernel.d.ts +9 -0
  40. package/dist/kernel/kernel.js +1396 -0
  41. package/dist/kernel/permissions.d.ts +27 -0
  42. package/dist/kernel/permissions.js +118 -0
  43. package/dist/kernel/pipe-manager.d.ts +64 -0
  44. package/dist/kernel/pipe-manager.js +267 -0
  45. package/dist/kernel/proc-layer.d.ts +11 -0
  46. package/dist/kernel/proc-layer.js +501 -0
  47. package/dist/kernel/process-table.d.ts +124 -0
  48. package/dist/kernel/process-table.js +631 -0
  49. package/dist/kernel/pty.d.ts +108 -0
  50. package/dist/kernel/pty.js +541 -0
  51. package/dist/kernel/socket-table.d.ts +305 -0
  52. package/dist/kernel/socket-table.js +1124 -0
  53. package/dist/kernel/timer-table.d.ts +54 -0
  54. package/dist/kernel/timer-table.js +108 -0
  55. package/dist/kernel/types.d.ts +500 -0
  56. package/dist/kernel/types.js +89 -0
  57. package/dist/kernel/user.d.ts +29 -0
  58. package/dist/kernel/user.js +35 -0
  59. package/dist/kernel/vfs.d.ts +54 -0
  60. package/dist/kernel/vfs.js +8 -0
  61. package/dist/kernel/wait.d.ts +45 -0
  62. package/dist/kernel/wait.js +112 -0
  63. package/dist/kernel/wstatus.d.ts +21 -0
  64. package/dist/kernel/wstatus.js +33 -0
  65. package/dist/module-resolver.d.ts +4 -0
  66. package/dist/module-resolver.js +4 -0
  67. package/dist/package-bundler.d.ts +6 -1
  68. package/dist/runtime-driver.d.ts +3 -1
  69. package/dist/shared/bridge-contract.d.ts +329 -20
  70. package/dist/shared/bridge-contract.js +60 -5
  71. package/dist/shared/console-formatter.js +8 -4
  72. package/dist/shared/global-exposure.js +269 -19
  73. package/dist/shared/in-memory-fs.d.ts +30 -11
  74. package/dist/shared/in-memory-fs.js +383 -109
  75. package/dist/shared/permissions.d.ts +4 -6
  76. package/dist/shared/permissions.js +19 -39
  77. package/dist/types.d.ts +8 -159
  78. package/dist/types.js +5 -0
  79. package/package.json +12 -22
  80. package/dist/bridge/active-handles.d.ts +0 -22
  81. package/dist/bridge/active-handles.js +0 -55
  82. package/dist/bridge/child-process.d.ts +0 -99
  83. package/dist/bridge/child-process.js +0 -670
  84. package/dist/bridge/fs.d.ts +0 -281
  85. package/dist/bridge/fs.js +0 -2235
  86. package/dist/bridge/index.d.ts +0 -10
  87. package/dist/bridge/index.js +0 -41
  88. package/dist/bridge/module.d.ts +0 -75
  89. package/dist/bridge/module.js +0 -308
  90. package/dist/bridge/network.d.ts +0 -350
  91. package/dist/bridge/network.js +0 -2050
  92. package/dist/bridge/os.d.ts +0 -13
  93. package/dist/bridge/os.js +0 -256
  94. package/dist/bridge/polyfills.d.ts +0 -2
  95. package/dist/bridge/polyfills.js +0 -11
  96. package/dist/bridge/process.d.ts +0 -89
  97. package/dist/bridge/process.js +0 -1015
  98. package/dist/bridge.js +0 -12496
  99. package/dist/python-runtime.d.ts +0 -16
  100. package/dist/python-runtime.js +0 -45
  101. package/dist/runtime.d.ts +0 -31
  102. package/dist/runtime.js +0 -69
@@ -0,0 +1,1396 @@
1
+ /**
2
+ * Kernel implementation.
3
+ *
4
+ * The kernel is the OS. It owns VFS, FD table, process table, device layer,
5
+ * pipe manager, command registry, and permissions. Runtimes are execution
6
+ * engines that make "syscalls" to the kernel.
7
+ */
8
+ import { createDeviceLayer } from "./device-layer.js";
9
+ import { createProcLayer } from "./proc-layer.js";
10
+ import { FDTableManager } from "./fd-table.js";
11
+ import { ProcessTable } from "./process-table.js";
12
+ import { PipeManager } from "./pipe-manager.js";
13
+ import { PtyManager } from "./pty.js";
14
+ import { FileLockManager } from "./file-lock.js";
15
+ import { CommandRegistry } from "./command-registry.js";
16
+ import { wrapFileSystem, checkChildProcess } from "./permissions.js";
17
+ import { UserManager } from "./user.js";
18
+ import { SocketTable } from "./socket-table.js";
19
+ import { TimerTable } from "./timer-table.js";
20
+ import { InodeTable } from "./inode-table.js";
21
+ import { InMemoryFileSystem } from "../shared/in-memory-fs.js";
22
+ import { FILETYPE_REGULAR_FILE, SEEK_SET, SEEK_CUR, SEEK_END, O_APPEND, O_CREAT, O_EXCL, O_TRUNC, SIGTERM, SIGPIPE, SIGWINCH, F_DUPFD, F_GETFD, F_SETFD, F_GETFL, F_DUPFD_CLOEXEC, FD_CLOEXEC, KernelError, } from "./types.js";
23
+ export function createKernel(options) {
24
+ return new KernelImpl(options);
25
+ }
26
+ class KernelImpl {
27
+ vfs;
28
+ rawInMemoryFs;
29
+ fdTableManager = new FDTableManager();
30
+ processTable = new ProcessTable();
31
+ pipeManager = new PipeManager();
32
+ ptyManager = new PtyManager((pgid, signal, excludeLeaders) => {
33
+ try {
34
+ if (excludeLeaders) {
35
+ return this.processTable.killGroupExcludeLeaders(pgid, signal);
36
+ }
37
+ this.processTable.kill(-pgid, signal);
38
+ }
39
+ catch { /* no-op if pgid gone */ }
40
+ return 0;
41
+ });
42
+ fileLockManager = new FileLockManager();
43
+ commandRegistry = new CommandRegistry();
44
+ socketTable;
45
+ timerTable;
46
+ inodeTable;
47
+ userManager;
48
+ drivers = [];
49
+ driverPids = new Map();
50
+ permissions;
51
+ maxProcesses;
52
+ env;
53
+ cwd;
54
+ disposed = false;
55
+ pendingBinEntries = [];
56
+ posixDirsReady;
57
+ constructor(options) {
58
+ this.inodeTable = new InodeTable();
59
+ if (options.filesystem instanceof InMemoryFileSystem) {
60
+ options.filesystem.setInodeTable(this.inodeTable);
61
+ this.rawInMemoryFs = options.filesystem;
62
+ }
63
+ // Apply pseudo-filesystems before permissions so dynamic entries are
64
+ // subject to the same policy as regular files.
65
+ let fs = createDeviceLayer(options.filesystem);
66
+ fs = createProcLayer(fs, {
67
+ processTable: this.processTable,
68
+ fdTableManager: this.fdTableManager,
69
+ hostname: options.env?.HOSTNAME,
70
+ });
71
+ // Apply permission wrapping
72
+ if (options.permissions) {
73
+ fs = wrapFileSystem(fs, options.permissions);
74
+ }
75
+ this.vfs = fs;
76
+ this.permissions = options.permissions;
77
+ this.maxProcesses = options.maxProcesses;
78
+ this.env = { ...options.env };
79
+ this.cwd = options.cwd ?? "/home/user";
80
+ this.userManager = new UserManager();
81
+ this.socketTable = new SocketTable({
82
+ vfs: this.vfs,
83
+ hostAdapter: options.hostNetworkAdapter,
84
+ getSignalState: (pid) => this.processTable.getSignalState(pid),
85
+ });
86
+ this.timerTable = new TimerTable();
87
+ // Clean up FD table and sockets when a process exits
88
+ this.processTable.onProcessExit = (pid) => {
89
+ this.cleanupProcessFDs(pid);
90
+ this.socketTable.closeAllForProcess(pid);
91
+ this.timerTable.clearAllForProcess(pid);
92
+ };
93
+ // Clean up driver PID ownership when zombie is reaped
94
+ this.processTable.onProcessReap = (pid) => {
95
+ const entry = this.processTable.get(pid);
96
+ if (entry)
97
+ this.driverPids.get(entry.driver)?.delete(pid);
98
+ };
99
+ // Deliver SIGPIPE default action: terminate writer with 128+SIGPIPE
100
+ this.pipeManager.onBrokenPipe = (pid) => {
101
+ try {
102
+ this.processTable.kill(pid, SIGPIPE);
103
+ }
104
+ catch {
105
+ // Process may already be exited
106
+ }
107
+ };
108
+ // Create standard POSIX directory hierarchy so all programs see /tmp,
109
+ // /usr, /etc, etc. — matching a real Linux root filesystem layout.
110
+ this.posixDirsReady = this.initPosixDirs();
111
+ }
112
+ async initPosixDirs() {
113
+ const dirs = [
114
+ "/tmp",
115
+ "/bin",
116
+ "/lib",
117
+ "/sbin",
118
+ "/boot",
119
+ "/etc",
120
+ "/root",
121
+ "/run",
122
+ "/srv",
123
+ "/sys",
124
+ "/proc",
125
+ "/opt",
126
+ "/mnt",
127
+ "/media",
128
+ "/home",
129
+ "/usr",
130
+ "/usr/bin",
131
+ "/usr/games",
132
+ "/usr/include",
133
+ "/usr/lib",
134
+ "/usr/libexec",
135
+ "/usr/man",
136
+ "/usr/sbin",
137
+ "/usr/share",
138
+ "/usr/share/man",
139
+ "/var",
140
+ "/var/cache",
141
+ "/var/empty",
142
+ "/var/lib",
143
+ "/var/lock",
144
+ "/var/log",
145
+ "/var/run",
146
+ "/var/spool",
147
+ "/var/tmp",
148
+ ];
149
+ for (const dir of dirs) {
150
+ try {
151
+ await this.vfs.mkdir(dir, { recursive: true });
152
+ }
153
+ catch {
154
+ // Directory may already exist
155
+ }
156
+ }
157
+ // Standard utility that many scripts expect
158
+ try {
159
+ await this.vfs.writeFile("/usr/bin/env", new Uint8Array(1));
160
+ }
161
+ catch {
162
+ // File may already exist
163
+ }
164
+ }
165
+ // -----------------------------------------------------------------------
166
+ // Kernel public API
167
+ // -----------------------------------------------------------------------
168
+ async mount(driver) {
169
+ this.assertNotDisposed();
170
+ await this.posixDirsReady;
171
+ // Track PIDs owned by this driver
172
+ if (!this.driverPids.has(driver.name)) {
173
+ this.driverPids.set(driver.name, new Set());
174
+ }
175
+ // Initialize the driver with a scoped kernel interface
176
+ await driver.init(this.createKernelInterface(driver.name));
177
+ // Register commands
178
+ this.commandRegistry.register(driver);
179
+ this.drivers.push(driver);
180
+ // Populate /bin stubs for shell PATH lookup
181
+ await this.commandRegistry.populateBin(this.vfs);
182
+ }
183
+ async dispose() {
184
+ if (this.disposed)
185
+ return;
186
+ this.disposed = true;
187
+ // Terminate all running processes
188
+ await this.processTable.terminateAll();
189
+ // Clean up all sockets
190
+ this.socketTable.disposeAll();
191
+ this.timerTable.disposeAll();
192
+ // Dispose all drivers (reverse mount order)
193
+ for (let i = this.drivers.length - 1; i >= 0; i--) {
194
+ try {
195
+ await this.drivers[i].dispose();
196
+ }
197
+ catch {
198
+ // Best effort cleanup
199
+ }
200
+ }
201
+ this.drivers.length = 0;
202
+ }
203
+ /**
204
+ * Flush pending /bin stub entries created by on-demand command discovery.
205
+ * Ensures VFS is consistent before shell PATH lookups.
206
+ */
207
+ async flushPendingBinEntries() {
208
+ if (this.pendingBinEntries.length > 0) {
209
+ await Promise.all(this.pendingBinEntries);
210
+ this.pendingBinEntries.length = 0;
211
+ }
212
+ }
213
+ async exec(command, options) {
214
+ this.assertNotDisposed();
215
+ // Flush pending /bin stubs before shell PATH lookup
216
+ await this.flushPendingBinEntries();
217
+ // Route through shell
218
+ const shell = this.commandRegistry.resolve("sh");
219
+ if (!shell) {
220
+ throw new Error("No shell available. Mount a WasmVM runtime to enable exec().");
221
+ }
222
+ const proc = this.spawnInternal("sh", ["-c", command], options);
223
+ // Write stdin if provided
224
+ if (options?.stdin) {
225
+ const data = typeof options.stdin === "string"
226
+ ? new TextEncoder().encode(options.stdin)
227
+ : options.stdin;
228
+ proc.writeStdin(data);
229
+ proc.closeStdin();
230
+ }
231
+ // Collect output
232
+ const stdoutChunks = [];
233
+ const stderrChunks = [];
234
+ proc.onStdout = (data) => {
235
+ stdoutChunks.push(data);
236
+ options?.onStdout?.(data);
237
+ };
238
+ proc.onStderr = (data) => {
239
+ stderrChunks.push(data);
240
+ options?.onStderr?.(data);
241
+ };
242
+ // Wait with optional timeout
243
+ let exitCode;
244
+ if (options?.timeout) {
245
+ let timer;
246
+ try {
247
+ exitCode = await Promise.race([
248
+ proc.wait().then((code) => {
249
+ clearTimeout(timer);
250
+ return code;
251
+ }),
252
+ new Promise((_, reject) => {
253
+ timer = setTimeout(() => {
254
+ // Kill process and detach output callbacks
255
+ proc.onStdout = null;
256
+ proc.onStderr = null;
257
+ proc.kill(SIGTERM);
258
+ reject(new KernelError("ETIMEDOUT", "exec timeout"));
259
+ }, options.timeout);
260
+ }),
261
+ ]);
262
+ }
263
+ catch (err) {
264
+ clearTimeout(timer);
265
+ throw err;
266
+ }
267
+ }
268
+ else {
269
+ exitCode = await proc.wait();
270
+ }
271
+ return {
272
+ exitCode,
273
+ stdout: concatUint8(stdoutChunks),
274
+ stderr: concatUint8(stderrChunks),
275
+ };
276
+ }
277
+ spawn(command, args, options) {
278
+ this.assertNotDisposed();
279
+ return this.spawnManaged(command, args, options);
280
+ }
281
+ openShell(options) {
282
+ this.assertNotDisposed();
283
+ const command = options?.command ?? "sh";
284
+ const args = options?.args ?? [];
285
+ // Allocate a controller PID with an FD table to hold the PTY master
286
+ const controllerPid = this.processTable.allocatePid();
287
+ const controllerTable = this.fdTableManager.create(controllerPid);
288
+ // Create PTY pair in the controller's FD table
289
+ const { masterFd, slaveFd } = this.ptyManager.createPtyFDs(controllerTable);
290
+ const masterDescId = controllerTable.get(masterFd).description.id;
291
+ // Spawn shell with PTY slave as stdin/stdout/stderr
292
+ const proc = this.spawnInternal(command, args, {
293
+ env: options?.env,
294
+ cwd: options?.cwd,
295
+ stdinFd: slaveFd,
296
+ stdoutFd: slaveFd,
297
+ stderrFd: slaveFd,
298
+ }, controllerPid);
299
+ // Shell becomes its own process group leader, set as PTY foreground
300
+ this.processTable.setpgid(proc.pid, proc.pid);
301
+ this.ptyManager.setForegroundPgid(masterDescId, proc.pid);
302
+ this.ptyManager.setSessionLeader(masterDescId, proc.pid);
303
+ // Close controller's copy of slave FD (child inherited its own copy via fork).
304
+ // Without this, slave refCount stays >0 after shell exits, preventing EOF on master.
305
+ const slaveEntry = controllerTable.get(slaveFd);
306
+ const slaveDescId = slaveEntry.description.id;
307
+ controllerTable.close(slaveFd);
308
+ if (slaveEntry.description.refCount <= 0) {
309
+ this.ptyManager.close(slaveDescId);
310
+ }
311
+ // Start read pump: master reads → onData callback
312
+ // Use object wrapper so TypeScript doesn't narrow to null in the async closure
313
+ const pump = { onData: null, exited: false };
314
+ const pumpPromise = (async () => {
315
+ try {
316
+ while (!pump.exited) {
317
+ const data = await this.ptyManager.read(masterDescId, 4096);
318
+ if (!data || data.length === 0)
319
+ break;
320
+ try {
321
+ pump.onData?.(data);
322
+ }
323
+ catch (cbErr) {
324
+ // Propagate callback errors — don't silently swallow
325
+ console.error("openShell readPump: onData callback error:", cbErr);
326
+ }
327
+ }
328
+ }
329
+ catch (err) {
330
+ // Master closed or PTY gone — expected when shell exits
331
+ if (pump.exited)
332
+ return;
333
+ console.error("openShell readPump: PTY read error:", err);
334
+ }
335
+ })();
336
+ // wait() resolves after both shell exit AND pump drain
337
+ const waitPromise = proc.wait().then(async (exitCode) => {
338
+ pump.exited = true;
339
+ // Wait for pump to finish delivering remaining data
340
+ await pumpPromise;
341
+ // Clean up controller PID's FD table (incl. PTY master)
342
+ this.cleanupProcessFDs(controllerPid);
343
+ return exitCode;
344
+ });
345
+ return {
346
+ pid: proc.pid,
347
+ write: (data) => {
348
+ const bytes = typeof data === "string"
349
+ ? new TextEncoder().encode(data)
350
+ : data;
351
+ this.ptyManager.write(masterDescId, bytes);
352
+ },
353
+ get onData() { return pump.onData; },
354
+ set onData(fn) { pump.onData = fn; },
355
+ resize: (_cols, _rows) => {
356
+ const fgPgid = this.ptyManager.getForegroundPgid(masterDescId);
357
+ if (fgPgid > 0) {
358
+ try {
359
+ this.processTable.kill(-fgPgid, SIGWINCH);
360
+ }
361
+ catch { /* pgid may be gone */ }
362
+ }
363
+ },
364
+ kill: (signal) => {
365
+ proc.kill(signal ?? SIGTERM);
366
+ },
367
+ wait: () => waitPromise,
368
+ };
369
+ }
370
+ async connectTerminal(options) {
371
+ this.assertNotDisposed();
372
+ const stdin = process.stdin;
373
+ const stdout = process.stdout;
374
+ const isTTY = stdin.isTTY;
375
+ let onStdinData;
376
+ let onResize;
377
+ try {
378
+ const shell = this.openShell(options);
379
+ // Set raw mode so keypresses pass through directly
380
+ if (isTTY)
381
+ stdin.setRawMode(true);
382
+ // Forward stdin to shell
383
+ onStdinData = (data) => shell.write(data);
384
+ stdin.on("data", onStdinData);
385
+ stdin.resume();
386
+ // Forward shell output to stdout or custom handler
387
+ const outputHandler = options?.onData
388
+ ?? ((data) => { stdout.write(data); });
389
+ shell.onData = outputHandler;
390
+ // Handle terminal resize
391
+ onResize = () => {
392
+ shell.resize(stdout.columns || 80, stdout.rows || 24);
393
+ };
394
+ if (stdout.isTTY)
395
+ stdout.on("resize", onResize);
396
+ // Set initial terminal size
397
+ if (stdout.isTTY) {
398
+ shell.resize(stdout.columns || 80, stdout.rows || 24);
399
+ }
400
+ return await shell.wait();
401
+ }
402
+ finally {
403
+ // Restore terminal — guard each cleanup since setup may have partially completed
404
+ if (onStdinData)
405
+ stdin.removeListener("data", onStdinData);
406
+ stdin.pause();
407
+ if (isTTY)
408
+ stdin.setRawMode(false);
409
+ if (onResize && stdout.isTTY)
410
+ stdout.removeListener("resize", onResize);
411
+ }
412
+ }
413
+ // Filesystem convenience wrappers
414
+ readFile(path) { return this.vfs.readFile(path); }
415
+ writeFile(path, content) { return this.vfs.writeFile(path, content); }
416
+ mkdir(path) { return this.vfs.mkdir(path); }
417
+ readdir(path) { return this.vfs.readDir(path); }
418
+ stat(path) { return this.vfs.stat(path); }
419
+ exists(path) { return this.vfs.exists(path); }
420
+ // Introspection
421
+ get commands() {
422
+ return this.commandRegistry.list();
423
+ }
424
+ get processes() {
425
+ return this.processTable.listProcesses();
426
+ }
427
+ get zombieTimerCount() {
428
+ return this.processTable.zombieTimerCount;
429
+ }
430
+ // -----------------------------------------------------------------------
431
+ // Internal spawn
432
+ // -----------------------------------------------------------------------
433
+ spawnInternal(command, args, options, callerPid) {
434
+ let driver = this.commandRegistry.resolve(command);
435
+ // On-demand discovery: ask mounted drivers to resolve unknown commands
436
+ if (!driver) {
437
+ const basename = command.includes("/")
438
+ ? command.split("/").pop()
439
+ : command;
440
+ if (basename) {
441
+ for (const d of this.drivers) {
442
+ if (d.tryResolve?.(basename)) {
443
+ this.commandRegistry.registerCommand(basename, d);
444
+ // Store pending promise so exec() can flush before shell PATH lookup
445
+ const p = this.commandRegistry.populateBinEntry(this.vfs, basename);
446
+ this.pendingBinEntries.push(p);
447
+ p.then(() => {
448
+ const idx = this.pendingBinEntries.indexOf(p);
449
+ if (idx >= 0)
450
+ this.pendingBinEntries.splice(idx, 1);
451
+ });
452
+ driver = d;
453
+ break;
454
+ }
455
+ }
456
+ }
457
+ }
458
+ if (!driver) {
459
+ throw new KernelError("ENOENT", `command not found: ${command}`);
460
+ }
461
+ // Check childProcess permission
462
+ checkChildProcess(this.permissions, command, args, options?.cwd);
463
+ // Enforce maxProcesses budget
464
+ if (this.maxProcesses !== undefined && this.processTable.runningCount() >= this.maxProcesses) {
465
+ throw new KernelError("EAGAIN", "maximum process limit reached");
466
+ }
467
+ // Allocate PID atomically
468
+ const pid = this.processTable.allocatePid();
469
+ // Register PID ownership before driver.spawn() so the driver can use it
470
+ this.driverPids.get(driver.name)?.add(pid);
471
+ // Cross-runtime spawn: parent driver must also track child PID so
472
+ // it can waitpid/kill/interact with the child process
473
+ if (callerPid !== undefined) {
474
+ for (const [name, pids] of this.driverPids) {
475
+ if (name !== driver.name && pids.has(callerPid)) {
476
+ pids.add(pid);
477
+ break;
478
+ }
479
+ }
480
+ }
481
+ // Create FD table — wire pipe FDs when overrides are provided
482
+ const table = this.createChildFDTable(pid, options, callerPid);
483
+ // Check which stdio channels are piped (data flows through kernel, not callbacks)
484
+ const stdoutPiped = this.isStdioPiped(table, 1);
485
+ const stderrPiped = this.isStdioPiped(table, 2);
486
+ // Buffer stdout/stderr — wired before spawn so nothing is lost
487
+ const stdoutBuf = [];
488
+ const stderrBuf = [];
489
+ // Resolve output callbacks: when a child inherits non-piped stdio from
490
+ // a parent, forward output to the parent's DriverProcess callbacks so
491
+ // cross-runtime child output reaches the top-level collector.
492
+ // When piped, wire a callback that forwards through the pipe/PTY so
493
+ // drivers that emit output via callbacks (Node) reach the PTY/pipe.
494
+ let stdoutCb;
495
+ let stderrCb;
496
+ if (stdoutPiped) {
497
+ stdoutCb = this.createPipedOutputCallback(table, 1, pid);
498
+ }
499
+ else {
500
+ if (options?.onStdout) {
501
+ stdoutCb = options.onStdout;
502
+ }
503
+ else if (callerPid !== undefined) {
504
+ const parent = this.processTable.get(callerPid);
505
+ if (parent?.driverProcess.onStdout) {
506
+ stdoutCb = parent.driverProcess.onStdout;
507
+ }
508
+ }
509
+ if (!stdoutCb)
510
+ stdoutCb = (data) => stdoutBuf.push(data);
511
+ }
512
+ if (stderrPiped) {
513
+ stderrCb = this.createPipedOutputCallback(table, 2, pid);
514
+ }
515
+ else {
516
+ if (options?.onStderr) {
517
+ stderrCb = options.onStderr;
518
+ }
519
+ else if (callerPid !== undefined) {
520
+ const parent = this.processTable.get(callerPid);
521
+ if (parent?.driverProcess.onStderr) {
522
+ stderrCb = parent.driverProcess.onStderr;
523
+ }
524
+ }
525
+ if (!stderrCb)
526
+ stderrCb = (data) => stderrBuf.push(data);
527
+ }
528
+ // Inherit env from parent process if spawned by another process, else use kernel defaults
529
+ const parentEntry = callerPid ? this.processTable.get(callerPid) : undefined;
530
+ const baseEnv = parentEntry?.env ?? this.env;
531
+ // Detect PTY slave on stdio FDs
532
+ const stdinIsTTY = this.isFdPtySlave(table, 0);
533
+ const stdoutIsTTY = this.isFdPtySlave(table, 1);
534
+ const stderrIsTTY = this.isFdPtySlave(table, 2);
535
+ // Build process context with pre-wired callbacks
536
+ const ctx = {
537
+ pid,
538
+ ppid: callerPid ?? 0,
539
+ env: { ...baseEnv, ...options?.env },
540
+ cwd: options?.cwd ?? this.cwd,
541
+ fds: { stdin: 0, stdout: 1, stderr: 2 },
542
+ stdinIsTTY,
543
+ stdoutIsTTY,
544
+ stderrIsTTY,
545
+ onStdout: stdoutCb,
546
+ onStderr: stderrCb,
547
+ };
548
+ // Spawn via driver
549
+ const driverProcess = driver.spawn(command, args, ctx);
550
+ // Capture data emitted via DriverProcess callbacks after spawn returns.
551
+ if (!stdoutPiped) {
552
+ driverProcess.onStdout = stdoutCb ?? ((data) => stdoutBuf.push(data));
553
+ }
554
+ if (!stderrPiped) {
555
+ driverProcess.onStderr = stderrCb ?? ((data) => stderrBuf.push(data));
556
+ }
557
+ // Register in process table
558
+ const entry = this.processTable.register(pid, driver.name, command, args, ctx, driverProcess);
559
+ return {
560
+ pid: entry.pid,
561
+ driverProcess,
562
+ wait: () => driverProcess.wait(),
563
+ writeStdin: (data) => driverProcess.writeStdin(data),
564
+ closeStdin: () => driverProcess.closeStdin(),
565
+ kill: (signal) => driverProcess.kill(signal ?? 15),
566
+ get onStdout() { return driverProcess.onStdout; },
567
+ set onStdout(fn) {
568
+ driverProcess.onStdout = fn;
569
+ // Replay buffered data
570
+ if (fn)
571
+ for (const chunk of stdoutBuf)
572
+ fn(chunk);
573
+ stdoutBuf.length = 0;
574
+ },
575
+ get onStderr() { return driverProcess.onStderr; },
576
+ set onStderr(fn) {
577
+ driverProcess.onStderr = fn;
578
+ if (fn)
579
+ for (const chunk of stderrBuf)
580
+ fn(chunk);
581
+ stderrBuf.length = 0;
582
+ },
583
+ };
584
+ }
585
+ spawnManaged(command, args, options, callerPid) {
586
+ const internal = this.spawnInternal(command, args, options, callerPid);
587
+ let exitCode = null;
588
+ // Note: options.onStdout/onStderr are already wired through ctx.onStdout
589
+ // by spawnInternal. Do NOT also set them on driverProcess.onStdout here —
590
+ // the driver calls both ctx.onStdout and proc.onStdout per message, so
591
+ // setting both to the same callback would double-deliver output.
592
+ internal.driverProcess.wait().then((code) => {
593
+ exitCode = code;
594
+ });
595
+ return {
596
+ pid: internal.pid,
597
+ writeStdin: (data) => {
598
+ const bytes = typeof data === "string"
599
+ ? new TextEncoder().encode(data)
600
+ : data;
601
+ internal.writeStdin(bytes);
602
+ },
603
+ closeStdin: () => internal.closeStdin(),
604
+ kill: (signal) => this.processTable.kill(internal.pid, signal ?? 15),
605
+ wait: () => internal.driverProcess.wait(),
606
+ get exitCode() { return exitCode; },
607
+ };
608
+ }
609
+ // -----------------------------------------------------------------------
610
+ // Kernel interface (exposed to drivers)
611
+ // -----------------------------------------------------------------------
612
+ createKernelInterface(driverName) {
613
+ // Validate that the calling driver owns the target PID
614
+ const assertOwns = (pid) => {
615
+ if (this.driverPids.get(driverName)?.has(pid))
616
+ return;
617
+ // Check if any driver owns this PID — if not, the PID doesn't exist
618
+ for (const pids of this.driverPids.values()) {
619
+ if (pids.has(pid)) {
620
+ throw new KernelError("EPERM", `driver "${driverName}" does not own PID ${pid}`);
621
+ }
622
+ }
623
+ throw new KernelError("ESRCH", `no such process ${pid}`);
624
+ };
625
+ const kernelInterface = {
626
+ vfs: this.vfs,
627
+ // FD operations
628
+ fdOpen: (pid, path, flags, mode) => {
629
+ assertOwns(pid);
630
+ // /dev/fd/N → dup(N): equivalent to open() on the underlying FD
631
+ if (path.startsWith("/dev/fd/")) {
632
+ const raw = path.slice(8);
633
+ const n = parseInt(raw, 10);
634
+ if (isNaN(n) || n < 0 || String(n) !== raw)
635
+ throw new KernelError("EBADF", `bad file descriptor: ${path}`);
636
+ const table = this.getTable(pid);
637
+ const entry = table.get(n);
638
+ if (!entry)
639
+ throw new KernelError("EBADF", `bad file descriptor ${n}`);
640
+ return table.dup(n);
641
+ }
642
+ const created = (flags & (O_CREAT | O_EXCL | O_TRUNC)) !== 0
643
+ ? this.prepareOpenSync(path, flags)
644
+ : false;
645
+ const table = this.getTable(pid);
646
+ const filetype = FILETYPE_REGULAR_FILE;
647
+ const fd = table.open(path, flags, filetype);
648
+ const fdEntry = table.get(fd);
649
+ if (fdEntry) {
650
+ this.trackDescriptionInode(fdEntry.description);
651
+ }
652
+ // Stash the effective mode for the first write that materializes a new file.
653
+ if (created && (flags & O_CREAT)) {
654
+ const entry = this.processTable.get(pid);
655
+ const umask = entry?.umask ?? 0o022;
656
+ const requestedMode = mode ?? 0o666;
657
+ if (fdEntry) {
658
+ fdEntry.description.creationMode = requestedMode & ~umask;
659
+ }
660
+ }
661
+ return fd;
662
+ },
663
+ fdRead: async (pid, fd, length) => {
664
+ assertOwns(pid);
665
+ const table = this.getTable(pid);
666
+ const entry = table.get(fd);
667
+ if (!entry)
668
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
669
+ // Pipe reads route through PipeManager
670
+ if (this.pipeManager.isPipe(entry.description.id)) {
671
+ const data = await this.pipeManager.read(entry.description.id, length);
672
+ return data ?? new Uint8Array(0);
673
+ }
674
+ // PTY reads route through PtyManager
675
+ if (this.ptyManager.isPty(entry.description.id)) {
676
+ const data = await this.ptyManager.read(entry.description.id, length);
677
+ return data ?? new Uint8Array(0);
678
+ }
679
+ // Positional read from VFS — avoids loading entire file
680
+ const cursor = Number(entry.description.cursor);
681
+ const slice = await this.preadDescription(entry.description, cursor, length);
682
+ entry.description.cursor += BigInt(slice.length);
683
+ return slice;
684
+ },
685
+ fdWrite: (pid, fd, data) => {
686
+ assertOwns(pid);
687
+ const table = this.getTable(pid);
688
+ const entry = table.get(fd);
689
+ if (!entry)
690
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
691
+ if (this.pipeManager.isPipe(entry.description.id)) {
692
+ return this.pipeManager.write(entry.description.id, data, pid);
693
+ }
694
+ if (this.ptyManager.isPty(entry.description.id)) {
695
+ return this.ptyManager.write(entry.description.id, data);
696
+ }
697
+ // Write to VFS at cursor position (async — returns Promise)
698
+ return this.vfsWrite(entry, data);
699
+ },
700
+ fdClose: (pid, fd) => {
701
+ assertOwns(pid);
702
+ const table = this.getTable(pid);
703
+ const entry = table.get(fd);
704
+ if (!entry)
705
+ return;
706
+ const descId = entry.description.id;
707
+ const isPipe = this.pipeManager.isPipe(descId);
708
+ const isPty = this.ptyManager.isPty(descId);
709
+ // Close FD first (decrements refCount on shared FileDescription)
710
+ table.close(fd);
711
+ // Only signal pipe/pty/lock closure when last reference is dropped
712
+ if (entry.description.refCount <= 0) {
713
+ this.releaseDescriptionInode(entry.description);
714
+ if (isPipe)
715
+ this.pipeManager.close(descId);
716
+ if (isPty)
717
+ this.ptyManager.close(descId);
718
+ this.fileLockManager.releaseByDescription(descId);
719
+ }
720
+ },
721
+ fdSeek: async (pid, fd, offset, whence) => {
722
+ assertOwns(pid);
723
+ const table = this.getTable(pid);
724
+ const entry = table.get(fd);
725
+ if (!entry)
726
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
727
+ // Pipes and PTYs are not seekable
728
+ if (this.pipeManager.isPipe(entry.description.id) || this.ptyManager.isPty(entry.description.id)) {
729
+ throw new KernelError("ESPIPE", "illegal seek");
730
+ }
731
+ let newCursor;
732
+ switch (whence) {
733
+ case SEEK_SET:
734
+ newCursor = offset;
735
+ break;
736
+ case SEEK_CUR:
737
+ newCursor = entry.description.cursor + offset;
738
+ break;
739
+ case SEEK_END: {
740
+ newCursor = BigInt(await this.getDescriptionSize(entry.description)) + offset;
741
+ break;
742
+ }
743
+ default:
744
+ throw new KernelError("EINVAL", `invalid whence ${whence}`);
745
+ }
746
+ if (newCursor < 0n)
747
+ throw new KernelError("EINVAL", "negative seek position");
748
+ entry.description.cursor = newCursor;
749
+ return newCursor;
750
+ },
751
+ fdPread: async (pid, fd, length, offset) => {
752
+ assertOwns(pid);
753
+ const table = this.getTable(pid);
754
+ const entry = table.get(fd);
755
+ if (!entry)
756
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
757
+ // Pipes and PTYs are not seekable
758
+ if (this.pipeManager.isPipe(entry.description.id) || this.ptyManager.isPty(entry.description.id)) {
759
+ throw new KernelError("ESPIPE", "illegal seek");
760
+ }
761
+ // Read from VFS at given offset without moving cursor
762
+ return this.preadDescription(entry.description, Number(offset), length);
763
+ },
764
+ fdPwrite: async (pid, fd, data, offset) => {
765
+ assertOwns(pid);
766
+ const table = this.getTable(pid);
767
+ const entry = table.get(fd);
768
+ if (!entry)
769
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
770
+ // Pipes and PTYs are not seekable
771
+ if (this.pipeManager.isPipe(entry.description.id) || this.ptyManager.isPty(entry.description.id)) {
772
+ throw new KernelError("ESPIPE", "illegal seek");
773
+ }
774
+ // Write at offset without moving cursor.
775
+ const content = await this.readDescriptionFile(entry.description);
776
+ const pos = Number(offset);
777
+ const endPos = pos + data.length;
778
+ const newContent = new Uint8Array(Math.max(content.length, endPos));
779
+ newContent.set(content);
780
+ newContent.set(data, pos);
781
+ await this.writeDescriptionFile(entry.description, newContent);
782
+ return data.length;
783
+ },
784
+ fdDup: (pid, fd) => {
785
+ assertOwns(pid);
786
+ return this.getTable(pid).dup(fd);
787
+ },
788
+ fdDup2: (pid, oldFd, newFd) => {
789
+ assertOwns(pid);
790
+ const table = this.getTable(pid);
791
+ const targetEntry = table.get(newFd);
792
+ const targetDesc = targetEntry?.description;
793
+ const targetDescId = targetDesc?.id;
794
+ table.dup2(oldFd, newFd);
795
+ if (targetDesc && targetDesc.refCount <= 0) {
796
+ this.releaseDescriptionInode(targetDesc);
797
+ if (targetDescId !== undefined) {
798
+ if (this.pipeManager.isPipe(targetDescId))
799
+ this.pipeManager.close(targetDescId);
800
+ if (this.ptyManager.isPty(targetDescId))
801
+ this.ptyManager.close(targetDescId);
802
+ this.fileLockManager.releaseByDescription(targetDescId);
803
+ }
804
+ }
805
+ },
806
+ fdDupMin: (pid, fd, minFd) => {
807
+ assertOwns(pid);
808
+ return this.getTable(pid).dupMinFd(fd, minFd);
809
+ },
810
+ fdStat: (pid, fd) => {
811
+ assertOwns(pid);
812
+ return this.getTable(pid).stat(fd);
813
+ },
814
+ fdPoll: (pid, fd) => {
815
+ try {
816
+ const table = this.getTable(pid);
817
+ const entry = table.get(fd);
818
+ if (!entry)
819
+ return { readable: false, writable: false, hangup: false, invalid: true };
820
+ const descId = entry.description.id;
821
+ if (this.pipeManager.isPipe(descId)) {
822
+ const ps = this.pipeManager.pollState(descId);
823
+ return ps ? { ...ps, invalid: false } : { readable: false, writable: false, hangup: false, invalid: true };
824
+ }
825
+ // Regular files are always readable/writable
826
+ return { readable: true, writable: true, hangup: false, invalid: false };
827
+ }
828
+ catch {
829
+ return { readable: false, writable: false, hangup: false, invalid: true };
830
+ }
831
+ },
832
+ fdPollWait: async (pid, fd, timeoutMs) => {
833
+ assertOwns(pid);
834
+ const table = this.getTable(pid);
835
+ const entry = table.get(fd);
836
+ if (!entry)
837
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
838
+ const descId = entry.description.id;
839
+ if (this.pipeManager.isPipe(descId)) {
840
+ await this.pipeManager.waitForPoll(descId, timeoutMs);
841
+ }
842
+ },
843
+ fdSetCloexec: (pid, fd, value) => {
844
+ assertOwns(pid);
845
+ const table = this.getTable(pid);
846
+ const entry = table.get(fd);
847
+ if (!entry)
848
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
849
+ entry.cloexec = value;
850
+ },
851
+ fdGetCloexec: (pid, fd) => {
852
+ assertOwns(pid);
853
+ const table = this.getTable(pid);
854
+ const entry = table.get(fd);
855
+ if (!entry)
856
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
857
+ return entry.cloexec;
858
+ },
859
+ fcntl: (pid, fd, cmd, arg) => {
860
+ assertOwns(pid);
861
+ const table = this.getTable(pid);
862
+ const entry = table.get(fd);
863
+ if (!entry)
864
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
865
+ switch (cmd) {
866
+ case F_DUPFD:
867
+ return table.dupMinFd(fd, arg ?? 0);
868
+ case F_DUPFD_CLOEXEC: {
869
+ const newFd = table.dupMinFd(fd, arg ?? 0);
870
+ table.get(newFd).cloexec = true;
871
+ return newFd;
872
+ }
873
+ case F_GETFD:
874
+ return entry.cloexec ? FD_CLOEXEC : 0;
875
+ case F_SETFD:
876
+ entry.cloexec = ((arg ?? 0) & FD_CLOEXEC) !== 0;
877
+ return 0;
878
+ case F_GETFL:
879
+ return entry.description.flags;
880
+ default:
881
+ throw new KernelError("EINVAL", `unsupported fcntl command ${cmd}`);
882
+ }
883
+ },
884
+ // Advisory file locking
885
+ flock: async (pid, fd, operation) => {
886
+ assertOwns(pid);
887
+ const table = this.getTable(pid);
888
+ const entry = table.get(fd);
889
+ if (!entry)
890
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
891
+ await this.fileLockManager.flock(entry.description.path, entry.description.id, operation);
892
+ },
893
+ // Process operations
894
+ spawn: (command, args, ctx) => {
895
+ if (ctx.ppid)
896
+ assertOwns(ctx.ppid);
897
+ return this.spawnManaged(command, args, {
898
+ env: ctx.env,
899
+ cwd: ctx.cwd,
900
+ onStdout: ctx.onStdout,
901
+ onStderr: ctx.onStderr,
902
+ stdinFd: ctx.stdinFd,
903
+ stdoutFd: ctx.stdoutFd,
904
+ stderrFd: ctx.stderrFd,
905
+ }, ctx.ppid);
906
+ },
907
+ waitpid: (pid, options) => {
908
+ try {
909
+ assertOwns(pid);
910
+ }
911
+ catch (e) {
912
+ return Promise.reject(e);
913
+ }
914
+ return this.processTable.waitpid(pid, options);
915
+ },
916
+ kill: (pid, signal) => {
917
+ // Negative PID = process group kill, handled by kernel directly
918
+ if (pid >= 0)
919
+ assertOwns(pid);
920
+ this.processTable.kill(pid, signal);
921
+ },
922
+ getpid: (pid) => {
923
+ assertOwns(pid);
924
+ return pid;
925
+ },
926
+ getppid: (pid) => {
927
+ assertOwns(pid);
928
+ return this.processTable.getppid(pid);
929
+ },
930
+ // Process group / session
931
+ setpgid: (pid, pgid) => {
932
+ assertOwns(pid);
933
+ this.processTable.setpgid(pid, pgid);
934
+ },
935
+ getpgid: (pid) => {
936
+ assertOwns(pid);
937
+ return this.processTable.getpgid(pid);
938
+ },
939
+ setsid: (pid) => {
940
+ assertOwns(pid);
941
+ return this.processTable.setsid(pid);
942
+ },
943
+ getsid: (pid) => {
944
+ assertOwns(pid);
945
+ return this.processTable.getsid(pid);
946
+ },
947
+ // Pipe operations
948
+ pipe: (pid) => {
949
+ assertOwns(pid);
950
+ const table = this.getTable(pid);
951
+ return this.pipeManager.createPipeFDs(table);
952
+ },
953
+ // PTY operations
954
+ openpty: (pid) => {
955
+ assertOwns(pid);
956
+ const table = this.getTable(pid);
957
+ return this.ptyManager.createPtyFDs(table);
958
+ },
959
+ isatty: (pid, fd) => {
960
+ assertOwns(pid);
961
+ const table = this.getTable(pid);
962
+ const entry = table.get(fd);
963
+ if (!entry)
964
+ return false;
965
+ return this.ptyManager.isSlave(entry.description.id);
966
+ },
967
+ ptySetDiscipline: (pid, fd, config) => {
968
+ assertOwns(pid);
969
+ const table = this.getTable(pid);
970
+ const entry = table.get(fd);
971
+ if (!entry)
972
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
973
+ this.ptyManager.setDiscipline(entry.description.id, config);
974
+ },
975
+ ptySetForegroundPgid: (pid, fd, pgid) => {
976
+ assertOwns(pid);
977
+ const table = this.getTable(pid);
978
+ const entry = table.get(fd);
979
+ if (!entry)
980
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
981
+ this.ptyManager.setForegroundPgid(entry.description.id, pgid);
982
+ },
983
+ // Termios operations
984
+ tcgetattr: (pid, fd) => {
985
+ assertOwns(pid);
986
+ const table = this.getTable(pid);
987
+ const entry = table.get(fd);
988
+ if (!entry)
989
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
990
+ return this.ptyManager.getTermios(entry.description.id);
991
+ },
992
+ tcsetattr: (pid, fd, termios) => {
993
+ assertOwns(pid);
994
+ const table = this.getTable(pid);
995
+ const entry = table.get(fd);
996
+ if (!entry)
997
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
998
+ this.ptyManager.setTermios(entry.description.id, termios);
999
+ },
1000
+ tcsetpgrp: (pid, fd, pgid) => {
1001
+ assertOwns(pid);
1002
+ const table = this.getTable(pid);
1003
+ const entry = table.get(fd);
1004
+ if (!entry)
1005
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
1006
+ // Validate target PGID refers to an existing process group
1007
+ if (!this.processTable.hasProcessGroup(pgid)) {
1008
+ throw new KernelError("ESRCH", `no such process group ${pgid}`);
1009
+ }
1010
+ this.ptyManager.setForegroundPgid(entry.description.id, pgid);
1011
+ },
1012
+ tcgetpgrp: (pid, fd) => {
1013
+ assertOwns(pid);
1014
+ const table = this.getTable(pid);
1015
+ const entry = table.get(fd);
1016
+ if (!entry)
1017
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
1018
+ return this.ptyManager.getForegroundPgid(entry.description.id);
1019
+ },
1020
+ // /dev/fd operations
1021
+ devFdReadDir: (pid) => {
1022
+ assertOwns(pid);
1023
+ const table = this.fdTableManager.get(pid);
1024
+ if (!table)
1025
+ return [];
1026
+ const fds = [];
1027
+ for (const entry of table)
1028
+ fds.push(entry.fd);
1029
+ return fds.sort((a, b) => a - b).map(String);
1030
+ },
1031
+ devFdStat: async (pid, fd) => {
1032
+ assertOwns(pid);
1033
+ const table = this.getTable(pid);
1034
+ const entry = table.get(fd);
1035
+ if (!entry)
1036
+ throw new KernelError("EBADF", `bad file descriptor ${fd}`);
1037
+ // Pipe/PTY FDs return a synthetic character device stat
1038
+ if (this.pipeManager.isPipe(entry.description.id) || this.ptyManager.isPty(entry.description.id)) {
1039
+ const now = Date.now();
1040
+ return {
1041
+ mode: 0o666,
1042
+ size: 0,
1043
+ isDirectory: false,
1044
+ isSymbolicLink: false,
1045
+ atimeMs: now,
1046
+ mtimeMs: now,
1047
+ ctimeMs: now,
1048
+ birthtimeMs: now,
1049
+ ino: entry.description.id,
1050
+ nlink: 1,
1051
+ uid: 0,
1052
+ gid: 0,
1053
+ };
1054
+ }
1055
+ // Regular file — stat the underlying path
1056
+ return this.statDescription(entry.description);
1057
+ },
1058
+ // Environment
1059
+ getenv: (pid) => {
1060
+ assertOwns(pid);
1061
+ const entry = this.processTable.get(pid);
1062
+ return entry?.env ?? { ...this.env };
1063
+ },
1064
+ setenv: (pid, key, value) => {
1065
+ assertOwns(pid);
1066
+ const entry = this.processTable.get(pid);
1067
+ if (!entry)
1068
+ throw new KernelError("ESRCH", `no such process ${pid}`);
1069
+ entry.env[key] = value;
1070
+ },
1071
+ unsetenv: (pid, key) => {
1072
+ assertOwns(pid);
1073
+ const entry = this.processTable.get(pid);
1074
+ if (!entry)
1075
+ throw new KernelError("ESRCH", `no such process ${pid}`);
1076
+ delete entry.env[key];
1077
+ },
1078
+ getcwd: (pid) => {
1079
+ assertOwns(pid);
1080
+ const entry = this.processTable.get(pid);
1081
+ return entry?.cwd ?? this.cwd;
1082
+ },
1083
+ // Working directory
1084
+ chdir: async (pid, path) => {
1085
+ assertOwns(pid);
1086
+ const entry = this.processTable.get(pid);
1087
+ if (!entry)
1088
+ throw new KernelError("ESRCH", `no such process ${pid}`);
1089
+ // Validate path exists and is a directory
1090
+ let st;
1091
+ try {
1092
+ st = await this.vfs.stat(path);
1093
+ }
1094
+ catch {
1095
+ throw new KernelError("ENOENT", `no such file or directory: ${path}`);
1096
+ }
1097
+ if (!st.isDirectory) {
1098
+ throw new KernelError("ENOTDIR", `not a directory: ${path}`);
1099
+ }
1100
+ entry.cwd = path;
1101
+ },
1102
+ // Alarm (SIGALRM)
1103
+ alarm: (pid, seconds) => {
1104
+ assertOwns(pid);
1105
+ return this.processTable.alarm(pid, seconds);
1106
+ },
1107
+ // File mode creation mask
1108
+ umask: (pid, newMask) => {
1109
+ assertOwns(pid);
1110
+ const entry = this.processTable.get(pid);
1111
+ if (!entry)
1112
+ throw new KernelError("ESRCH", `no such process ${pid}`);
1113
+ const old = entry.umask;
1114
+ if (newMask !== undefined) {
1115
+ entry.umask = newMask & 0o777;
1116
+ }
1117
+ return old;
1118
+ },
1119
+ // Directory creation with umask
1120
+ mkdir: async (pid, path, mode) => {
1121
+ assertOwns(pid);
1122
+ const entry = this.processTable.get(pid);
1123
+ const umask = entry?.umask ?? 0o022;
1124
+ const requestedMode = mode ?? 0o777;
1125
+ const effectiveMode = requestedMode & ~umask;
1126
+ await this.vfs.mkdir(path);
1127
+ await this.vfs.chmod(path, effectiveMode);
1128
+ },
1129
+ // Socket table (shared across runtimes)
1130
+ socketTable: this.socketTable,
1131
+ timerTable: this.timerTable,
1132
+ // Process table (shared across runtimes)
1133
+ processTable: this.processTable,
1134
+ };
1135
+ return kernelInterface;
1136
+ }
1137
+ /**
1138
+ * Create FD table for a child process via fork + optional FD overrides.
1139
+ *
1140
+ * When callerPid exists, forks the parent's FD table so the child inherits
1141
+ * all open FDs (shared cursors via refcounted FileDescription). Then applies
1142
+ * stdinFd/stdoutFd/stderrFd overrides on top of the forked table.
1143
+ */
1144
+ createChildFDTable(childPid, options, callerPid) {
1145
+ // Fork parent's FD table if parent exists
1146
+ if (callerPid && this.fdTableManager.get(callerPid)) {
1147
+ const table = this.fdTableManager.fork(callerPid, childPid);
1148
+ // Apply FD overrides on top of the forked table
1149
+ const hasFdOverrides = options?.stdinFd !== undefined ||
1150
+ options?.stdoutFd !== undefined ||
1151
+ options?.stderrFd !== undefined;
1152
+ if (hasFdOverrides) {
1153
+ const callerTable = this.fdTableManager.get(callerPid);
1154
+ this.applyStdioOverride(table, callerTable, 0, options.stdinFd);
1155
+ this.applyStdioOverride(table, callerTable, 1, options.stdoutFd);
1156
+ this.applyStdioOverride(table, callerTable, 2, options.stderrFd);
1157
+ }
1158
+ // Close inherited pipe FDs above stdio that share a pipe with an
1159
+ // overridden stdio FD — prevents pipe deadlocks (close-on-exec for
1160
+ // counterpart pipe ends only, so tests that intentionally inherit pipe
1161
+ // FDs without overrides are not affected).
1162
+ if (hasFdOverrides) {
1163
+ const overridePipeIds = new Set();
1164
+ for (const fd of [0, 1, 2]) {
1165
+ const e = table.get(fd);
1166
+ if (e && this.pipeManager.isPipe(e.description.id)) {
1167
+ const pipeId = this.pipeManager.pipeIdFor(e.description.id);
1168
+ if (pipeId !== undefined)
1169
+ overridePipeIds.add(pipeId);
1170
+ }
1171
+ }
1172
+ if (overridePipeIds.size > 0) {
1173
+ const toClose = [];
1174
+ for (const entry of table) {
1175
+ if (entry.fd > 2 && this.pipeManager.isPipe(entry.description.id)) {
1176
+ const pid2 = this.pipeManager.pipeIdFor(entry.description.id);
1177
+ if (pid2 !== undefined && overridePipeIds.has(pid2)) {
1178
+ toClose.push(entry.fd);
1179
+ }
1180
+ }
1181
+ }
1182
+ for (const fd of toClose) {
1183
+ table.close(fd);
1184
+ }
1185
+ }
1186
+ }
1187
+ return table;
1188
+ }
1189
+ return this.fdTableManager.create(childPid);
1190
+ }
1191
+ /** Close inherited stdio FD and install an override from the caller's table. */
1192
+ applyStdioOverride(childTable, callerTable, targetFd, overrideFd) {
1193
+ if (overrideFd === undefined)
1194
+ return;
1195
+ if (overrideFd === 0xFFFFFFFF)
1196
+ return; // /dev/null sentinel — keep inherited
1197
+ const entry = callerTable.get(overrideFd);
1198
+ if (!entry)
1199
+ return;
1200
+ // Close the inherited FD and install the override
1201
+ const existing = childTable.get(targetFd);
1202
+ childTable.close(targetFd);
1203
+ if (existing && existing.description.refCount <= 0) {
1204
+ this.releaseDescriptionInode(existing.description);
1205
+ const descId = existing.description.id;
1206
+ if (this.pipeManager.isPipe(descId))
1207
+ this.pipeManager.close(descId);
1208
+ if (this.ptyManager.isPty(descId))
1209
+ this.ptyManager.close(descId);
1210
+ this.fileLockManager.releaseByDescription(descId);
1211
+ }
1212
+ childTable.openWith(entry.description, entry.filetype, targetFd);
1213
+ }
1214
+ /** Check if a stdio FD (0/1/2) in a process's table is a pipe or PTY. */
1215
+ isStdioPiped(table, fd) {
1216
+ const entry = table.get(fd);
1217
+ if (!entry)
1218
+ return false;
1219
+ return this.pipeManager.isPipe(entry.description.id) || this.ptyManager.isPty(entry.description.id);
1220
+ }
1221
+ /** Check if an FD in the given table refers to a PTY slave (terminal). */
1222
+ isFdPtySlave(table, fd) {
1223
+ const entry = table.get(fd);
1224
+ if (!entry)
1225
+ return false;
1226
+ return this.ptyManager.isSlave(entry.description.id);
1227
+ }
1228
+ /**
1229
+ * Create a callback that forwards data through a piped stdio FD.
1230
+ * Needed for drivers (like Node) that emit output via callbacks rather
1231
+ * than kernel FD writes (like WasmVM does via WASI fd_write).
1232
+ */
1233
+ createPipedOutputCallback(table, fd, pid) {
1234
+ const entry = table.get(fd);
1235
+ if (!entry)
1236
+ return undefined;
1237
+ const descId = entry.description.id;
1238
+ if (this.pipeManager.isPipe(descId)) {
1239
+ return (data) => {
1240
+ try {
1241
+ this.pipeManager.write(descId, data, pid);
1242
+ }
1243
+ catch { /* pipe closed */ }
1244
+ };
1245
+ }
1246
+ if (this.ptyManager.isPty(descId)) {
1247
+ return (data) => {
1248
+ try {
1249
+ this.ptyManager.write(descId, data);
1250
+ }
1251
+ catch { /* pty closed */ }
1252
+ };
1253
+ }
1254
+ return undefined;
1255
+ }
1256
+ /** Clean up all FDs for a process, closing pipe/PTY ends when last reference drops. */
1257
+ cleanupProcessFDs(pid) {
1258
+ const table = this.fdTableManager.get(pid);
1259
+ if (!table)
1260
+ return;
1261
+ // Collect descriptions before closing so we can check refCounts after.
1262
+ const descriptions = new Map();
1263
+ const managedDescs = [];
1264
+ for (const entry of table) {
1265
+ descriptions.set(entry.description.id, entry.description);
1266
+ const descId = entry.description.id;
1267
+ if (this.pipeManager.isPipe(descId)) {
1268
+ managedDescs.push({ id: descId, description: entry.description, type: "pipe" });
1269
+ }
1270
+ else if (this.ptyManager.isPty(descId)) {
1271
+ managedDescs.push({ id: descId, description: entry.description, type: "pty" });
1272
+ }
1273
+ else if (this.fileLockManager.hasLock(descId)) {
1274
+ managedDescs.push({ id: descId, description: entry.description, type: "lock" });
1275
+ }
1276
+ }
1277
+ // Close all FDs and remove the table
1278
+ this.fdTableManager.remove(pid);
1279
+ // Release inode-backed file data after the last shared reference closes.
1280
+ for (const description of descriptions.values()) {
1281
+ if (description.refCount <= 0) {
1282
+ this.releaseDescriptionInode(description);
1283
+ }
1284
+ }
1285
+ // Signal closure for managed descriptions whose last reference was dropped.
1286
+ for (const { id, description, type } of managedDescs) {
1287
+ if (description.refCount <= 0) {
1288
+ if (type === "pipe")
1289
+ this.pipeManager.close(id);
1290
+ else if (type === "pty")
1291
+ this.ptyManager.close(id);
1292
+ else if (type === "lock")
1293
+ this.fileLockManager.releaseByDescription(id);
1294
+ }
1295
+ }
1296
+ }
1297
+ async vfsWrite(entry, data) {
1298
+ let content;
1299
+ try {
1300
+ content = await this.readDescriptionFile(entry.description);
1301
+ }
1302
+ catch {
1303
+ content = new Uint8Array(0);
1304
+ }
1305
+ // O_APPEND: every write seeks to end of file first (POSIX)
1306
+ const cursor = (entry.description.flags & O_APPEND)
1307
+ ? content.length
1308
+ : Number(entry.description.cursor);
1309
+ const endPos = cursor + data.length;
1310
+ const newContent = new Uint8Array(Math.max(content.length, endPos));
1311
+ newContent.set(content);
1312
+ newContent.set(data, cursor);
1313
+ await this.writeDescriptionFile(entry.description, newContent);
1314
+ // Apply creation mode once the descriptor's newly created file is materialized.
1315
+ if (entry.description.creationMode !== undefined) {
1316
+ await this.vfs.chmod(entry.description.path, entry.description.creationMode);
1317
+ entry.description.creationMode = undefined;
1318
+ }
1319
+ entry.description.cursor = BigInt(endPos);
1320
+ return data.length;
1321
+ }
1322
+ trackDescriptionInode(description) {
1323
+ if (!this.rawInMemoryFs || description.inode !== undefined)
1324
+ return;
1325
+ const ino = this.rawInMemoryFs.getInodeForPath(description.path);
1326
+ if (ino === null)
1327
+ return;
1328
+ description.inode = ino;
1329
+ this.inodeTable.incrementOpenRefs(ino);
1330
+ }
1331
+ releaseDescriptionInode(description) {
1332
+ if (description.inode === undefined)
1333
+ return;
1334
+ this.inodeTable.decrementOpenRefs(description.inode);
1335
+ if (this.inodeTable.shouldDelete(description.inode)) {
1336
+ this.rawInMemoryFs?.deleteInodeData(description.inode);
1337
+ this.inodeTable.delete(description.inode);
1338
+ }
1339
+ description.inode = undefined;
1340
+ }
1341
+ async readDescriptionFile(description) {
1342
+ if (description.inode !== undefined && this.rawInMemoryFs) {
1343
+ return this.rawInMemoryFs.readFileByInode(description.inode);
1344
+ }
1345
+ return this.vfs.readFile(description.path);
1346
+ }
1347
+ async writeDescriptionFile(description, content) {
1348
+ if (description.inode !== undefined && this.rawInMemoryFs) {
1349
+ this.rawInMemoryFs.writeFileByInode(description.inode, content);
1350
+ return;
1351
+ }
1352
+ await this.vfs.writeFile(description.path, content);
1353
+ this.trackDescriptionInode(description);
1354
+ }
1355
+ prepareOpenSync(path, flags) {
1356
+ const syncVfs = this.vfs;
1357
+ return syncVfs.prepareOpenSync?.(path, flags) ?? false;
1358
+ }
1359
+ async preadDescription(description, offset, length) {
1360
+ if (description.inode !== undefined && this.rawInMemoryFs) {
1361
+ return this.rawInMemoryFs.preadByInode(description.inode, offset, length);
1362
+ }
1363
+ return this.vfs.pread(description.path, offset, length);
1364
+ }
1365
+ async getDescriptionSize(description) {
1366
+ return (await this.statDescription(description)).size;
1367
+ }
1368
+ async statDescription(description) {
1369
+ if (description.inode !== undefined && this.rawInMemoryFs) {
1370
+ return this.rawInMemoryFs.statByInode(description.inode);
1371
+ }
1372
+ return this.vfs.stat(description.path);
1373
+ }
1374
+ getTable(pid) {
1375
+ const table = this.fdTableManager.get(pid);
1376
+ if (!table)
1377
+ throw new KernelError("ESRCH", `no FD table for PID ${pid}`);
1378
+ return table;
1379
+ }
1380
+ assertNotDisposed() {
1381
+ if (this.disposed)
1382
+ throw new Error("Kernel is disposed");
1383
+ }
1384
+ }
1385
+ function concatUint8(chunks) {
1386
+ if (chunks.length === 0)
1387
+ return "";
1388
+ const total = chunks.reduce((sum, c) => sum + c.length, 0);
1389
+ const buf = new Uint8Array(total);
1390
+ let offset = 0;
1391
+ for (const chunk of chunks) {
1392
+ buf.set(chunk, offset);
1393
+ offset += chunk.length;
1394
+ }
1395
+ return new TextDecoder().decode(buf);
1396
+ }