@ai-ecoverse/slicc-kernel 1.93.3 → 1.94.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 CHANGED
@@ -207,7 +207,7 @@ By default `nodeTransport()` refuses destinations on the host itself, because No
207
207
  - **How it checks:** it matches on the addresses the name resolves to, so a public name that resolves to `127.0.0.1` is refused too. It checks every request and WebSocket open, `host.slicc.internal` and each redirect hop included.
208
208
  - **What a refusal looks like:** a `403` whose body names the option, and `EACCES` for a WebSocket, before any connection is made.
209
209
  - **The opt-in:** `nodeTransport({ hostLoopback: true })` allows the host's loopback except ports 9222–9229. `nodeTransport({ hostLoopback: [8080, 9222] })` allows exactly the listed ports, so DevTools ports are reachable only when named. Link-local stays refused either way.
210
- - **Connect-time enforcement:** the kernel also checks the address each connection actually reaches, before anything is sent, so a name whose DNS answer changes after the check is still refused. A keep-alive connection opened outside this kernel's policy (by the host application, or by another kernel in the same process whose `hostLoopback` allows it) to the same origin could be reused without a new check. When a proxy is in use (`HTTP_PROXY`/`HTTPS_PROXY` with `--use-env-proxy`), the connected socket is the proxy's, so only the pre-check applies; a proxy that resolves names itself closes the rebinding window on its side.
210
+ - **Connect-time enforcement:** the kernel also checks the address each connection actually reaches, before anything is sent, so a name whose DNS answer changes after the check is still refused. A keep-alive connection opened outside this kernel's policy (by the host application, or by another kernel in the same process whose `hostLoopback` allows it) to the same origin could be reused without a new check. When a proxy is in use (`HTTP_PROXY`/`HTTPS_PROXY` with `--use-env-proxy` or `NODE_USE_ENV_PROXY=1`), a connection opened to the proxy's own host and port is allowed even on the host's loopback, and so is any request later tunnelled over that same live TCP connection (matched by its local and remote address and port, and forgotten when it closes), so only the pre-check applies to the target; a direct connection, a `NO_PROXY` host's included, is checked as usual, and a proxy that resolves names itself closes the rebinding window on its side.
211
211
 
212
212
  ## Commands
213
213
 
@@ -223,7 +223,7 @@ A WASI preview1 or WASIX program (Zig, Rust, Go, wasi-libc or wasix-libc C) has
223
223
  { "slicc": { "abi": "wasi", "commands": { "rg": { "wasm": "bin/rg.wasm" }, "rustc": { "script": "bin/rustc" } } } }
224
224
  ```
225
225
 
226
- 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. Of two packages that define a name, the one whose name sorts 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. `sh` runs bash as `sh` unless a package provides its own. 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. 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), 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. 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.
226
+ 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. 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. 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), 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. 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.
227
227
 
228
228
  **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`). 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'`.
229
229
 
@@ -673,7 +673,7 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
673
673
  - **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>) Cwd <dir> Cmnd <path> <args…>` to `/etc/sudoers.d/<user>`, root's `0440`, so it's never asked again for this command, these arguments, in this directory; 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'`.
674
674
  - **What `'always'` covers:** the same command with the same arguments in the same directory, so a relative argument (`tee config.toml`) can't be pointed elsewhere by running it from another directory. It doesn't make a command that writes, or an interpreter (`bash -c`, `tee`, `cp`), safe to allow: what such a command touches depends on the files it is given, which the caller may control (a symlink, a file it owns). Keep `'always'` to commands that are safe in any state of the files they're given. Lines that kernels before `Cwd` recorded have no `Cwd`, so they match in any directory; to drop them, rewrite `/etc/sudoers.d/<user>` as root.
