@ai-ecoverse/slicc-kernel 1.89.0 → 1.91.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 +33 -5
- package/dist/driver-worker.js +89 -1
- package/dist/driver-worker.js.map +3 -3
- package/dist/index.js +135 -5
- package/dist/index.js.map +3 -3
- package/dist/kernel-worker.js +767 -95
- package/dist/kernel-worker.js.map +4 -4
- package/dist/node-driver-worker.js +89 -1
- package/dist/node-driver-worker.js.map +3 -3
- package/dist/node-process-worker.js +192 -87
- package/dist/node-process-worker.js.map +4 -4
- package/dist/node.js +1801 -1056
- package/dist/node.js.map +4 -4
- package/dist/process-worker.js +154 -49
- package/dist/process-worker.js.map +3 -3
- package/dist/testing.js.map +1 -1
- package/package.json +3 -3
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 }>`
|
|
@@ -176,6 +177,7 @@ Attaches to a kernel over a port from `kernel.connect()`, in any realm: a worker
|
|
|
176
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` |
|
|
177
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 |
|
|
178
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` |
|
|
179
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 |
|
|
180
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 |
|
|
181
183
|
| `closed` | resolves with the error that ended the client |
|
|
@@ -196,7 +198,7 @@ const { status, stdout } = await kernel.run(['bash', '-c', 'echo hi'], { cwd: '/
|
|
|
196
198
|
kernel.terminate();
|
|
197
199
|
```
|
|
198
200
|
|
|
199
|
-
`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 `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.
|
|
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.
|
|
200
202
|
|
|
201
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.
|
|
202
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`.
|
|
@@ -259,7 +261,7 @@ On top of preview1 a WASI program gets:
|
|
|
259
261
|
- **Threads**: `wasi.thread-spawn` (wasm32-wasip1-threads) and WASIX `thread_spawn_v2` start a worker per thread on the process's shared memory, sized from the module's own import (a 2 GiB maximum when the engine cannot reserve more). The threads share one descriptor table, and a file written, created, renamed or removed in one thread shows its new size and metadata to `stat` in every other thread at once, under any of its names (a symlink, a hard link, a new name after a rename in the same process), also when another thread renames a file over that name meanwhile, since the kernel reports which file an open or a change reached; this holds among the kernel's own processes, not against other tabs, `hostfs` writers or programs that aren't WASI; another process's changes show at close and after a `spawn` or `wait`, as with close-to-open caching, so a file another process renames while this one holds it open may show its old size under the new name until then; `exit` or a trap in any thread ends the process. At most 64 threads per process; `SLICC_WASM_THREADS` sets another cap, up to 256.
|
|
260
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`.
|
|
261
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.
|
|
262
|
-
- **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).
|
|
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).
|
|
263
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.
|
|
264
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.
|
|
265
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.
|
|
@@ -314,6 +316,7 @@ The module's default export (or its `main` export) is called with a context and
|
|
|
314
316
|
- `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`;
|
|
315
317
|
- `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;
|
|
316
318
|
- `fetch(input, init)`, the web's `fetch` over the kernel's transport, the one the proxy uses, so the embedder's transport, its WebSocket and proxy settings and the kernel's routes apply. The response body streams as the program reads it. The request body is read whole first, since a transport takes it whole (streaming it is [#238](https://github.com/ai-ecoverse/slicc-kernel/issues/238)), and one larger than the transport's `maxRequestBody` is answered `413`, as through the proxy. A signal aborts the request at any stage. Redirects follow the fetch rules: at most 20, a 301 or 302 after a `POST` and a 303 turn into a `GET` without a body, `Authorization` is dropped across origins, and `redirect: 'manual'` returns the redirect itself. On a transport that follows redirects on its own (`fetchTransport()` in a browser) only `'follow'` works: `'manual'` and `'error'` reject with a `TypeError` whose `cause.code` is `ENOTSUP`, since such a transport never shows the program a redirect. A status the transport chose comes back as a response whose body is the reason. An unreachable host, or no transport at all, rejects with a `TypeError` whose `cause.code` is `ECONNREFUSED` or `ENETUNREACH`, as Node's `fetch` does. A `data:` URL is answered by `fetch` itself, as the fetch standard decodes it, without a transport: for any method, a `HEAD` without a body, and its type parsed and serialized as a MIME type (lowercase, no spaces, invalid parameters dropped), as Node's `fetch` answers; any other scheme than `http:` and `https:` rejects with a `TypeError` whose `cause.code` is `EPROTONOSUPPORT`, at the start as for a redirect target. The global `fetch` stays withheld;
|
|
319
|
+
- **`fetch` and `websocket` to the kernel's own loopback:** a URL whose host is `127.0.0.0/8`, `0.0.0.0`, `[::1]`, `localhost` or `*.localhost` goes to the kernel's own loopback, as a guest's sockets do (`<port>.kernel.localhost` takes its port from the name). So it reaches whatever listens on that port in the kernel, the CDP facade at `SLICC_CDP_URL` and its `/json` included. It never goes to the transport: with nothing listening, it rejects with a `TypeError` whose `cause.code` is `ECONNREFUSED`, with or without a transport. The kernel speaks HTTP/1.1 and WebSocket (RFC 6455) to the listener itself, and `fetch` handles the redirects and compressed bodies it answers, as for any server. `https:` and `wss:` there reject with `ENOTSUP`. The listener sees a connection from `127.0.0.1` whose `SO_PEERCRED` is the program's pid, uid and gid, as if the program had connected itself. `host.slicc.internal` still goes to the transport as the real machine's loopback, for `fetch` too, under the transport's policy (`nodeTransport({ hostLoopback })`). A redirect from any other URL into the kernel's loopback rejects with `cause.code` `EACCES`, so a server elsewhere can't steer a program's request to the kernel's own servers; `redirect: 'manual'` still returns it;
|
|
317
320
|
- `tty`, for programs on a terminal: `getAttr(fd)` and `setAttr(fd, termios)` read and set its termios (`{ c_iflag, c_oflag, c_cflag, c_lflag, c_cc }`, Linux values), `setRaw(fd, raw)` switches raw mode on as libuv (and Node's `setRawMode`) does (no echo, no line editing, no `^C` signal, each key read as it comes) and off again to the termios it had before (per terminal: after raw mode on fds 0 and 1 of one terminal, turning it off on either restores the termios from before the first), `size(fd)` answers `{ columns, rows }`, and `foreground(fd)` and `setForeground(fd, pgrp)` get and set the foreground process group, under the same job-control rules as other programs. A window resize arrives as `SIGWINCH` through `signals`. A terminal the program left raw is set back when the program ends, whether it returns, exits or throws (a program killed by a signal cannot do so). On a descriptor that is not a terminal, each call fails with `ENOTTY`;
|
|
318
321
|
- `exit(status)`, which ends the program once its pending writes are done;
|
|
319
322
|
- `onUncaught(listener)` and `onUnhandledRejection(listener)`, which take the errors the program throws outside any call and the promises it leaves rejected (`listener(error, promise)`), as Node's `uncaughtException` and `unhandledRejection` events do. While a listener is registered, the program decides what such an error means; each call answers a function that removes its listener. A listener that throws ends the program as an unhandled error does.
|
|
@@ -416,7 +419,7 @@ Programs drive a browser with the Chrome DevTools Protocol over a WebSocket, whi
|
|
|
416
419
|
- **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.
|
|
417
420
|
- **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.
|
|
418
421
|
- **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.
|
|
419
|
-
- **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.
|
|
422
|
+
- **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.
|
|
420
423
|
|
|
421
424
|
An embedder offers a browser with the `cdp` option of `createKernel` or `createNodeKernel`, or from an attached client with `client.serveCdp(hook, { runtime })`:
|
|
422
425
|
|
|
@@ -434,6 +437,31 @@ A connection is one browser-level CDP session; a host with only per-tab debuggin
|
|
|
434
437
|
|
|
435
438
|
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`.
|
|
436
439
|
|
|
440
|
+
### Page facades
|
|
441
|
+
|
|
442
|
+
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.
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
createKernel({ facades: { link: { port: 7400, hook } } });
|
|
446
|
+
|
|
447
|
+
type FacadeHook = (stamp: ConnectorStamp, info: { name: string }) => FacadeConnection | Promise<FacadeConnection>;
|
|
448
|
+
|
|
449
|
+
interface FacadeConnection {
|
|
450
|
+
send(bytes: Uint8Array): void;
|
|
451
|
+
onmessage: ((bytes: Uint8Array) => void) | null;
|
|
452
|
+
onclose: ((reason?: string) => void) | null;
|
|
453
|
+
close(reason?: string): void;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
interface ConnectorStamp { pid: number; ppid: number; uid: number; gid: number; user?: string; groups: number[]; users: 'off' | 'report' | 'enforce'; tag?: string }
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
- **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.
|
|
460
|
+
- **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.
|
|
461
|
+
- **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.
|
|
462
|
+
- **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.
|
|
463
|
+
- **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`.
|
|
464
|
+
|
|
437
465
|
### Local proxy protocol
|
|
438
466
|
|
|
439
467
|
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'`:
|
|
@@ -470,7 +498,7 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
|
|
|
470
498
|
- **Rules on a mount:**
|
|
471
499
|
- a rename across mounts is `EXDEV`, so `mv` copies;
|
|
472
500
|
- 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;
|
|
473
|
-
- 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 (
|
|
501
|
+
- 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;
|
|
474
502
|
- `df` reports each mount from its driver's `statfs`;
|
|
475
503
|
- each mount has its own device number;
|
|
476
504
|
- without `chmod` support, `chmod` succeeds and changes nothing;
|
|
@@ -731,7 +759,7 @@ OPFS stays authoritative: an entry is consulted only for a path that exists in O
|
|
|
731
759
|
|
|
732
760
|
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.
|
|
733
761
|
|
|
734
|
-
Not covered: hard links (they
|
|
762
|
+
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.
|
|
735
763
|
|
|
736
764
|
Reads retry briefly when a concurrent write has invalidated the OPFS file snapshot (`NotReadableError`).
|
|
737
765
|
|
package/dist/driver-worker.js
CHANGED
|
@@ -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,
|
|
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);
|