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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/generated/isolate-runtime.d.ts +1 -1
  2. package/dist/generated/isolate-runtime.js +1 -1
  3. package/dist/index.d.ts +17 -4
  4. package/dist/index.js +10 -2
  5. package/dist/isolate-runtime/require-setup.js +145 -7
  6. package/dist/kernel/device-backend.d.ts +14 -0
  7. package/dist/kernel/device-backend.js +251 -0
  8. package/dist/kernel/device-layer.js +9 -0
  9. package/dist/kernel/index.d.ts +4 -4
  10. package/dist/kernel/index.js +3 -3
  11. package/dist/kernel/kernel.js +141 -119
  12. package/dist/kernel/mount-table.d.ts +75 -0
  13. package/dist/kernel/mount-table.js +353 -0
  14. package/dist/kernel/permissions.d.ts +9 -0
  15. package/dist/kernel/permissions.js +33 -1
  16. package/dist/kernel/proc-backend.d.ts +30 -0
  17. package/dist/kernel/proc-backend.js +428 -0
  18. package/dist/kernel/proc-layer.js +6 -0
  19. package/dist/kernel/process-table.d.ts +3 -1
  20. package/dist/kernel/process-table.js +23 -3
  21. package/dist/kernel/pty.d.ts +3 -2
  22. package/dist/kernel/pty.js +13 -2
  23. package/dist/kernel/types.d.ts +45 -4
  24. package/dist/kernel/types.js +9 -0
  25. package/dist/kernel/vfs.d.ts +30 -2
  26. package/dist/kernel/vfs.js +19 -2
  27. package/dist/shared/api-types.d.ts +6 -0
  28. package/dist/shared/console-formatter.js +8 -8
  29. package/dist/shared/in-memory-fs.d.ts +14 -62
  30. package/dist/shared/in-memory-fs.js +101 -636
  31. package/dist/shared/permissions.js +5 -0
  32. package/dist/test/block-store-conformance.d.ts +34 -0
  33. package/dist/test/block-store-conformance.js +251 -0
  34. package/dist/test/metadata-store-conformance.d.ts +37 -0
  35. package/dist/test/metadata-store-conformance.js +646 -0
  36. package/dist/test/vfs-conformance.d.ts +65 -0
  37. package/dist/test/vfs-conformance.js +842 -0
  38. package/dist/types.d.ts +1 -0
  39. package/dist/vfs/chunked-vfs.d.ts +66 -0
  40. package/dist/vfs/chunked-vfs.js +1290 -0
  41. package/dist/vfs/host-block-store.d.ts +19 -0
  42. package/dist/vfs/host-block-store.js +97 -0
  43. package/dist/vfs/memory-block-store.d.ts +16 -0
  44. package/dist/vfs/memory-block-store.js +45 -0
  45. package/dist/vfs/memory-metadata.d.ts +75 -0
  46. package/dist/vfs/memory-metadata.js +528 -0
  47. package/dist/vfs/sqlite-metadata.d.ts +91 -0
  48. package/dist/vfs/sqlite-metadata.js +582 -0
  49. package/dist/vfs/types.d.ts +210 -0
  50. package/dist/vfs/types.js +8 -0
  51. package/package.json +20 -1
  52. package/dist/kernel/inode-table.d.ts +0 -43
  53. package/dist/kernel/inode-table.js +0 -85
@@ -5,8 +5,9 @@
5
5
  * pipe manager, command registry, and permissions. Runtimes are execution
6
6
  * engines that make "syscalls" to the kernel.
7
7
  */
8
- import { createDeviceLayer } from "./device-layer.js";
9
- import { createProcLayer } from "./proc-layer.js";
8
+ import { createDeviceBackend } from "./device-backend.js";
9
+ import { createProcBackend } from "./proc-backend.js";
10
+ import { MountTable } from "./mount-table.js";
10
11
  import { FDTableManager } from "./fd-table.js";
11
12
  import { ProcessTable } from "./process-table.js";
12
13
  import { PipeManager } from "./pipe-manager.js";
@@ -17,33 +18,21 @@ import { wrapFileSystem, checkChildProcess } from "./permissions.js";
17
18
  import { UserManager } from "./user.js";
18
19
  import { SocketTable } from "./socket-table.js";
19
20
  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";
