@secure-exec/core 0.1.1-rc.3 → 0.2.0-rc.2

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