@ai-ecoverse/slicc-kernel 1.88.1 → 1.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,6 +45,7 @@ const { status, stdout, stderr } = await kernel.run(['bash', '-c', 'echo hi > he
45
45
  | `clipboard` | none | `{ read(stamp) => Promise<string>, write(text, stamp) => Promise<void> }`: the clipboard behind [`/dev/clipboard`](#devclipboard). `createNodeKernel` takes it too |
46
46
  | `approve` | none | `(req, { signal }) => 'once' \| 'always' \| 'deny'` (or a promise of one): decides a `sudo` request that needs approval (see [Sudo](#sudo)); without it, such a request is denied. `createNodeKernel` takes it too |
47
47
  | `cdp` | none | `({ runtime }) => Promise<CdpConnection>`: a browser-level DevTools connection for programs that drive a browser (see [Browser automation](#browser-automation-cdp)) |
48
+ | `facades` | none | `{ link: { port, hook? } }`: kernel-only loopback ports whose connections a hook on the page serves, with the connecting program's identity (see [Page facades](#page-facades)) |
48
49
  | `protect` | none | `{ paths, writers? }`: directories no process may change, root included, in every `users` mode, except processes with one of the `writers` tags (see [Protected paths](#protected-paths)). `createNodeKernel` takes it too |
49
50
 
50
51
  ### `kernel.run(argv, options?) → Promise<{ status, signal, stdout, stderr }>`
@@ -125,6 +126,10 @@ The cone's home lets its group in only to pass through (`0710`), and the cone op
125
126
 
126
127
  Sets a file's permission bits (`0` to `0o7777`, `EINVAL` otherwise) as root, through the mounts, as the processes see them, for a file the page wrote some other way. For files the page writes straight to OPFS before any kernel runs, see `writeMetadata` under [Metadata](#metadata).
127
128
 
129
+ ### `kernel.integrity.revoke(path?) → Promise<void>`
130
+
131
+ Drops what [integrity](#protected-paths) verified for `path` (absolute), or for everything under it, or for every path, so the next start asks `lookup` again. Call it when `lookup`'s answers change; see **Revoking** under Protected paths.
132
+
128
133
  ### `kernel.setRoutes({ prefixes, exit }) → Promise<void>`
129
134
 
130
135
  Replaces the [uplink](#uplink)'s route table: `prefixes` are addresses or CIDR prefixes (`100.64.0.0/10`, `fd7a:115c:a1e0::/48`), and `exit: true` routes every address, as an exit node does. A prefix that does not parse rejects the whole table and keeps the old one.
@@ -172,6 +177,7 @@ Attaches to a kernel over a port from `kernel.connect()`, in any realm: a worker
172
177
  | `fetch({ url, method, headers, body, signal })` | through the kernel's network transport (the one the page passed to `createKernel`), with a streaming body; `transport` is the same as a `NetworkTransport` |
173
178
  | `fs.watch(paths, { recursive }, onChange)` | resolves with `{ close() }`; `onChange` gets `{ paths }`, the changed paths at, below (or, without `recursive`, directly in) the watched ones, batched per task, or `{ overflow: true }` when there were too many to list, to rescan; it covers every change made through this kernel, by its processes and its clients |
174
179
  | `serveCdp(hook, { runtime })` | offers this client's browser to the kernel's programs (protocol 1.5): `hook` is the `cdp` hook of `createKernel`, asked once per program connection; resolves with `{ close() }`, and closing or detaching takes the offer back and closes its connections (see [Browser automation](#browser-automation-cdp)) |
180
+ | `serveFacade(name, hook) → { close() }` | serves a facade that `createKernel({ facades })` declared, the same way as its `hook` (protocol 1.11); the latest registration wins, over the embedder's hook too, and `close()` hands it back. An undeclared name is `EINVAL` |
175
181
  | `onTraits(listener) → () => void` | calls `listener(traits)` each time the kernel announces new transport traits (`kernel.updateTransportTraits()`, protocol 1.10), after `client.transport.traits` has been updated; the function it returns removes the listener |
176
182
  | `close({ kill })` | detaches: the client's processes keep running unless `kill` is set, which ends their process groups with `SIGKILL`; its terminals are hung up |
177
183
  | `closed` | resolves with the error that ended the client |
@@ -192,7 +198,7 @@ const { status, stdout } = await kernel.run(['bash', '-c', 'echo hi'], { cwd: '/
192
198
  kernel.terminate();
193
199
  ```
194
200
 
195
- `createNodeKernel({ root, modules, commands, env, network, worker, processMounts, fstabRetries, cdp, hostfs, hostfsOrigin, hostfsFetch, users, onDenial, approve })` takes the options of `createKernel` except `metadata`, and `fstabRetries`, the delays in ms between tries of a failing `/etc/fstab` line (default `[1000, 4000, 16000]`). `root` is an in-memory directory by default (`memoryRoot()` makes another), which keeps each file in 1 MiB chunks, and POSIX metadata stays in memory. Processes and threads run on `worker_threads`. The kernel has `run`, `openTerminal`, `users`, `setRoutes`, `updateTransportTraits` and `terminate` as above, plus `root`, `writeFile(path, data, { mode }?)` (creating the parent directories; `mode` sets the permission bits, `0` to `0o7777`, before the data is written), `chmod(path, mode)` and `readFile(path)` to put files in place and read results; like the `fs` of an attached client, they go through the mounts, as the processes see them. `nodeTransport()` is `fetchTransport()` with Node's `fetch`, which no CORS binds (`crossOrigin: 'any'`). `connect({ user })` and `attachKernel` work as in the browser: the port is a `worker_threads` `MessagePort`, which a worker thread can attach with, and `terminate()` ends every attached client.
201
+ `createNodeKernel({ root, modules, commands, env, network, worker, facades, processMounts, fstabRetries, cdp, hostfs, hostfsOrigin, hostfsFetch, users, onDenial, approve })` takes the options of `createKernel` except `metadata`, and `fstabRetries`, the delays in ms between tries of a failing `/etc/fstab` line (default `[1000, 4000, 16000]`). `root` is an in-memory directory by default (`memoryRoot()` makes another), which keeps each file in 1 MiB chunks, and POSIX metadata stays in memory. Processes and threads run on `worker_threads`. The kernel has `run`, `openTerminal`, `users`, `setRoutes`, `updateTransportTraits` and `terminate` as above, plus `integrity.revoke(path?)`, `root`, `writeFile(path, data, { mode }?)` (creating the parent directories; `mode` sets the permission bits, `0` to `0o7777`, before the data is written), `chmod(path, mode)` and `readFile(path)` to put files in place and read results; like the `fs` of an attached client, they go through the mounts, as the processes see them. `nodeTransport()` is `fetchTransport()` with Node's `fetch`, which no CORS binds (`crossOrigin: 'any'`). `connect({ user })` and `attachKernel` work as in the browser: the port is a `worker_threads` `MessagePort`, which a worker thread can attach with, and `terminate()` ends every attached client.
196
202
 
197
203
  By default `nodeTransport()` refuses destinations on the host itself, because Node can reach them and a page can't: local DevTools on 9222–9229, dev servers, local admin APIs, and the cloud metadata address.
198
204
  - **What it refuses:** loopback (`127.0.0.0/8`, `::1`, and their IPv4-mapped forms), unspecified addresses (`0.0.0.0/8`, `::`), `localhost` and `*.localhost`, and link-local addresses (`169.254.0.0/16`, `fe80::/10`), plus AWS's IPv6 metadata address `fd00:ec2::254`.
@@ -256,7 +262,7 @@ On top of preview1 a WASI program gets:
256
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`.
257
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.
258
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 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).
259
- - **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.
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.
260
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.
261
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.
262
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.
@@ -292,6 +298,7 @@ The module's default export (or its `main` export) is called with a context and
292
298
  - `open(path, { read, write, append, create, exclusive, truncate })`, which returns a file with `read(position, length)`, `write(position, bytes)`, `size()`, `truncate(size)`, `sync()` and `close()`. Reads and writes go through the kernel's paged files at those positions, so a large file is never read whole;
293
299
  - `fs`: `stat`, `lstat`, `exists`, `readdir`, `mkdir` (with parents, or, with `{ recursive: false }`, as Node's `fs.mkdir` without `recursive`: `EEXIST` for an existing entry and `ENOENT` for a missing parent, atomically among the processes of one kernel, so it can serve as a lock there, but not between two kernels on one origin, such as two tabs ([#519](https://github.com/ai-ecoverse/slicc-kernel/issues/519)); `sync.fs.mkdir` takes the same option), `rm` (recursive), `unlink`, `rename`, `symlink`, `readlink`, `statfs`, `chmod(path, mode)`, and `readFile`/`writeFile` for small files. `chmod` takes the permission bits (`0` to `0o7777`, `EINVAL` otherwise) and follows a symlink; as `chmod(2)`, only the file's owner or root may (`EPERM`), so a program can make a script it wrote runnable by path. `sync.fs.chmod` is the same, synchronously. `statfs(path)` answers `{ type, bsize, blocks, bfree, bavail, files, ffree, quota, usage }`: the fields of Node's `StatFs`, plus `quota` and `usage` in bytes (what jsh 0.17 reads). The values come from the same source as an Emscripten program's `statfs`/`df`: the driver of the mount the path is on, or, on the root file system, the origin's storage estimate (`navigator.storage.estimate()`). `bsize` is 4096, `bfree` and `bavail` are what's left of the quota, and `type`, `files` and `ffree` are 0, since the kernel doesn't know them. Where there's no estimate (the Node entry's root), every count is 0. A missing path is `ENOENT`. `sync.fs` has it too. `rm` removes a whole tree in one kernel call (on the OPFS root, one recursive removal and one metadata delete: 50,000 files take about 3 s in Chrome, where `rm -r` takes about 25 s) with the result of a file-by-file removal: symlinks inside the tree are removed, never followed; a tree holding a protected path is refused with `EROFS` and nothing is removed; a mount inside the tree is emptied and its mount point answers `EBUSY`, while the rest is removed; under `users`, what the user may remove is removed and the call then fails with the first `EACCES`/`EPERM`; files a process holds open stay readable to it;
294
300
  - `/dev`, in `open`, `fs` and their `sync` forms, as a WASI or Emscripten program sees it, with the same modes and owners as under `users`: `null`, `zero`, `full`, `random`, `urandom` and `tty` (character devices, `0666`, root's), `stdin`, `stdout` and `stderr` (links to `/proc/self/fd/0`, `1`, `2`), `fd` (a link to `/proc/self/fd`, so `/dev/fd/N` is descriptor N) and the kernel's terminals `/dev/ttyN` and `/dev/pts/N` (with users on, owned by the session's user or the master's opener, `0620`). `/dev/shm` and `/dev/ptmx` aren't there. `stat` through `/dev/stdin`, `/dev/stdout` or `/dev/fd/N` describes the program's own descriptor (the file it is redirected to, a pipe, a terminal), `readlink` of `/dev/fd/N` names it (`pipe:[N]` and `socket:[N]` for a pipe or a socket), and a closed descriptor is `ENOENT`. Opening a device gives a kernel descriptor on it; opening `/dev/std*` or `/dev/fd/N` opens the file again with the flags asked for when the descriptor is on a file (as Linux reopens `/proc/self/fd/N`, so `append` appends and `truncate` truncates), and otherwise duplicates the descriptor; `/dev/tty` is the program's controlling terminal (`ENXIO` without one); and `/dev/ttyN` and `/dev/pts/N` are opened by name through the kernel, so a terminal that isn't the user's is `EACCES` under `enforce`. A file on anything but a regular file reads and writes as a stream (the position is ignored) and only in the directions it was opened for (`EBADF` otherwise), its `size()` is 0 and `truncate` is `EINVAL`. `readFile` reads a device or a descriptor to its end, up to 64 MiB, and fails with `EFBIG` past that (where Node's `readFile` throws `ERR_FS_FILE_TOO_LARGE`), so `readFile('/dev/zero')` fails instead of hanging, while a bounded `read` of it works. Other paths pay only a prefix check: they make the same kernel calls as before. Creating, removing or renaming names in `/dev` goes to the root file system as before;
301
+ - `/proc`, in `fs`, `open` and their `sync` forms, the same as a WASI program's (see [`/proc`](#proc)): `stat`, `lstat`, `exists`, `readdir`, `readlink` and `readFile` answer it, and `/proc/self` is the program's own pid. It's read-only, so every change in it is `EROFS`: `writeFile`, `mkdir`, `rm`, `unlink`, `chmod`, a `rename`, `link` or `symlink` into or out of it, and an `open` for writing, creating or truncating. A file opened in it is a snapshot read at the open, with no descriptor (`fd` is `-1`): its `read` and `size` answer from the snapshot until it's closed, and `write` and `truncate` are `EBADF`. Other paths pay only a prefix check;
295
302
  - `signals`: `on(signal, handler)` catches a signal (by number or as `'SIGINT'`), `ignore(signal)` ignores it and `reset(signal)` restores its default action. A call blocked in a read or write is restarted after a handler runs; signals without a handler keep their default actions, so an uncaught `SIGINT` ends the program with 130 and `SIGKILL` always does;
296
303
  - `sync`: `read`, `write`, `close`, `open` (whose files' methods are synchronous too) and `fs`, made synchronously, for code that cannot `await`, such as a compiled program's file system or a `readFileSync`. A synchronous call holds the worker until the kernel answers, so nothing else of the program runs meanwhile, timers and callbacks included; a signal that arrives during a blocked read or write runs its handler, and the call is then restarted. Descriptors are shared with the rest of the context, so a file opened with `sync.open` can be closed with `close`, and the other way around; `sync.run` and `sync.exit` are described next;
297
304
  - `sync.cred()` returns the process's credentials, `{ ruid, euid, suid, rgid, egid, sgid, groups }`, and `sync.setcred(change)` changes them, in the shape of the Emscripten call (see [Users](#users)): each field is optional, `-1` keeps an id, and `groups` sets the group list. They are synchronous, so a runtime can build Node's `process.setuid`, `seteuid`, `setgid`, `setegid`, `setgroups` and `initgroups` on them, and follow the same rules as every other program: root may set anything, anyone else only its own real, effective and saved ids (`EPERM` otherwise), and only root may set the groups; a bad value is `EINVAL`. The new ids count at once for the program's own files and for every child it starts, with any `users` mode (with `'off'`, only the ids change);
@@ -411,7 +418,7 @@ Programs drive a browser with the Chrome DevTools Protocol over a WebSocket, whi
411
418
  - **Sessions** are Chrome's flattened ones: `Target.attachToTarget` with `flatten: true`, then commands with a top-level `sessionId`. Messages pass unchanged, as text frames up to 256 MiB (a larger one closes the socket with `1009`). Each program WebSocket gets a host connection of its own: closing the socket (or exiting) closes it, which detaches its sessions, and a host that closes is passed on as a close frame (`1011` with its reason). The kernel then waits up to a second for the program's close frame before it hangs up, as RFC 6455 has it, so a program that answers the close can still report the code. `terminate()` closes them all.
412
419
  - **Runtime**: a program adds `runtime=<name>` to the query of the URL it opens, and the kernel gives it to the host; the kernel does not interpret it.
413
420
  - **No host**: `/json/*` and the WebSocket handshake answer `503` with `slicc-kernel: no CDP host is attached (createKernel({ cdp }) or client.serveCdp)`, and a host that refuses (an unknown runtime, say) gives `502` with its message.
414
- - **Kernel-only**: port `9222` takes connections only from programs in the kernel. `kernel.dial`, a client's `dial` and `loopbackFetch` (and so `9222.kernel.localhost` and slicc-node's tunnel) are refused with `ECONNREFUSED`, since the facade drives the user's real browser.
421
+ - **Kernel-only**: port `9222` is reserved (a program's `bind()` to it fails with `EADDRINUSE`, so none can squat it) and takes connections only from programs in the kernel. `kernel.dial`, a client's `dial` and `loopbackFetch` (and so `9222.kernel.localhost` and slicc-node's tunnel) are refused with `ECONNREFUSED`, since the facade drives the user's real browser.
415
422
 
416
423
  An embedder offers a browser with the `cdp` option of `createKernel` or `createNodeKernel`, or from an attached client with `client.serveCdp(hook, { runtime })`:
417
424
 
@@ -429,6 +436,31 @@ A connection is one browser-level CDP session; a host with only per-tab debuggin
429
436
 
430
437
  Process workers can also use the transport directly, without a socket: the `net-request`, `net-read` and `net-close` syscalls (`Module.sliccKernel.http` in a process) open a request and read its body in pieces. `net-open` starts a request and answers its handle at once, and `net-head` waits for its response head, so `net-close` can abort a request whose head has not come yet. Requests are per process and closed when it exits, and a request body has no size limit of its own (the transport's `maxRequestBody` applies to the proxy). `net-traits` answers the transport's `traits` with `crossOrigin`: `'cors'` when it is bound by CORS like `fetchTransport()`, `'any'` (the default for a transport that does not say) when it is not; without a transport it fails with `ENETUNREACH`.
431
438
 
439
+ ### Page facades
440
+
441
+ A facade is a loopback port that only programs in the kernel can reach, whose connections the embedder serves, much like the CDP facade, but as a plain duplex byte stream with no protocol of the kernel's. slicc's link layer is the first user.
442
+
443
+ ```ts
444
+ createKernel({ facades: { link: { port: 7400, hook } } });
445
+
446
+ type FacadeHook = (stamp: ConnectorStamp, info: { name: string }) => FacadeConnection | Promise<FacadeConnection>;
447
+
448
+ interface FacadeConnection {
449
+ send(bytes: Uint8Array): void;
450
+ onmessage: ((bytes: Uint8Array) => void) | null;
451
+ onclose: ((reason?: string) => void) | null;
452
+ close(reason?: string): void;
453
+ }
454
+
455
+ interface ConnectorStamp { pid: number; ppid: number; uid: number; gid: number; user?: string; groups: number[]; users: 'off' | 'report' | 'enforce'; tag?: string }
456
+ ```
457
+
458
+ - **Declared at boot:** `facades` names each facade (`[a-z][a-z0-9-]*`) and its port, from 1 to 65535. A port may not be 9222 (the CDP facade), 3128 (the realm proxy) or another facade's; anything else throws a `TypeError`. `createNodeKernel` takes `facades` too. The hook may be left out and supplied later by an attached client's `client.serveFacade(name, hook)`, which works only for a declared name.
459
+ - **Programs** find each facade in their environment as `SLICC_<NAME>_URL`, upper-cased with `-` as `_`, e.g. `SLICC_LINK_URL=tcp://127.0.0.1:7400`, and connect to it with an ordinary TCP `connect()`: a JS program's `ctx.net.connect`, an Emscripten program's socket, or a WASIX program's `sock_connect`. Every ABI ends in the same kernel call.
460
+ - **The stamp** is the connecting process's identity, taken by the kernel at `connect()` from its credentials, as Linux's `SO_PEERCRED` is: its pid and parent pid as `ps` shows them, effective uid and gid, user name, groups, the `users` mode and the process's tag (see `kernel.run`), the same fields as a `/dev/sealed` request's `caller`. A program cannot forge it. The hook gets it and `{ name }`, so one function can serve several facades, and decides: it resolves with a connection, whose bytes then pass both ways until either side closes, or it rejects, and the program's connection is closed. A connection while no hook is attached is closed at once, the same as a refusal. Bytes from the hook wait in the kernel until the program reads them, up to 16 MiB per connection: past that, the connection is closed and the hook's `close(reason)` gets `slicc-kernel: more than 16777216 bytes are waiting for the program to read`. A program that connects to anything else pays nothing for this: the stamp is built only for a facade's port.
461
+ - **Handing a connection on hands over the identity:** a facade connection that a process passes to another (a fork, an exec, an inherited descriptor) still speaks as the process that connected, as on Linux. So a privileged process should not pass one to a less trusted one.
462
+ - **Reserved:** a facade's port is reserved from the start. A program's `bind()` to it fails with `EADDRINUSE`, so no program can squat it and impersonate the page, and ephemeral ports skip it. Port 9222 is reserved the same way. Like 9222, a facade's port is kernel-only: the page's `kernel.dial`, a client's `dial` and `loopbackFetch` (and so `<port>.kernel.localhost`) are refused with `ECONNREFUSED`.
463
+
432
464
  ### Local proxy protocol
433
465
 
434
466
  The page and the proxy speak raw mode of SLICC's `/api/fetch-proxy`. Every request is one `POST {url}/api/fetch-proxy` with `credentials: 'omit'`:
@@ -464,7 +496,8 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
464
496
  - **File contents:** on a mount with `ranges`, as `tmpfs` is, and on the root file system when it is in OPFS (in a browser with sync access handles) or an in-memory root, a program reads and writes a file in 64 KiB pages: reads come from the driver 1 MiB at a time, and changed pages go back in 1 MiB runs once 8 MiB have changed, at `fsync`, and when the file is closed (from the kernel's own descriptors also 250 ms after a write). A file of any size the mount can hold works, with memory to spare for about one more copy of it. In OPFS a write opens a sync access handle for just that write. An `fsa` mount (a picked folder) has no sync access handles, and Chrome copies the whole file into a swap file for every writable stream, so it writes through one stream per open file: the first write back opens it, later runs go into it at their offsets, and it is committed, one copy of the file, when the last process writing the file closes it (or exits), before another process reads what was written, and before a rename, unlink or unmount. Until then other processes see the new size but read the old bytes outside what was written. Elsewhere (`hostfs` with an older proxy, and a root outside OPFS), file contents are read whole when a program opens a file and written back when it closes it. Before a program's next call into the kernel (any wait, read of a pipe or terminal, sleep or spawn), its changed pages also go back, so other processes and the embedder see a write as soon as the writer does anything but compute: a daemon's pidfile, a log another process follows. On a whole-file mount that holds only for files of up to 64 KiB; a larger one goes back at close, fsync and exit, as before. Beyond that, both give close-to-open consistency, as NFS has. A process that ends without closing its files keeps what it wrote: on exit, on a trap, and when a signal kills it. For a killed WASI program, the kernel lets its worker write its changed pages back before `waitpid` reports the death. The only exception is a worker that makes no call into the kernel for 2 seconds after the signal; it is stopped without that write-back.
465
497
  - **Rules on a mount:**
466
498
  - a rename across mounts is `EXDEV`, so `mv` copies;
467
- - 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. The other names of a linked file keep the attributes the kernel has cached for them for up to the mount's `attrTtl` (`tmpfs` caches none). Emscripten programs (bash, coreutils `ln`) get `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;
499
+ - 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;
500
+ - 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. The other names of a linked file keep the attributes the kernel has cached for them for up to the mount's `attrTtl` (`tmpfs` caches none). 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;
468
501
  - `df` reports each mount from its driver's `statfs`;
469
502
  - each mount has its own device number;
470
503
  - without `chmod` support, `chmod` succeeds and changes nothing;
@@ -635,11 +668,12 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
635
668
  - **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'`.
636
669
  - **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.
637
670
  - **Running:** the command runs as the caller's child, with all six ids and the groups of the target user, in the caller's directory, on its terminal, standard input, output and error, and in its process group; `sudo` exits with its status, and passes `SIGTERM` and `SIGHUP` on to it, also one that comes while it starts (the kernel lets it signal the command it started, which its own user may not); `SIGINT` and `SIGQUIT` it leaves to the command, which gets them from the terminal itself. Two narrow gaps remain ([#424](https://github.com/ai-ecoverse/slicc-kernel/issues/424)): a relayed signal reaches the command but not the processes it started itself, which keep running as the target user if it ends; and right after the command starts, a `SIGINT` or `SIGQUIT` sent to `sudo`'s own pid (not through the terminal) ends `sudo` and leaves the command running. Closing both needs the command in a process group of its own, which changes job control. It's found among the system's commands only, never a user's own. Its environment is the kernel's (the embedder's `env` and the proxy settings), the target's `HOME`, `USER`, `LOGNAME`, `SHELL`, `PNPM_HOME` and `NPM_CONFIG_PREFIX`, `PATH` set to the path above, `PWD`, the caller's `TERM` when it is a plain name (letters, digits, `.`, `_`, `+`, `-`), and `SUDO_USER`, `SUDO_UID`, `SUDO_GID` and `SUDO_COMMAND`; nothing else of the caller's comes through.
638
- - **Cost:** with `users: 'off'`, and with owners on for programs that don't call `sudo`, nothing is added to any call.
671
+ - **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)).
672
+ - **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.
639
673
 
640
674
  ### `/dev/sealed`
641
675
 
642
- A character device (`0666`) through which a program talks to the embedder's `sealed` hook (`createKernel({ sealed })`), the secrets vault on the page. Emscripten programs, WASI programs (`std::fs`) and JS programs (`ctx.open`, `ctx.sync.open`, listed by `ctx.fs.readdir('/dev')`) all open it, through the same kernel device, with the same framing, errnos and stamp: a JS program's stamp is built in the kernel from its own process, as for any other. The kernel holds the mode a descriptor was opened with, in every runtime, for its `dup`s and for the copies children inherit through fork, spawn and exec: one opened read-only refuses writes with `EBADF`, and one opened write-only refuses reads. Emscripten programs see it in `/dev`.
676
+ A character device (`0666`) through which a program talks to the embedder's `sealed` hook (`createKernel({ sealed })`), the secrets vault on the page. Emscripten programs, WASI programs (`std::fs`) and JS programs (`ctx.open`, `ctx.sync.open`, listed by `ctx.fs.readdir('/dev')`) all open it, through the same kernel device, with the same framing, errnos and stamp: a JS program's stamp is built in the kernel from its own process, as for any other. The kernel holds the mode a descriptor was opened with, in every runtime, for its `dup`s and for the copies children inherit through fork, spawn and exec: one opened read-only refuses writes with `EBADF`, and one opened write-only refuses reads. Emscripten programs see it in `/dev` and open it by any path that leads to it (`./sealed` from `/dev`, `..`, a symlink to it or to `/dev`), and `fstat` and `/proc/self/fd` show it as `/dev/sealed`, a character device, after fork and exec too; WASI and JS programs open it by a path with no symlink on the way, and WASI programs by one without `..`.
643
677
  - **State per open:** each `open()` is independent; `dup` and fork share one, as on Linux.
644
678
  - **One write is one request:** a single UTF-8 JSON object of at most 64 KiB with `v` 1 and a known `op` (`seal`, `placeholder`, `list`, `test`, `request`, `status`, `cancel`, `materialize`). The write takes all the bytes or fails: `EMSGSIZE` over 64 KiB, `EINVAL` for anything else that isn't such an object. These are the only errnos; every other outcome is a JSON reply. The kernel zeroes the bytes it was given once it has parsed them.
645
679
  - **The stamp:** the hook gets the request with `caller: { pid, ppid, uid, gid, user?, groups, users, tag? }`, from the process that writes (at the write; not the one that opened it): `uid` and `gid` the effective ids, `user` the `/etc/passwd` name of `uid` read at that moment (left out when there is none), `groups` the process's supplementary groups (only root can change them), `users` the kernel's `users` mode, since a uid means more under `'enforce'`, and `tag` the process's tag (see `kernel.run`) when it has one. A `caller` in the request is replaced.
@@ -663,7 +697,7 @@ The kernel then asks the hook `{ v: 1, op: 'materializeConsume', ticket, stamp,
663
697
 
664
698
  ### `/dev/clipboard`
665
699
 
666
- A character device (`0666`), there in every `users` mode, through which programs read and write the embedder's clipboard (`createKernel({ clipboard: { read, write } })`; on the page, bios answers with `navigator.clipboard`). Emscripten programs (`cat /dev/clipboard`, `cat > /dev/clipboard`, see it in `/dev`), WASI programs (`std::fs`) and JS programs (`ctx.fs.readFile`, `ctx.fs.writeFile`, `ctx.open`) all open it.
700
+ A character device (`0666`), there in every `users` mode, through which programs read and write the embedder's clipboard (`createKernel({ clipboard: { read, write } })`; on the page, bios answers with `navigator.clipboard`). Emscripten programs (`cat /dev/clipboard`, `cat > /dev/clipboard`, see it in `/dev`, reach it by any path that leads to it, and see it as a character device after fork and exec), WASI programs (`std::fs`) and JS programs (`ctx.fs.readFile`, `ctx.fs.writeFile`, `ctx.open`) all open it.
667
701
  - **State per open file description:** `dup`'d and fork-inherited descriptors share one snapshot and one buffer, as on Linux; each `open()` is new. `lseek` fails with `ESPIPE`, and `poll`/`select` report it readable and writable at once.
668
702
  - **Reading:** the first `read()` asks `read(stamp)` and takes a snapshot of the text as UTF-8; later reads continue it, and its end is end-of-file. A failed read keeps no snapshot, so the next read asks again. A signal ends the wait with `EINTR`.
669
703
  - **Writing:** the bytes are buffered, and the last close of the description calls `write(text, stamp)` with all of them as one string, and waits for it, so `close()` returns its error. A description opened `O_WRONLY` always commits (with nothing written, `write('')` clears the clipboard, so `: > /dev/clipboard` works); one opened `O_RDWR` commits only once it has been written to, and one opened `O_RDONLY` never. More than 1 MiB in all fails that `write()` with `EFBIG`, and the description stays failed: later writes fail too and its close commits nothing. Bytes that aren't valid UTF-8 fail the close with `EILSEQ`, and the hook isn't called. With `O_RDWR`, reads come from the snapshot and writes go to the buffer, independently.
@@ -678,7 +712,7 @@ A character device (`0666`), there in every `users` mode, through which programs
678
712
 
679
713
  `createKernel({ protect: { paths: ['/node_modules', '/os'] } })` makes those directories read-only for every process, root included, in every `users` mode. They hold code every later process runs, which only the embedder writes, straight to OPFS.
680
714
  - **What fails:** any change under a path fails with `EROFS` ("Read-only file system"): a write, a create, a truncate, `mkdir`, removing or renaming (from or to), a hard or symbolic link created there, `chmod`, `chown` and setting times. So does removing or renaming a directory that holds one (`rm -rf /`). A missing working directory for `run` or `openTerminal` isn't created there either. Reads are unaffected. `'/'` protects the whole file system.
681
- - **Stats and symlinks:** files under a path show as on a read-only mount, also when reached through a symlink or a symlinked directory, so opens for writing fail at once, and `access(W_OK)` (`[ -w ]`) answers `EROFS`. A change refused there fails with `EROFS` before any permission check, as on Linux, so a user sees "Read-only file system" rather than "Permission denied". `paths` must be a list of absolute paths.
715
+ - **Stats and symlinks:** files under a path show as on a read-only mount, also when reached through a symlink or a symlinked directory, so opens for writing fail at once, and `access(W_OK)` (`[ -w ]`) answers `EROFS` (with `AT_SYMLINK_NOFOLLOW`, for the link itself: a link elsewhere to a protected file is writable). A change refused there fails with `EROFS` before any permission check, as on Linux, also removing or renaming a directory that holds a path, so a user sees "Read-only file system" rather than "Permission denied". `paths` must be a list of absolute paths.
682
716
  - **Where it's checked:** the root file system itself checks every change, from the moment the kernel is constructed, inside the change and against the path it really lands on. So a symlink swapped mid-way can't redirect a write. The kernel's own writes there are refused too, so don't protect what the kernel writes after it starts:
683
717
  - `/etc` (`passwd`, `group` and the proxy's CA);
684
718
  - `/home` and `/root` (new users' homes);
@@ -696,14 +730,16 @@ A character device (`0666`), there in every `users` mode, through which programs
696
730
  - **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.
697
731
  - **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.
698
732
  - **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.
733
+ - **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.
699
734
  - **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.
735
+ - **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.
700
736
  - **Scripts** are checked when they're executed; the interpreter reads the file again afterwards, so a change in between isn't caught ([#454](https://github.com/ai-ecoverse/slicc-kernel/issues/454)).
701
737
  - **Not covered:** what a running program reads itself, such as a JS runtime's modules, python's `.py` files and `dlopen`'s side modules ([#454](https://github.com/ai-ecoverse/slicc-kernel/issues/454)). Protected paths still keep those unwritable.
702
738
  - **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.
703
739
 
704
740
  ### `/proc`
705
741
 
706
- 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). Neither has `/proc/<pid>/fd` yet.
742
+ 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.
707
743
 
708
744
  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.
709
745
 
@@ -722,7 +758,7 @@ OPFS stays authoritative: an entry is consulted only for a path that exists in O
722
758
 
723
759
  The database is `slicc-kernel` (or the `metadata` option), schema version 1, with one object store, `entries`, keyed by absolute path (`keyPath: 'path'`) and an index `dir` on the parent directory of symlinks. A record looks like `{ path, mode?, atimeMs?, mtimeMs?, mtimeFor?, ctimeMs?, ino?, link?, dir?, uid?, gid?, heir?, writes?, epoch? }`, where `mtimeFor` is the OPFS `lastModified` an explicit `mtimeMs` belongs to, `uid`, `gid` and `heir` (`[uid, gid]`) are the owners of [File owners](#file-owners), `writes` counts the writes kernels with `integrity` made to a protected file (see [Protected paths](#protected-paths)), whose `lastModified` OPFS leaves alone for a write through a sync access handle, and `epoch`, on the entry of `/`, counts their renames and symlinks at or above a protected path. A future schema bumps the version and migrates in `onupgradeneeded`; an older kernel then closes its connection. The sidecar is keyed by path from the `root` handle, so use a different `metadata` name for each distinct root.
724
760
 
725
- Not covered: hard links (they fail with `EMLINK`, as Emscripten reports; OPFS cannot share contents between names). File owners are stored only with `users: 'report'` or `'enforce'` (see [File owners](#file-owners)). With `users: 'off'`, an Emscripten process built against the kernel's credential calls (its glue imports `slicc_cred_js`, as homescoop's H1 builds do) sees every file as owned by its own effective uid and gid: root's 0 by default, a user's own ids when it runs as one, and the new ids after a `setuid`. Owner checks such as git's `safe.directory`, ssh's key checks and gnupg's home directory check then pass, as WASIX programs already see through their libc. An older program, whose libc says it is uid 1000 whoever runs it, sees every file as owned by 1000:1000 as before, so its own checks pass too. The kernel decides this once, when it loads the program. Emscripten also doesn't enforce permission bits, so a file runs whether or not its exec bit is set; the bits are what `ls`, `stat` and `test` report. With `users: 'off'` a file or directory with no permission bits at all shows as `0666` or `0777` to Emscripten programs; with owners on they see the real mode (`0`), and the kernel, not Emscripten's own checks, decides who may read, write or enter it (`cd` asks the kernel for `x` on the directory), so root still enters a `000` directory.
761
+ Not covered: hard links (OPFS cannot share contents between names; they work only within a mount whose driver offers `links`, such as `tmpfs`). File owners are stored only with `users: 'report'` or `'enforce'` (see [File owners](#file-owners)). With `users: 'off'`, an Emscripten process built against the kernel's credential calls (its glue imports `slicc_cred_js`, as homescoop's H1 builds do) sees every file as owned by its own effective uid and gid: root's 0 by default, a user's own ids when it runs as one, and the new ids after a `setuid`. Owner checks such as git's `safe.directory`, ssh's key checks and gnupg's home directory check then pass, as WASIX programs already see through their libc. An older program, whose libc says it is uid 1000 whoever runs it, sees every file as owned by 1000:1000 as before, so its own checks pass too. The kernel decides this once, when it loads the program. Emscripten also doesn't enforce permission bits, so a file runs whether or not its exec bit is set; the bits are what `ls`, `stat` and `test` report. With `users: 'off'` a file or directory with no permission bits at all shows as `0666` or `0777` to Emscripten programs; with owners on they see the real mode (`0`), and the kernel, not Emscripten's own checks, decides who may read, write or enter it (`cd` asks the kernel for `x` on the directory), so root still enters a `000` directory.
726
762
 
727
763
  Reads retry briefly when a concurrent write has invalidated the OPFS file snapshot (`NotReadableError`).
728
764
 
@@ -758,7 +794,7 @@ npm run test:unit
758
794
 
759
795
  `npm test` builds `dist/` and runs the integration tests in Chromium from `playwright-core`, over raw CDP through the [harness from slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web#integration-test-harness), against a cross-origin isolated test page that installs bash and coreutils into OPFS. Each test writes screenshots, console logs and CPU profiles to `artifacts/`; V8 coverage from the page and every worker, mapped back to `src/` through the source maps, goes to `coverage/`, and `artifacts/hotspots.md` lists where the time went.
760
796
 
761
- Unit tests live in `test/unit/`, which stays out of git. They run real wasm bash and coreutils through the whole stack in Node `worker_threads` against an in-memory OPFS. The pre-commit hook runs them under coverage and requires every changed line in `src/` to be covered. `tools/heavy.sh` runs them two files at a time, in one of two unit slots of the host (`/tmp/slicc-unit-N.lock`, waiting while both are taken; `SLICC_UNIT_SLOTS` sets how many, `SLICC_UNIT_SLOT=0` takes none, as for CI), never in the browser runs' slots (those go through `slicc-heavy`; `SLICC_HEAVY_SLOT=1` runs the unit tests through `~/.bb/coord-bin/slicc-heavy` instead, when it is there), and stops the run if its processes use more than 3,000,000 KB together or 2,000,000 KB in one process, or run longer than 900 s once it has its slot (`SLICC_HEAVY_TIMEOUT`; each test also has 180 s) (`SLICC_HEAVY_REPORT=1` prints the peak). `SLICC_HEAVY_TREE_KB` and `SLICC_HEAVY_PROC_KB` raise the caps, but only for measuring a run under a higher ceiling that still stops a runaway, never to get a commit through. The guard follows the command's process group and every process it has seen, including children that leave the group, but a process that forks, calls `setsid` and whose parent exits within one 0.25 s sample can escape: macOS has no unprivileged containment, and a test that daemonizes is a bug. The compiler tests (zig, rustc and cargo), each a few GB, are integration tests in `test/integration/compilers.test.mjs`, which CI runs.
797
+ Unit tests live in `test/unit/`, which stays out of git. They run real wasm bash and coreutils through the whole stack in Node `worker_threads` against an in-memory OPFS. The pre-commit hook runs them under coverage and requires every changed line in `src/` to be covered. `tools/heavy.sh` runs them two files at a time, in one of two unit slots of the host (`/tmp/slicc-unit-N.lock`, waiting while both are taken; a lock it can't take for any other reason, such as a lock file it can't open, ends the run with `lockf`'s status; `SLICC_UNIT_SLOTS` sets how many, `SLICC_UNIT_SLOT=0` takes none, as for CI; a HUP, INT or TERM to `heavy.sh` stops the run it holds or waits for), never in the browser runs' slots (those go through `slicc-heavy`; `SLICC_HEAVY_SLOT=1` runs the unit tests through `~/.bb/coord-bin/slicc-heavy` instead, when it is there), and stops the run if its processes use more than 3,000,000 KB together or 2,000,000 KB in one process (2,800,000 KB for the command itself, the coverage tool that merges the report at the end; `SLICC_HEAVY_ROOT_KB`, never below the per-process cap), or run longer than 900 s once it has its slot (`SLICC_HEAVY_TIMEOUT`; each test also has 180 s) (`SLICC_HEAVY_REPORT=1` prints the peak). `SLICC_HEAVY_TREE_KB` and `SLICC_HEAVY_PROC_KB` raise the caps, but only for measuring a run under a higher ceiling that still stops a runaway, never to get a commit through. The guard follows the command's process group and every process it has seen, including children that leave the group, but a process that forks, calls `setsid` and whose parent exits within one 0.25 s sample can escape: macOS has no unprivileged containment, and a test that daemonizes is a bug. The compiler tests (zig, rustc and cargo), each a few GB, are integration tests in `test/integration/compilers.test.mjs`, which CI runs.
762
798
 
763
799
  The Biome, TypeScript, lefthook, Renovate and CI configuration comes from [slicc-shared-web](https://github.com/ai-ecoverse/slicc-shared-web), which also provides the `slicc-lint-comments` (no comments anywhere), `slicc-no-unit-tests` (no unit tests in git) and `slicc-diff-cover` (100% coverage of changed lines) commands that `npm run lint` and the pre-commit hook use.
764
800
 
@@ -43,6 +43,56 @@ function serveCdpPort(port, hook, request, onDone) {
43
43
  };
44
44
  }
45
45
 
46
+ // src/facade/port.ts
47
+ function serveFacadePort(port, hook, { name, stamp }, onDone) {
48
+ let conn;
49
+ let done = false;
50
+ const end = () => {
51
+ done = true;
52
+ port.close();
53
+ onDone?.();
54
+ };
55
+ const finish = () => {
56
+ if (done) return;
57
+ port.postMessage({ close: true });
58
+ end();
59
+ };
60
+ port.addEventListener(
61
+ "message",
62
+ ({ data }) => {
63
+ if (data.b !== void 0) conn?.send(data.b);
64
+ else if (data.close && !done) {
65
+ end();
66
+ conn?.close(typeof data.close === "string" ? data.close : void 0);
67
+ }
68
+ }
69
+ );
70
+ port.start?.();
71
+ new Promise((resolve) => resolve(hook(stamp, { name }))).then(
72
+ (opened) => {
73
+ conn = opened;
74
+ if (done) return opened.close();
75
+ opened.onmessage = (bytes) => {
76
+ if (done) return;
77
+ const copy = bytes.slice();
78
+ port.postMessage({ b: copy }, [copy.buffer]);
79
+ };
80
+ opened.onclose = () => finish();
81
+ port.postMessage({ open: true });
82
+ },
83
+ (e) => {
84
+ if (done) return;
85
+ port.postMessage({ error: e instanceof Error ? e.message : String(e) });
86
+ end();
87
+ }
88
+ );
89
+ return () => {
90
+ if (done) return;
91
+ conn?.close();
92
+ finish();
93
+ };
94
+ }
95
+
46
96
  // src/kernel/net/ws-queue.ts
47
97
  var INBOUND_LIMIT = 16 * 1024 * 1024;
48
98
  var MESSAGE_LIMIT = 64 * 1024 * 1024;
@@ -573,7 +623,7 @@ async function loopbackFetch(dial, input, options) {
573
623
  }
574
624
 
575
625
  // src/client/protocol.ts
576
- var PROTOCOL = [1, 10];
626
+ var PROTOCOL = [1, 11];
577
627
  function locksOf(scope = globalThis) {
578
628
  const locks = scope.navigator?.locks;
579
629
  return typeof locks?.request === "function" ? locks : void 0;
@@ -640,6 +690,8 @@ var Channel = class {
640
690
  watching = /* @__PURE__ */ new Map();
641
691
  cdpHooks = /* @__PURE__ */ new Map();
642
692
  cdpOpen = /* @__PURE__ */ new Set();
693
+ facadeHooks = /* @__PURE__ */ new Map();
694
+ facadeOpen = /* @__PURE__ */ new Set();
643
695
  protocol = PROTOCOL;
644
696
  nextId = 0;
645
697
  port;
@@ -662,6 +714,8 @@ var Channel = class {
662
714
  this.remote?.fail(error);
663
715
  for (const close of [...this.cdpOpen]) close();
664
716
  this.cdpOpen.clear();
717
+ for (const close of [...this.facadeOpen]) close();
718
+ this.facadeOpen.clear();
665
719
  for (const call of this.pending.values()) call.reject(error);
666
720
  this.pending.clear();
667
721
  this.release();
@@ -691,6 +745,10 @@ var Channel = class {
691
745
  this.openCdp(data);
692
746
  return;
693
747
  }
748
+ if (data.facadeOpen && data.port) {
749
+ this.openFacade(data);
750
+ return;
751
+ }
694
752
  if (data.watch !== void 0) {
695
753
  this.watching.get(data.watch)?.(data.change);
696
754
  return;
@@ -733,7 +791,36 @@ var Channel = class {
733
791
  const close = serveCdpPort(port, hook, request, () => this.cdpOpen.delete(close));
734
792
  this.cdpOpen.add(close);
735
793
  }
794
+ openFacade({ facadeOpen: { registration, ...request }, port }) {
795
+ const hook = registration === void 0 ? void 0 : this.facadeHooks.get(registration);
796
+ if (!hook || this.failure) {
797
+ port.postMessage({ error: "this client no longer serves the facade" });
798
+ port.close();
799
+ return;
800
+ }
801
+ const close = serveFacadePort(port, hook, request, () => this.facadeOpen.delete(close));
802
+ this.facadeOpen.add(close);
803
+ }
736
804
  };
805
+ async function serveFacadeOn(channel, name, hook) {
806
+ if (typeof hook !== "function") throw new TypeError("serveFacade: hook must be a function");
807
+ await since(channel, 11, "facade serving", async () => {
808
+ });
809
+ const { id, done } = channel.request({ op: "serve-facade", name });
810
+ channel.facadeHooks.set(id, hook);
811
+ try {
812
+ await done;
813
+ } catch (error) {
814
+ channel.facadeHooks.delete(id);
815
+ throw error;
816
+ }
817
+ return {
818
+ close: () => {
819
+ if (!channel.facadeHooks.delete(id)) return;
820
+ void channel.call({ op: "unserve-facade", registration: id }).catch(() => void 0);
821
+ }
822
+ };
823
+ }
737
824
  async function serveCdpOn(channel, hook, options) {
738
825
  await since(channel, 5, "CDP serving", async () => {
739
826
  });
@@ -967,6 +1054,7 @@ async function attachKernel(port, options = {}) {
967
1054
  loopbackFetch: (input, options2) => loopbackFetch((o) => dialOn(channel, o), input, options2),
968
1055
  fs: fsOn(channel),
969
1056
  serveCdp: (hook, options2 = {}) => serveCdpOn(channel, hook, options2),
1057
+ serveFacade: (name, hook) => serveFacadeOn(channel, name, hook),
970
1058
  onTraits(listener) {
971
1059
  channel.traitsListeners.add(listener);
972
1060
  return () => channel.traitsListeners.delete(listener);