@ai-ecoverse/slicc-kernel 1.77.2 → 1.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,7 +38,7 @@ const { status, stdout, stderr } = await kernel.run(['bash', '-c', 'echo hi > he
38
38
  | `onMountPending` | none | `({ target, source, insert }) => void`: an `fsa` mount needs a folder; call `insert()` from a user gesture |
39
39
  | `processMounts` | `true` | whether programs may call `mount(2)` and `umount2(2)`: `false`, or `(req) => boolean \| Promise<boolean>` to decide each call (see [Mounting from a program](#mounting-from-a-program)) |
40
40
  | `hostname` | `'slicc'` | the kernel's node name (see [Node name](#node-name)) |
41
- | `users` | `'off'` | `'off'`, `'report'` or `'enforce'`: whether files have owners and their modes are checked (see [File owners](#file-owners) and [Permissions](#permissions)). `'off'` behaves exactly as kernels before owners did; any other value rejects. `createNodeKernel` takes it too |
41
+ | `users` | `'enforce'` | `'off'`, `'report'` or `'enforce'`: whether files have owners and their modes are checked (see [File owners](#file-owners) and [Permissions](#permissions)), and whether `sudo` elevates (see [Sudo](#sudo)). `'off'` behaves exactly as kernels before owners did; any other value rejects. `createNodeKernel` takes it too, with the same default. Kernels up to 1.73 defaulted to `'off'` |
42
42
  | `onDenial` | none | `({ pid, uid, op, path, errno, tag? }) => void`: called for every permission check that fails, under `'report'` and `'enforce'` (see [Permissions](#permissions)). `createNodeKernel` takes it too |
43
43
  | `sealed` | none | `(req, { signal }) => Promise<object>`: answers requests written to [`/dev/sealed`](#devsealed). `createNodeKernel` takes it too |
44
44
  | `clipboard` | none | `{ read(stamp) => Promise<string>, write(text, stamp) => Promise<void> }`: the clipboard behind [`/dev/clipboard`](#devclipboard). `createNodeKernel` takes it too |
@@ -46,9 +46,9 @@ const { status, stdout, stderr } = await kernel.run(['bash', '-c', 'echo hi > he
46
46
  | `cdp` | none | `({ runtime }) => Promise<CdpConnection>`: a browser-level DevTools connection for programs that drive a browser (see [Browser automation](#browser-automation-cdp)) |
47
47
  | `protect` | none | `{ paths, writers? }`: directories no process may change, root included, in every `users` mode, except processes with one of the `writers` tags (see [Protected paths](#protected-paths)). `createNodeKernel` takes it too |
48
48
 
49
- ### `kernel.run(argv, options?) → Promise<{ status, stdout, stderr }>`
49
+ ### `kernel.run(argv, options?) → Promise<{ status, signal, stdout, stderr }>`
50
50
 
51
- Runs `argv[0]` with `argv` as its arguments and resolves when it exits. `stdout` and `stderr` are decoded as UTF-8; `status` is the exit status, `128 + n` for a process killed by signal `n`, and `127` when `argv[0]` cannot be found.
51
+ Runs `argv[0]` with `argv` as its arguments and resolves when it exits. `stdout` and `stderr` are decoded as UTF-8; `status` is the exit status, `128 + n` for a process killed by signal `n`, and `127` when `argv[0]` cannot be found. `signal` is there only when a signal ended the process, named as `'SIGKILL'`, so a `kill -9` (`status: 137, signal: 'SIGKILL'`) can be told from an `exit 137` (`status: 137`, no `signal`).
52
52
 
53
53
  | option | |
54
54
  | --- | --- |
@@ -77,6 +77,7 @@ Starts `argv` as the session leader on a new terminal and resolves once it is ru
77
77
  | `resize(cols, rows)` | sets the window size (`TIOCGWINSZ`) and sends `SIGWINCH` to the foreground process group |
78
78
  | `signal(name)` | sends a signal (`'SIGINT'`, `'SIGTSTP'`, `'SIGQUIT'`, `'SIGHUP'`, …) to the foreground process group, regardless of termios |
79
79
  | `exited` | resolves with the session leader's exit status (`128 + n` if killed by signal `n`) |
80
+ | `ended` | resolves with `{ status, signal }` at the same time: `status` as `exited`, and `signal` named (`'SIGKILL'`) only when a signal ended it |
80
81
  | `close()` | hangs up: `SIGHUP` to the foreground process group, and reads from the terminal return end-of-file |
81
82
 
82
83
  ### `kernel.terminate()`
@@ -131,6 +132,7 @@ Reach a server a program runs on the kernel's loopback (a dev server, `python -m
131
132
  - **`loopbackFetch`** sends an HTTP/1.1 request over `dial` and resolves with a `Response` as soon as the headers arrive. The body streams (chunked, counted, or until the connection closes), so server-sent events and long polls work; cancelling the body or aborting `signal` closes the connection. The request is routed by `port`, not by the URL, whose host is sent as `Host`; a request body is sent with `Content-Length`, and every request uses a connection of its own (`Connection: close`).
132
133
  - **Errors** reject with an `Error` whose `code` is the errno: `ECONNREFUSED` when nothing listens on the port or the port is kernel-only (`9222`, the [CDP facade](#browser-automation-cdp)), `ENETUNREACH` for a host that is not the loopback, `EINVAL` for a port outside 1–65535.
133
134
  - **Names**: a page reaches the kernel's loopback as `<port>.kernel.localhost`, which is the loopback and a secure context, so a request no service worker routes lands on port 80 of the real machine instead of a real server on that port; the service worker routes the name to `loopbackFetch` with that port. Programs find the name in `SLICC_PAGE_LOOPBACK` (`kernel.localhost`) and `SLICC_PAGE_LOOPBACK_URL` (`http://{port}.kernel.localhost`), to print or inject the right URL.
135
+ - **No per-user isolation**: as on Linux, any user's process can connect to any listener on `127.0.0.1`, and the page reaches every one as `<port>.kernel.localhost`, so the loopback is no privilege boundary; a service that needs one must authenticate its clients.
134
136
 
135
137
  ### `kernel.attest(path)` and `kernel.watch(paths, onChange)`
136
138
 
@@ -152,9 +154,9 @@ Attaches to a kernel over a port from `kernel.connect()`, in any realm: a worker
152
154
 
153
155
  | Client | |
154
156
  |---|---|
155
- | `spawn(argv, { cwd, user, env, stdin, pgid, onStdout, onStderr })` | runs over pipes and resolves with `{ pid, pgid, exited, signal(name) }` once the process has started; output arrives in chunks, as bytes, and the kernel keeps no copy; every spawn leads its own process group, which `signal` (default `SIGTERM`) signals, unless `pgid` names an existing group to join (protocol 1.3): the process then joins that group and that group's session, as `setpgid` would (a client has no session of its own: each of its spawns and terminals leads one, and `setpgid` cannot cross sessions), and `signal` signals the whole group. The group must have been started by this client (a spawn or a terminal it opened), even if its leader has exited; an unknown group rejects with `code: 'ESRCH'`, another session's with `code: 'EPERM'`, and against a 1.2 kernel the client rejects with `code: 'ENOSYS'` before sending anything, as an older kernel would ignore the option; a program that cannot start rejects with `code: 'ENOENT'`. `user` runs it as another user, which only a root client may (see `kernel.connect`); against a 1.7 kernel the client rejects with `code: 'ENOSYS'` |
156
- | `run(argv, options?)` | `spawn` and wait: `{ pid, status, stdout, stderr }` as text, with `onStdout`/`onStderr` streaming text |
157
- | `openTerminal(argv, options?)` | a pty session, as `kernel.openTerminal` |
157
+ | `spawn(argv, { cwd, user, env, stdin, pgid, onStdout, onStderr })` | runs over pipes and resolves with `{ pid, pgid, exited, ended, signal(name) }` once the process has started; `ended` resolves with `{ status, signal }`, `signal` named only when a signal ended the process, as for `kernel.run` (an older kernel never names one); output arrives in chunks, as bytes, and the kernel keeps no copy; every spawn leads its own process group, which `signal` (default `SIGTERM`) signals, unless `pgid` names an existing group to join (protocol 1.3): the process then joins that group and that group's session, as `setpgid` would (a client has no session of its own: each of its spawns and terminals leads one, and `setpgid` cannot cross sessions), and `signal` signals the whole group. The group must have been started by this client (a spawn or a terminal it opened), even if its leader has exited; an unknown group rejects with `code: 'ESRCH'`, another session's with `code: 'EPERM'`, and against a 1.2 kernel the client rejects with `code: 'ENOSYS'` before sending anything, as an older kernel would ignore the option; a program that cannot start rejects with `code: 'ENOENT'`. `user` runs it as another user, which only a root client may (see `kernel.connect`); against a 1.7 kernel the client rejects with `code: 'ENOSYS'` |
158
+ | `run(argv, options?)` | `spawn` and wait: `{ pid, status, signal, stdout, stderr }` as text, with `onStdout`/`onStderr` streaming text; `signal` as for `kernel.run` |
159
+ | `openTerminal(argv, options?)` | a pty session, as `kernel.openTerminal`, `ended` included |
158
160
  | `ps()` | the kernel's processes: `{ pid, ppid, pgid, sid, argv, tty, started, state, rss, utime, stime, uid, gid }`, `state` being `'S'`, `'T'` (stopped) or `'Z'`, `rss` the process's resident memory in bytes (the size of its wasm memory, what `/proc/<pid>/status` shows as `VmRSS`; absent from kernels before 1.61), `utime`/`stime` the CPU time it has used, in milliseconds, in its program and in the kernel's calls (see [`/proc`](#proc); absent for a JS program and from kernels before 1.63) and `uid`/`gid` the effective ids (absent from a 1.7 kernel). A client bound to a user sees only that user's processes |
159
161
  | `kill(pid, signal?)` | a process, or a process group with a negative pid; no such process rejects with `code: 'ESRCH'`, and one the client's user may not signal with `code: 'EPERM'` |
160
162
  | `fs` | `readFile`, `readText`, `writeFile(path, string \| bytes)`, `stat`, `lstat`, `readdir`, `mkdir` (with parents), `rm(path, { force })` (recursive), `rename`, `realpath`, `symlink(target, path)`, `readlink`, `exists`, on the processes' file system, mounts included (a path under a `tmpfs`, `fsa` or `hostfs` mount reads and writes the mounted file system, as `cat` in a process would); failures reject with `KernelCallError` and a POSIX `code` |
@@ -570,7 +572,7 @@ Programs ask the kernel for their ids:
570
572
 
571
573
  ### File owners
572
574
 
573
- With `createKernel({ users: 'report' })` or `'enforce'`, files have owners, and [Permissions](#permissions) checks them against a process's credentials. With `'off'`, the default, nothing below happens: the kernel stores no owner, and every file looks owned by the caller as described under [Metadata](#metadata).
575
+ With `users: 'enforce'`, the default, or `'report'`, files have owners, and [Permissions](#permissions) checks them against a process's credentials. With `'off'` nothing below happens: the kernel stores no owner, and every file looks owned by the caller as described under [Metadata](#metadata).
574
576
 
575
577
  - **Where owners live:** on the root file system, in the sidecar record's `uid` and `gid` (see [Metadata](#metadata)). A path with no owner of its own has the owner of its nearest ancestor that has one, and `/` belongs to root, so a root written before owners existed, or by the BIOS through the OPFS API, is root's throughout. A directory whose owner changes keeps the owner its unowned contents had, in `heir`, so `chown cone /old` gives `cone` the directory and leaves the files below it to their old owner. A rename keeps the owner of what it moves.
576
578
  - **New files:** a file, directory or symlink a process creates belongs to its effective uid, and to its effective gid, or to the directory's group when the directory has the setgid bit; a new directory in a setgid directory gets the setgid bit too. Every file and directory a process creates gets the mode it asked for (`0666` and `0777` when it can't ask, as a JS program's `ctx.fs` can't) less the process's umask, whichever runtime made it; an Emscripten program's file no longer gets `0644` whatever it asked for. A file written through a client's `fs` belongs to the client's user (root for a page) and gets `0666`, a directory `0777`, less the umask `kernel.users.add` gave that user (`022` for root and by default); the kernel's own files, such as `/etc/passwd`, have no owner of their own.
@@ -596,7 +598,7 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
596
598
  - **A client's `fs`:** checked as the client's user; a page's client (root) passes.
597
599
  - **Removed files still open:** a process that holds a removed file may look it up (`stat`, `lstat`) under the hidden name the kernel gave it in `/.slicc-unlinked`, which is root's `0700`; every other process is refused there, so the kernel's own bookkeeping of held files raises no denials for their holders.
598
600
 
599
- `users: 'enforce'` is not the default yet; it becomes so once the embedders have checked their programs under `'report'`.
601
+ `users: 'enforce'` is the default of `createKernel` and `createNodeKernel`; pass `'report'` to find what a program would be refused without refusing it, or `'off'` for kernels without owners. A `Launcher` made directly (see [What is in here](#what-is-in-here)) still defaults to `'off'`.
600
602
 
601
603
  ### Sudo
602
604
 
@@ -608,7 +610,7 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
608
610
  - **The command** is found once, on `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` for a bare name or against the caller's directory for a path, and its real path (symlinks resolved) is what runs and what the policy sees. That file, and every file that will really run for it (a catalog command's glue, module and imports module, and the `package.json` that defines it, a script and the interpreter its `#!` line or `binfmt` suffix names), must be root's and writable by root only, and so must every directory above each, or `sudo` refuses (`EPERM`), whatever the policy says. A command that wouldn't start as the target user (a `#!` line naming an interpreter that is only the caller's, say) fails with `command not found` before anything is asked. Other files a program loads at run time (a package's libraries) are the admin's to keep safe, as on Linux. The caller's directory, which the program passes on, must be a directory the caller may enter, or `sudo` refuses (`EACCES`). The kernel resolves it to its real path once, shows that in the request, and runs the command there; if it no longer resolves the same, or the caller can no longer enter it, when the answer comes or when the command starts, nothing runs.
609
611
  - **The decision:** a caller whose effective uid is 0 runs anything (the check on the command's path still applies). Otherwise a matching `NOPASSWD` line runs the command at once; any other matching line asks `approve`; nothing matching is refused (`sudo: cone is not allowed to run /usr/bin/ls as root`).
610
612
  - **Approval:** `approve(req, { signal })` gets `{ id, kind: 'sudo', user, uid, target, argv, path, cwd, pid, tag?, tty?, createdAt, expiresAt, always: true }`: the caller's name and real uid, the target user, the argv as typed, the resolved `path`, the caller's directory, pid, tag and terminal, and the times in ms. It answers `'once'` (run it), `'always'` (run it, and append `NOPASSWD <user> (<target>) Cmnd <path> <args…>` to `/etc/sudoers.d/<user>`, root's `0440`, so it's never asked again; if that can't be written, nothing runs) or `'deny'`; a throw, any other answer or no hook at all is `sudo: approval denied`. After 5 minutes the request expires (`signal` aborts with `'expired'`, and `sudo` fails with `sudo: the request expired`); when the requester ends or gets a signal while it waits, `^C` included, `signal` aborts with `'withdrawn'` and `sudo` ends with that signal's status. A user has at most one request waiting; another fails with `sudo: another request is pending`. On the page, `createKernel` relays the request from the kernel worker and back, and the abort too; `terminate()` or a failed worker aborts every request still waiting with `'withdrawn'`.
611
- - **Running:** the command runs as the caller's child, with all six ids and the groups of the target user, in the caller's directory, on its terminal, standard input, output and error, and in its process group; `sudo` exits with its status, and passes `SIGTERM` and `SIGHUP` on to it, also one that comes while it starts (the kernel lets it signal the command it started, which its own user may not); `SIGINT` and `SIGQUIT` it leaves to the command, which gets them from the terminal itself. It's found among the system's commands only, never a user's own. Its environment is the kernel's (the embedder's `env` and the proxy settings), the target's `HOME`, `USER`, `LOGNAME`, `SHELL`, `PNPM_HOME` and `NPM_CONFIG_PREFIX`, `PATH` set to the path above, `PWD`, the caller's `TERM` when it is a plain name (letters, digits, `.`, `_`, `+`, `-`), and `SUDO_USER`, `SUDO_UID`, `SUDO_GID` and `SUDO_COMMAND`; nothing else of the caller's comes through.
613
+ - **Running:** the command runs as the caller's child, with all six ids and the groups of the target user, in the caller's directory, on its terminal, standard input, output and error, and in its process group; `sudo` exits with its status, and passes `SIGTERM` and `SIGHUP` on to it, also one that comes while it starts (the kernel lets it signal the command it started, which its own user may not); `SIGINT` and `SIGQUIT` it leaves to the command, which gets them from the terminal itself. Two narrow gaps remain ([#424](https://github.com/ai-ecoverse/slicc-kernel/issues/424)): a relayed signal reaches the command but not the processes it started itself, which keep running as the target user if it ends; and right after the command starts, a `SIGINT` or `SIGQUIT` sent to `sudo`'s own pid (not through the terminal) ends `sudo` and leaves the command running. Closing both needs the command in a process group of its own, which changes job control. It's found among the system's commands only, never a user's own. Its environment is the kernel's (the embedder's `env` and the proxy settings), the target's `HOME`, `USER`, `LOGNAME`, `SHELL`, `PNPM_HOME` and `NPM_CONFIG_PREFIX`, `PATH` set to the path above, `PWD`, the caller's `TERM` when it is a plain name (letters, digits, `.`, `_`, `+`, `-`), and `SUDO_USER`, `SUDO_UID`, `SUDO_GID` and `SUDO_COMMAND`; nothing else of the caller's comes through.
612
614
  - **Cost:** with `users: 'off'`, and with owners on for programs that don't call `sudo`, nothing is added to any call.
613
615
 
614
616
  ### `/dev/sealed`
@@ -280,6 +280,54 @@ var RemoteTransport = class {
280
280
  }
281
281
  };
282
282
 
283
+ // src/kernel/signals.ts
284
+ var SIG = {
285
+ HUP: 1,
286
+ INT: 2,
287
+ QUIT: 3,
288
+ ILL: 4,
289
+ TRAP: 5,
290
+ ABRT: 6,
291
+ BUS: 7,
292
+ FPE: 8,
293
+ KILL: 9,
294
+ USR1: 10,
295
+ SEGV: 11,
296
+ USR2: 12,
297
+ PIPE: 13,
298
+ ALRM: 14,
299
+ TERM: 15,
300
+ STKFLT: 16,
301
+ CHLD: 17,
302
+ CONT: 18,
303
+ STOP: 19,
304
+ TSTP: 20,
305
+ TTIN: 21,
306
+ TTOU: 22,
307
+ URG: 23,
308
+ XCPU: 24,
309
+ XFSZ: 25,
310
+ VTALRM: 26,
311
+ PROF: 27,
312
+ WINCH: 28,
313
+ POLL: 29,
314
+ PWR: 30,
315
+ SYS: 31
316
+ };
317
+ var IGNORED_BY_DEFAULT = /* @__PURE__ */ new Set([SIG.CHLD, SIG.URG, SIG.WINCH]);
318
+ var STOPPING = /* @__PURE__ */ new Set([SIG.STOP, SIG.TSTP, SIG.TTIN, SIG.TTOU]);
319
+ function quiet(p) {
320
+ p.catch(() => void 0);
321
+ return p;
322
+ }
323
+ var SIGNAL_BY_NAME = {
324
+ SIGINT: SIG.INT,
325
+ SIGTERM: SIG.TERM,
326
+ SIGKILL: SIG.KILL,
327
+ SIGSTOP: SIG.STOP,
328
+ SIGCONT: SIG.CONT
329
+ };
330
+
283
331
  // src/bytes.ts
284
332
  function fresh(bytes, start = 0, end = bytes.length) {
285
333
  return new Uint8Array(bytes.subarray(start, end));
@@ -665,6 +713,7 @@ var Channel = class {
665
713
  return;
666
714
  }
667
715
  this.pending.delete(reply.id);
716
+ call.ended?.(reply.signal);
668
717
  if (reply.error !== void 0) call.reject(new KernelCallError(reply.error, reply.code));
669
718
  else call.resolve(reply.result);
670
719
  }
@@ -713,12 +762,14 @@ async function spawnOn(channel, argv, opts) {
713
762
  if (opts.user !== void 0) await since(channel, 8, "spawn as a user", async () => {
714
763
  });
715
764
  const started = Promise.withResolvers();
765
+ let endedBy;
716
766
  const options = { ...rest, ...stdin !== void 0 ? { stdin: bytesOf(stdin) } : {} };
717
767
  const { done } = channel.request(
718
768
  { op: "spawn", argv, options },
719
769
  {
720
770
  output: (fd, bytes) => (fd === 1 ? onStdout : onStderr)?.(bytes),
721
- started: started.resolve
771
+ started: started.resolve,
772
+ ended: (signal) => endedBy = signal
722
773
  }
723
774
  );
724
775
  done.catch(started.reject);
@@ -728,9 +779,18 @@ async function spawnOn(channel, argv, opts) {
728
779
  pid,
729
780
  pgid,
730
781
  exited: done,
782
+ ended: endedOf(done, () => endedBy),
731
783
  signal: async (name = "SIGTERM") => void await channel.call({ op: "kill", pid: -pgid, signal: name })
732
784
  };
733
785
  }
786
+ function endedOf(done, signal) {
787
+ return quiet(
788
+ done.then((status) => {
789
+ const by = signal();
790
+ return { status, ...by ? { signal: by } : {} };
791
+ })
792
+ );
793
+ }
734
794
  async function runOn(channel, argv, opts) {
735
795
  const { onStdout, onStderr, ...rest } = opts;
736
796
  const chunks = { 1: [], 2: [] };
@@ -744,9 +804,15 @@ async function runOn(channel, argv, opts) {
744
804
  onStdout: tee(1, onStdout),
745
805
  onStderr: tee(2, onStderr)
746
806
  });
747
- const status = await child.exited;
807
+ const { status, signal } = await child.ended;
748
808
  const text = (fd) => new TextDecoder().decode(concat(chunks[fd]));
749
- return { pid: child.pid, status, stdout: text(1), stderr: text(2) };
809
+ return {
810
+ pid: child.pid,
811
+ status,
812
+ ...signal ? { signal } : {},
813
+ stdout: text(1),
814
+ stderr: text(2)
815
+ };
750
816
  }
751
817
  async function terminalOn(channel, argv, opts) {
752
818
  const { onData, ...options } = opts;
@@ -755,11 +821,13 @@ async function terminalOn(channel, argv, opts) {
755
821
  let listener = onData ?? null;
756
822
  const backlog = [];
757
823
  const started = Promise.withResolvers();
824
+ let endedBy;
758
825
  const { id, done } = channel.request(
759
826
  { op: "open-terminal", argv, options },
760
827
  {
761
828
  output: (_fd, bytes) => listener ? listener(bytes) : backlog.push(bytes),
762
- started: started.resolve
829
+ started: started.resolve,
830
+ ended: (signal) => endedBy = signal
763
831
  }
764
832
  );
765
833
  done.catch(started.reject);
@@ -768,6 +836,7 @@ async function terminalOn(channel, argv, opts) {
768
836
  return {
769
837
  pid,
770
838
  exited: done,
839
+ ended: endedOf(done, () => endedBy),
771
840
  get onData() {
772
841
  return listener;
773
842
  },