@ai-ecoverse/slicc-kernel 1.174.3 → 1.175.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -2
- package/dist/index.js.map +2 -2
- package/dist/kernel-worker.js +223 -14
- package/dist/kernel-worker.js.map +4 -4
- package/dist/node-process-worker.cjs +1 -1
- package/dist/node-process-worker.js +1 -1
- package/dist/node.js +223 -14
- package/dist/node.js.map +4 -4
- package/dist/process-worker.js +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/kernel/process.d.ts +1 -0
- package/dist/types/kernel/sealed.d.ts +21 -2
- package/dist/types/launcher.d.ts +4 -0
- package/dist/types/node.d.ts +1 -1
- package/dist/types/sealed-command.d.ts +4 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -234,6 +234,8 @@ A WASI preview1 or WASIX program (Zig, Rust, Go, wasi-libc or wasix-libc C) has
|
|
|
234
234
|
{ "slicc": { "abi": "wasi", "commands": { "rg": { "wasm": "bin/rg.wasm" }, "rustc": { "script": "bin/rustc" } } } }
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
+
**Kernel commands:** `sudo`, `visudo`, `crontab` and `sealed` are the kernel's own, in `/usr/bin` for every user (see [Sudo](#sudo), [Cron](#cron) and [`/dev/sealed`](#devsealed)). A package's command of the same name, in any root, never wins over them.
|
|
238
|
+
|
|
237
239
|
A command's name is any single path component without whitespace or control characters, so coreutils' `[` is in `/usr/bin` too; a name that is empty, `.` or `..`, or has a `/`, a space, a tab, a newline, NUL or another control character is skipped. Entries may also set `argv0`, `args` and `env`, and a package can set `slicc.env` for all of its commands. A WASI entry with `"preopenRoot": true` also gets `/` as a preopen (see [WASI programs](#wasi-programs)). An entry with `"argv0Path": true` gets the absolute path it was run by as `argv[0]` when that path is a link outside `/bin` and `/usr/bin` to the command, as a venv's `bin/python` is (the `#!` line of a script naming such a link included), so it can find what sits beside that link; other entries keep their `argv0`. In `env`, `${package}` is the package directory, `${cwd}` the process's working directory, and `${NAME}` the caller's `NAME` when the program starts (a default naming an unset variable is left out); a relative path that exists in the package (`lib/python3.14`, `./`) becomes that absolute path. The caller's environment wins over these defaults; a `null` value removes the variable for the command, after the caller's environment is applied (a host-networked program can drop the kernel's proxy settings that way), and a command entry can set a variable its package's `slicc.env` removes. The catalog reads its roots in order, and the first root that provides a name wins it: a user's own global packages (for that user's processes), `/node_modules`, root's global packages, then each appended root. Within a root, packages are read by name and the first wins (compared as strings by UTF-16 code unit, so a scoped `@scope/name` comes before names that start with a letter, but after those that start with a digit), whatever order the file system lists them in; a `PNPM_HOME`'s install groups count as one root, read group by group (by the group directory's name, effectively arbitrary for pnpm's hashed names), each by package name. A variant can take over from the package it extends with `"slicc": { "replaces": ["@ai-ecoverse/wasm-imagemagick"] }`: when it and a package it names are in the same root, it wins every command name they share, whatever the order; names only the other provides stay the other's, a third package that provides the name and sorts first still wins it, and packages that replace each other fall back to the name order. A replace never reaches into an earlier root, so root's global packages can't take a name from `/node_modules` or a user's own packages. The field must be an array of package names; anything else is ignored. `sh` runs bash as `sh` unless a package provides its own. A package's `engines.slicc-kernel` is a floor the kernel holds it to: when the running kernel's version doesn't satisfy that npm range (comparators, `^`, `~`, x-ranges, hyphen ranges, `||`), or the range can't be read, its commands are refused. They stay in `/usr/bin`, and running one by name, by path or as a `#!` interpreter exits 126 with `<cmd>: <pkg>@<version> needs slicc-kernel <range>; this kernel is <version>` on stderr. Each refused package is logged once to the console. A non-string `engines.slicc-kernel`, or none, is no constraint. A development build reports the newest release tag as `<tag>+dev` and checks against that; a build of unknown version (`0.0.0`) checks nothing. The refusal follows the program, not the name: its glue or `.wasm` run by path (directly or through a symlink), and a byte-identical copy of its glue beside it named for one of its commands (#390, #899), exit 126 with the same message ([#1235](https://github.com/ai-ecoverse/slicc-kernel/issues/1235)). Only commands are refused: a package without commands (a `py-*` library, a side module) isn't, and a refused package that replaces base commands makes those commands fail with 126 rather than fall back to the package it replaces. The installed commands appear as executables in a virtual `/usr/bin` and `/bin`, so `PATH` lookups, `command -v` and `ls /usr/bin` work without writing anything to OPFS. An entry reads as its program, as a PATH search that opens what it finds expects (`ruby -S rake`, `perl -S`, Pod::Usage looking itself up, bundler checking that `/usr/bin/ruby` isn't empty). A script command reads as its script, a JS command as its module, and a WASI or Emscripten command as its `.wasm`, so `cat /usr/bin/hello` prints the script and `head -c4 /usr/bin/cat` gives `\0asm`. The entry is a plain file and no link: it stays a root-owned `0755` regular file with its own inode, `readlink` of it is `EINVAL`, and `which` and `command -v` print `/usr/bin/<name>`. No file stands behind it, so unlinking it is `ENOENT`, open or not, for a user's own entry in `~/.local/share/pnpm/bin` too: `rm -f` does nothing, `pnpm remove -g` counts it as gone, and the entry leaves with its package. Its size and times are the program's when the caller may read the program (anyone, for a kernel-internal look or with users off, only when everyone may), and 0 and the epoch otherwise. Running it still runs the catalog command, with its `env`, `args` and `argv0`. The read is the caller's: with users on, reading an entry also needs `r` on its program, and that includes a user's own commands in `~/.local/share/pnpm/bin`, so an entry whose program the caller can't read is `EACCES`. A process can also run a program by the path of its glue when the `.wasm` sits next to it, a wasm module by its own path (as a WASI program; when it is the `wasm` of a WASI command in the caller's catalog, the user's own packages included, it runs as that command, with its `env` defaults, `args` and `argv0`, the command whose name matches the file's, without `.wasm`, if several share it, while a copy elsewhere runs bare), and a script through its `#!` line (including `#!/usr/bin/env name`). Executing any other file fails with `ENOEXEC`, so bash runs it as a shell script, as POSIX shells do. A spawn by a bare name (no `/`) that the catalog doesn't have, and that didn't ask for no search (a WASIX `posix_spawn` or `execve`, as opposed to its `posix_spawnp`/`execvp`, keeps its bare name), searches the `PATH` of the spawn's own environment, in order, with an empty entry meaning the working directory; each candidate is run exactly as a spawn of that path would be. The first that runs wins; if one was found but refused and nothing else matched, the spawn fails with `EACCES` (126), otherwise `ENOENT` (127). A catalog command still wins over the same name on `PATH`, the reverse of a shell's order. A searched bare name is never looked up in the working directory itself: a file of that name there runs only through an empty `PATH` entry, as with `execvp` ([#1234](https://github.com/ai-ecoverse/slicc-kernel/issues/1234)). The `PATH` searched is the new process's, from the spawn's environment; POSIX `posix_spawnp` uses the caller's, which is the same unless the caller hands the child another one. This is `posix_spawnp`/`execvp` semantics for every caller that asks for a search: JS programs' `ctx.spawn`, WASI programs' spawn import, WASIX `posix_spawnp`, and Emscripten programs built on homescoop's shim before its own `PATH` search (gmake, tar, git and others hand the kernel the bare name). wasm-coreutils from 9.12.0-11 searches `PATH` itself and hands the kernel a path ([#1165](https://github.com/ai-ecoverse/slicc-kernel/issues/1165), [#1168](https://github.com/ai-ecoverse/slicc-kernel/issues/1168)). A `..` in a path that is run is resolved physically, as the file system resolves it: `pkg/../tools/x` after a symlinked `pkg` is the `tools/x` beside the link's target, and its exec permission is checked there. The walk is the caller's: with users on, each directory it passes needs search (`x`) permission (`EACCES`, 126 in bash, as on Linux), and a sudo process follows only the links it trusts, as for any other path. Commands also come from pnpm's global directory. Every process gets a `PNPM_HOME` and has `$PNPM_HOME/bin` on `PATH`: root's is `/usr/local/share/pnpm` (unless the embedder sets its own), a user's `~/.local/share/pnpm` (see [Users](#users)). The kernel scans each of pnpm's global install groups (`$PNPM_HOME/global/*/<group>/node_modules`) after `/node_modules`, which wins when both provide a command. The catalog is re-read whenever anything under either changes, so `pnpm add -g @ai-ecoverse/wasi-ripgrep` makes `rg` work in the same shell, and `pnpm remove -g` removes it again.
|
|
238
240
|
|
|
239
241
|
**More roots:** `createKernel({ commands: { append: ['/opt/user/node_modules'] } })` (and `createNodeKernel`) scans more directories, each absolute and normalised (anything else throws a `TypeError`). A name is looked up in a user's own global packages first, then in `modules`, then in root's global packages, then in the appended roots in the order given, and the first match wins, so an appended root never shadows another command (or `sudo`). Commands of a package that a global install only brought along as a dependency come after all of these: see [A global package's dependencies](#commands). Their commands are in `/usr/bin` for every user, like root's global packages, and their `binfmt` and file system entries count too. A package in an appended root also gets a command for each `bin` entry of its `package.json` (a string `bin` is named after the package, without its scope), which runs as a `#!` script; a name its `slicc.commands` gives wins. A `bin` entry is skipped when its name isn't a command name (see above), or its file, with symlinks resolved, isn't a regular file inside the package. `/node_modules` and the global packages keep reading only `slicc.commands`. A missing root counts as empty until it appears, and an install or removal in a root is picked up as it happens. With `users: 'enforce'` a root counts only when it and every directory above it are root's and not writable by group or others; another root is skipped without a word. Under `'report'` such a root is still scanned, and reported once to `onDenial` and the report log as `{ pid: 0, uid: 0, op: 'commands', path, errno: 'EACCES' }`. Running a `bin` command needs `r` and `x` on its file under `users` (see [Permissions](#permissions)), checked when it runs, so a `0644` file is `EACCES` under `'enforce'`.
|
|
@@ -1125,10 +1127,21 @@ The kernel runs scheduled jobs itself, with no daemon process: a cron service st
|
|
|
1125
1127
|
|
|
1126
1128
|
A character device (`0666`) through which a program talks to the embedder's `sealed` hook (`createKernel({ sealed })`), the secrets vault on the page. Emscripten programs, WASI programs (`std::fs`) and JS programs (`ctx.open`, `ctx.sync.open`, listed by `ctx.fs.readdir('/dev')`) all open it, through the same kernel device, with the same framing, errnos and stamp: a JS program's stamp is built in the kernel from its own process, as for any other. The kernel holds the mode a descriptor was opened with, in every runtime, for its `dup`s and for the copies children inherit through fork, spawn and exec: one opened read-only refuses writes with `EBADF`, and one opened write-only refuses reads. Emscripten programs see it in `/dev` and open it by any path that leads to it (`./sealed` from `/dev`, `..`, a symlink to it or to `/dev`), and `fstat` and `/proc/self/fd` show it as `/dev/sealed`, a character device, after fork and exec too; WASI and JS programs open it by a path with no symlink on the way, and WASI programs by one without `..`.
|
|
1127
1129
|
- **State per open:** each `open()` is independent; `dup` and fork share one, as on Linux.
|
|
1128
|
-
- **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.
|
|
1130
|
+
- **One write is one request:** a single UTF-8 JSON object of at most 64 KiB with `v` 1 and a known `op` (`ask`, `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.
|
|
1129
1131
|
- **The stamp:** the hook gets the request with `caller: { pid, ppid, uid, gid, user?, groups, users, tag? }`, from the process that writes (at the write; not the one that opened it): `uid` and `gid` the effective ids, `user` the `/etc/passwd` name of `uid` read at that moment (left out when there is none), `groups` the process's supplementary groups (only root can change them), `users` the kernel's `users` mode, since a uid means more under `'enforce'`, and `tag` the process's tag (see `kernel.run`) when it has one. A `caller` in the request is replaced.
|
|
1130
1132
|
- **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. The kernel zeroes its copy of a reply once it has been read to the end, dropped or closed.
|
|
1131
|
-
- **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.
|
|
1133
|
+
- **The hook:** `signal` aborts with `'timeout'` after 10 s (the reply is then `{ v: 1, error: { code: 'ETIMEDOUT' } }`; an `ask` has 305 s, below), `'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, with no time limit of its own, so an `ask` held open for minutes comes back as it was answered.
|
|
1134
|
+
|
|
1135
|
+
**Asking the user (`ask`).** A program asks the user for a secret with `{ v: 1, op: 'ask', name, prompt?, reveal?, replace? }`, and the vault answers it from a dialog. `name` matches `^[A-Za-z_][A-Za-z0-9_]*$` and is at most 128 characters, `prompt` is a string of at most 200 characters (code points), shown to the user as the program's text, and `reveal` and `replace` are booleans; any other field, or a field of another shape, is `EINVAL` at the write (a `caller` or `requester` is replaced, as below).
|
|
1136
|
+
- **What the hook gets:** `{ v: 1, op: 'ask', name, prompt?, reveal, replace, caller, requester }`, `reveal` and `replace` always there (`false` unless the program set them), `caller` the stamp above, and `requester: { exe, argv, cwd, tty, stdin }`, the kernel's facts about the writing process when the hook is asked: `exe` is `/usr/bin/<name>` for a command of the catalog, a kernel command such as `sealed` included, and otherwise the file `/proc/<pid>/exe` names, with its links resolved; `argv` the process's arguments, as `/proc/<pid>/cmdline` shows them; `cwd` its working directory, with its links resolved; `tty` the terminal it started on (`/dev/ttyN` or `/dev/pts/N`), or `null`; and `stdin` what its standard input is, as for [`sudo`](#sudo): `tty`, `none` (closed or `/dev/null`), `pipe` or `file`. A program can't supply or change any of it. A process the kernel has no record of (none of its own, so only under a custom embedding) gets an `EIO` reply and the hook isn't asked.
|
|
1137
|
+
- **How long:** the hook's `signal` aborts with `'expired'` after 305 s, and the reader then gets `{ v: 1, state: 'expired' }`; the vault's own dialog ends at 300 s. `'replaced'` and `'withdrawn'` are as for every op, so a program that exits, is killed or closes the descriptor takes its request back. A read waits the whole time; a signal ends it with `EINTR` and the request stays outstanding.
|
|
1138
|
+
- **The reply** is the hook's, as for every op: `{ v: 1, state: 'done', placeholder, record, value? }`, with `value` only when `reveal` was asked and the user allowed it, `{ v: 1, state: 'cancelled' | 'expired' }`, or `{ v: 1, error: { code, message } }` (`EAGAIN`, `EACCES`, `ETIMEDOUT`, `ENOTCONN`, `EIO`). The kernel doesn't scrub it (S2, below, scrubs only what a redeemed exec ticket swapped in) and zeroes its copy once it has been read, dropped or closed. A value a program reveals is the program's, as anything it reads is: what reaches the agent passes through the vault's `redact`, and the vault keeps the value sealed.
|
|
1139
|
+
|
|
1140
|
+
**The `sealed` command.** `/usr/bin/sealed` is a command of the kernel itself, as `crontab` is: a package's `sealed` never wins over it. It talks to `/dev/sealed` for scripts and the shell:
|
|
1141
|
+
- `sealed ask NAME [--prompt TEXT] [--reveal] [--replace]` sends an `ask` and prints the placeholder, or with `--reveal` the value. `--replace` asks for a new value even when the vault has one (the vault then rotates it and keeps its placeholder). `--prompt=TEXT` works too;
|
|
1142
|
+
- `sealed placeholder NAME` prints the placeholder of a secret, and `sealed list` lists the vault's secrets, one `NAME<TAB>PLACEHOLDER<TAB>STATE` line each (`--json`: the vault's `secrets` array as one JSON line). Neither asks the user;
|
|
1143
|
+
- the placeholder or value ends in a newline only when standard output is a terminal, so `T=$(sealed ask T --reveal)` and `sealed ask T > f` hold the value exactly;
|
|
1144
|
+
- it exits with 0 when done, 1 when the user cancelled or denied (and for an answer it can't use, `ENOTCONN`, `EIO` or an unknown name), 2 for a usage error or `EINVAL`, 3 for `EACCES`, 4 for `EAGAIN`, 124 when the request expired (`expired` or `ETIMEDOUT`), and `128 + n` when signal `n` ended it, which takes its request back. Every message goes to standard error and starts with `sealed: `.
|
|
1132
1145
|
|
|
1133
1146
|
**Exec tickets.** A `materialize` request needs `argv` (an array of strings), `path` and `cwd` (strings), and `names` (an array of strings) or `shellGrants: true`; anything else is `EINVAL` at the write. When the hook's reply carries a `ticket` (32 lowercase hex digits, no `error`), the kernel books it for the process that wrote the request: the pid, the `path`, the sha256 of the JSON of `argv`, and the `cwd`, for 30 s, at most 256 at a time. A spawn or exec whose environment has `SLICC_SEALED_TICKET` (from WASIX `proc_spawn`/`proc_exec`, an Emscripten `exec`, a JS program's `spawn` or `sync.run`, or `sudo`) redeems it, and the kernel takes the variable out of the child's environment either way. It also takes it out of what `run`, `spawn` and `openTerminal` are given. The ticket is used up by any attempt, and the spawn fails with `EACCES` (status 126 in bash, with no message beyond the errno) unless all of these hold:
|
|
1134
1147
|
- the ticket is booked and under 30 s old;
|