675
675
  - **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, in a **process group of its own** in the caller's session, as sudo(8) runs it without a pty. When `sudo` has the terminal's foreground, it hands the terminal to the command's group, so `^C`, `^\` and `^Z` reach the command, and takes it back when the command stops or ends. `sudo` exits with the command's status, and passes `SIGTERM`, `SIGHUP`, `SIGINT`, `SIGQUIT`, `SIGUSR1`, `SIGUSR2` and `SIGALRM` on to the command's whole group, so its children get them too; it installs these relays before it asks, so one that comes while the command starts is passed on once it runs (the kernel lets `sudo` signal the group of the command it started, which its own user may not). When the command stops (`^Z`), `sudo` stops by the same signal, so the shell's `jobs`, `fg` and `bg` work on it; when it is continued, it hands the terminal back to the command if it is in the foreground (`fg`) and continues the command's group. A `sudo` killed with `-9` leaves the command running in its group, as sudo(8) does, and the shell takes the terminal back as for any job;
676
- - **Symlinks:** a command `sudo` started, and every process it starts in turn, follows a symlink only when root or the target user owns it: Linux's `protected_symlinks`, applied in every directory rather than only in sticky world-writable ones. Any other link is `EACCES`, and the denial names the link. That holds in every runtime: in the kernel's own path walk (JS programs' `ctx.fs` and `ctx.open`, WASI paths, `exec` and `cd` by path), in an Emscripten program's lookups (where the denial's op is `follow`), and where a WASI program resolves `..` itself. So a link the caller put where a rule's file or directory is expected (`/tmp/k4l` → `/etc/fnox`, or `config.toml` → `/etc/fnox/config.toml` in the caller's own directory) leads nowhere. `readlink`, `ls -l`, `lstat`, and removing or renaming a link still work, since they don't follow it. One gap remains: the kernel checks the links in its walk ahead of each operation, and the operation then resolves the path again, so a link swapped in between the two, by a process racing the operation, can still be followed ([#542](https://github.com/ai-ecoverse/slicc-kernel/issues/542)).
676
+ - **Symlinks:** a command `sudo` started, and every process it starts in turn, follows a symlink only when root or the target user owns it: Linux's `protected_symlinks`, applied in every directory rather than only in sticky world-writable ones. Any other link is `EACCES`, and the denial names the link. That holds in every runtime: in the kernel's own path walk (JS programs' `ctx.fs` and `ctx.open`, WASI paths, `exec` and `cd` by path), in an Emscripten program's lookups (where the denial's op is `follow`), and where a WASI program resolves `..` itself. So a link the caller put where a rule's file or directory is expected (`/tmp/k4l` → `/etc/fnox`, or `config.toml` → `/etc/fnox/config.toml` in the caller's own directory) leads nowhere. `readlink`, `ls -l`, `lstat`, and removing or renaming a link still work, since they don't follow it.
677
677
  - **Cost:** with `users: 'off'`, and with owners on for programs that don't call `sudo`, nothing is added to any call. The symlink check costs those processes one owner compare per link in the kernel's walk (the owner is already loaded) and one kernel call per link an Emscripten program follows; other processes pay nothing for it.
678
678
 
679
679
  ### `/dev/sealed`
@@ -748,7 +748,7 @@ A character device (`0666`), there in every `users` mode, through which programs
748
748
 
749
749
  ### `/proc`
750
750
 
751
- 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`. `/proc/version` names the kernel, Linux-shaped, with the slicc-kernel version that built it: `Linux version 6.0.0-slicc (slicc-kernel <version>) #1 SMP PREEMPT_DYNAMIC`. `/proc/sys/kernel/osrelease` is `6.0.0-slicc` and `/proc/sys/kernel/ostype` is `Linux`. The version is the one npm publishes, set when the package is built (`0.0.0-dev` when run from the sources). A JS program reads it as `ctx.kernel.version`. 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:`; a process with a tag, see `kernel.run`, also has `Tag:\t<tag>` after `Gid:`, shown to root and to the same uid only, with newlines, tabs and carriage returns written `\n`, `\t` and `\r`) 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 the times of the children a process has waited for (theirs and their own waited-for children's, as POSIX counts them) show as its `cutime` and `cstime` in `/proc/<pid>/stat`; a stopped process is `T` there, in `/proc/<pid>/status` (`T (stopped)`) and in `ps()`; a WASI or WASIX program's `CLOCK_PROCESS_CPUTIME_ID` and `CLOCK_THREAD_CPUTIME_ID` (`clock_time_get`) count only the time its thread runs, its own waits and blocked kernel calls left out (a sleep is not CPU time), per thread for now, so a threaded program's process clock is its calling thread's; 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). JS programs read the same `/proc` through `ctx.fs` and `ctx.open` (see [JS programs](#js-programs)). Neither has `/proc/<pid>/fd` yet.
751
+ 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`. `/proc/version` names the kernel, Linux-shaped, with the slicc-kernel version that built it: `Linux version 6.0.0-slicc (slicc-kernel <version>) #1 SMP PREEMPT_DYNAMIC`. `/proc/sys/kernel/osrelease` is `6.0.0-slicc` and `/proc/sys/kernel/ostype` is `Linux`. The version is the one npm publishes, set when the package is built (`0.0.0-dev` when run from the sources). A JS program reads it as `ctx.kernel.version`. 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:`; a process with a tag, see `kernel.run`, also has `Tag:\t<tag>` after `Gid:`, shown to root and to the same uid only, with newlines, tabs and carriage returns written `\n`, `\t` and `\r`) 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, from the moment the call actually starts to wait (a call that could wait but answers at once, such as a `select` with a zero timeout or a ready descriptor, a `waitpid` with `WNOHANG`, or a futex wait whose value has already changed, 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 the times of the children a process has waited for (theirs and their own waited-for children's, as POSIX counts them) show as its `cutime` and `cstime` in `/proc/<pid>/stat`; a stopped process is `T` there, in `/proc/<pid>/status` (`T (stopped)`) and in `ps()`; a WASI or WASIX program's `CLOCK_PROCESS_CPUTIME_ID` and `CLOCK_THREAD_CPUTIME_ID` (`clock_time_get`) count only the time its thread runs, its own waits and blocked kernel calls left out (a sleep is not CPU time), per thread for now, so a threaded program's process clock is its calling thread's; 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). JS programs read the same `/proc` through `ctx.fs` and `ctx.open` (see [JS programs](#js-programs)). Neither has `/proc/<pid>/fd` yet.
752
752
 
753
753
  The kernel writes `/etc/passwd` and `/etc/group` when it starts (see [Users](#users)). While the root has none of them, or no `/etc/mtab`, reading them gets per-process ones: root is uid 0 and the realm user, uid 1000, is named after `USER`, or `web_user` (Emscripten's default) without one, so it matches `$USER`, with `HOME` as its home, so `id`, `whoami`, `ls -l` and `ps` show names. Nothing is written to OPFS, and a real file there wins.
754
754
 
@@ -1800,6 +1800,7 @@ var OpfsFs = class {
1800
1800
  this.touch(now, parent.path, made2);
1801
1801
  }
1802
1802
  async mkdirExcl(path, owner) {
1803
+ if (normalizePath(path) === "/") throw fsError("EEXIST", path);
1803
1804
  const parent = await this.locate(parentOf3(path), true);
1804
1805
  if (parent.handle?.kind !== "directory") throw fsError("ENOTDIR", path);
1805
1806
  const dir = parent.handle;
@@ -7787,10 +7788,15 @@ async function binCommandsOf(physical, pkg, manifest, taken) {
7787
7788
  }
7788
7789
  return out;
7789
7790
  }
7790
- async function scanCommands(fs, modules, binsFrom = Number.POSITIVE_INFINITY) {
7791
- const found = /* @__PURE__ */ new Map();
7792
- const physical = physicalResolver(fs);
7793
- for (const [index, root] of (typeof modules === "string" ? [modules] : modules).entries()) {
7791
+ var PACKAGE_NAME = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
7792
+ function replacesOf(manifest) {
7793
+ const replaces = manifest.slicc?.replaces;
7794
+ if (!Array.isArray(replaces)) return [];
7795
+ return replaces.every((name2) => typeof name2 === "string" && PACKAGE_NAME.test(name2)) ? replaces : [];
7796
+ }
7797
+ async function tierPackages(fs, physical, roots, bins) {
7798
+ const out = [];
7799
+ for (const root of roots) {
7794
7800
  for (const pkg of await packages(fs, root)) {
7795
7801
  let manifest;
7796
7802
  try {
@@ -7800,11 +7806,47 @@ async function scanCommands(fs, modules, binsFrom = Number.POSITIVE_INFINITY) {
7800
7806
  }
7801
7807
  const own = commandsOf(pkg, manifest);
7802
7808
  const named = new Set(own.map((command) => command.name));
7803
- const bins = index >= binsFrom ? await binCommandsOf(physical, pkg, manifest, named) : [];
7804
- for (const command of [...own, ...bins]) {
7805
- if (!found.has(command.name))
7806
- found.set(command.name, await withPackagePaths(fs, pkg, command));
7807
- }
7809
+ const extra = bins ? await binCommandsOf(physical, pkg, manifest, named) : [];
7810
+ out.push({
7811
+ name: pkg.slice(root.length + 1),
7812
+ pkg,
7813
+ replaces: replacesOf(manifest),
7814
+ commands: [...own, ...extra]
7815
+ });
7816
+ }
7817
+ }
7818
+ return out;
7819
+ }
7820
+ function winners(tier) {
7821
+ const providers = /* @__PURE__ */ new Map();
7822
+ for (const entry of tier) {
7823
+ for (const command of entry.commands) {
7824
+ const list = providers.get(command.name) ?? [];
7825
+ if (!list.includes(entry)) list.push(entry);
7826
+ providers.set(command.name, list);
7827
+ }
7828
+ }
7829
+ const out = /* @__PURE__ */ new Map();
7830
+ for (const [name2, list] of providers) {
7831
+ const kept = list.filter(
7832
+ (entry2) => !list.some((other) => other !== entry2 && other.replaces.includes(entry2.name))
7833
+ );
7834
+ const entry = kept[0] ?? list[0];
7835
+ const command = entry.commands.find((c) => c.name === name2);
7836
+ out.set(name2, { pkg: entry.pkg, command });
7837
+ }
7838
+ return out;
7839
+ }
7840
+ async function scanCommands(fs, modules, binsFrom = Number.POSITIVE_INFINITY) {
7841
+ const found = /* @__PURE__ */ new Map();
7842
+ const physical = physicalResolver(fs);
7843
+ const tiers = (typeof modules === "string" ? [modules] : modules).map(
7844
+ (tier) => typeof tier === "string" ? [tier] : tier
7845
+ );
7846
+ for (const [index, roots] of tiers.entries()) {
7847
+ const tier = await tierPackages(fs, physical, roots, index >= binsFrom);
7848
+ for (const [name2, { pkg, command }] of winners(tier)) {
7849
+ if (!found.has(name2)) found.set(name2, await withPackagePaths(fs, pkg, command));
7808
7850
  }
7809
7851
  }
7810
7852
  if (!found.has("sh") && found.has("bash")) {
@@ -11178,14 +11220,6 @@ function attachSyncSabResponder(port, sab, token, opts) {
11178
11220
  }
11179
11221
 
11180
11222
  // src/kernel/cpu-clock.ts
11181
- var IDLE_OPS = /* @__PURE__ */ new Set([
11182
- "fd-select",
11183
- "proc-wait",
11184
- "proc-exec",
11185
- "sig-pause",
11186
- "sock-accept",
11187
- "sock-connect"
11188
- ]);
11189
11223
  var SYS_CAP_US = 2e4;
11190
11224
  var LOCAL = SAB_CLOCK_OFFSET;
11191
11225
  var BLOCKED = 0;
@@ -11217,9 +11251,16 @@ var CpuClock = class {
11217
11251
  this.now = now;
11218
11252
  this.start = now();
11219
11253
  }
11220
- enter(op) {
11254
+ enter() {
11221
11255
  this.since = this.now();
11222
- this.kind = this.stopped || IDLE_OPS.has(op) ? "idle" : "sys";
11256
+ this.kind = this.stopped ? "idle" : "sys";
11257
+ }
11258
+ block() {
11259
+ if (this.since === 0 || this.kind === "idle" || this.frozen !== void 0) return;
11260
+ const now = this.now();
11261
+ this.add(now - this.since);
11262
+ this.since = now;
11263
+ this.kind = "idle";
11223
11264
  }
11224
11265
  leave() {
11225
11266
  if (this.since === 0) return;
@@ -11309,9 +11350,10 @@ var ProcessClocks = class {
11309
11350
  }
11310
11351
  timed(clock, dispatch2) {
11311
11352
  const c = clock ?? this.main;
11353
+ const block = () => c.block();
11312
11354
  return (req) => {
11313
- c.enter(req.op);
11314
- return dispatch2(req).finally(() => c.leave());
11355
+ c.enter();
11356
+ return dispatch2(req, block).finally(() => c.leave());
11315
11357
  };
11316
11358
  }
11317
11359
  all() {
@@ -11570,7 +11612,7 @@ function mountTable(mounts = []) {
11570
11612
  }
11571
11613
 
11572
11614
  // src/version.ts
11573
- var KERNEL_VERSION = true ? "1.93.3" : "0.0.0-dev";
11615
+ var KERNEL_VERSION = true ? "1.94.1" : "0.0.0-dev";
11574
11616
  var OS_RELEASE = "6.0.0-slicc";
11575
11617
  function procVersion() {
11576
11618
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -14498,7 +14540,8 @@ var WasmProcess = class {
14498
14540
  this.wake?.();
14499
14541
  for (const listener of this.stateListeners) listener("continued", SIG.CONT);
14500
14542
  }
14501
- async syscall(req, cancelled) {
14543
+ blocks = /* @__PURE__ */ new WeakMap();
14544
+ async syscall(req, cancelled, block) {
14502
14545
  this.options.onSyscall?.(req);
14503
14546
  for (; ; ) {
14504
14547
  await this.resumed;
@@ -14508,6 +14551,7 @@ var WasmProcess = class {
14508
14551
  const forward = () => scope.abort();
14509
14552
  if (cancelled?.aborted) scope.abort();
14510
14553
  cancelled?.addEventListener("abort", forward, { once: true });
14554
+ if (block) this.blocks.set(scope.signal, block);
14511
14555
  const result = await this.dispatch(req, scope.signal).finally(() => {
14512
14556
  cancelled?.removeEventListener("abort", forward);
14513
14557
  this.waiting.delete(scope.signal);
@@ -14590,6 +14634,7 @@ var WasmProcess = class {
14590
14634
  }
14591
14635
  if (this.options.hasPending?.() || cancelled.aborted) throw new KernelError("EINTR");
14592
14636
  if (owing) this.waiting.add(cancelled);
14637
+ if (owing) this.blocks.get(cancelled)?.();
14593
14638
  return this.cancelSignal(cancelled);
14594
14639
  }
14595
14640
  cancelSignal(cancelled) {
@@ -15231,6 +15276,7 @@ var WasmProcess = class {
15231
15276
  case "proc-identity":
15232
15277
  return { ok: true, kind: "json", json: await this.options.identity?.() ?? null };
15233
15278
  case "proc-exec": {
15279
+ this.blocks.get(cancelled)?.();
15234
15280
  this.adoptExec(req.pid);
15235
15281
  this.options.jobs?.exec(this.pid, req.pid);
15236
15282
  await this.fds.closeAll();
@@ -15561,6 +15607,9 @@ function killThread(thread, caught, sig) {
15561
15607
  function unblocked(header3) {
15562
15608
  return Atomics.load(header3, SAB_I_SIGNALS) & ~Atomics.load(header3, SAB_I_BLOCKED);
15563
15609
  }
15610
+ function threadCall(process, req, cancelled, block) {
15611
+ return isWasmSyscall(req) ? process.syscall(req, cancelled, block) : dispatchSyncFs(req);
15612
+ }
15564
15613
  function threadSignalsOf(sab) {
15565
15614
  return { header: new Int32Array(sab, 0, SAB_HEADER_I32), interrupt: new AbortController() };
15566
15615
  }
@@ -15764,7 +15813,9 @@ function spawnWasmProcess(opts) {
15764
15813
  process.keptHandle = (handle2) => holds.keptFor(handle2)?.hidden;
15765
15814
  const worker = opts.createWorker();
15766
15815
  const dying = new Dying();
15767
- const dispatch2 = (req) => dying.serving(() => isWasmSyscall(req) ? process.syscall(req) : dispatchSyncFs(req));
15816
+ const dispatch2 = (req, block) => dying.serving(
15817
+ () => isWasmSyscall(req) ? process.syscall(req, void 0, block) : dispatchSyncFs(req)
15818
+ );
15768
15819
  const clocks = new ProcessClocks(sab, opts.program.abi !== "js", process);
15769
15820
  const responder = attachSyncSabResponder(worker, sab, token, {
15770
15821
  dispatch: clocks.timed(void 0, dispatch2)
@@ -15834,9 +15885,7 @@ function spawnWasmProcess(opts) {
15834
15885
  const tsignals = threadSignalsOf(tsab);
15835
15886
  if (thread.blocked) storeBlocked(tsignals.header, thread.blocked);
15836
15887
  threadSignals.set(thread.tid, tsignals);
15837
- const tdispatch = (req) => dying.serving(
15838
- () => isWasmSyscall(req) ? process.syscall(req, threadCancel(tsignals)) : dispatchSyncFs(req)
15839
- );
15888
+ const tdispatch = (req, block) => dying.serving(() => threadCall(process, req, threadCancel(tsignals), block));
15840
15889
  const tresponder = attachSyncSabResponder(tw, tsab, token, {
15841
15890
  dispatch: clocks.timed(clocks.thread(thread.tid, tsab), tdispatch)
15842
15891
  });
@@ -19458,8 +19507,8 @@ var Launcher = class {
19458
19507
  let found = this.catalogs.get(scope);
19459
19508
  if (!found) {
19460
19509
  found = this.trustedAppended().then(async (appended) => {
19461
- const roots = await this.roots(scope, appended);
19462
- return scanCommands(this.scanned, roots, roots.length - appended.length);
19510
+ const tiers = await this.tiers(scope, appended);
19511
+ return scanCommands(this.scanned, tiers, tiers.length - appended.length);
19463
19512
  }).then((commands) => commands.set(SUDO_COMMAND.name, SUDO_COMMAND));
19464
19513
  this.catalogs.set(scope, found);
19465
19514
  }
@@ -19487,13 +19536,16 @@ var Launcher = class {
19487
19536
  }
19488
19537
  return found;
19489
19538
  }
19490
- async roots(scope = "", appended) {
19539
+ async tiers(scope = "", appended) {
19491
19540
  const system = [
19492
- this.modulesDir,
19493
- ...await pnpmGlobalRoots(this.base, this.pnpmHome),
19494
- ...appended ?? await this.trustedAppended()
19541
+ [this.modulesDir],
19542
+ await pnpmGlobalRoots(this.base, this.pnpmHome),
19543
+ ...(appended ?? await this.trustedAppended()).map((root) => [root])
19495
19544
  ];
19496
- return scope ? [...await pnpmGlobalRoots(this.base, scope), ...system] : system;
19545
+ return scope ? [await pnpmGlobalRoots(this.base, scope), ...system] : system;
19546
+ }
19547
+ async roots(scope = "") {
19548
+ return (await this.tiers(scope)).flat();
19497
19549
  }
19498
19550
  async rootOnlyPath(root) {
19499
19551
  for (const at of chain(root)) {