@ai-ecoverse/slicc-kernel 1.67.1 → 1.69.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
@@ -40,6 +40,7 @@ const { status, stdout, stderr } = await kernel.run(['bash', '-c', 'echo hi > he
40
40
  | `hostname` | `'slicc'` | the kernel's node name (see [Node name](#node-name)) |
41
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 |
42
42
  | `onDenial` | none | `({ pid, uid, op, path, errno }) => void`: called for every permission check that fails, under `'report'` and `'enforce'` (see [Permissions](#permissions)). `createNodeKernel` takes it too |
43
+ | `sealed` | none | `(req, { signal }) => Promise<object>`: answers requests written to [`/dev/sealed`](#devsealed). `createNodeKernel` takes it too |
43
44
  | `approve` | none | `(req, { signal }) => 'once' \| 'always' \| 'deny'` (or a promise of one): decides a `sudo` request that needs approval (see [Sudo](#sudo)); without it, such a request is denied. `createNodeKernel` takes it too |
44
45
  | `cdp` | none | `({ runtime }) => Promise<CdpConnection>`: a browser-level DevTools connection for programs that drive a browser (see [Browser automation](#browser-automation-cdp)) |
45
46
 
@@ -249,6 +250,7 @@ The module's default export (or its `main` export) is called with a context and
249
250
  - `fdStatus(fd)` (`{ type, tty, seekable }`, `type` being `file`, `pipe`, `tty`, `socket`, `device` or `directory`) and `isatty(fd)`, to tell `< file` from a pipe;
250
251
  - `open(path, { read, write, append, create, exclusive, truncate })`, which returns a file with `read(position, length)`, `write(position, bytes)`, `size()`, `truncate(size)`, `sync()` and `close()`. Reads and writes go through the kernel's paged files at those positions, so a large file is never read whole;
251
252
  - `fs`: `stat`, `lstat`, `exists`, `readdir`, `mkdir` (with parents), `rm` (recursive), `unlink`, `rename`, `symlink`, `readlink`, and `readFile`/`writeFile` for small files;
253
+ - `/dev`, in `open`, `fs` and their `sync` forms, as a WASI or Emscripten program sees it, with the same modes and owners as under `users`: `null`, `zero`, `full`, `random`, `urandom` and `tty` (character devices, `0666`, root's), `stdin`, `stdout` and `stderr` (links to `/proc/self/fd/0`, `1`, `2`), `fd` (a link to `/proc/self/fd`, so `/dev/fd/N` is descriptor N) and the kernel's terminals `/dev/ttyN` and `/dev/pts/N` (with users on, owned by the session's user or the master's opener, `0620`). `/dev/shm` and `/dev/ptmx` aren't there. `stat` through `/dev/stdin`, `/dev/stdout` or `/dev/fd/N` describes the program's own descriptor (the file it is redirected to, a pipe, a terminal), `readlink` of `/dev/fd/N` names it (`pipe:[N]` and `socket:[N]` for a pipe or a socket), and a closed descriptor is `ENOENT`. Opening a device gives a kernel descriptor on it; opening `/dev/std*` or `/dev/fd/N` opens the file again with the flags asked for when the descriptor is on a file (as Linux reopens `/proc/self/fd/N`, so `append` appends and `truncate` truncates), and otherwise duplicates the descriptor; `/dev/tty` is the program's controlling terminal (`ENXIO` without one); and `/dev/ttyN` and `/dev/pts/N` are opened by name through the kernel, so a terminal that isn't the user's is `EACCES` under `enforce`. A file on anything but a regular file reads and writes as a stream (the position is ignored) and only in the directions it was opened for (`EBADF` otherwise), its `size()` is 0 and `truncate` is `EINVAL`. `readFile` reads a device or a descriptor to its end, up to 64 MiB, and fails with `EFBIG` past that (where Node's `readFile` throws `ERR_FS_FILE_TOO_LARGE`), so `readFile('/dev/zero')` fails instead of hanging, while a bounded `read` of it works. Other paths pay only a prefix check: they make the same kernel calls as before. Creating, removing or renaming names in `/dev` goes to the root file system as before;
252
254
  - `signals`: `on(signal, handler)` catches a signal (by number or as `'SIGINT'`), `ignore(signal)` ignores it and `reset(signal)` restores its default action. A call blocked in a read or write is restarted after a handler runs; signals without a handler keep their default actions, so an uncaught `SIGINT` ends the program with 130 and `SIGKILL` always does;
253
255
  - `sync`: `read`, `write`, `close`, `open` (whose files' methods are synchronous too) and `fs`, made synchronously, for code that cannot `await`, such as a compiled program's file system or a `readFileSync`. A synchronous call holds the worker until the kernel answers, so nothing else of the program runs meanwhile, timers and callbacks included; a signal that arrives during a blocked read or write runs its handler, and the call is then restarted. Descriptors are shared with the rest of the context, so a file opened with `sync.open` can be closed with `close`, and the other way around; `sync.run` and `sync.exit` are described next;
254
256
  - `sync.run({ argv, env, cwd, input, stdin, stdout, stderr, maxBuffer, timeout, killSignal })` runs a command to its end and answers `{ pid, status, signal, stdout, stderr, error }`, as Node's `spawnSync` does. It suits `execSync`, `spawnSync` and `execFileSync`. The kernel does the whole run while the program's worker waits; other processes go on.
@@ -589,6 +591,15 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
589
591
  - **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.
590
592
  - **Cost:** with `users: 'off'`, and with owners on for programs that don't call `sudo`, nothing is added to any call.
591
593
 
594
+ ### `/dev/sealed`
595
+
596
+ A character device (`0666`, major 10, minor 250) through which a program talks to the embedder's `sealed` hook (`createKernel({ sealed })`), the secrets vault on the page. Emscripten and WASI programs can open it (Emscripten programs see it in `/dev`); JS programs can't yet.
597
+ - **State per open:** each `open()` is independent; `dup` and fork share one, as on Linux.
598
+ - **One write is one request:** a single UTF-8 JSON object of at most 64 KiB with `v` 1 and a known `op` (`seal`, `placeholder`, `list`, `test`, `request`, `status`, `cancel`, `materialize`). The write takes all the bytes or fails: `EMSGSIZE` over 64 KiB, `EINVAL` for anything else that isn't such an object. These are the only errnos; every other outcome is a JSON reply. The kernel zeroes the bytes it was given once it has parsed them.
599
+ - **The stamp:** the hook gets the request with `caller: { pid, ppid, uid, gid, tag?, users }`, from the process that writes (effective ids, at the write; not the one that opened it), `tag` the process's tag (see `kernel.run`) when it has one, and `users` the kernel's `users` mode, since a uid means more under `'enforce'`. A `caller` in the request is replaced.
600
+ - **Reading:** the reply comes from the same descriptor, as JSON (`{ v: 1, … }`). A read waits for it, drains it, then returns end-of-file; with no request outstanding it returns end-of-file at once. A signal ends the wait with `EINTR` and the request stays outstanding, `O_NONBLOCK` gives `EAGAIN`, and `poll`/`select` report the descriptor readable once the reply is there. A new write drops an unread reply.
601
+ - **The hook:** `signal` aborts with `'timeout'` after 10 s (the reply is then `{ v: 1, error: { code: 'ETIMEDOUT' } }`), `'replaced'` when a new write on the descriptor comes first, and `'withdrawn'` when the writer exits or the descriptor is closed. A throw or a reply that isn't an object becomes an `EIO` error reply, with the first line of the message; no hook is `ENOTCONN`. On the page, `createKernel` relays the request from the kernel worker and back, and the abort too.
602
+
592
603
  ### `/proc`
593
604
 
594
605
  Every process of the kernel has `/proc/<pid>/` with `cmdline`, `comm`, `stat`, `statm` and `status`, in the formats procps reads, and `/proc/self` is a link to the `/proc/<pid>` of the process that reads it, as on Linux (`readlink /proc/self` is its pid, the one it was exec'd under included). They come from the kernel's process table when they are opened, so a terminal sees the processes of every client. A process that exits while a program reads the `/proc` directory doesn't fail or cut short the listing: its pid is still listed, and opening its files fails with `ENOENT`, which `ps`, `pgrep` and `pidof` skip, as on Linux; an exec'd program shows under the pid its parent knows. `/proc/uptime`, `/proc/loadavg`, `/proc/stat` and `/proc/meminfo` are there too, and `/proc/mounts`. A process with no parent in the kernel (one a page, a client or the Node entry started) has parent 1, as children of init do, in `getppid()`, `/proc/<pid>/stat` and `ps` alike, and so does a process whose parent has ended, waited for or not (`getppid()` asks the kernel each time; a shell's `$PPID` keeps the value it started with, as on Linux). Pids, parents, process groups, sessions, command lines, terminals, start and boot times are real, and so are the credentials in `status` (`Uid:`, `Gid:`, `Groups:`) and each process's memory: the size of its wasm memory (`VSZ` and `RSS` alike, as wasm has no paging), which `/proc/meminfo` counts as used. CPU times are measured per process, its threads and the images it exec'd included: time a worker spends waiting for the kernel's answer is system time (at most 20 ms of each call; the rest of a longer one, such as a write into a full pipe, counts as blocked), time it spends blocked (a read, a `poll` or `select` that waits, a sleep, `wait`, a futex, while stopped) is neither (a readiness check that never waits is system time), and the rest is user time; a process's times stop when it ends, a call still in flight then adds nothing, so a loop that never calls the kernel shows as user time while it runs; they show in `/proc/<pid>/stat`, `ps` and `ps()`, and `/proc/stat` counts every process of the kernel, the reaped ones included, and `/proc/loadavg` averages the processes not blocked, sampled every 5 seconds. A JS program's time isn't measured yet. `MemTotal` (from `navigator.deviceMemory`, else 4 GiB) is a placeholder. A process that isn't root sees only its own user's processes (see [Users](#users)). WASI and WASIX programs see the same `/proc`, read-only (opening a file in it for writing, or linking one of its files elsewhere, fails with `EROFS`), from every thread, also through a symlink that points into it (where every change, `mkdir`, `rename`, `unlink`, `chmod`, a hard link from it or into it, and so on, fails too: with `EROFS` when the root file system has a physical `/proc`, which stays hidden and untouched, and otherwise with the `ENOENT` of the missing directory, since that check is only paid for when a physical `/proc` exists; with one, a symlink whose target passes `..` after a file or a missing name, such as `/f/../proc`, fails with `ENOTDIR` or `ENOENT`, as on Linux), and `/` lists it, so a WASI program gets a preopen for it; `/proc/self` is the program's own pid there too (the pid an exec'd program took over). Neither has `/proc/<pid>/fd` yet.
package/dist/index.js CHANGED
@@ -494,6 +494,42 @@ function transferOf(reply) {
494
494
  return [reply.bytes.buffer];
495
495
  }
496
496
 
497
+ // src/sealed-relay.ts
498
+ function sealedAnswerer(port, hook) {
499
+ const asked = /* @__PURE__ */ new Map();
500
+ const ask = async ({ id, req }) => {
501
+ const control = new AbortController();
502
+ asked.set(id, control);
503
+ try {
504
+ if (!hook) throw new Error("no vault is connected");
505
+ const reply = await hook(req, { signal: control.signal });
506
+ port.postMessage({ sealedReply: { id, reply } });
507
+ } catch (err) {
508
+ const error = err instanceof Error ? err.message : String(err);
509
+ port.postMessage({ sealedReply: { id, error } });
510
+ } finally {
511
+ asked.delete(id);
512
+ }
513
+ };
514
+ const abort = ({ id, reason }) => {
515
+ asked.get(id)?.abort(reason);
516
+ asked.delete(id);
517
+ };
518
+ return {
519
+ handle(data) {
520
+ const message = data;
521
+ if (message?.sealedAsk) void ask(message.sealedAsk);
522
+ else if (message?.sealedAbort) abort(message.sealedAbort);
523
+ else return false;
524
+ return true;
525
+ },
526
+ close() {
527
+ for (const control of asked.values()) control.abort("withdrawn");
528
+ asked.clear();
529
+ }
530
+ };
531
+ }
532
+
497
533
  // src/body-idle.ts
498
534
  var BODY_IDLE_MS = 5 * 60 * 1e3;
499
535
  function idleFailure(ms) {
@@ -1804,6 +1840,7 @@ function initCall(options, transport) {
1804
1840
  ...options.hostname !== void 0 ? { hostname: options.hostname } : {},
1805
1841
  ...options.users !== void 0 ? { users: options.users } : {},
1806
1842
  ...options.onDenial ? { denials: true } : {},
1843
+ ...options.sealed ? { sealed: true } : {},
1807
1844
  ...options.approve ? { approve: true } : {},
1808
1845
  ...options.processMounts !== void 0 ? {
1809
1846
  processMounts: typeof options.processMounts === "function" ? "ask" : options.processMounts
@@ -1891,11 +1928,14 @@ function approver(approve, worker) {
1891
1928
  };
1892
1929
  return handlers;
1893
1930
  }
1894
- async function createKernel(options = {}) {
1931
+ function checkOptions(options) {
1895
1932
  if (!globalThis.crossOriginIsolated) throw new Error(ISOLATION);
1896
1933
  if (options.hostname !== void 0 && !isHostname(options.hostname)) {
1897
1934
  throw new Error(`not a host name: ${String(options.hostname)}`);
1898
1935
  }
1936
+ }
1937
+ async function createKernel(options = {}) {
1938
+ checkOptions(options);
1899
1939
  const url = options.worker ?? new URL("./kernel-worker.js", import.meta.url);
1900
1940
  const worker = new Worker(url, { type: "module", name: "slicc-kernel" });
1901
1941
  const pending = /* @__PURE__ */ new Map();
@@ -1903,10 +1943,12 @@ async function createKernel(options = {}) {
1903
1943
  let failure3;
1904
1944
  const dials = dialer((req) => call(req));
1905
1945
  const cdp = cdpBridge(options.cdp);
1946
+ const sealed = sealedAnswerer(worker, options.sealed);
1906
1947
  const approvals = approver(options.approve, worker);
1907
1948
  const fail = (error) => {
1908
1949
  dials.reset();
1909
1950
  cdp.close();
1951
+ sealed.close();
1910
1952
  approvals.close();
1911
1953
  failure3 = error;
1912
1954
  bridge?.close();
@@ -1942,8 +1984,13 @@ async function createKernel(options = {}) {
1942
1984
  source,
1943
1985
  insert: async () => void await call({ op: "insert", target, source, handle: await chosen(handle) })
1944
1986
  });
1987
+ const aside = (data) => {
1988
+ if (!("denial" in data)) return sealed.handle(data);
1989
+ options.onDenial?.(data.denial);
1990
+ return true;
1991
+ };
1945
1992
  worker.addEventListener("message", ({ data }) => {
1946
- if ("denial" in data) return void options.onDenial?.(data.denial);
1993
+ if (aside(data)) return;
1947
1994
  if (approvals.handle(data)) return;
1948
1995
  if ("net" in data) return bridge?.answer(data);
1949
1996
  if ("uplink" in data) return uplinks?.answer(data);