@ai-ecoverse/slicc-kernel 1.65.2 → 1.66.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -9
- package/dist/driver-worker.js +8 -3
- package/dist/driver-worker.js.map +3 -3
- package/dist/driver.js +6 -1
- package/dist/driver.js.map +3 -3
- package/dist/index.js +61 -25
- package/dist/index.js.map +3 -3
- package/dist/kernel-worker.js +802 -202
- package/dist/kernel-worker.js.map +4 -4
- package/dist/node-driver-worker.js +8 -3
- package/dist/node-driver-worker.js.map +3 -3
- package/dist/node-process-worker.js +6 -0
- package/dist/node-process-worker.js.map +2 -2
- package/dist/node.js +770 -194
- package/dist/node.js.map +4 -4
- package/dist/process-worker.js +6 -0
- package/dist/process-worker.js.map +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ const { status, stdout, stderr } = await kernel.run(['bash', '-c', 'echo hi > he
|
|
|
40
40
|
| `hostname` | `'slicc'` | the kernel's node name (see [Node name](#node-name)) |
|
|
41
41
|
| `users` | `'off'` | `'off'`, `'report'` or `'enforce'`: whether files have owners and their modes are checked (see [File owners](#file-owners) and [Permissions](#permissions)). `'off'` behaves exactly as kernels before owners did; any other value rejects. `createNodeKernel` takes it too |
|
|
42
42
|
| `onDenial` | none | `({ pid, uid, op, path, errno }) => void`: called for every permission check that fails, under `'report'` and `'enforce'` (see [Permissions](#permissions)). `createNodeKernel` takes it too |
|
|
43
|
+
| `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 |
|
|
43
44
|
| `cdp` | none | `({ runtime }) => Promise<CdpConnection>`: a browser-level DevTools connection for programs that drive a browser (see [Browser automation](#browser-automation-cdp)) |
|
|
44
45
|
|
|
45
46
|
### `kernel.run(argv, options?) → Promise<{ status, stdout, stderr }>`
|
|
@@ -50,6 +51,7 @@ Runs `argv[0]` with `argv` as its arguments and resolves when it exits. `stdout`
|
|
|
50
51
|
| --- | --- |
|
|
51
52
|
| `cwd` | working directory, default `/`; created in OPFS if missing |
|
|
52
53
|
| `user` | the user to run as, by name or uid (see [Users](#users)); default `root`. An unknown user rejects with `code: 'ENOENT'` |
|
|
54
|
+
| `tag` | a string the kernel keeps with the process and every process it starts (fork, spawn and exec), and hands to `approve` with a [`sudo`](#sudo) request, so the embedder knows which agent asks; nothing a program does changes it. Anything but a string rejects with a `TypeError` |
|
|
53
55
|
| `env` | extra environment, over the user's defaults: `HOME`, `USER`, `LOGNAME`, `PNPM_HOME` and `PATH=$PNPM_HOME/bin:/usr/local/bin:/usr/bin:/bin` (see [Users](#users)), and `PWD=<cwd>` |
|
|
54
56
|
| `stdin` | a string or `Uint8Array`; without it stdin is `/dev/null` |
|
|
55
57
|
| `onStdout`, `onStderr` | called with each chunk of text as it is written |
|
|
@@ -60,7 +62,7 @@ Starts `argv` as the session leader on a new terminal and resolves once it is ru
|
|
|
60
62
|
|
|
61
63
|
| option | |
|
|
62
64
|
| --- | --- |
|
|
63
|
-
| `cwd`, `user`, `env` | as for `run`; `TERM=xterm-256color` and `COLORTERM=truecolor` are added |
|
|
65
|
+
| `cwd`, `user`, `tag`, `env` | as for `run`; `TERM=xterm-256color` and `COLORTERM=truecolor` are added |
|
|
64
66
|
| `cols`, `rows` | initial size, default 80 × 24, so `$COLUMNS` and `$LINES` are right before the first prompt |
|
|
65
67
|
| `onData` | called with each chunk of output as raw bytes (escape sequences included) |
|
|
66
68
|
|
|
@@ -78,11 +80,11 @@ Starts `argv` as the session leader on a new terminal and resolves once it is ru
|
|
|
78
80
|
|
|
79
81
|
Stops the kernel worker and every process. Pending and later calls reject.
|
|
80
82
|
|
|
81
|
-
### `kernel.connect({ user }?) → Promise<MessagePort>`
|
|
83
|
+
### `kernel.connect({ user, tag }?) → Promise<MessagePort>`
|
|
82
84
|
|
|
83
85
|
Makes a port for another client of the same kernel. Hand it to a dedicated worker, or through a SharedWorker to another tab: it can be transferred any number of times. All clients share one process table, so a terminal on the page can `ps` and `kill` what a worker started.
|
|
84
86
|
|
|
85
|
-
**`user`** (a name or uid) binds the client to that user; without it the client is root. The binding is a capability: the client's `spawn`, `run` and `openTerminal` run as that user, its `ps` lists only that user's processes, and its `kill` follows POSIX permission. A bound client can't run anything as another user (`code: 'EPERM'`). A root client may pass `user` to `spawn`, `run` or `openTerminal` to run one command as any user. An unknown user rejects with `code: 'ENOENT'`.
|
|
87
|
+
**`user`** (a name or uid) binds the client to that user; without it the client is root. The binding is a capability: the client's `spawn`, `run` and `openTerminal` run as that user, its `ps` lists only that user's processes, and its `kill` follows POSIX permission. A bound client can't run anything as another user (`code: 'EPERM'`). A root client may pass `user` to `spawn`, `run` or `openTerminal` to run one command as any user. An unknown user rejects with `code: 'ENOENT'`. **`tag`** is the tag (see `kernel.run`) of every process the client starts; its own `spawn`, `run` and `openTerminal` cannot name another.
|
|
86
88
|
|
|
87
89
|
### `kernel.users`
|
|
88
90
|
|
|
@@ -146,7 +148,7 @@ Attaches to a kernel over a port from `kernel.connect()`, in any realm: a worker
|
|
|
146
148
|
| `closed` | resolves with the error that ended the client |
|
|
147
149
|
| `users` | the kernel's `users` mode, `'off'`, `'report'` or `'enforce'`; `'off'` from kernels that don't say |
|
|
148
150
|
|
|
149
|
-
When the kernel goes away (the page closed or reloaded, `terminate()`), pending calls and `exited` reject with `KernelGoneError`, and so do later calls. Each side holds a Web Lock and waits on the other's, since a `MessagePort` reports no close in browsers; in Node, the port's `close` event does the same. The first message is a handshake on the protocol version, `1.
|
|
151
|
+
When the kernel goes away (the page closed or reloaded, `terminate()`), pending calls and `exited` reject with `KernelGoneError`, and so do later calls. Each side holds a Web Lock and waits on the other's, since a `MessagePort` reports no close in browsers; in Node, the port's `close` event does the same. The first message is a handshake on the protocol version, `1.9` (`1.0` had no `watch`, `1.1` no mounts, `1.2` no `spawn` into a group, `1.3` no `dial` and `1.4` no `serveCdp`, which reject with `code: 'ENOSYS'` on such a kernel; a `1.5` kernel has no [uplink](#uplink), a `1.6` kernel resolves names through one but connects nothing to it, a `1.7` kernel has no users, and a `1.8` kernel has no `sudo` and no tags): a client or kernel of another major version is refused with an error naming both.
|
|
150
152
|
|
|
151
153
|
### Headless in Node, for tests
|
|
152
154
|
|
|
@@ -161,7 +163,7 @@ const { status, stdout } = await kernel.run(['bash', '-c', 'echo hi'], { cwd: '/
|
|
|
161
163
|
kernel.terminate();
|
|
162
164
|
```
|
|
163
165
|
|
|
164
|
-
`createNodeKernel({ root, modules, env, network, worker, processMounts, fstabRetries, cdp, hostfs, hostfsOrigin, hostfsFetch, users, onDenial })` 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` and `terminate` as above, plus `root`, `writeFile(path, data)` (creating the parent directories) 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.
|
|
166
|
+
`createNodeKernel({ root, modules, 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` and `terminate` as above, plus `root`, `writeFile(path, data)` (creating the parent directories) 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.
|
|
165
167
|
|
|
166
168
|
## Commands
|
|
167
169
|
|
|
@@ -257,6 +259,7 @@ The module's default export (or its `main` export) is called with a context and
|
|
|
257
259
|
- `sync.exit(status)`, which ends the process at once, as Node's `process.exit()` does: no `catch`, `finally`, microtask, timer or signal handler runs after it, because the call never returns. First the writes the program has made reach their descriptors, in order (a broken pipe drops the rest for that descriptor), and terminal modes the program set with `tty` are put back. Then the process ends the way any exit does: its descriptors are closed and written back, its parent gets `SIGCHLD`, and `wait` reports `status`. Children keep running. A JS program has no threads of its own (`Worker` is withheld), so nothing of it outlives the call. `exit(status)` (below) unwinds instead, so the program's `catch` and `finally` blocks run;
|
|
258
260
|
- `spawn({ argv, env, cwd, stdin, stdout, stderr })` starts a command (found as `kernel.run` finds it) as a child process and resolves with `{ pid, stdin, stdout, stderr, wait(), kill(signal) }`. Each stdio is `'inherit'` (the default: the program's own), `'pipe'` (a web stream on a kernel pipe), `'null'`, or one of the program's descriptors. `env` is the child's whole environment (the program's by default), and `cwd` defaults to the program's. A command that does not exist rejects with `ENOENT`;
|
|
259
261
|
- `wait(pid)` resolves with `{ status }` once a child ends, or `{ status: 128 + signal, signal }` when a signal ended it, without blocking the program; `kill(pid, signal)` signals any process, by number or as `'SIGTERM'` (the default);
|
|
262
|
+
- `sudo(args)`, what the [`sudo`](#sudo) command calls: `args` are its arguments after `sudo`, and it resolves with `{ pid }`, the command the kernel started as the program's child, to `wait` for; `{ run }`, an argv to run as the caller (with `users: 'off'`); `{ list }`, the text of `sudo -l`; or `{ error, errno }`, the message to print;
|
|
260
263
|
- `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`;
|
|
261
264
|
- `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;
|
|
262
265
|
- `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;
|
|
@@ -482,7 +485,7 @@ Programs mount and unmount with `mount(2)` and `umount2(2)`, so a `mount`/`umoun
|
|
|
482
485
|
- **Options:** `data` is the `-o` string, such as `ro,maxfile=1G`. `key=value` and `key` become `options`, `rw` cancels `ro`, and generic words (`defaults`, `noatime`, `nofail`, …) are dropped. A bad `maxfile` is `EINVAL`.
|
|
483
486
|
- **Unmounting:** `umount2(target, 0)` fails with `EBUSY` while a file is open under the mount. `MNT_DETACH` (`umount -l`) and `MNT_FORCE` unmount anyway: the mount leaves the table and `/proc/mounts` at once and its driver stops, and descriptors still open on it fail with `EIO`, without ever writing to the directory below.
|
|
484
487
|
- **Waiting:** `mount` waits for the mount to be made, which for `hostfs` is the grant and the first watch, up to 15 s. A signal ends the wait with `EINTR`, and a mount that is made after that is unmounted again.
|
|
485
|
-
- **Policy:** any program may mount, whatever user it runs as, unless the page passes `processMounts: false` (then every call fails with `EPERM`) or a function. The function gets `{ op: 'mount' | 'umount', pid, target, type?, source?, options? }` and returns whether to allow it. Whatever the policy, the kernel refuses mounts on `/` and on or under `/proc` and `/dev` with `EBUSY`.
|
|
488
|
+
- **Policy:** any program may mount, whatever user it runs as, unless the page passes `processMounts: false` (then every call fails with `EPERM`) or a function. The function gets `{ op: 'mount' | 'umount', pid, target, type?, source?, options? }` and returns whether to allow it. Whatever the policy, the kernel refuses mounts on `/` and on or under `/proc` and `/dev` with `EBUSY`. With `users: 'enforce'`, a program that isn't root, and a client bound to a user that isn't root (its `mount` and `umount`), may also mount only on a directory it owns, and unmount only a mount that is its own (`uid=` its effective uid), or the call fails with `EPERM`; such a mount always gets the caller's ids, so no user can lay a file system over root's commands.
|
|
486
489
|
- **Errors** are Linux's: `ENODEV` for an unknown type (or `hostfs` without a hook), `ENOENT` and `ENOTDIR` for the target, `EBUSY` for a target that is mounted already, and `EINVAL` for unmounting what is not a mount point.
|
|
487
490
|
- **`/proc/mounts`** escapes spaces and backslashes as Linux does, and adds `nomedium` or `failed` to the options of a mount in that state.
|
|
488
491
|
|
|
@@ -553,7 +556,7 @@ With `createKernel({ users: 'report' })` or `'enforce'`, files have owners, and
|
|
|
553
556
|
- **Homes:** every `kernel.users.add` gives the user's home (only a real directory at `/home/<dir>`, not a symlink or a path through one) and everything in it without an owner of its own to the user, and sets the home's mode: `0700`, `0750` for a `scoop-` user, `2750` with `homeGroup`, and its `~/.local/share/pnpm`'s to `0700` (`0750` for a `scoop-` user). The embedder calls `add` for each user at boot, as it already does, so homes made before owners were on become the user's. That also means a user's own `chmod` or `chgrp` of its home lasts only until the next `add`, and `add` must get the same `homeGroup` every time.
|
|
554
557
|
- **`chown`, `lchown`, `fchown`:** Emscripten programs, and WASI programs through `slicc_fs` (see [File modes and the umask](#file-modes-and-the-umask)), change owners through the kernel, by the Linux rules: root may set any owner and group; a file's owner may set its group to one it belongs to, or leave it as it is; anything else fails with `EPERM`. `-1` keeps an id. A `chown` that sets an owner or group of a file clears its setuid bit, and its setgid bit if the file is group-executable, whoever calls it, root included, as on Linux. With `users: 'off'`, `chown` succeeds and changes nothing, as before.
|
|
555
558
|
- **What programs see:** `stat` shows the owners to Emscripten programs, whatever their libc reports for their own ids, so a program built before H1 sees real owners too, and its owner checks can fail. WASI programs read them with `slicc_fs.fd_owner` and `path_owner`. The commands in `/usr/bin` and `/bin` are root's, and a user's own commands in `~/.local/share/pnpm/bin` belong to the owner of the closest real directory above them, normally the user.
|
|
556
|
-
- **Mounts:** the options `uid=` and `gid=` (decimal ids) give a mount its owner; without them it belongs to whoever mounted it: root for `kernel.mount`, `/etc/fstab` and a client, the process's effective ids for `mount(2)`, which then shows them in its options. A mount whose driver keeps owners (`tmpfs`) starts with its root owned so, and its files get owners as on the root file system, `chown` included. On `hostfs`, `fsa` and any driver without `owners`, every file shows as the mount's owner and `chown` fails with `EPERM`, root's too, as on a FAT drive. An embedder that mounts a folder for a user (bios's linked and picked folders) passes that user's `uid` and `gid`, or the user's programs see root's files there, and git calls the repository's ownership dubious.
|
|
559
|
+
- **Mounts:** the options `uid=` and `gid=` (decimal ids) give a mount its owner; without them it belongs to whoever mounted it: root for `kernel.mount`, `/etc/fstab` and a client, the process's effective ids for `mount(2)`, which then shows them in its options (a process that isn't root gets its own ids whatever `uid=` and `gid=` it passes, so it can't make a mount look root's). A mount whose driver keeps owners (`tmpfs`) starts with its root owned so, and its files get owners as on the root file system, `chown` included. On `hostfs`, `fsa` and any driver without `owners`, every file shows as the mount's owner and `chown` fails with `EPERM`, root's too, as on a FAT drive. An embedder that mounts a folder for a user (bios's linked and picked folders) passes that user's `uid` and `gid`, or the user's programs see root's files there, and git calls the repository's ownership dubious.
|
|
557
560
|
|
|
558
561
|
### Permissions
|
|
559
562
|
|
|
@@ -567,11 +570,24 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
|
|
|
567
570
|
- **Opening a file:** an open needs `r` to read and `w` to write or truncate, checked once when the file is opened, in every runtime: Emscripten, WASI (a second open of a path the program already has open is checked as well), JS programs' `ctx.open` and `ctx.fs.writeFile`, and the kernel's own opens. A file the open itself creates is not checked (only its directory is), so `open(O_CREAT | O_WRONLY, 0444)` works. An open descriptor keeps the access it was opened with, through a later `chmod`, and so do descriptors a child inherits. Reads and writes without a descriptor (`ctx.fs.readFile`, `ctx.sync.fs.readFile`) are checked each time.
|
|
568
571
|
- **`access(2)`:** `faccessat` (bash's `[ -r ]`, `test -w`) answers from the same checks, with the real uid and gid, or the effective ones with `AT_EACCESS`, for Emscripten programs, and for WASI programs through `slicc_fs.path_access` (see [File modes and the umask](#file-modes-and-the-umask)).
|
|
569
572
|
- **Exec:** running a program by path needs `x` on it, and `r` too when it's a script (`#!` or a `binfmt` suffix); a WASI or Emscripten binary needs only `x`. Root needs an `x` bit as well. Commands found by name, and `/usr/bin` and `/bin`, are always executable; a user's own commands in `~/.local/share/pnpm/bin` are reached through the user's home, so another user gets `EACCES` there, as for any file. A refused exec is `EACCES` (126 in bash).
|
|
570
|
-
- **The layout:** with `users: 'report'` or `'enforce'`, the kernel gives its own paths an owner and a mode when they have no owner record yet: on a fresh root, and once on a root from before owners. These are `/` `0755`, `/root` `0700`, `/home` `0755`, `/etc` `0755`, `/etc/passwd` and `/etc/group` `0644`, `/usr` and `/usr/local` `0755`, `/node_modules` (or the `modules` option) and the system `PNPM_HOME` `0755`, all root's. A path that has a record keeps it, so an admin's `chmod 711 /home` or `chown cone /node_modules` lasts across restarts; a mounted path is left to its mount, and a path below a symlinked directory is stamped where it really is. Only a root that records owners is stamped: the OPFS root with its sidecar, which the page and the Node entry (in memory) both use; a custom root file system without owner records is left as it is. The directory for files removed while open, `/.slicc-unlinked`, is made root's `0700`. `/tmp` is the exception: it is set to `1777` at every start. Emscripten programs see `/` at `0755`, `/dev` at `0755`, `null`, `zero`, `full`, `random`, `urandom` and `tty` at `0666`, and `/dev/shm` at `1777`. With `users: 'off'` nothing is stamped. Paths the embedder makes (bios's `/opt` and `/var/lib/slicc`) are the embedder's to set
|
|
573
|
+
- **The layout:** with `users: 'report'` or `'enforce'`, the kernel gives its own paths an owner and a mode when they have no owner record yet: on a fresh root, and once on a root from before owners. These are `/` `0755`, `/root` `0700`, `/home` `0755`, `/etc` `0755`, `/etc/passwd` and `/etc/group` `0644`, `/usr` and `/usr/local` `0755`, `/node_modules` (or the `modules` option) and the system `PNPM_HOME` `0755`, all root's. A path that has a record keeps it, so an admin's `chmod 711 /home` or `chown cone /node_modules` lasts across restarts; a mounted path is left to its mount, and a path below a symlinked directory is stamped where it really is. Only a root that records owners is stamped: the OPFS root with its sidecar, which the page and the Node entry (in memory) both use; a custom root file system without owner records is left as it is. The directory for files removed while open, `/.slicc-unlinked`, is made root's `0700`. `/tmp` is the exception: it is set to `1777` at every start. Emscripten programs see `/` at `0755`, `/dev` at `0755`, `null`, `zero`, `full`, `random`, `urandom` and `tty` at `0666`, and `/dev/shm` at `1777`. With `users: 'off'` nothing is stamped. Paths the embedder makes (bios's `/opt` and `/var/lib/slicc`) are the embedder's to set. `/etc/sudoers` and `/etc/sudoers.d` are written when missing (see [Sudo](#sudo)).
|
|
571
574
|
- **Terminals:** a terminal from `openTerminal` belongs to its session's user, with group `tty` when `/etc/group` has one (the user's own group otherwise), mode `0620`; a pseudo-terminal's slave `/dev/pts/N` belongs to the uid and gid that opened `/dev/ptmx`, mode `0620`. Opening one by name (`/dev/ttyN`, `/dev/pts/N`, from every runtime) needs both `r` and `w`, so only its owner and root open it: the kernel's terminal descriptor always reads and writes, so the group's `w` bit (`write`, `wall` on Linux) does not let a group member open it. `/dev/tty` is the opener's own controlling terminal and is not checked, and descriptors a process inherited (across `sudo` or a `setresuid` too) keep working. Emscripten programs' `stat` of `/dev/ttyN` and `/dev/pts/N` shows the owner and mode. With `users: 'off'` terminals have no owner, as before.
|
|
572
575
|
- **A client's `fs`:** checked as the client's user; a page's client (root) passes.
|
|
573
576
|
|
|
574
|
-
|
|
577
|
+
`users: 'enforce'` is not the default yet; it becomes so once the embedders have checked their programs under `'report'`.
|
|
578
|
+
|
|
579
|
+
### Sudo
|
|
580
|
+
|
|
581
|
+
`sudo` is a command of the kernel itself, in `/usr/bin` (a package's `sudo` never wins over it): `sudo [-n] [-u user] [--] command [args…]` runs `command` as `user` (root by default), and `sudo -l` lists what the caller may run. `-n` never asks: a command that needs approval fails with `sudo: a password is required`. The decision, the credentials and the environment are all made in the kernel; the command only passes its arguments on (`ctx.sudo`, see [JS programs](#js-programs)), so no program can grant itself more by calling the kernel some other way.
|
|
582
|
+
|
|
583
|
+
- **`sudo` elevates only under `users: 'enforce'`.** With `'off'` and `'report'`, `sudo command` just runs `command`, as the caller, with its environment. Under `'report'` nothing is refused, so anyone could rewrite the policy or a command; `sudo` grants nothing until the kernel enforces.
|
|
584
|
+
- **The policy** is `/etc/sudoers` and the files in `/etc/sudoers.d/` (in name order, skipping names that start with `.` or end in `~`), which the kernel writes at start when they're missing, with owners on: `/etc/sudoers` root's `0440` with the one line `%sudo Cmnd *`, and `/etc/sudoers.d` root's `0750`. There is no API: an admin (bios, as root) edits `/etc/sudoers.d/<user>`. A line is `[NOPASSWD] <user|%group|ALL> [(<target>)] Cmnd <argv…|*>`: who (a user name, a group, or anyone), the user it may run as (`root` by default, or `ALL`), and the command, as shell-quoted words whose first is an absolute path, matched exactly against the resolved path and the arguments, or `*` for any command. `#` lines and blank lines are ignored. A line that doesn't parse is skipped, and so is a policy file that isn't root's, is a symlink, or is writable by group or others, or one in a directory that is (each logged once with `console.warn`): what's skipped grants nothing. Groups are the caller's process groups, as `id` shows them.
|
|
585
|
+
- **The embedders' recipe** puts cones in `sudo` (`groups: ['sudo']`) and scoops not, so a cone may ask for any command and a scoop for none.
|
|
586
|
+
- **The command** is found once, on `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` for a bare name or against the caller's directory for a path, and its real path (symlinks resolved) is what runs and what the policy sees. That file, and every file that will really run for it (a catalog command's glue, module and imports module, and the `package.json` that defines it, a script and the interpreter its `#!` line or `binfmt` suffix names), must be root's and writable by root only, and so must every directory above each, or `sudo` refuses (`EPERM`), whatever the policy says. Other files a program loads at run time (a package's libraries) are the admin's to keep safe, as on Linux. The caller's directory, which the program passes on, must be a directory the caller may enter, or `sudo` refuses (`EACCES`). The kernel resolves it to its real path once, shows that in the request, and runs the command there; if it no longer resolves the same, or the caller can no longer enter it, when the answer comes or when the command starts, nothing runs.
|
|
587
|
+
- **The decision:** a caller whose effective uid is 0 runs anything (the check on the command's path still applies). Otherwise a matching `NOPASSWD` line runs the command at once; any other matching line asks `approve`; nothing matching is refused (`sudo: cone is not allowed to run /usr/bin/ls as root`).
|
|
588
|
+
- **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>) Cmnd <path> <args…>` to `/etc/sudoers.d/<user>`, root's `0440`, so it's never asked again; 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'`.
|
|
589
|
+
- **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. 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.
|
|
590
|
+
- **Cost:** with `users: 'off'`, and with owners on for programs that don't call `sudo`, nothing is added to any call.
|
|
575
591
|
|
|
576
592
|
### `/proc`
|
|
577
593
|
|
package/dist/driver-worker.js
CHANGED
|
@@ -280,6 +280,11 @@ var RemoteTransport = class {
|
|
|
280
280
|
}
|
|
281
281
|
};
|
|
282
282
|
|
|
283
|
+
// src/bytes.ts
|
|
284
|
+
function fresh(bytes, start = 0, end = bytes.length) {
|
|
285
|
+
return new Uint8Array(bytes.subarray(start, end));
|
|
286
|
+
}
|
|
287
|
+
|
|
283
288
|
// src/client/dial-stream.ts
|
|
284
289
|
function errorWithCode(message) {
|
|
285
290
|
const code = /^E[A-Z]+/.exec(message)?.[0];
|
|
@@ -333,7 +338,7 @@ function dialStream(port, onClose) {
|
|
|
333
338
|
const writable = new WritableStream({
|
|
334
339
|
write(chunk) {
|
|
335
340
|
if (closed) throw errorWithCode("EPIPE: the connection is closed");
|
|
336
|
-
const copy = chunk
|
|
341
|
+
const copy = fresh(chunk);
|
|
337
342
|
const acked = new Promise((resolve, reject) => acks.push({ resolve, reject }));
|
|
338
343
|
port.postMessage(copy, [copy.buffer]);
|
|
339
344
|
return acked;
|
|
@@ -520,7 +525,7 @@ async function loopbackFetch(dial, input, options) {
|
|
|
520
525
|
}
|
|
521
526
|
|
|
522
527
|
// src/client/protocol.ts
|
|
523
|
-
var PROTOCOL = [1,
|
|
528
|
+
var PROTOCOL = [1, 9];
|
|
524
529
|
function locksOf(scope = globalThis) {
|
|
525
530
|
const locks = scope.navigator?.locks;
|
|
526
531
|
return typeof locks?.request === "function" ? locks : void 0;
|
|
@@ -1013,7 +1018,7 @@ function serveFilesystem(port, handlers, capabilities = {}, options = {}) {
|
|
|
1013
1018
|
(result) => {
|
|
1014
1019
|
if (result instanceof Uint8Array) {
|
|
1015
1020
|
const whole = result.byteOffset === 0 && result.byteLength === result.buffer.byteLength;
|
|
1016
|
-
const bytes = options.owned && whole ? result : result
|
|
1021
|
+
const bytes = options.owned && whole ? result : fresh(result);
|
|
1017
1022
|
reply({ id: data.id, result: bytes }, [bytes.buffer]);
|
|
1018
1023
|
} else reply({ id: data.id, result: result ?? null });
|
|
1019
1024
|
},
|