21
+ 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, noopKernelLogger, } from "./types.js";
23
22
  export function createKernel(options) {
24
23
  return new KernelImpl(options);
25
24
  }
26
25
  class KernelImpl {
27
26
  vfs;
28
- rawInMemoryFs;
27
+ mountTable;
29
28
  fdTableManager = new FDTableManager();
30
- processTable = new ProcessTable();
29
+ processTable;
31
30
  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
- });
31
+ ptyManager;
42
32
  fileLockManager = new FileLockManager();
43
33
  commandRegistry = new CommandRegistry();
44
34
  socketTable;
45
35
  timerTable;
46
- inodeTable;
47
36
  userManager;
48
37
  drivers = [];
49
38
  driverPids = new Map();
@@ -54,21 +43,38 @@ class KernelImpl {
54
43
  disposed = false;
55
44
  pendingBinEntries = [];
56
45
  posixDirsReady;
46
+ log;
57
47
  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, {
48
+ this.log = options.logger ?? noopKernelLogger;
49
+ this.processTable = new ProcessTable(this.log.child({ component: "process" }));
50
+ this.ptyManager = new PtyManager((pgid, signal, excludeLeaders) => {
51
+ try {
52
+ if (excludeLeaders) {
53
+ return this.processTable.killGroupExcludeLeaders(pgid, signal);
54
+ }
55
+ this.processTable.kill(-pgid, signal);
56
+ }
57
+ catch { /* no-op if pgid gone */ }
58
+ return 0;
59
+ }, this.log.child({ component: "pty" }));
60
+ // Build mount table: root FS → /dev → /proc → user mounts.
61
+ const mt = new MountTable(options.filesystem);
62
+ mt.mount("/dev", createDeviceBackend());
63
+ mt.mount("/proc", createProcBackend({
67
64
  processTable: this.processTable,
68
65
  fdTableManager: this.fdTableManager,
69
66
  hostname: options.env?.HOSTNAME,
70
- });
71
- // Apply permission wrapping
67
+ mountTable: mt,
68
+ }));
69
+ // Mount user-supplied filesystems
70
+ if (options.mounts) {
71
+ for (const m of options.mounts) {
72
+ mt.mount(m.path, m.fs, { readOnly: m.readOnly });
73
+ }
74
+ }
75
+ this.mountTable = mt;
76
+ // Apply permission wrapping on top of the mount table
77
+ let fs = mt;
72
78
  if (options.permissions) {
73
79
  fs = wrapFileSystem(fs, options.permissions);
74
80
  }
