@ai-ecoverse/slicc-kernel 1.92.0 → 1.93.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 +15 -11
- package/dist/index.js +40 -7
- package/dist/index.js.map +2 -2
- package/dist/kernel-worker.js +832 -202
- package/dist/kernel-worker.js.map +4 -4
- package/dist/meta.js.map +2 -2
- package/dist/node-driver-worker.js +150 -11
- package/dist/node-driver-worker.js.map +4 -4
- package/dist/node-process-worker.js +832 -213
- package/dist/node-process-worker.js.map +4 -4
- package/dist/node.js +920 -211
- package/dist/node.js.map +4 -4
- package/dist/process-worker.js +1899 -1292
- package/dist/process-worker.js.map +4 -4
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ The user database, `/etc/passwd` and `/etc/group` (see [Users](#users)):
|
|
|
99
99
|
| | |
|
|
100
100
|
| --- | --- |
|
|
101
101
|
| `mode` | the `users` option the kernel was created with |
|
|
102
|
-
| `add({ name, home?, groups?, umask?, homeGroup?, homeMode? })` | adds a user and resolves with `{ name, uid, gid, home, shell, groups, umask? }`. It's idempotent: an existing name resolves with its entry, keeps its ids and home, joins the groups in `groups` it isn't in yet (it never leaves one), remembers `umask`, creates a missing pnpm home or `config.yaml` (an existing pnpm home keeps its mode with `users: 'off'`), and, with owners on, gives the home to the user again. The new user gets the lowest free id from 1000 up, a private group of the same id and name, membership of `users` and of each group in `groups` (such as `sudo`), `home` (default `/home/<name>`, created), its pnpm home `~/.local/share/pnpm` (`0700`, `0750` for a `scoop-` user) and `~/.config/pnpm/config.yaml` with `minimumReleaseAge: 1440` (pnpm skips releases less than a day old), each only if missing (with owners on, the directories it makes on the way, `~/.config` and `~/.local` included, get `0777` less the user's `umask`, the file `0666` less it, and in a home with `homeGroup` they belong to that group and have the setgid bit, as new directories in a setgid directory do), which an `add` of an existing user also does, and `/bin/bash`. `umask` is the umask its processes start with; the kernel keeps it in memory, so pass it again after a reload. With owners on, every `add`, of a new or an existing user, also gives a home under `/home/<dir>` to the user and sets its pnpm home to `0700` (`0750` for a `scoop-` user) again (see [File owners](#file-owners)). `homeGroup` names the group the home belongs to instead of the user's own, without making the user a member (`groups` does that), and its mode is then `2750`. `homeMode` sets the home's mode instead (setgid included, so `0o2750` makes new files in it take the home's group); like the owner, it is set again by every `add` with owners on, on the home itself only. A name must match `[a-z_][a-z0-9_.-]*`; a group that doesn't exist, in `groups` or `homeGroup`, rejects with `ENOENT` |
|
|
102
|
+
| `add({ name, home?, groups?, umask?, homeGroup?, homeMode? })` | adds a user and resolves with `{ name, uid, gid, home, shell, groups, umask? }`. It's idempotent: an existing name resolves with its entry, keeps its ids and home, joins the groups in `groups` it isn't in yet (it never leaves one), remembers `umask`, creates a missing pnpm home or `config.yaml` (an existing pnpm home keeps its mode with `users: 'off'`), and, with owners on, gives the home to the user again. The new user gets the lowest free id from 1000 up, a private group of the same id and name, membership of `users` and of each group in `groups` (such as `sudo`), `home` (default `/home/<name>`, created; stored normalised, so `/srv/../srv/cone/` becomes `/srv/cone`), its pnpm home `~/.local/share/pnpm` (`0700`, `0750` for a `scoop-` user) and `~/.config/pnpm/config.yaml` with `minimumReleaseAge: 1440` (pnpm skips releases less than a day old), each only if missing (with owners on, the directories it makes on the way, `~/.config` and `~/.local` included, get `0777` less the user's `umask`, the file `0666` less it, and in a home with `homeGroup` they belong to that group and have the setgid bit, as new directories in a setgid directory do), which an `add` of an existing user also does, and `/bin/bash`. `umask` is the umask its processes start with; the kernel keeps it in memory, so pass it again after a reload. With owners on, every `add`, of a new or an existing user, also gives a home under `/home/<dir>` to the user and sets its pnpm home to `0700` (`0750` for a `scoop-` user) again (see [File owners](#file-owners)). `homeGroup` names the group the home belongs to instead of the user's own, without making the user a member (`groups` does that), and its mode is then `2750`. `homeMode` sets the home's mode instead (setgid included, so `0o2750` makes new files in it take the home's group); like the owner, it is set again by every `add` with owners on, on the home itself only. A name must match `[a-z_][a-z0-9_.-]*`; a group that doesn't exist, in `groups` or `homeGroup`, rejects with `ENOENT` |
|
|
103
103
|
| `remove(name, { keepHome }?)` | removes the user, its private group and its group memberships, and deletes its home unless `keepHome` is set. A home outside `/home/<dir>` is never deleted. Resolves `false` for an unknown name; system accounts (ids below 1000 or above 60000) reject with `EPERM` |
|
|
104
104
|
| `list()` | every user, as `add` returns them |
|
|
105
105
|
|
|
@@ -205,7 +205,7 @@ By default `nodeTransport()` refuses destinations on the host itself, because No
|
|
|
205
205
|
- **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.
|
|
206
206
|
- **What a refusal looks like:** a `403` whose body names the option, and `EACCES` for a WebSocket, before any connection is made.
|
|
207
207
|
- **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.
|
|
208
|
-
- **
|
|
208
|
+
- **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.
|
|
209
209
|
|
|
210
210
|
## Commands
|
|
211
211
|
|
|
@@ -262,7 +262,7 @@ On top of preview1 a WASI program gets:
|
|
|
262
262
|
- **WASIX** (a module importing `wasix_32v1`): fork and setjmp/longjmp through Asyncify (the module is built with `wasm-opt --asyncify`), exec, `posix_spawn`, `waitpid` (with `WNOHANG`, and `WUNTRACED` for a child that stops; WASIX has no `WCONTINUED`), pipes, `dup2`, signal handlers and interval timers, terminal modes, and dynamic linking: a position-independent main module loads side modules with `dlopen` from `LD_LIBRARY_PATH`, its runtime path, `/lib`, `/usr/lib` and `/usr/local/lib`. Each thread has its own copy of the function table, so the kernel hands out its slots (a side module's range, a `dlsym`'d function, a closure): threads loading or looking up at the same time never get the same slot. As in glibc, one `dlopen` at a time runs in a process, constructors included: a `dlopen` from a library's constructor goes ahead in its own thread, and another thread's `dlopen` waits, then finds the library loaded and gets the same handle. A thread waiting there still ends or stops with its process, but a signal handler it has runs once that `dlopen` returns. The lock is let go when a `dlopen` fails (an exception, a trap or `pthread_exit` out of a constructor included) and when its thread ends; a forked child starts with it free. In such a program, `dlopen(NULL)`, and `dlopen` of the program's own file (also by a path that resolves to it through symlinks), give a handle for the program that is already running (no second copy is loaded); a program that isn't position-independent gets an error from `dlopen(NULL)`, as from any `dlopen`; `dlsym` on it searches the program, then the loaded libraries, and `dlclose` of it does nothing. A function already in the table (the program's own, or a library's) keeps its slot, so `dlsym(handle, "strlen") == strlen`.
|
|
263
263
|
- **Foreign calls** (libffi's WASIX backend, so Python's `ctypes`): `call_dynamic` calls a function through the program's indirect function table (a static module's exported `__indirect_function_table`, or the dynamic linker's), its arguments and results packed in memory one after another (`i32`/`f32` 4 bytes, `i64`/`f64` 8). With `strict` set, the buffers must fit the function exactly (else `EINVAL`, before the function is called); without it, missing arguments are zero (one cut short keeps the bytes given, with zeros above them) and results that do not fit are left out. A trap or exception of the callee goes through to the caller. JS cannot ask a function for its type, so the kernel reads the types from the module files (the program's and each side module's, once, when a program first calls through `call_dynamic`): a table slot that is empty, past the end, holds a function of no module the kernel knows of, or takes or returns a `v128`, gives `EINVAL`. For closures (callbacks such as `CFUNCTYPE`), a dynamically linked program gets table slots from `closure_allocate`, and `closure_free` gives them back for reuse and empties the slot in every thread; a static program gets `ENOTSUP`. `closure_prepare` puts a trampoline with the requested signature (any of `i32`, `i64`, `f32`, `f64`, `v128`) into such a slot, in every thread: each call stores its arguments below the stack pointer and calls the backing function as `backing(values, results, user_data)`, then returns what it wrote to `results`. The trampolines are small wasm modules made and compiled at run time, once per signature in each thread (an instance per closure), so a page whose Content Security Policy forbids compiling WebAssembly (no `'wasm-unsafe-eval'`) cannot run them; it cannot run the program either. A `fork` or a `longjmp` (both through Asyncify) inside a function reached through `call_dynamic` cannot unwind through it.
|
|
264
264
|
- **Job control:** `^Z`, `SIGTSTP`, `SIGTTIN` and `SIGTTOU` stop a WASIX program at once, unless it has a handler for them. A stopped program that is busy computing is stopped for job control (`jobs` shows it, `fg`, `bg` and `SIGCONT` work), but its worker keeps a core busy until its next system call, since a worker's running code cannot be frozen from outside. Agents should `kill` such a job rather than leave it stopped. An Emscripten program stops at its next kernel call as well, and at its next write, read or sleep that its glue answers without the kernel (a write to its own `/dev/null`, as in `yes > /dev/null`), so a stopped one keeps no core busy unless it computes without any of these calls. A stopped program doesn't move on while stopped: a `sleep` (or a blocked read) that would end meanwhile ends after `SIGCONT`. A signal for such a program is also delivered at those glue-answered calls, so a handler runs in a loop that only writes to `/dev/null`. For the same reason, a signal with a handler or a terminating default (`^C`, `SIGTERM`) reaches a program that is busy computing only at its next system call, so a CPU-bound loop may not end on `^C`; `kill -9` always ends it. A pending signal is delivered at the program's next WASI or WASIX call, including the calls its worker answers without the kernel (`random_get`, `args_get`, …), except the exempt ones: `clock_time_get` (a tight loop of it measured up to 30% slower), `proc_exit`, `proc_exit2`, `thread_exit`, `thread_spawn_v2` and `wasi.thread-spawn` (wasi-threads), the calls that install handlers (`callback_signal`, `slicc.sigaction_set`), and calls made while Asyncify unwinds or rewinds the stack for `fork` or `longjmp`. A signal that arrives while a thread computes is thus delivered at its next call; when that call is a wait (`poll_oneoff` with a timeout or a descriptor, so `nanosleep` and `poll`/`select` too, `sigmask_poll`, or a futex wait), the handler has run during the call, so the wait ends at once: `EINTR` for a libc of -22 or later (an older one sees its clocks fire, as for any interruption), and a futex wait returns woken. A thread blocked in the kernel or waiting on a lock (`pthread_cond_wait`, a futex) is woken by a signal for it (a futex wait checks for signals every 50 ms, so each waiting thread wakes briefly that often), and `pthread_kill` runs the handler on the thread it names. A sleep, or a clock in `poll_oneoff`, never ends before the time asked (it can end later, never earlier). A signal that ends a sleep or a `poll`/`select` with a timeout makes the call fail with `EINTR` in a program whose libc says it is wasix-sysroot 2025.9.30-22 or later (its `slicc.libc` custom section). Older libcs turn that answer into `ENOTSUP` from `nanosleep` (up to -18), so for a program without the section the wait ends as if it had timed out, unless it imports `fd_fdflags_set` (then `EINTR`, as before).
|
|
265
|
-
- **Blocked signals** (`sigprocmask`, `pthread_sigmask`, `sigpending`): a program whose libc imports `slicc.sigmask_set(how, set_lo, set_hi, old_ptr)` and `slicc.sigpending_get(ptr)` (wasix-sysroot 2025.9.30-28 and later) has a blocked mask per thread. `how` is musl's `SIG_BLOCK` 0, `SIG_UNBLOCK` 1 or `SIG_SETMASK` 2, the set is the 64-bit `sigset_t` (bit `sig - 1`), and the old mask is written as a u64 at `old_ptr`; any other `how` is `EINVAL`. `SIGKILL` and `SIGSTOP` cannot be blocked. A blocked signal is not delivered, does not interrupt a sleep or a blocked call, and its default action (terminate or stop) waits: it stays pending, `sigpending` shows it (the thread's pending signals and the process's), and it is delivered when it is unblocked, before `sigmask_set` returns. A signal ignored with `SIG_IGN` is discarded even while blocked. A forked child, the program an `exec` starts and a spawned child keep the mask, and so does a new thread; `/proc/<pid>/status` shows the main thread's as `SigBlk`. The mask is kept in a word of the worker's shared header, so changing it costs no kernel call. A program without the import (an older libc) has no mask and gets every signal at once, as before, and one it starts by `exec` with a mask set starts with none. A signal for the whole process goes to the main thread, even if the main thread blocks it and another thread does not. `slicc.sigmask_poll(set_lo, set_hi, in_ptr, out_ptr, nsubscriptions, nevents_ptr)` is `pselect`, `ppoll` and `sigsuspend`: it swaps the thread's mask to the given set for the call and puts the old one back before it returns. It delivers a pending signal the new mask unblocks, without polling, or polls as `poll_oneoff` does, under the new mask, so a signal it unblocks interrupts the wait with no window between the swap and the wait; with no subscriptions it waits until a signal is delivered. An interrupted call returns `EINTR` with no events, after the handler has run.
|
|
265
|
+
- **Blocked signals** (`sigprocmask`, `pthread_sigmask`, `sigpending`): a program whose libc imports `slicc.sigmask_set(how, set_lo, set_hi, old_ptr)` and `slicc.sigpending_get(ptr)` (wasix-sysroot 2025.9.30-28 and later) has a blocked mask per thread. `how` is musl's `SIG_BLOCK` 0, `SIG_UNBLOCK` 1 or `SIG_SETMASK` 2, the set is the 64-bit `sigset_t` (bit `sig - 1`), and the old mask is written as a u64 at `old_ptr`; any other `how` is `EINVAL`. `SIGKILL` and `SIGSTOP` cannot be blocked. A blocked signal is not delivered, does not interrupt a sleep or a blocked call, and its default action (terminate or stop) waits: it stays pending, `sigpending` shows it (the thread's pending signals and the process's), and it is delivered when it is unblocked, before `sigmask_set` returns. A signal ignored with `SIG_IGN` is discarded even while blocked. A forked child (with the mask of the thread that forked), the program an `exec` starts and a spawned child keep the mask, all 64 bits of it, and so does a new thread; `/proc/<pid>/status` shows the main thread's as `SigBlk`. The mask is kept in a word of the worker's shared header, so changing it costs no kernel call. A program without the import (an older libc) has no mask and gets every signal at once, as before, and one it starts by `exec` with a mask set starts with none. A signal for the whole process goes to the main thread, even if the main thread blocks it and another thread does not. A signal sent to one thread (`pthread_kill`) that it blocks waits for that thread, and if it has no handler, the thread's unblock ends the whole process by the signal, whatever the other threads block. `slicc.sigmask_poll(set_lo, set_hi, in_ptr, out_ptr, nsubscriptions, nevents_ptr)` is `pselect`, `ppoll` and `sigsuspend`: it swaps the thread's mask to the given set for the call and puts the old one back before it returns. It delivers a pending signal the new mask unblocks, without polling, or polls as `poll_oneoff` does, under the new mask, so a signal it unblocks interrupts the wait with no window between the swap and the wait; with no subscriptions it waits until a signal is delivered. An interrupted call returns `EINTR` with no events, after the handler has run.
|
|
266
266
|
- **Pipes** have no file type for WASI and WASIX programs' `fstat` (`st_mode` type 0), because WASI has no FIFO file type. A socket type, as kernels 1.47.3 to 1.48.1 gave them, made Ruby treat a `popen` pipe as a socket (`close_write` then never ends the child's input). The kernel's `slicc_fs.fd_mode` answers `S_IFIFO | 0600` (Linux numbering) for a pipe, and wasix-sysroot 2025.9.30-21 and later take the type from it, so programs built on it see a FIFO (`S_ISFIFO`); older libcs keep only the permission bits. Until then Python's asyncio refuses a pipe for a subprocess (`Pipe transport is only for pipes, sockets and character devices`). They are read and written as pipes; socket calls on them fail with `ENOTSOCK`, and `isatty()` is false. Emscripten programs, bash included, see a FIFO. The same holds for any kernel descriptor that is neither seekable nor a terminal or device, such as captured output.
|
|
267
267
|
- **Sockets**: the WASIX socket calls (`sock_open`, `sock_pair` for an `AF_UNIX` stream pair, `bind`, `listen`, `connect`, `accept`, options, local and peer names) are the kernel's loopback sockets, and preview1's `sock_recv` / `sock_send` / `sock_shutdown` work on them and on inherited ones. Writing to a socket whose peer is gone fails with `EPIPE`, through `fd_write` as through `sock_send`, as if `MSG_NOSIGNAL` were set, since a WASI program cannot ignore `SIGPIPE`; a pipe or terminal whose reader is gone still ends the program with status 141. HTTP and HTTPS leave through the realm proxy that `https_proxy` names, as for any other program; `resolve` answers `/etc/hosts` names and literal addresses, and the [uplink](#uplink)'s names.
|
|
268
268
|
- **Diagnostics**: a trap ends the program with 134 and its message on stderr; with `SLICC_WASM_BACKTRACE=1` the wasm frames follow, named from the module's name section, or from a sidecar for a module shipped without one (`<module>.names` beside it, or the same path in a `<package>-names` package). `SLICC_WASI_STATS=1` makes a program print its calls, counted and timed, as it ends.
|
|
@@ -313,7 +313,7 @@ The module's default export (or its `main` export) is called with a context and
|
|
|
313
313
|
- `sync.kill(pid, signal)`, a synchronous `kill`. When the signal reaches the program itself (its own pid, `0` for its process group, or `-pgid` of its own group) and its action is to end the program (no handler, not ignored), the call never returns: the program's writes reach their descriptors and its terminal modes are put back, as for `sync.exit`, then the rest of the group is signalled and the program ends by the signal, so a parent's `wait` and `sync.run` report `signal`, as Node does for `process.kill(process.pid)`. A signal with a handler runs it when the call returns, an ignored one does nothing, and a stop signal stops the program. A signal whose default action dumps core (`SIGQUIT`, `SIGABRT`, `SIGSEGV`, …) ends it the same way; the kernel writes no core and sets no core flag in the wait status. `-1` signals every process the program may signal except itself and pid 1, as on Linux, and returns (`ESRCH` when there was none, `EPERM` when there were some but it may signal none of them); a process that exec'd gets the signal once. Without users, every process runs as root, so `kill -9 -1` from any program ends every other process; with users it reaches only what the sender may signal (its own uid's processes, or all of them as root);
|
|
314
314
|
- `sync.exit(status)`, which ends the process at once, as Node's `process.exit()` does: no `catch`, `finally`, microtask, timer or signal handler runs after it, because the call never returns. First the writes the program has made reach their descriptors, in order (a broken pipe drops the rest for that descriptor), and terminal modes the program set with `tty` are put back. Then the process ends the way any exit does: its descriptors are closed and written back, its parent gets `SIGCHLD`, and `wait` reports `status`. Children keep running. A JS program has no threads of its own (`Worker` is withheld), so nothing of it outlives the call. `exit(status)` (below) unwinds instead, so the program's `catch` and `finally` blocks run;
|
|
315
315
|
- `spawn({ argv, argv0, env, cwd, pgid, session, parentDeathSignal, stdin, stdout, stderr })` starts a command (found as `kernel.run` finds it) as a child process and resolves with `{ pid, stdin, stdout, stderr, wait(), kill(signal) }`. Each stdio is `'inherit'` (the default: the program's own), `'pipe'` (a web stream on a kernel pipe), `'null'`, or one of the program's descriptors. `env` is the child's whole environment (the program's by default), and `cwd` defaults to the program's. The command run is `argv[0]`; `argv0`, when given, is the `argv[0]` the child sees instead, as given (no basename; an empty string too), as with Node's `options.argv0`: `spawn({ argv: ['/bin/sh', '-c', 'echo $0'], argv0: '/bin/sh' })` prints `/bin/sh`, where without it the child sees the command's own name (`sh`). A command whose package manifest sets its `argv0` (a multi-call program's applets, such as coreutils') keeps that one. An `argv0` that isn't a string is `EINVAL`. The child is in the program's process group and session, as after `fork` (Node's `detached: false`), so the terminal's `^C` and `^Z` reach it along with the program. `pgid: 0` makes it lead a new group in the program's session, set before it runs, so a `kill(-pid)` right after the call reaches it and everything it starts (`npx`, then `node`); on the program's terminal that group is in the background, so a read from the terminal stops it with `SIGTTIN` (`EIO` when it ignores `SIGTTIN`), as on Linux. `pgid: N` joins group N, which must be in the program's session: `EPERM` for another session's group, `ESRCH` when there is none. `session: true` makes it lead a new session and a new group (`setsid`, what Node's `detached: true` does on POSIX): its pid is its `pgid` and its `sid`, it has no controlling terminal (`/dev/tty` is `ENXIO`) and the terminal's keys don't signal it. `session` together with `pgid`, or a `pgid` that isn't a non-negative integer, is `EINVAL`. A command that does not exist rejects with `ENOENT`. `parentDeathSignal` (a number or `'SIGTERM'`, as Linux's `PR_SET_PDEATHSIG`) is sent to the child when the program ends, killed or not: to the child's whole group when it leads one (`pgid: 0` or `session: true`), else to the child alone. It's sent with the program's credentials, so a child the program may no longer signal (one that gained privilege) doesn't get it. If the program has already ended by the time the spawn completes, it's sent at once. A child of its own (not a group leader) that has been waited for gets nothing, and `sync.run` has no such option. A bad signal is `EINVAL`;
|
|
316
|
-
- `wait(pid)` resolves with `{ status }` once a child ends, or `{ status: 128 + signal, signal }` when a signal ended it, without blocking the program; `kill(pid, signal)` signals any process, by number or as `'SIGTERM'` (the default);
|
|
316
|
+
- `wait(pid, { untraced })` resolves with `{ status }` once a child ends, or `{ status: 128 + signal, signal }` when a signal ended it, without blocking the program; with `untraced: true` it also resolves when the child stops, with `{ status: 128 + signal, signal, stopped: true }`, as `waitpid` with `WUNTRACED` (a child's `wait({ untraced })` is the same), and options that aren't an object are `EINVAL`; `kill(pid, signal)` signals any process, by number or as `'SIGTERM'` (the default);
|
|
317
317
|
- `sudo(args)`, what the [`sudo`](#sudo) command calls: `args` are its arguments after `sudo`, and it resolves with `{ pid }`, the command the kernel started as the program's child, to `wait` for; `{ run }`, an argv to run as the caller (with `users: 'off'`); `{ list }`, the text of `sudo -l`; or `{ error, errno }`, the message to print;
|
|
318
318
|
- `net`: `listen({ port, host, backlog })` and `connect({ host, port, signal })` (`host` defaults to `127.0.0.1`) on the kernel's loopback, the same sockets WASIX programs use, so a JS server is reached by other processes, by the page as `<port>.kernel.localhost` (`loopbackFetch`, `dial`) and by attached clients. A listener has its `port` (as bound, so port `0` picks a free one), `accept({ signal })`, `close()` and `for await` over its connections. A connection has `local` and `remote` addresses, `readable` and `writable` web streams (closing the writable shuts down the sending side, so the peer reads the end), and `close()`. The loopback is IPv4 (`127.0.0.1` by default, or `0.0.0.0`), so an IPv6 host such as `::1` fails with `EADDRNOTAVAIL` for both `listen` and `connect`, and binding and connecting fail as they would for a WASIX program (`EADDRINUSE`, `ECONNREFUSED`, `EADDRNOTAVAIL`). A blocked accept, read or write waits without holding anything in the kernel, so aborting it or closing the socket leaves nothing behind. A write to a connection the peer closed fails with `EPIPE` and no `SIGPIPE`;
|
|
319
319
|
- `websocket(url, { protocols, headers, signal })`, a WebSocket over the kernel's transport (its `websocket`, so `fetchTransport({ webSocket: true })` in a browser), shaped like `WebSocketStream`. It resolves once the socket is open, with `url`, the `protocol` the far end chose (always one the program offered: a transport that hands back a socket with another fails the open with `EPROTO`, while browsers and Node's `WebSocket`, which `fetchTransport()` uses, fail such a handshake themselves without saying why, so the open fails with `ECONNREFUSED`), a `readable` of messages (`string` for text, `Uint8Array` for binary), a `writable` for sending them, `closed` (the `{ code, reason }` of the close, from either side, whether or not the program reads) and `close({ code, reason })`. An `http(s):` URL is taken as `ws(s):`. Sending waits while more than 1 MiB is buffered. A signal aborts the open, and after it the socket. A transport without WebSockets rejects with a `TypeError` whose `cause.code` is `ENOTSUP`. One that cannot connect rejects with the `code` its error carries (an errno, such as `EAI_NONAME`, `ECONNREFUSED`, `ETIMEDOUT` or `EPROTO`), else `ECONNREFUSED`. `ws://host.slicc.internal:port` reaches the real machine's loopback, as it does through the proxy;
|
|
@@ -325,7 +325,7 @@ The module's default export (or its `main` export) is called with a context and
|
|
|
325
325
|
|
|
326
326
|
A program that throws, or leaves an error or a rejected promise unhandled with no listener for it, ends with status 1 and the message on stderr; one whose module cannot be loaded, or exports no function, ends with 126. A write to a pipe whose reader is gone ends the program with 141, unless it ignores or catches `SIGPIPE` (an ignore inherited from its parent counts), in which case the write fails with `EPIPE`. Errors carry a POSIX `code`. Reads and writes of pipes, terminals and sockets go through the kernel's asynchronous operations, so a program blocked reading stdin can still write and handle signals; other calls of one process are made one at a time. A JS program cannot `fork`, and threads it starts itself are not the kernel's.
|
|
327
327
|
|
|
328
|
-
The module has the trust of an Emscripten program's glue or a WASI program's imports module: it comes from a package that was installed, and it runs in the worker of its own process. The kernel withholds the worker's own ways out before it loads the module: `XMLHttpRequest`, `WebSocketStream`, `WebTransport`, `EventSource`, `importScripts`, `indexedDB`, `caches`, `cookieStore`, `Worker`, `SharedWorker`, `postMessage`, `close`, Chrome's old file system API (`webkitRequestFileSystem`, `webkitRequestFileSystemSync`, `webkitResolveLocalFileSystemURL` and `webkitResolveLocalFileSystemSyncURL`, which reach the origin's OPFS), and `navigator.storage`, `navigator.storageBuckets`, `navigator.locks`, `navigator.serviceWorker`, `navigator.usb`, `navigator.hid` and `navigator.serial` are `undefined`, also through their prototypes, and a worker where one of them cannot be removed refuses to run the program (126). `fetch`, `WebSocket` and `BroadcastChannel` are `undefined` too, but stay writable and configurable, so a program can install its own over `ctx.fetch` and `ctx.websocket`, or a `BroadcastChannel` within the program (as slicc-jsh does). The platform's `BroadcastChannel`, which would reach the origin's other contexts around the kernel, is gone from the scope and its prototypes before the program runs, so deleting the program's own brings back `undefined`, not it; a worker where it can't be removed refuses to run the program (126), as for the others. A program's files are the kernel's, through the context; it reaches the kernel only through its own process's system calls, as the context makes them, and no other process's. This is a guard against programs going around the kernel, not a sandbox: `import()` of a URL is a request the kernel cannot withhold. In a browser, every process worker gets the same lockdown before any program code runs, so an Emscripten program's glue and a WASI program's imports module (any user can write either) see the same globals `undefined`, and a worker where one cannot be removed ends the process with 126. Under the Node entry,
|
|
328
|
+
The module has the trust of an Emscripten program's glue or a WASI program's imports module: it comes from a package that was installed, and it runs in the worker of its own process. The kernel withholds the worker's own ways out before it loads the module: `XMLHttpRequest`, `WebSocketStream`, `WebTransport`, `EventSource`, `importScripts`, `indexedDB`, `caches`, `cookieStore`, `Worker`, `SharedWorker`, `postMessage`, `close`, Chrome's old file system API (`webkitRequestFileSystem`, `webkitRequestFileSystemSync`, `webkitResolveLocalFileSystemURL` and `webkitResolveLocalFileSystemSyncURL`, which reach the origin's OPFS), and `navigator.storage`, `navigator.storageBuckets`, `navigator.locks`, `navigator.serviceWorker`, `navigator.usb`, `navigator.hid` and `navigator.serial` are `undefined`, also through their prototypes, and a worker where one of them cannot be removed refuses to run the program (126). `fetch`, `WebSocket` and `BroadcastChannel` are `undefined` too, but stay writable and configurable, so a program can install its own over `ctx.fetch` and `ctx.websocket`, or a `BroadcastChannel` within the program (as slicc-jsh does). The platform's `BroadcastChannel`, which would reach the origin's other contexts around the kernel, is gone from the scope and its prototypes before the program runs, so deleting the program's own brings back `undefined`, not it; a worker where it can't be removed refuses to run the program (126), as for the others. A program's files are the kernel's, through the context; it reaches the kernel only through its own process's system calls, as the context makes them, and no other process's. This is a guard against programs going around the kernel, not a sandbox: `import()` of a URL is a request the kernel cannot withhold. In a browser, every process worker gets the same lockdown before any program code runs, so an Emscripten program's glue and a WASI program's imports module (any user can write either) see the same globals `undefined`, and a worker where one cannot be removed ends the process with 126. Under the Node entry, every process worker gets the same lockdown before any program code runs: an Emscripten program's glue, a WASI program's imports module and a package driver all see `require`, `process`, `module`, `__dirname` and every Node builtin `undefined`, and `import` of a builtin or a non-`data:` URL fails with `ERR_ACCESS_DENIED`.
|
|
329
329
|
|
|
330
330
|
Under the Node entry, the kernel also withholds the host's Node from the program before its module loads ([#297](https://github.com/ai-ecoverse/slicc-kernel/issues/297)). `require`, `module`, `exports`, `__filename`, `__dirname`, `localStorage` and `sessionStorage` are `undefined` for good. `process`, `global`, `setImmediate` and `clearImmediate` are `undefined` too, but stay writable, so a program can install its own (as slicc-jsh does). `Buffer` stays Node's own, since Node's `Response`, `Request`, `Blob` and stream bodies read it when they run, and a program can replace it. Process workers share Node's memory allocator, so its `allocUnsafe` and `allocUnsafeSlow` zero-fill and its pool is off (`Buffer.poolSize` is 0): a program never sees memory another process freed. A host can also start Node with `--zero-fill-buffers`. Imports of Node's builtin modules (`node:fs`, `fs`, `node:worker_threads`, …) and of anything but a `data:` URL, `file:` included, fail with `ERR_ACCESS_DENIED`, whether static, dynamic or through `import.meta.resolve`. A process worker that exits or crashes on its own ends its process with 70. This is the same guard, not a sandbox: a host that runs code it does not trust, such as an agent's, needs its own isolation around the Node process as well.
|
|
331
331
|
|
|
@@ -500,7 +500,7 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
|
|
|
500
500
|
- **Rules on a mount:**
|
|
501
501
|
- a rename across mounts is `EXDEV`, so `mv` copies;
|
|
502
502
|
- a symlink on the root file system that leads onto a mount, such as `~/proj` → `/mnt/drive/proj`, or a chain from a mount to the root and back, is followed onto the mount by every operation, in every runtime: reads, writes, `stat`, listings, `mkdir`, removals, renames, `chmod`, `chown` and times. Removing the link itself removes the link. Before [#547](https://github.com/ai-ecoverse/slicc-kernel/issues/547) was fixed, the kernel resolved such a link against the directory under the mount point for JS and WASI programs, the embedder's `fs` and attached clients (Emscripten programs weren't affected): reads failed with `ENOENT`, and writes went into that hidden directory, which shows again once the mount is gone;
|
|
503
|
-
- a hard link (`link()`, `linkat()`, Python's `os.link`, `ctx.fs.link`) works within a mount whose driver offers `links`, as `tmpfs` does: both names are one file with one inode, `st_nlink` counts them, and the data goes with the last name and the last open descriptor. Across mounts it's `EXDEV`. It's `EPERM` on the root file system (OPFS or memory), on `fsa` folders, on `hostfs` until slicc-node offers links, and for directories; on the root the names are checked first, as Linux does: a missing source (or, when following, a dangling one) or target directory is `ENOENT`, a target directory that isn't one is `ENOTDIR`, and a taken name is `EEXIST`. `link()` links a symlink itself and `linkat(…, AT_SYMLINK_FOLLOW)` links its target, as on Linux. Descriptors the kernel holds (JS programs' files, and a WASI program's files once it forks or spawns with them) share one cache for all names of a file, so a write through one name is read at once through another, also after either name is removed or replaced while open; `link()` writes the source's pending changes back first. Within one WASI program, its own descriptors of a file's names share one buffer too. Once it starts a subprocess, the files it had open are kernel descriptors, and a later open of one of them, under that name or another (same inode), goes through the kernel and shares their cache; between programs, a WASI program's own descriptors see the other names' writes at open, as for one name across processes. A WASI program that removes or renames over the name it has open, while another link remains, keeps writing to the file through its descriptor, and the other names see those writes; `linkat(…, AT_SYMLINK_FOLLOW)` writes back the program's pending changes to the symlink's target first. `st_nlink` leaves out the names the kernel keeps in `.slicc-unlinked` for open descriptors, so `fstat` on a descriptor whose name was removed counts the names left: 0 once the last one is gone.
|
|
503
|
+
- a hard link (`link()`, `linkat()`, Python's `os.link`, `ctx.fs.link`) works within a mount whose driver offers `links`, as `tmpfs` does: both names are one file with one inode, `st_nlink` counts them, and the data goes with the last name and the last open descriptor. Across mounts it's `EXDEV`. It's `EPERM` on the root file system (OPFS or memory), on `fsa` folders, on `hostfs` until slicc-node offers links, and for directories; on the root the names are checked first, as Linux does: a missing source (or, when following, a dangling one) or target directory is `ENOENT`, a target directory that isn't one is `ENOTDIR`, and a taken name is `EEXIST`. `link()` links a symlink itself and `linkat(…, AT_SYMLINK_FOLLOW)` links its target, as on Linux. Descriptors the kernel holds (JS programs' files, and a WASI program's files once it forks or spawns with them) share one cache for all names of a file, so a write through one name is read at once through another, also after either name is removed or replaced while open; `link()` writes the source's pending changes back first. Within one WASI program, its own descriptors of a file's names share one buffer too. Once it starts a subprocess, the files it had open are kernel descriptors, and a later open of one of them, under that name or another (same inode), goes through the kernel and shares their cache; between programs, a WASI program's own descriptors see the other names' writes at open, as for one name across processes. A WASI program that removes or renames over the name it has open, while another link remains, keeps writing to the file through its descriptor, and the other names see those writes; `linkat(…, AT_SYMLINK_FOLLOW)` writes back the program's pending changes to the symlink's target first. `st_nlink` leaves out the names the kernel keeps in `.slicc-unlinked` for open descriptors, so `fstat` on a descriptor whose name was removed counts the names left: 0 once the last one is gone. A change of mode, owner, times or size through one name, or a new or removed name, also drops the attributes the kernel has cached for the file's other names (same inode, or, when the changed name's inode isn't cached, every file with more than one link), so on a mount with an `attrTtl` they show the change at once. So it works for WASI, WASIX and JS programs, and for Emscripten programs built with a `__syscall_linkat` import (wasm-coreutils and wasm-tar from the next homescoop builds), which get `EMLINK` where the others get `EPERM` because the file system has no hard links (the root, `fsa`, `hostfs`), as Emscripten itself answers. Older Emscripten builds (wasm-coreutils 9.12.0-5's `ln`, wasm-tar 1.35.0-5) answer `EMLINK` from their own C library, which never asks the kernel, but they see links other programs made: their open descriptors of one file's names share one buffer, and one that removes the name it has open while another link remains keeps writing to the file;
|
|
504
504
|
- `df` reports each mount from its driver's `statfs`;
|
|
505
505
|
- each mount has its own device number;
|
|
506
506
|
- without `chmod` support, `chmod` succeeds and changes nothing;
|
|
@@ -670,7 +670,7 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
|
|
|
670
670
|
- **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`).
|
|
671
671
|
- **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'`.
|
|
672
672
|
- **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.
|
|
673
|
-
- **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,
|
|
673
|
+
- **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;
|
|
674
674
|
- **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)).
|
|
675
675
|
- **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.
|
|
676
676
|
|
|
@@ -729,15 +729,19 @@ A character device (`0666`), there in every `users` mode, through which programs
|
|
|
729
729
|
- **Clients:** a client's (`kernel.connect`) file calls, mounts and unmounts see the paths read-only, as its processes do.
|
|
730
730
|
- **Not affected:** the page's own writes to OPFS.
|
|
731
731
|
- **Writers:** `protect: { paths, writers: ['bios-installer'] }` names process tags whose processes may change the paths, such as the embedder's own installs (`kernel.run(['pnpm', 'install'], { tag: 'bios-installer' })`). A tag matches exactly, with no prefix or case folding. A process gets its tag only from the embedder, through `kernel.run`, `kernel.openTerminal` or `kernel.connect({ tag })`, and passes it to its children, forks and `exec`s; a program or a client can't choose one. Writers work on their own view of the root file system, so a file a writer changes is seen by other processes as between two tabs: close-to-open, at their next open after the writer closes it.
|
|
732
|
-
- **Integrity:** `protect: { paths, integrity: { lookup, unknown } }` also checks what a program is made of before it runs. That covers a wasm module, an Emscripten program's glue, a WASI program's imports module, a JS program's module, the `package.json` of each package under a path (the kernel builds its commands, `binfmt` suffixes and drivers only from manifests that pass, so a package whose manifest changed has no commands, 127, and no drivers, `ENODEV`), a script started through its `#!` line or a `binfmt` suffix, a package driver's module and the TLS engine, when the file lies under a path.
|
|
732
|
+
- **Integrity:** `protect: { paths, integrity: { lookup, unknown, sources } }` also checks what a program is made of before it runs. That covers a wasm module, an Emscripten program's glue, a WASI program's imports module, a JS program's module, the `package.json` of each package under a path (the kernel builds its commands, `binfmt` suffixes and drivers only from manifests that pass, so a package whose manifest changed has no commands, 127, and no drivers, `ENODEV`), a script started through its `#!` line or a `binfmt` suffix, a package driver's module and the TLS engine, when the file lies under a path.
|
|
733
733
|
- **The check:** the kernel hashes the exact bytes it is about to compile or hand to a worker (SHA-256, hex), and asks `lookup(path)` (the real path; on the page, it may be async) for the hash it expects. On a mismatch the program doesn't start: `kernel.run` rejects and a spawn fails with `EACCES` ("integrity check failed"; 126 in a shell, not `ENOEXEC`, which would make a shell run the file as a script), a driver fails to mount, TLS fails, and `onDenial` gets `{ op: 'exec-integrity', path, errno: 'EACCES', pid, uid, tag?, expected, actual }`. A `lookup` that throws counts as a mismatch.
|
|
734
734
|
- **No hash known:** when `lookup` has none (`undefined`), `unknown: 'allow'` (the default) runs the file and reports `{ op: 'exec-unverified', errno: 'ENODATA', … }` once per path, while `unknown: 'deny'` refuses it as a mismatch.
|
|
735
735
|
- **The cache:** a result is kept per real path and the file's version: what OPFS itself reports (its `lastModified` and `size`), plus the count of writes kernels made to the file, which the metadata store keeps (Chrome doesn't move `lastModified` for a write through a sync access handle). A kernel with `integrity` set counts a write to a protected file before its bytes change, and again once the write is over (also when it failed), at wherever the file is by then; the count follows a rename, and no `utimes` turns it back. The version also holds the count of moves into protection: a rename or a symlink at or above a protected path counts one before and after it, so a move into a protected path re-verifies every protected program once. So a change made around the kernel, same size included, is checked again at the next start. A kernel without `integrity` counts nothing (so writes into protected paths cost no metadata update), and its writes don't move the version another kernel on the origin reads: give every kernel on one origin the same `integrity`. The cache is the kernel's own, in memory, so nothing verified outlives it. The compiled module is cached by the same key, so a program that hasn't changed costs nothing more to start.
|
|
736
736
|
- **Revoking:** a result is kept until the file changes, so when `lookup`'s answers change (an updated hash list, a package revoked or reinstalled), call `await kernel.integrity.revoke(path?)` (on the page and in Node). It drops what the kernel verified for that path, or for everything under it when it's a directory (its real path and the name a link reached it by both count), or for every path without one, and rebuilds the command catalog from the manifests as checked again. A load that was under way when `revoke` was called keeps nothing. It takes effect at the next start: running processes keep what they loaded. `path` must be absolute; without `integrity` it does nothing.
|
|
737
737
|
- **Where `lookup` gets its hashes:** from a source independent of the installed files, such as the per-file hashes the embedder derives from the certified package tarball when it builds its catalog. Hashes read back from the installed files prove nothing.
|
|
738
738
|
- **Links out of the paths:** a file reached through a protected path but whose real path lies outside every path (a symlink at or below a protected path, or one of its directories, pointing elsewhere, a command of the catalog included) is still checked, under the last protected name on the way: the kernel asks `lookup(name)` for the bytes of the real file, `unknown` applies when it has no hash, and `onDenial` reports the name as `path`. Writes outside the paths aren't counted, so such a result isn't cached: the file is hashed again at every start.
|
|
739
|
-
- **
|
|
740
|
-
|
|
739
|
+
- **Sources a program reads itself** ([#454](https://github.com/ai-ecoverse/slicc-kernel/issues/454)): a runtime that loads code on its own is checked too, when a process reads the file through the kernel. That covers jsh's and slicc-node's modules and the `package.json` files whose `main`/`exports` they follow, python's `.py`, `.pyc`, `.pth` and zip imports, `dlopen`'s side modules, and a script's interpreter reading the script after its exec check (a file swapped in between is refused then).
|
|
740
|
+
- **Which files:** those under a path whose name ends in one of `integrity.sources`, plus every regular file with an execute bit (a `#!` script without a suffix, a sourced script). The default is `['.js', '.mjs', '.cjs', '.json', '.py', '.pyc', '.pth', '.zip', '.so', '.wasm', '.sh']`. An embedder can replace the list, and `sources: []` turns these checks off and leaves only the ones above. Data files aren't checked, since a package can hold files no `lookup` knows (lock files, caches), which `unknown: 'deny'` would make unreadable.
|
|
741
|
+
- **Where:** a whole-file read (a JS program's `ctx.fs.readFile` and `ctx.sync.fs.readFile`, a `.json` included, and the dynamic linker's) gets exactly the bytes that were hashed. An open (WASI and Emscripten programs, so ranged reads too) is checked when the file is opened. The result is cached per real path and version, as for programs, so an unchanged file costs a version check: OPFS's own `lastModified` and size, plus one metadata read.
|
|
742
|
+
- **Refused:** a read or an open fails with `EACCES`, and `onDenial` gets `{ op: 'read-integrity', … }`. A file with no known hash is reported once as `read-unverified`.
|
|
743
|
+
- **Writers** (`protect.writers`) aren't checked, nor their children and the lifecycle scripts they run, which inherit the tag. A writer may change these files anyway, and an install loads modules while it replaces them. Its writes are counted, so the next read or start by any other process verifies them against `lookup` again. A writer misled by a file changed around the kernel can't get unverified code run elsewhere, so writing OPFS around the kernel gains nothing.
|
|
744
|
+
- **Not caught:** a file changed around the kernel between an open and a later ranged read of it is read as it is then. Checking every ranged read would cost a metadata read for each one. Nor is a same-size rewrite by a writer in another kernel on the origin (another tab), read before that tab's change notice arrives: these checks take the write count from the metadata store's cache, which the notice clears, so the window runs from the write to the notice. OPFS's own `lastModified` and size are read every time, so a change made outside every kernel is still caught.
|
|
741
745
|
- **Where it is a boundary:** it's one in a browser, where programs' own code can't reach OPFS around the kernel (see [JS programs](#js-programs)). Under the Node entry it isn't, until [#407](https://github.com/ai-ecoverse/slicc-kernel/issues/407), and there `kernel.writeFile` under a path fails too, so write those files into `root` before creating the kernel.
|
|
742
746
|
|
|
743
747
|
### `/proc`
|
|
@@ -803,7 +807,7 @@ The Biome, TypeScript, lefthook, Renovate and CI configuration comes from [slicc
|
|
|
803
807
|
|
|
804
808
|
`tools/big-file.mjs [bytes]` is a manual check, not part of any suite. It writes a file of that size (64 MiB by default) through a tmpfs mount with the unit-test launcher, reads it back, and prints the peak RSS and its ratio to the file size. A 256 MiB file peaks at about 1.8 times its size. Before ranged I/O (#127), a 1.1 GiB file peaked at 18 GB of RSS. **Do not run it past 1 GiB on a workstation.** It refuses anything over 256 MiB unless `SLICC_BIG_FILE_I_HAVE_THE_RAM=1` is set. Measure a small file and extrapolate instead. The unit suite checks a 32 MiB file the same way, and allows a fixed 192 MiB for garbage V8 has yet to collect, which dominates at that size.
|
|
805
809
|
|
|
806
|
-
Releases are cut by semantic-release on every push to `main`.
|
|
810
|
+
Releases are cut by semantic-release on every push to `main`, in two jobs, and only on `main`. The first job runs on the bench pool, so it starts within a minute of the push, before `main` has moved on. It computes the version, tags the commit and creates the GitHub release, with `npmPublish: false`. A run that starts after `main` has moved skips; the run for the newer commit releases it. The second job runs on a GitHub-hosted runner, because npm's trusted publishing and provenance accept no self-hosted runner. It publishes the tagged commit with `tools/release-recover.mjs`. However long it waits for a runner, it publishes exactly the commit the run started on, so the provenance, the tag and the package's `gitHead` agree. For any version tagged at HEAD, `tools/release-recover.mjs` pushes the tag if origin lacks it, publishes the version if npm lacks it (with retries), and creates its GitHub release if that is missing too. So a failure after the version is tagged (a rejected push, a failed `npm publish`) doesn't strand it, and re-running the Release workflow completes it the same way.
|
|
807
811
|
|
|
808
812
|
## License
|
|
809
813
|
|
package/dist/index.js
CHANGED
|
@@ -386,9 +386,13 @@ function protectInit(protection) {
|
|
|
386
386
|
return {
|
|
387
387
|
paths: [...protection.paths],
|
|
388
388
|
...writers.length > 0 ? { writers } : {},
|
|
389
|
-
...integrity ? { integrity:
|
|
389
|
+
...integrity ? { integrity: integrityInit(integrity) } : {}
|
|
390
390
|
};
|
|
391
391
|
}
|
|
392
|
+
function integrityInit(integrity) {
|
|
393
|
+
const unknown = integrityUnknown(integrity);
|
|
394
|
+
return integrity.sources === void 0 ? { unknown } : { unknown, sources: integritySources(integrity) };
|
|
395
|
+
}
|
|
392
396
|
function writerTags(protection) {
|
|
393
397
|
const writers = protection?.writers ?? [];
|
|
394
398
|
if (!Array.isArray(writers) || writers.some((tag) => typeof tag !== "string" || tag === "")) {
|
|
@@ -398,11 +402,33 @@ function writerTags(protection) {
|
|
|
398
402
|
}
|
|
399
403
|
|
|
400
404
|
// src/fs/integrity.ts
|
|
405
|
+
var SOURCE_SUFFIXES = [
|
|
406
|
+
".js",
|
|
407
|
+
".mjs",
|
|
408
|
+
".cjs",
|
|
409
|
+
".json",
|
|
410
|
+
".py",
|
|
411
|
+
".pyc",
|
|
412
|
+
".pth",
|
|
413
|
+
".zip",
|
|
414
|
+
".so",
|
|
415
|
+
".wasm",
|
|
416
|
+
".sh"
|
|
417
|
+
];
|
|
401
418
|
function integrityOptions(options) {
|
|
402
419
|
if (options && typeof options.lookup !== "function") {
|
|
403
420
|
throw new TypeError("protect.integrity.lookup must be a function");
|
|
404
421
|
}
|
|
405
422
|
integrityUnknown(options);
|
|
423
|
+
integritySources(options);
|
|
424
|
+
}
|
|
425
|
+
function integritySources(options) {
|
|
426
|
+
const sources = options?.sources ?? SOURCE_SUFFIXES;
|
|
427
|
+
const suffix = (s) => typeof s === "string" && /^\.[^/]+$/.test(s);
|
|
428
|
+
if (!Array.isArray(sources) || !sources.every(suffix)) {
|
|
429
|
+
throw new TypeError("protect.integrity.sources must be a list of suffixes such as .js");
|
|
430
|
+
}
|
|
431
|
+
return [...sources];
|
|
406
432
|
}
|
|
407
433
|
function integrityUnknown(options) {
|
|
408
434
|
const unknown = options?.unknown ?? "allow";
|
|
@@ -903,17 +929,21 @@ function openWebSocket(Socket, request, withHeaders) {
|
|
|
903
929
|
} catch {
|
|
904
930
|
}
|
|
905
931
|
});
|
|
906
|
-
socket.addEventListener(
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
932
|
+
socket.addEventListener(
|
|
933
|
+
"close",
|
|
934
|
+
(event) => {
|
|
935
|
+
queue.end({ code: event.code, reason: event.reason, wasClean: event.wasClean === true });
|
|
936
|
+
if (open) return;
|
|
937
|
+
fail(unopened(url, event.code));
|
|
938
|
+
}
|
|
939
|
+
);
|
|
911
940
|
});
|
|
912
941
|
}
|
|
913
942
|
|
|
914
943
|
// src/transport.ts
|
|
915
944
|
var SEND_BUFFER = 1024 * 1024;
|
|
916
945
|
var CLOSE_WAIT_MS = 5e3;
|
|
946
|
+
var DNS_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "EAI_AGAIN", "EAI_NONAME", "EAI_FAIL", "ENODATA"]);
|
|
917
947
|
var NO_WEBSOCKET = "WebSocket not supported by this transport (fetchTransport needs { webSocket: true })";
|
|
918
948
|
var MAX_REQUEST_BODY = 64 * 1024 * 1024;
|
|
919
949
|
function joinHeaders(headers) {
|
|
@@ -965,11 +995,14 @@ function fetchTransport(options = {}) {
|
|
|
965
995
|
});
|
|
966
996
|
} catch (e) {
|
|
967
997
|
if (request.signal.aborted) throw e;
|
|
998
|
+
const causeCode = e?.cause?.code;
|
|
999
|
+
const dns = typeof causeCode === "string" && DNS_CODES.has(causeCode);
|
|
968
1000
|
throw Object.assign(
|
|
969
1001
|
new Error(
|
|
970
1002
|
`fetch ${request.url} failed (unreachable, or not allowed by CORS)${options.hint ? `: ${options.hint}` : ""}`
|
|
971
1003
|
),
|
|
972
|
-
{ status: 502 }
|
|
1004
|
+
{ status: 502 },
|
|
1005
|
+
dns ? { code: causeCode } : {}
|
|
973
1006
|
);
|
|
974
1007
|
}
|
|
975
1008
|
const headers2 = [];
|