@ai-ecoverse/slicc-kernel 1.61.1 → 1.62.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
@@ -38,7 +38,8 @@ const { status, stdout, stderr } = await kernel.run(['bash', '-c', 'echo hi > he
38
38
  | `onMountPending` | none | `({ target, source, insert }) => void`: an `fsa` mount needs a folder; call `insert()` from a user gesture |
39
39
  | `processMounts` | `true` | whether programs may call `mount(2)` and `umount2(2)`: `false`, or `(req) => boolean \| Promise<boolean>` to decide each call (see [Mounting from a program](#mounting-from-a-program)) |
40
40
  | `hostname` | `'slicc'` | the kernel's node name (see [Node name](#node-name)) |
41
- | `users` | `'off'` | `'off'`, `'report'` or `'enforce'`: whether files have owners (see [File owners](#file-owners)). `'off'` behaves exactly as kernels before owners did; any other value rejects. `createNodeKernel` takes it too |
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
+ | `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 |
42
43
  | `cdp` | none | `({ runtime }) => Promise<CdpConnection>`: a browser-level DevTools connection for programs that drive a browser (see [Browser automation](#browser-automation-cdp)) |
43
44
 
44
45
  ### `kernel.run(argv, options?) → Promise<{ status, stdout, stderr }>`
@@ -160,7 +161,7 @@ const { status, stdout } = await kernel.run(['bash', '-c', 'echo hi'], { cwd: '/
160
161
  kernel.terminate();
161
162
  ```
162
163
 
163
- `createNodeKernel({ root, modules, env, network, worker, processMounts, fstabRetries, cdp, hostfs, hostfsOrigin, hostfsFetch })` 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.
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.
164
165
 
165
166
  ## Commands
166
167
 
@@ -228,7 +229,8 @@ A WASI command can name an ES module of its package that provides imports of its
228
229
  - `spawn({ argv, env, cwd, stdin, stdout, stderr })` starts a command (resolved like `kernel.run`) as a child and returns `{ pid, stdin, stdout, stderr }`. `env` is the child's whole environment (the program's own by default), `cwd` the program's by default. Each stdio is `'inherit'` (the default: the program's own), `'null'`, or `'pipe'`: a kernel pipe whose end the result holds as a descriptor, read and written with `fd-read` (`{ op: 'fd-read', fd, max }`, an empty read at the end), `fd-write` (`{ op: 'fd-write', fd, body }`) and `fd-close`, through `syscall` or `async.submit`; the child's ends are closed in the program. A command that does not exist throws `ENOENT`;
229
230
  - `wait(pid)` blocks until a child ends and returns `{ status }`, or `{ status: 128 + signal, signal }` when a signal ended it; `kill(pid, sig)` signals it;
230
231
  - `async`: operations of the process that any of its threads can wait for: `submit(req)` runs a kernel syscall without blocking the caller, `resolve(value)` completes at once, `hold()` never completes, and each returns an id; `wait(timeoutMs?)` returns the id of a completed one (0 after the timeout, -1 once `close()` was called), `take(id)` its `{ value }` or `{ error }` (`undefined` while it is pending), and `cancel(id)` drops one;
231
- - `errno(err)`: the WASI errno of an error.
232
+ - `errno(err)`: the WASI errno of an error;
233
+ - `uid` and `gid`, with `users: 'report'` or `'enforce'`: the process's effective uid and gid, read again at each use, so they follow a `setresuid`. `fstat` then shows a file's real owner, and the process's ids for a descriptor with no path. With `users: 'off'`, `ctx` has no `uid` or `gid`, and `fstat` answers 1000 for both, as before.
232
234
 
233
235
  ### JS programs
234
236
 
@@ -406,7 +408,7 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
406
408
  - **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.
407
409
  - **Rules on a mount:**
408
410
  - a rename across mounts is `EXDEV`, so `mv` copies;
409
- - 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. `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, except after it starts a subprocess: the files it had open are kernel descriptors from then on, and a later open of the same file, under that name or another, is only consistent with them at open and close ([#355](https://github.com/ai-ecoverse/slicc-kernel/issues/355)); 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;
411
+ - 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. `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;
410
412
  - `df` reports each mount from its driver's `statfs`;
411
413
  - each mount has its own device number;
412
414
  - without `chmod` support, `chmod` succeeds and changes nothing;
@@ -520,7 +522,7 @@ Lowering a soft resource limit (`ulimit -S -n 64`) still fails with `EPERM` in E
520
522
 
521
523
  ### Users
522
524
 
523
- Every process has credentials in the kernel, as on Linux: real, effective and saved user and group ids, and supplementary groups. A process gets them from the user it was started as (`run`, `openTerminal`, a client's `spawn`, or the client's own user; root by default) and keeps its parent's across fork, spawn and exec. Setuid and setgid bits are never honoured on exec. Files may have owners (see [File owners](#file-owners)) but aren't checked against the credentials yet; they decide who may signal whom, and what `/proc` and `ps` show.
525
+ Every process has credentials in the kernel, as on Linux: real, effective and saved user and group ids, and supplementary groups. A process gets them from the user it was started as (`run`, `openTerminal`, a client's `spawn`, or the client's own user; root by default) and keeps its parent's across fork, spawn and exec. Setuid and setgid bits are never honoured on exec. Files may have owners (see [File owners](#file-owners)), checked against the credentials with `users: 'enforce'` (see [Permissions](#permissions)); the credentials also decide who may signal whom, and what `/proc` and `ps` show.
524
526
 
525
527
  - **The database:** `/etc/passwd` and `/etc/group` are real files. On first start the kernel writes them when they're missing, with `root` (uid 0, home `/root`, created `0700`) and `nobody` (65534), and the groups `root`, `sudo` (27), `users` (100) and `nogroup` (65534); files already there are kept as they are. The kernel reads them for every lookup, so an edit counts at once. [`kernel.users`](#kernelusers) adds and removes users.
526
528
  - **Environment:** a process started as a user gets `HOME`, `USER`, `LOGNAME`, `PNPM_HOME`, `NPM_CONFIG_PREFIX` and `PATH`. A user's `PNPM_HOME` is `~/.local/share/pnpm` and its npm prefix `~/.local`; root's are `/usr/local/share/pnpm` (or the embedder's `env.PNPM_HOME`) and `/usr/local`. `PATH` is `$PNPM_HOME/bin:/usr/local/bin:/usr/bin:/bin`.
@@ -536,7 +538,7 @@ Programs ask the kernel for their ids:
536
538
 
537
539
  ### File owners
538
540
 
539
- With `createKernel({ users: 'report' })` or `'enforce'`, files have owners. Nothing checks them against a process's credentials yet; `'report'` and `'enforce'` differ once permissions are checked. With `'off'`, the default, nothing below happens: the kernel stores no owner, and every file looks owned by the caller as described under [Metadata](#metadata).
541
+ With `createKernel({ users: 'report' })` or `'enforce'`, files have owners, and [Permissions](#permissions) checks them against a process's credentials. With `'off'`, the default, nothing below happens: the kernel stores no owner, and every file looks owned by the caller as described under [Metadata](#metadata).
540
542
 
541
543
  - **Where owners live:** on the root file system, in the sidecar record's `uid` and `gid` (see [Metadata](#metadata)). A path with no owner of its own has the owner of its nearest ancestor that has one, and `/` belongs to root, so a root written before owners existed, or by the BIOS through the OPFS API, is root's throughout. A directory whose owner changes keeps the owner its unowned contents had, in `heir`, so `chown cone /old` gives `cone` the directory and leaves the files below it to their old owner. A rename keeps the owner of what it moves.
542
544
  - **New files:** a file, directory or symlink a process creates belongs to its effective uid, and to its effective gid, or to the directory's group when the directory has the setgid bit; a new directory in a setgid directory gets the setgid bit too. Every file and directory a process creates gets the mode it asked for (`0666` and `0777` when it can't ask, as a JS program's `ctx.fs` can't) less the process's umask, whichever runtime made it; an Emscripten program's file no longer gets `0644` whatever it asked for. A file written through a client's `fs` belongs to the client's user (root for a page) and gets `0666`, a directory `0777`, less the umask `kernel.users.add` gave that user (`022` for root and by default); the kernel's own files, such as `/etc/passwd`, have no owner of their own.
@@ -545,6 +547,18 @@ With `createKernel({ users: 'report' })` or `'enforce'`, files have owners. Noth
545
547
  - **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.
546
548
  - **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.
547
549
 
550
+ ### Permissions
551
+
552
+ With `users: 'enforce'` the kernel checks every file call that reaches it against the caller's effective uid, effective gid and supplementary groups, as Linux does: the owner bits for the file's owner, else the group bits for a member of its group, else the other bits. Root (effective uid 0) passes every check except executing a file with no `x` bit at all. A failed check is `EACCES`; changing what only the owner may change is `EPERM`. With `users: 'report'` the kernel makes the same checks and refuses nothing: it logs each denial once per user, operation, errno and path, with `console.warn`, up to 10,000 of them, then says that more were suppressed. With both, `onDenial` gets each denial as `{ pid, uid, op, path, errno }` (`pid` 0 for a client's `fs`); the shape is stable. With `users: 'off'` nothing is checked and no call is added.
553
+
554
+ - **Paths:** a path needs `x` on every directory it goes through, the targets of symlinks on the way included, resolved as the file system resolves them (a `..` after a symlinked directory goes to the target's parent). `stat`, `lstat`, `readlink` and `chown` need only that; `exists` answers `false` where it's refused. Listing a directory needs `r` and `x` on it.
555
+ - **Creating and removing:** `mkdir`, creating a file and `symlink` need `w` and `x` on the directory; `mkdir -p` needs them on the closest directory that exists, and nothing for one that exists already. Unlinking, `rmdir` and `rename` need `w` and `x` on both directories, and a directory moved to another needs `w` on itself. A recursive remove (`ctx.fs.rm`, a client's `rm`) needs `r`, `w` and `x` on every directory in the tree.
556
+ - **The sticky bit:** in a directory with mode `1000` set, such as `/tmp` at `1777`, only the file's owner, the directory's owner or root may unlink or rename a file (`EPERM` otherwise).
557
+ - **Hard links:** `link` needs `w` and `x` on the target's directory. As with Linux's `protected_hardlinks`, a user who doesn't own a file may link it only when it may read and write it (`EPERM` otherwise); root may link anything.
558
+ - **Modes and times:** only the owner or root may `chmod` (`EPERM`), and a `chmod` by a user that isn't in the file's group clears its setgid bit. Only the owner or root may set a file's times (`EPERM`).
559
+ - **A client's `fs`:** checked as the client's user; a page's client (root) passes.
560
+ - **Not yet checked:** opening a file and reading or writing it, executing it, and `access(2)`; until they are, `users: 'enforce'` isn't a security boundary, and no release turns it on by default before `sudo` exists.
561
+
548
562
  ### `/proc`
549
563
 
550
564
  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`. 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:`) 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, load and `MemTotal` (from `navigator.deviceMemory`, else 4 GiB) are placeholders. 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.
package/dist/index.js CHANGED
@@ -630,8 +630,8 @@ function openWebSocket(Socket, request, withHeaders) {
630
630
  reject(error);
631
631
  };
632
632
  const abort = () => {
633
- socket.close(1e3, "aborted");
634
633
  fail(signal.reason);
634
+ socket.close(1e3, "aborted");
635
635
  };
636
636
  signal.addEventListener("abort", abort, { once: true });
637
637
  socket.addEventListener("open", () => {
@@ -655,12 +655,12 @@ function openWebSocket(Socket, request, withHeaders) {
655
655
  });
656
656
  socket.addEventListener("error", () => {
657
657
  if (open || settled) return;
658
+ fail(unopened(url, 1006));
659
+ queue.end({ code: 1006, reason: "" });
658
660
  try {
659
661
  socket.close();
660
662
  } catch {
661
663
  }
662
- queue.end({ code: 1006, reason: "" });
663
- fail(unopened(url, 1006));
664
664
  });
665
665
  socket.addEventListener("close", (event) => {
666
666
  queue.end({ code: event.code, reason: event.reason });
@@ -1798,6 +1798,7 @@ function initCall(options, transport) {
1798
1798
  ...options.cdp ? { cdp: true } : {},
1799
1799
  ...options.hostname !== void 0 ? { hostname: options.hostname } : {},
1800
1800
  ...options.users !== void 0 ? { users: options.users } : {},
1801
+ ...options.onDenial ? { denials: true } : {},
1801
1802
  ...options.processMounts !== void 0 ? {
1802
1803
  processMounts: typeof options.processMounts === "function" ? "ask" : options.processMounts
1803
1804
  } : {}
@@ -1843,6 +1844,15 @@ function kernelUsers(call, mode = "off") {
1843
1844
  list: async () => await call({ op: "users-list" })
1844
1845
  };
1845
1846
  }
1847
+ async function mountPolicy(options, worker, { id, req }) {
1848
+ const decide = options.processMounts;
1849
+ let allowed = false;
1850
+ try {
1851
+ allowed = typeof decide === "function" ? await decide(req) : decide !== false;
1852
+ } catch {
1853
+ }
1854
+ worker.postMessage({ mountAllowed: { id, allowed } });
1855
+ }
1846
1856
  async function createKernel(options = {}) {
1847
1857
  if (!globalThis.crossOriginIsolated) throw new Error(ISOLATION);
1848
1858
  if (options.hostname !== void 0 && !isHostname(options.hostname)) {
@@ -1886,15 +1896,7 @@ async function createKernel(options = {}) {
1886
1896
  worker.postMessage({ hostfsGrant: { id, error: err.message ?? String(err) } });
1887
1897
  }
1888
1898
  }
1889
- async function policy({ id, req }) {
1890
- const decide = options.processMounts;
1891
- let allowed = false;
1892
- try {
1893
- allowed = typeof decide === "function" ? await decide(req) : decide !== false;
1894
- } catch {
1895
- }
1896
- worker.postMessage({ mountAllowed: { id, allowed } });
1897
- }
1899
+ const policy = (asked) => mountPolicy(options, worker, asked);
1898
1900
  const pendingMedium = ({ target, source, handle }) => options.onMountPending?.({
1899
1901
  target,
1900
1902
  source,
@@ -1905,6 +1907,7 @@ async function createKernel(options = {}) {
1905
1907
  ({
1906
1908
  data
1907
1909
  }) => {
1910
+ if ("denial" in data) return void options.onDenial?.(data.denial);
1908
1911
  if ("net" in data) return bridge?.answer(data);
1909
1912
  if ("uplink" in data) return uplinks?.answer(data);
1910
1913
  if ("cdpOpen" in data) return cdp.open(data);