@@ -88,6 +94,7 @@ class KernelImpl {
88
94
  this.timerTable = new TimerTable();
89
95
  // Clean up FD table and sockets when a process exits
90
96
  this.processTable.onProcessExit = (pid) => {
97
+ this.log.debug({ pid }, "process exit cleanup");
91
98
  this.cleanupProcessFDs(pid);
92
99
  this.socketTable.closeAllForProcess(pid);
93
100
  this.timerTable.clearAllForProcess(pid);
@@ -112,6 +119,7 @@ class KernelImpl {
112
119
  this.posixDirsReady = this.initPosixDirs();
113
120
  }
114
121
  async initPosixDirs() {
122
+ // /dev and /proc are auto-created by MountTable mounts — don't create them here.
115
123
  const dirs = [
116
124
  "/tmp",
117
125
  "/bin",
@@ -123,7 +131,6 @@ class KernelImpl {
123
131
  "/run",
124
132
  "/srv",
125
133
  "/sys",
126
- "/proc",
127
134
  "/opt",
128
135
  "/mnt",
129
136
  "/media",
@@ -170,6 +177,7 @@ class KernelImpl {
170
177
  async mount(driver) {
171
178
  this.assertNotDisposed();
172
179
  await this.posixDirsReady;
180
+ this.log.debug({ driver: driver.name, commands: driver.commands }, "mounting runtime driver");
173
181
  // Track PIDs owned by this driver
174
182
  if (!this.driverPids.has(driver.name)) {
175
183
  this.driverPids.set(driver.name, new Set());
@@ -181,11 +189,21 @@ class KernelImpl {
181
189
  this.drivers.push(driver);
182
190
  // Populate /bin stubs for shell PATH lookup
183
191
  await this.commandRegistry.populateBin(this.vfs);
192
+ this.log.info({ driver: driver.name, commands: driver.commands }, "runtime driver mounted");
193
+ }
194
+ mountFs(path, fs, options) {
195
+ this.assertNotDisposed();
196
+ this.mountTable.mount(path, fs, options);
197
+ }
198
+ unmountFs(path) {
199
+ this.assertNotDisposed();
200
+ this.mountTable.unmount(path);
184
201
  }
185
202
  async dispose() {
186
203
  if (this.disposed)
187
204
  return;
188
205
  this.disposed = true;
206
+ this.log.info({}, "kernel disposing");
189
207
  // Terminate all running processes
190
208
  await this.processTable.terminateAll();
191
209
  // Clean up all sockets
@@ -214,6 +232,7 @@ class KernelImpl {
214
232
  }
215
233
  async exec(command, options) {
216
234
  this.assertNotDisposed();
235
+ this.log.debug({ command, timeout: options?.timeout, cwd: options?.cwd }, "exec start");
217
236
  // Flush pending /bin stubs before shell PATH lookup
218
237
  await this.flushPendingBinEntries();
219
238
  // Route through shell
@@ -254,6 +273,7 @@ class KernelImpl {
254
273
  new Promise((_, reject) => {
255
274
  timer = setTimeout(() => {
256
275
  // Kill process and detach output callbacks
276
+ this.log.warn({ command, timeout: options.timeout }, "exec timeout, sending SIGTERM");
257
277
  proc.onStdout = null;
258
278
  proc.onStderr = null;
259
279
  proc.kill(SIGTERM);
@@ -284,6 +304,7 @@ class KernelImpl {
284
304
  this.assertNotDisposed();
285
305
  const command = options?.command ?? "sh";
286
306
  const args = options?.args ?? [];
307
+ this.log.debug({ command, args, cols: options?.cols, rows: options?.rows, cwd: options?.cwd }, "openShell start");
287
308
  // Allocate a controller PID with an FD table to hold the PTY master
288
309
  const controllerPid = this.processTable.allocatePid();
289
310
  const controllerTable = this.fdTableManager.create(controllerPid);
@@ -291,8 +312,16 @@ class KernelImpl {
291
312
  const { masterFd, slaveFd } = this.ptyManager.createPtyFDs(controllerTable);
292
313
  const masterDescId = controllerTable.get(masterFd).description.id;
293
314
  // Spawn shell with PTY slave as stdin/stdout/stderr
315
+ // Propagate terminal dimensions as POSIX COLUMNS/LINES env vars
316
+ const cols = options?.cols;
317
+ const rows = options?.rows;
318
+ const dimEnv = {};
319
+ if (cols !== undefined)
320
+ dimEnv.COLUMNS = String(cols);
321
+ if (rows !== undefined)
322
+ dimEnv.LINES = String(rows);
294
323
  const proc = this.spawnInternal(command, args, {
295
- env: options?.env,
324
+ env: { ...options?.env, ...dimEnv },
296
325
  cwd: options?.cwd,
297
326
  stdinFd: slaveFd,
298
327
  stdoutFd: slaveFd,
@@ -302,6 +331,7 @@ class KernelImpl {
302
331
  this.processTable.setpgid(proc.pid, proc.pid);
303
332
  this.ptyManager.setForegroundPgid(masterDescId, proc.pid);
304
333
  this.ptyManager.setSessionLeader(masterDescId, proc.pid);
334
+ this.log.debug({ shellPid: proc.pid, controllerPid, masterFd, masterDescId }, "openShell PTY attached");
305
335
  // Close controller's copy of slave FD (child inherited its own copy via fork).
306
336
  // Without this, slave refCount stays >0 after shell exits, preventing EOF on master.
307
337
  const slaveEntry = controllerTable.get(slaveFd);
@@ -356,6 +386,7 @@ class KernelImpl {
356
386
  set onData(fn) { pump.onData = fn; },
357
387
  resize: (_cols, _rows) => {
358
388
  const fgPgid = this.ptyManager.getForegroundPgid(masterDescId);
389
+ this.log.trace({ shellPid: proc.pid, cols: _cols, rows: _rows, fgPgid }, "PTY resize");
359
390
  if (fgPgid > 0) {
360
391
  try {
361
392
  this.processTable.kill(-fgPgid, SIGWINCH);
@@ -371,6 +402,7 @@ class KernelImpl {
371
402
  }
372
403
  async connectTerminal(options) {
373
404
  this.assertNotDisposed();
405
+ this.log.debug({ command: options?.command, cols: options?.cols, rows: options?.rows }, "connectTerminal start");
374
406
  const stdin = process.stdin;
375
407
  const stdout = process.stdout;
376
408
  const isTTY = stdin.isTTY;
@@ -389,11 +421,13 @@ class KernelImpl {
389
421
  const outputHandler = options?.onData
390
422
  ?? ((data) => { stdout.write(data); });
391
423
  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;
424
+ // Forward terminal resize PTY SIGWINCH
425
+ if (stdout.isTTY) {
426
+ onResize = () => {
427
+ shell.resize(stdout.columns, stdout.rows);
428
+ };
429
+ stdout.on("resize", onResize);
430
+ }
397
431
  return await shell.wait();
398
432
  }
399
433
  finally {
@@ -414,6 +448,9 @@ class KernelImpl {
414
448
  readdir(path) { return this.vfs.readDir(path); }
415
449
  stat(path) { return this.vfs.stat(path); }
416
450
  exists(path) { return this.vfs.exists(path); }
451
+ removeFile(path) { return this.vfs.removeFile(path); }
452
+ removeDir(path) { return this.vfs.removeDir(path); }
453
+ rename(oldPath, newPath) { return this.vfs.rename(oldPath, newPath); }
417
454
  // Introspection
418
455
  get commands() {
419
456
  return this.commandRegistry.list();
@@ -428,6 +465,7 @@ class KernelImpl {
428
465
  // Internal spawn
429
466
  // -----------------------------------------------------------------------
430
467
  spawnInternal(command, args, options, callerPid) {
468
+ this.log.debug({ command, args, callerPid, cwd: options?.cwd }, "spawn start");
431
469
  let driver = this.commandRegistry.resolve(command);
432
470
  // On-demand discovery: ask mounted drivers to resolve unknown commands
433
471
  if (!driver) {
@@ -453,12 +491,20 @@ class KernelImpl {
453
491
  }
454
492
  }
455
493
  if (!driver) {
494
+ this.log.warn({ command }, "command not found");
456
495
  throw new KernelError("ENOENT", `command not found: ${command}`);
457
496
  }
458
497
  // Check childProcess permission
459
- checkChildProcess(this.permissions, command, args, options?.cwd);
498
+ try {
499
+ checkChildProcess(this.permissions, command, args, options?.cwd);
500
+ }
501
+ catch (err) {
502
+ this.log.warn({ command, args }, "spawn permission denied");
503
+ throw err;
504
+ }
460
505
  // Enforce maxProcesses budget
461
506
  if (this.maxProcesses !== undefined && this.processTable.runningCount() >= this.maxProcesses) {
507
+ this.log.warn({ command, running: this.processTable.runningCount(), max: this.maxProcesses }, "process limit reached");
462
508
  throw new KernelError("EAGAIN", "maximum process limit reached");
463
509
  }
464
510
  // Allocate PID atomically
@@ -483,44 +529,34 @@ class KernelImpl {
483
529
  // Buffer stdout/stderr — wired before spawn so nothing is lost
484
530
  const stdoutBuf = [];
485
531
  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;
532
+ // Resolve output callbacks. Drivers invoke BOTH ctx.onStdout and
533
+ // proc.onStdout per message, so the two must never point at the same
534
+ // callback otherwise the host sees every chunk twice.
535
+ //
536
+ // ctx callbacks — kernel-internal routing (pipes, parent forwarding)
537
+ // + temporary buffer during spawn() to catch any
538
+ // synchronous output (disabled right after spawn).
539
+ // proc callbacks — user / host callback (options.onStdout) or buffer
540
+ // for later replay. Set AFTER spawn returns.
541
+ let ctxStdoutCb;
542
+ let ctxStderrCb;
493
543
  if (stdoutPiped) {
494
- stdoutCb = this.createPipedOutputCallback(table, 1, pid);
544
+ ctxStdoutCb = this.createPipedOutputCallback(table, 1, pid);
495
545
  }
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
- }
546
+ else if (!options?.onStdout && callerPid !== undefined) {
547
+ const parent = this.processTable.get(callerPid);
548
+ if (parent?.driverProcess.onStdout) {
549
+ ctxStdoutCb = parent.driverProcess.onStdout;
505
550
  }
506
- if (!stdoutCb)
507
- stdoutCb = (data) => stdoutBuf.push(data);
508
551
  }
509
552
  if (stderrPiped) {
510
- stderrCb = this.createPipedOutputCallback(table, 2, pid);
553
+ ctxStderrCb = this.createPipedOutputCallback(table, 2, pid);
511
554
  }
512
- else {
513
- if (options?.onStderr) {
514
- stderrCb = options.onStderr;
555
+ else if (!options?.onStderr && callerPid !== undefined) {
556
+ const parent = this.processTable.get(callerPid);
557
+ if (parent?.driverProcess.onStderr) {
558
+ ctxStderrCb = parent.driverProcess.onStderr;
515
559
  }
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
560
  }
525
561
  // Inherit env from parent process if spawned by another process, else use kernel defaults
526
562
  const parentEntry = callerPid ? this.processTable.get(callerPid) : undefined;
@@ -529,27 +565,45 @@ class KernelImpl {
529
565
  const stdinIsTTY = this.isFdPtySlave(table, 0);
530
566
  const stdoutIsTTY = this.isFdPtySlave(table, 1);
531
567
  const stderrIsTTY = this.isFdPtySlave(table, 2);
532
- // Build process context with pre-wired callbacks
568
+ // Build process context with pre-wired callbacks.
569
+ // When not piped/forwarded, ctx gets a temporary buffer so that any
570
+ // data emitted synchronously during driver.spawn() is captured.
571
+ const resolvedCwd = options?.cwd ?? this.cwd;
533
572
  const ctx = {
534
573
  pid,
535
574
  ppid: callerPid ?? 0,
536
- env: { ...baseEnv, ...options?.env },
537
- cwd: options?.cwd ?? this.cwd,
575
+ env: { ...baseEnv, ...options?.env, PWD: resolvedCwd },
576
+ cwd: resolvedCwd,
538
577
  fds: { stdin: 0, stdout: 1, stderr: 2 },
539
578
  stdinIsTTY,
540
579
  stdoutIsTTY,
541
580
  stderrIsTTY,
542
- onStdout: stdoutCb,
543
- onStderr: stderrCb,
581
+ streamStdin: options?.streamStdin,
582
+ onStdout: ctxStdoutCb ?? (stdoutPiped ? undefined : (data) => stdoutBuf.push(data)),
583
+ onStderr: ctxStderrCb ?? (stderrPiped ? undefined : (data) => stderrBuf.push(data)),
544
584
  };
545
585
  // Spawn via driver
546
586
  const driverProcess = driver.spawn(command, args, ctx);
547
- // Capture data emitted via DriverProcess callbacks after spawn returns.
587
+ this.log.debug({
588
+ pid, command, driver: driver.name, callerPid,
589
+ stdinIsTTY, stdoutIsTTY, stderrIsTTY,
590
+ }, "process spawned");
591
+ // After spawn, disable the temporary ctx buffer so that async output
592
+ // flows only through proc.onStdout — prevents double-delivery.
593
+ // Pipe/parent-forwarding callbacks stay active (they live in ctxStdoutCb).
594
+ if (!stdoutPiped) {
595
+ ctx.onStdout = ctxStdoutCb;
596
+ }
597
+ if (!stderrPiped) {
598
+ ctx.onStderr = ctxStderrCb;
599
+ }
600
+ // User/host callback goes ONLY on driverProcess (never on ctx) to
601
+ // avoid double-delivery — drivers invoke both ctx and proc callbacks.
548
602
  if (!stdoutPiped) {
549
- driverProcess.onStdout = stdoutCb ?? ((data) => stdoutBuf.push(data));
603
+ driverProcess.onStdout = options?.onStdout ?? ((data) => stdoutBuf.push(data));
550
604
  }
551
605
  if (!stderrPiped) {
552
- driverProcess.onStderr = stderrCb ?? ((data) => stderrBuf.push(data));
606
+ driverProcess.onStderr = options?.onStderr ?? ((data) => stderrBuf.push(data));
553
607
  }
554
608
  // Register in process table
555
609
  const entry = this.processTable.register(pid, driver.name, command, args, ctx, driverProcess);
@@ -643,9 +697,6 @@ class KernelImpl {
643
697
  const filetype = FILETYPE_REGULAR_FILE;
644
698
  const fd = table.open(path, flags, filetype);
645
699
  const fdEntry = table.get(fd);
646
- if (fdEntry) {
647
- this.trackDescriptionInode(fdEntry.description);
648
- }
649
700
  // Stash the effective mode for the first write that materializes a new file.
650
701
  if (created && (flags & O_CREAT)) {
651
702
  const entry = this.processTable.get(pid);
@@ -768,14 +819,8 @@ class KernelImpl {
768
819
  if (this.pipeManager.isPipe(entry.description.id) || this.ptyManager.isPty(entry.description.id)) {
769
820
  throw new KernelError("ESPIPE", "illegal seek");
770
821
  }
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);
822
+ // Delegate positional write to VFS.
823
+ await this.pwriteDescription(entry.description, Number(offset), data);
779
824
  return data.length;
780
825
  },
781
826
  fdDup: (pid, fd) => {
@@ -894,6 +939,7 @@ class KernelImpl {
894
939
  return this.spawnManaged(command, args, {
895
940
  env: ctx.env,
896
941
  cwd: ctx.cwd,
942
+ streamStdin: ctx.streamStdin,
897
943
  onStdout: ctx.onStdout,
898
944
  onStderr: ctx.onStderr,
899
945
  stdinFd: ctx.stdinFd,
@@ -914,6 +960,7 @@ class KernelImpl {
914
960
  // Negative PID = process group kill, handled by kernel directly
915
961
  if (pid >= 0)
916
962
  assertOwns(pid);
963
+ this.log.debug({ pid, signal }, "signal delivery");
917
964
  this.processTable.kill(pid, signal);
918
965
  },
919
966
  getpid: (pid) => {
@@ -1095,6 +1142,7 @@ class KernelImpl {
1095
1142
  throw new KernelError("ENOTDIR", `not a directory: ${path}`);
1096
1143
  }
1097
1144
  entry.cwd = path;
1145
+ entry.env.PWD = path;
1098
1146
  },
1099
1147
  // Alarm (SIGALRM)
1100
1148
  alarm: (pid, seconds) => {
@@ -1273,7 +1321,7 @@ class KernelImpl {
1273
1321
  }
1274
1322
  // Close all FDs and remove the table
1275
1323
  this.fdTableManager.remove(pid);
1276
- // Release inode-backed file data after the last shared reference closes.
1324
+ // Flush buffered writes when the last shared reference closes.
1277
1325
  for (const description of descriptions.values()) {
1278
1326
  if (description.refCount <= 0) {
1279
1327
  this.releaseDescriptionInode(description);
@@ -1316,56 +1364,30 @@ class KernelImpl {
1316
1364
  entry.description.cursor = BigInt(endPos);
1317
1365
  return data.length;
1318
1366
  }
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
1367
  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;
1368
+ // Flush buffered writes to durable storage when the last FD is closed.
1369
+ void this.vfs.fsync?.(description.path);
1337
1370
  }
1338
1371
  async readDescriptionFile(description) {
1339
- if (description.inode !== undefined && this.rawInMemoryFs) {
1340
- return this.rawInMemoryFs.readFileByInode(description.inode);
1341
- }
1342
1372
  return this.vfs.readFile(description.path);
1343
1373
  }
1344
1374
  async writeDescriptionFile(description, content) {
1345
- if (description.inode !== undefined && this.rawInMemoryFs) {
1346
- this.rawInMemoryFs.writeFileByInode(description.inode, content);
1347
- return;
1348
- }
1349
1375
  await this.vfs.writeFile(description.path, content);
1350
- this.trackDescriptionInode(description);
1351
1376
  }
1352
1377
  prepareOpenSync(path, flags) {
1353
1378
  const syncVfs = this.vfs;
1354
1379
  return syncVfs.prepareOpenSync?.(path, flags) ?? false;
1355
1380
  }
1356
1381
  async preadDescription(description, offset, length) {
1357
- if (description.inode !== undefined && this.rawInMemoryFs) {
1358
- return this.rawInMemoryFs.preadByInode(description.inode, offset, length);
1359
- }
1360
1382
  return this.vfs.pread(description.path, offset, length);
1361
1383
  }
1384
+ async pwriteDescription(description, offset, data) {
1385
+ await this.vfs.pwrite(description.path, offset, data);
1386
+ }
1362
1387
  async getDescriptionSize(description) {
1363
1388
  return (await this.statDescription(description)).size;
1364
1389
  }
1365
1390
  async statDescription(description) {
1366
- if (description.inode !== undefined && this.rawInMemoryFs) {
1367
- return this.rawInMemoryFs.statByInode(description.inode);
1368
- }
1369
1391
  return this.vfs.stat(description.path);
1370
1392
  }
1371
1393
  getTable(pid) {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Mount Table.
3
+ *
4
+ * Linux-style VFS mount table that routes paths to mounted filesystem backends
5
+ * by longest-prefix matching. Replaces the hardcoded layer composition
6
+ * (DeviceLayer wraps ProcLayer wraps base FS) with a unified routing table.
7
+ */
8
+ import type { VirtualDirEntry, VirtualDirStatEntry, VirtualFileSystem, VirtualStat } from "./vfs.js";
9
+ export interface MountOptions {
10
+ readOnly?: boolean;
11
+ }
12
+ export interface MountEntry {
13
+ path: string;
14
+ readOnly: boolean;
15
+ }
16
+ export declare class MountTable implements VirtualFileSystem {
17
+ /**
18
+ * Mounts sorted by path length descending so longest-prefix match is first hit.
19
+ */
20
+ private mounts;
21
+ constructor(rootFs: VirtualFileSystem);
22
+ /**
23
+ * Mount a filesystem at the given path.
24
+ * Auto-creates the mount point directory in the parent filesystem if needed.
25
+ */
26
+ mount(path: string, fs: VirtualFileSystem, options?: MountOptions): void;
27
+ /**
28
+ * Unmount the filesystem at the given path.
29
+ */
30
+ unmount(path: string): void;
31
+ /**
32
+ * List all current mounts.
33
+ */
34
+ getMounts(): ReadonlyArray<MountEntry>;
35
+ private resolve;
36
+ private assertWritable;
37
+ readFile(path: string): Promise<Uint8Array>;
38
+ readTextFile(path: string): Promise<string>;
39
+ readDir(path: string): Promise<string[]>;
40
+ readDirWithTypes(path: string): Promise<VirtualDirEntry[]>;
41
+ exists(path: string): Promise<boolean>;
42
+ stat(path: string): Promise<VirtualStat>;
43
+ lstat(path: string): Promise<VirtualStat>;
44
+ realpath(path: string): Promise<string>;
45
+ readlink(path: string): Promise<string>;
46
+ pread(path: string, offset: number, length: number): Promise<Uint8Array>;
47
+ pwrite(path: string, offset: number, data: Uint8Array): Promise<void>;
48
+ writeFile(path: string, content: string | Uint8Array): Promise<void>;
49
+ createDir(path: string): Promise<void>;
50
+ mkdir(path: string, options?: {
51
+ recursive?: boolean;
52
+ }): Promise<void>;
53
+ removeFile(path: string): Promise<void>;
54
+ removeDir(path: string): Promise<void>;
55
+ symlink(target: string, linkPath: string): Promise<void>;
56
+ link(oldPath: string, newPath: string): Promise<void>;
57
+ rename(oldPath: string, newPath: string): Promise<void>;
58
+ chmod(path: string, mode: number): Promise<void>;
59
+ chown(path: string, uid: number, gid: number): Promise<void>;
60
+ utimes(path: string, atime: number, mtime: number): Promise<void>;
61
+ truncate(path: string, length: number): Promise<void>;
62
+ fsync(path: string): Promise<void>;
63
+ copy(srcPath: string, dstPath: string): Promise<void>;
64
+ readDirStat(path: string): Promise<VirtualDirStatEntry[]>;
65
+ /**
66
+ * Get basenames of child mount points under a directory.
67
+ * For example, if mounts exist at /dev and /proc, calling with "/" returns ["dev", "proc"].
68
+ */
69
+ private getChildMountBasenames;
70
+ /**
71
+ * Synchronous open preparation (O_CREAT, O_EXCL, O_TRUNC).
72
+ * Delegates to the underlying backend's prepareOpenSync if it exists.
73
+ */
74
+ prepareOpenSync(path: string, flags: number): boolean;
75
+ }