@ai-ecoverse/slicc-kernel 1.168.1 → 1.169.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 +14 -25
- package/dist/index.js +39 -223
- package/dist/index.js.map +4 -4
- package/dist/kernel-worker.js +1 -1
- package/dist/node-process-worker.cjs +1 -1
- package/dist/node-process-worker.js +1 -1
- package/dist/node.js +2 -2
- package/dist/node.js.map +1 -1
- package/dist/process-worker.js +1 -1
- package/dist/types/index.d.ts +0 -1
- package/package.json +2 -3
- package/dist/types/local-proxy-transport.d.ts +0 -39
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ TypeScript declarations ship in `dist/types/`, and each entry (`.`, `./node`, `.
|
|
|
35
35
|
| `worker` | `new URL('./kernel-worker.js', import.meta.url)` | the kernel worker script |
|
|
36
36
|
| `network` | none | `{ transport, uplink, doh }`: how programs reach the outside world (see [Network](#network), [Uplink](#uplink) and [DNS](#dns)) |
|
|
37
37
|
| `requestDirectory` | none | `() => Promise<FileSystemDirectoryHandle>`, typically `showDirectoryPicker`: how an `fsa` mount gets its folder (see [Removable media](#removable-media-fsa)) |
|
|
38
|
-
| `hostfs` | none | `(source, { readonly }) => Promise<{ url, token, capabilities? } \| { port, token?, capabilities? }>`: a grant for a `hostfs` mount from the
|
|
38
|
+
| `hostfs` | none | `(source, { readonly }) => Promise<{ url, token, capabilities? } \| { port, token?, capabilities? }>`: a grant for a `hostfs` mount from a host that serves the folder over HTTP, or a `MessagePort` the page serves the folder on (see [Host folders](#host-folders-hostfs)) |
|
|
39
39
|
| `onMountPending` | none | `({ target, source, insert }) => void`: an `fsa` mount needs a folder; call `insert()` from a user gesture |
|
|
40
40
|
| `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)) |
|
|
41
41
|
| `hostname` | `'slicc'` | the kernel's node name (see [Node name](#node-name)) |
|
|
@@ -635,9 +635,8 @@ WASIX programs get `AF_INET`/`AF_INET6` `SOCK_DGRAM` sockets, and `AF_UNIX` ones
|
|
|
635
635
|
- **Streamed request bodies** ([#238](https://github.com/ai-ecoverse/slicc-kernel/issues/238)): a request comes with its body whole, as `body` (a `Uint8Array`), unless the transport sets `traits.streamingBodies`. Then a body the kernel doesn't hold whole comes as `bodyStream`, an async iterable of `Uint8Array`, never together with `body`. That's an upload through the proxy (`Content-Length` or chunked, curl's `-T`, git's push) and a JS program's `fetch` with a stream body. The kernel reads from the program only as fast as the transport pulls, applies no `maxRequestBody` and reserves no memory for it. A transport with a limit of its own rejects with `status: 413`. When the transport answers before it has read the whole body, the proxy closes the program's connection after the response, since the rest of the body is still on it. A transport without the trait keeps getting whole bodies, capped at its `maxRequestBody`, so existing transports need no change. A transport that wraps another (a router, an injector, a logger) and passes on the inner `traits` must forward `bodyStream`, or not claim `streamingBodies`: a wrapper that rebuilds the request from `url`, `method`, `headers`, `body` and `signal` would drop a streamed upload and still answer. `nodeTransport()` streams (undici's `fetch` with `duplex: 'half'`). `fetchTransport({ streamingBodies: true })` turns it on for a page that knows its targets speak HTTP/2 or later, since Chrome streams a `fetch` upload only there, so it's off by default. The trait is also how an embedder's own transport, such as the `slicc` CLI link's exit, takes streamed uploads; across the page and the kernel's worker, and to attached clients, the body is pulled one chunk at a time. `fetchTransport()` is the plain `fetch` of the page, bound by CORS; its `hint` option is appended to the message of a request it could not make, so a program's 502 can name a way around CORS (`registry.npmjs.org` and jsDelivr allow it); an embedder with a way around CORS passes its own. A response body that delivers no data for `bodyIdleMs` (default 5 minutes; `0` or `Infinity` turns it off) fails with `ETIMEDOUT` instead of waiting forever, which matters because Chrome under heavy load can stop handing a page the rest of a body. A slow body that keeps delivering is never cut off.
|
|
636
636
|
- **Traits of the embedder's own:** `traits` may carry more fields than the kernel reads. The kernel keeps them, and attached clients get them as they are (`client.transport.traits`, `onTraits`). In TypeScript, `NetworkTraits` types its own fields and leaves any other as `unknown`. One is typed: `exit`, the default exit an embedder routes through, `{ kind, caps }` (`NetworkExit`). `kind` is `'local'`, `'remote'` or `'tailnet'` (or another string), `caps` lists what the exit carries (`['net', 'http', 'ws']`), and further fields are `unknown`. When traits change later, call `kernel.updateTransportTraits()`.
|
|
637
637
|
- **Redirect hops** ([#302](https://github.com/ai-ecoverse/slicc-kernel/issues/302)): a transport that follows redirects but can see them (a browser extension's `webRequest.onBeforeRedirect` can) sets `traits.redirectHops` and answers `redirects: [{ status, url, headers }]`, one entry per 3xx it followed, in order (`NetworkRedirectHop`). The kernel then treats it as one that does not follow. Programs get each 3xx as it was answered: through the proxy, curl, requests and `hf` see the real redirect, `X-Repo-Commit` and `ETag` included, and `ctx.fetch` with `redirect: 'manual'` answers it. The rest of the chain is held for the process that asked, at most 30 s and 8 chains per process (64 in all), with the final body unread. A follow-up from that process, with the method the redirect leads to and the same `Range`, `If-None-Match`, `If-Modified-Since`, `If-Range` and `Accept-Encoding` (and an `Authorization` if the first had one), is answered from it without a second request; any other request goes to the transport. A chain that expires or is pushed out is cancelled, and so is one whose follow-up arrives and does not match. After a redirect to another origin the follow-up is expected without `Authorization`, which HTTP clients drop when they follow one; a `Location` matches without its `#fragment`. The page's own `fetch` cannot show a redirect, so `fetchTransport()` in a page stays a transport that follows, and `redirect: 'manual'` fails there as before. `fetchTransport({ redirect: 'manual' })` hands redirects back unfollowed where the platform's `fetch` shows them (Node; a browser answers an opaque status 0), and `nodeTransport()` does so. A kernel socket also records who connected it (`SO_PEERCRED`): the connecting process's pid, uid and gid on the accepting side, and the listener's on the connecting side, kept when the socket passes to another process. The proxy uses this to keep each process's held chains its own.
|
|
638
|
-
- **WebSockets:** a `GET` with `Upgrade: websocket`, in absolute form or inside a tunnel, is bridged to the transport's optional `websocket({ url, protocols, headers, signal })`. It answers `{ protocol, buffered, send(data), messages, close(code, reason), closed }`, with `messages` an async iterable of strings and `Uint8Array`s and `closed` resolving with `{ code, reason }`; a transport that has it says so with `traits.websocket`. The program gets `101 Switching Protocols` with the far end's protocol, then its frames go out as messages and the far end's come back as frames. Pings are answered by the proxy, and close codes pass both ways (a code that may not go on the wire becomes 1000 or 1011). A message over 64 MiB, or more than 16 MiB of payload from the far end waiting for the program, closes with 1009. The proxy stops reading the program while 1 MiB of its messages wait to be sent. A refused connection is `502` with the reason. A transport without WebSockets answers `501` with a message naming the option below, and the kernel logs a warning once, so a program never hangs. `fetchTransport({ webSocket: true })` opens them with the page's `WebSocket` (or pass a constructor); they are off by default because a browser WebSocket carries the user's cookies for the target site, which `fetch` here never sends (`credentials: 'omit'`), so a program could reach a site as the user. A page also cannot send headers on a WebSocket, and an `https:` page cannot open `ws:` to anything but loopback, which is a `502` that says so. `nodeTransport()` has WebSockets on (Node keeps no cookies) and sends the request's headers. An open fails as soon as Node's `WebSocket` reports an error, also on Node 22, which never follows the error with a close. A transport's `websocket()` may reject with an `Error` whose `code` is an errno, and the kernel hands that on to the program. Node's `WebSocket` doesn't say why an open failed, so when one fails, `nodeTransport()` finds out: it looks up the host's name (`EAI_NONAME` when that fails), then makes one TCP connection to the target, or a TLS one for `wss:`, for at most 3 s, and closes it at once. A refusal there is `ECONNREFUSED`, an unreachable host `EHOSTUNREACH` or `ENETUNREACH`, no answer `ETIMEDOUT`, and a TLS failure (a bad certificate) or a server that takes the connection but refused the upgrade `EPROTO`. A server that refused the upgrade therefore sees a second connection. The diagnosis never runs when an open succeeds, and never in a page (`fetchTransport()`), whose failed opens stay `ECONNREFUSED`. The credential-free path for a page is the
|
|
639
|
-
-
|
|
640
|
-
- **The host machine** is `host.slicc.internal` (`SLICC_HOST_LOOPBACK`), as `host.docker.internal` is in Docker, while `localhost` and `127.0.0.1` stay the kernel's own loopback. The proxy forwards `http(s)://host.slicc.internal:<port>/…` to the transport as `http(s)://127.0.0.1:<port>/…`, so it reaches the real machine's loopback through the local proxy or Node's `fetch` (through the page's own `fetch`, a request needs CORS from that server and Chrome's Local Network Access permission); other loopback addresses are still refused. This is HTTP and HTTPS through the proxy only: `/etc/hosts` and WASIX name resolution give the name `10.0.2.2`, QEMU's address for its host, so a program that connects a socket to it directly fails with `ENETUNREACH` instead of reaching the kernel's loopback.
|
|
638
|
+
- **WebSockets:** a `GET` with `Upgrade: websocket`, in absolute form or inside a tunnel, is bridged to the transport's optional `websocket({ url, protocols, headers, signal })`. It answers `{ protocol, buffered, send(data), messages, close(code, reason), closed }`, with `messages` an async iterable of strings and `Uint8Array`s and `closed` resolving with `{ code, reason }`; a transport that has it says so with `traits.websocket`. The program gets `101 Switching Protocols` with the far end's protocol, then its frames go out as messages and the far end's come back as frames. Pings are answered by the proxy, and close codes pass both ways (a code that may not go on the wire becomes 1000 or 1011). A message over 64 MiB, or more than 16 MiB of payload from the far end waiting for the program, closes with 1009. The proxy stops reading the program while 1 MiB of its messages wait to be sent. A refused connection is `502` with the reason. A transport without WebSockets answers `501` with a message naming the option below, and the kernel logs a warning once, so a program never hangs. `fetchTransport({ webSocket: true })` opens them with the page's `WebSocket` (or pass a constructor); they are off by default because a browser WebSocket carries the user's cookies for the target site, which `fetch` here never sends (`credentials: 'omit'`), so a program could reach a site as the user. A page also cannot send headers on a WebSocket, and an `https:` page cannot open `ws:` to anything but loopback, which is a `502` that says so. `nodeTransport()` has WebSockets on (Node keeps no cookies) and sends the request's headers. An open fails as soon as Node's `WebSocket` reports an error, also on Node 22, which never follows the error with a close. A transport's `websocket()` may reject with an `Error` whose `code` is an errno, and the kernel hands that on to the program. Node's `WebSocket` doesn't say why an open failed, so when one fails, `nodeTransport()` finds out: it looks up the host's name (`EAI_NONAME` when that fails), then makes one TCP connection to the target, or a TLS one for `wss:`, for at most 3 s, and closes it at once. A refusal there is `ECONNREFUSED`, an unreachable host `EHOSTUNREACH` or `ENETUNREACH`, no answer `ETIMEDOUT`, and a TLS failure (a bad certificate) or a server that takes the connection but refused the upgrade `EPROTO`. A server that refused the upgrade therefore sees a second connection. The diagnosis never runs when an open succeeds, and never in a page (`fetchTransport()`), whose failed opens stay `ECONNREFUSED`. The credential-free path for a page is a transport that opens them outside the page, such as one over a linked slicc CLI (`npx sliccy@next`). After the program sends its close frame, data the far end sends before its own close still reaches the program, and the far end's close frame follows once it has drained, as RFC 6455 §5.5.1 has it; a far end that doesn't close within 5 s gets the program's own close echoed back ([#1471](https://github.com/ai-ecoverse/slicc-kernel/issues/1471)). The same holds when the kernel runs in a worker and the page's transport carries the socket ([#1506](https://github.com/ai-ecoverse/slicc-kernel/issues/1506)).
|
|
639
|
+
- **The host machine** is `host.slicc.internal` (`SLICC_HOST_LOOPBACK`), as `host.docker.internal` is in Docker, while `localhost` and `127.0.0.1` stay the kernel's own loopback. The proxy forwards `http(s)://host.slicc.internal:<port>/…` to the transport as `http(s)://127.0.0.1:<port>/…`, so it reaches the real machine's loopback through Node's `fetch` or a transport that runs on that machine (through the page's own `fetch`, a request needs CORS from that server and Chrome's Local Network Access permission); other loopback addresses are still refused. This is HTTP and HTTPS through the proxy only: `/etc/hosts` and WASIX name resolution give the name `10.0.2.2`, QEMU's address for its host, so a program that connects a socket to it directly fails with `ENETUNREACH` instead of reaching the kernel's loopback.
|
|
641
640
|
- **Without a transport**, every request is answered `502` with `slicc-kernel: no network transport (createKernel({ network: { transport } }))`.
|
|
642
641
|
|
|
643
642
|
**`AF_UNIX` datagram sockets** ([#891](https://github.com/ai-ecoverse/slicc-kernel/issues/891)), for syslog-style logging and `sd_notify`-style readiness:
|
|
@@ -733,13 +732,13 @@ In the browser, the page serves the uplink to the kernel worker over the same po
|
|
|
733
732
|
|
|
734
733
|
### Browser automation (CDP)
|
|
735
734
|
|
|
736
|
-
Programs drive a browser with the Chrome DevTools Protocol over a WebSocket, whichever host actually has the browser: slicc-extension's `chrome.debugger`,
|
|
735
|
+
Programs drive a browser with the Chrome DevTools Protocol over a WebSocket, whichever host actually has the browser: slicc-extension's `chrome.debugger`, a linked slicc CLI (`npx sliccy@next`), or a test harness. The kernel relays; it does not launch a browser.
|
|
737
736
|
|
|
738
737
|
- **Programs** find the endpoint in `SLICC_CDP_URL`, `ws://127.0.0.1:9222/devtools/browser/<id>`, where `<id>` is random for each kernel boot. `http://127.0.0.1:9222/json/version` answers `{ "Browser", "Protocol-Version", "webSocketDebuggerUrl" }` with the same URL (carrying the request's query), and `/json/list` (or `/json`) lists the host's targets, without per-target sockets. Every other path is `404`. The listener starts on the first connection, on the kernel's loopback; a program that binds `9222` first keeps it.
|
|
739
738
|
- **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.
|
|
740
739
|
- **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.
|
|
741
740
|
- **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.
|
|
742
|
-
- **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`
|
|
741
|
+
- **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`) are refused with `ECONNREFUSED`, since the facade drives the user's real browser.
|
|
743
742
|
|
|
744
743
|
An embedder offers a browser with the `cdp` option of `createKernel` or `createNodeKernel`, or from an attached client with `client.serveCdp(hook, { runtime })`:
|
|
745
744
|
|
|
@@ -782,16 +781,6 @@ interface ConnectorStamp { pid: number; ppid: number; uid: number; gid: number;
|
|
|
782
781
|
- **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.
|
|
783
782
|
- **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`.
|
|
784
783
|
|
|
785
|
-
### Local proxy protocol
|
|
786
|
-
|
|
787
|
-
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'`:
|
|
788
|
-
|
|
789
|
-
- `X-Bridge-Token: <key>`, the per-process proxy key. It travels only in this header, never in a URL.
|
|
790
|
-
- `X-Slicc-Raw-Request: <json>`, the head to send upstream: `{ "url", "method", "headers": [[name, value], …] }`, with the headers in order and repeats kept apart. Characters past U+007E are `\u`-escaped so the value is a valid header.
|
|
791
|
-
- The request body, if any, as the hop's body.
|
|
792
|
-
|
|
793
|
-
The proxy answers `200` with `Content-Type: application/vnd.slicc.raw-fetch`: a big-endian `u32` length, that many bytes of UTF-8 JSON `{ "status", "statusText", "headers": [[name, value], …], "url" }`, then the upstream body until the end of the hop. Any other answer is a refusal with a JSON `{ "error" }` body and `X-Proxy-Error: 1`; programs see its status (`400` malformed head, `403` wrong origin or key, `413` body too large, `502` upstream unreachable). A probe is a `POST` with the key and `X-Slicc-Raw-Probe: 1` instead of a head; it is answered with JSON `{ "rawFetch": 1, "requestBodyStreaming", "maxRequestBodyBytes" }` and fetches nothing. A proxy that tunnels to the kernel for `<port>.kernel.localhost` adds `"kernelTunnel"` and `"kernelPort"`, and one that serves a `/cdp` endpoint adds `"cdp"` (a version, e.g. `1`). `checkLocalProxy` and `probeLocalProxy` pass these on when they are numbers. The server side (origin allowlist, CORS, the Private Network Access preflight) is specified in [slicc-node's README](https://github.com/ai-ecoverse/slicc-node#protocol).
|
|
794
|
-
|
|
795
784
|
## Filesystem
|
|
796
785
|
|
|
797
786
|
`/` is the OPFS root. Each process mounts the top-level directories that exist when it starts (plus `/usr` and `/bin`), so files in them are shared by all processes and visible through the OPFS API as soon as the process that wrote them has closed them; `run` resolves only after that. `createKernel` creates `/tmp` and `/home` in OPFS, so they are shared too. Files directly in `/`, and directories created there after a process started, are OPFS as well, the same for Emscripten and WASI programs; only `/dev` and `/proc` live in each process's memory. A program's `mkdir` (WASI `path_create_directory`, Emscripten's `mkdir`) is exclusive, as on Linux: it fails with `EEXIST` when the name exists, and of several processes or threads making one name at once exactly one succeeds, so a directory works as a lock. Under a missing directory it fails with `ENOENT`, as on Linux (`mkdir -p` and `os.makedirs` make the parents in the program); a JS program's `ctx.fs.mkdir` makes parents itself. The kernel makes the system `PNPM_HOME` (`/usr/local/share/pnpm`, or the embedder's) when it starts, since pnpm makes only its `bin` directory there. A full origin storage (`QuotaExceededError`, which also comes early in a browser's private or off-the-record contexts, whatever `navigator.storage.estimate()` says) is `ENOSPC`, never retried. A read that meets `NotFoundError` while a writer outside the kernel swaps the file in with `createWritable` (and a `stat`, `lstat` or `utimes` that meets it) looks the name up again, up to three times, before it fails ([#1075](https://github.com/ai-ecoverse/slicc-kernel/issues/1075)), so a program whose package is overwritten in place keeps starting. An OPFS or IndexedDB failure that has no POSIX counterpart is `EIO`; its message names the DOM error and the operation (`lookup`, `read`, `write`, `commit` or `sidecar`), and the kernel worker logs the first of each kind to its console. File contents are buffered per open file and written back on close, `fsync` and exit, so a refused write (`ENOSPC`, `EIO`) surfaces at that close or `fsync`, and a program that ignores close errors doesn't see it (`echo x > f` can exit 0 with `f` empty, as with any buffered write-back). A write-back error a close has already reported to a WASI program isn't raised again when it exits, so the program keeps its own exit status. Metadata operations (`mkdir`, `rename`, `rm`, …) go straight to OPFS. The kernel worker is the only writer.
|
|
@@ -844,7 +833,7 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
|
|
|
844
833
|
|
|
845
834
|
`/dev/shm` is a tmpfs the kernel mounts at boot (source `shm`), mode `1777` and owned by root, so with `users` the sticky bit keeps one user from removing or renaming another's file there (`EPERM`). It's in `/proc/mounts`, but not in `kernel.mounts()`, which lists the mounts a caller made. Every process sees the same files, Emscripten (whose glue otherwise keeps a private `/dev/shm` in memory), WASI and JS programs alike, and they last until the kernel terminates, which is what musl's `shm_open` and `sem_open` need.
|
|
846
835
|
|
|
847
|
-
- **Built in:** `tmpfs`, which lives in memory until unmounted, in 1 MiB chunks; `fsa`, a folder the user picks (see [Removable media](#removable-media-fsa)); and `hostfs`, a folder the
|
|
836
|
+
- **Built in:** `tmpfs`, which lives in memory until unmounted, in 1 MiB chunks; `fsa`, a folder the user picks (see [Removable media](#removable-media-fsa)); and `hostfs`, a folder the host exports (see [Host folders](#host-folders-hostfs)).
|
|
848
837
|
- **Package drivers:** a package declares a type in `"slicc": { "filesystems": { "<type>": { "module": "<file>" } } }`, and the kernel starts that module in a worker of its own for each mount. The module is a single self-contained file: it can't import bare specifiers.
|
|
849
838
|
- Its default export receives `{ fetch }`, the kernel's network transport. It returns `{ handlers, capabilities }`.
|
|
850
839
|
- Its worker gets a JS program's lockdown before the module loads: its own `fetch`, `WebSocket`, storage and workers are `undefined`, and a worker where one cannot be removed does not start the driver.
|
|
@@ -887,7 +876,7 @@ Directories are renamed with `FileSystemHandle.move()` where available, else by
|
|
|
887
876
|
- **Rules on a mount:**
|
|
888
877
|
- a rename across mounts is `EXDEV`, so `mv` copies;
|
|
889
878
|
- 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;
|
|
890
|
-
- 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
|
|
879
|
+
- 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`, whose protocol has no links, and for directories; on the root the names are checked first, as Linux does: a missing source (or, when following, a dangling one) or target directory is `ENOENT`, a target directory that isn't one is `ENOTDIR`, and a taken name is `EEXIST`. `link()` links a symlink itself and `linkat(…, AT_SYMLINK_FOLLOW)` links its target, as on Linux. Descriptors the kernel holds (JS programs' files, and a WASI program's files once it forks or spawns with them) share one cache for all names of a file, so a write through one name is read at once through another, also after either name is removed or replaced while open; `link()` writes the source's pending changes back first. Within one WASI program, its own descriptors of a file's names share one buffer too. Once it starts a subprocess, the files it had open are kernel descriptors, and a later open of one of them, under that name or another (same inode), goes through the kernel and shares their cache; between programs, a WASI program's own descriptors see the other names' writes at open, as for one name across processes. A WASI program that removes or renames over the name it has open, while another link remains, keeps writing to the file through its descriptor, and the other names see those writes; `linkat(…, AT_SYMLINK_FOLLOW)` writes back the program's pending changes to the symlink's target first. `st_nlink` leaves out the names the kernel keeps in `.slicc-unlinked` for open descriptors, so `fstat` on a descriptor whose name was removed counts the names left: 0 once the last one is gone. A change of mode, owner, times or size through one name, or a new or removed name, also drops the attributes the kernel has cached for the file's other names (same inode, or, when the changed name's inode isn't cached, every file with more than one link), so on a mount with an `attrTtl` they show the change at once. So it works for WASI, WASIX and JS programs, and for Emscripten programs built with a `__syscall_linkat` import (wasm-coreutils and wasm-tar from the next homescoop builds), which get `EMLINK` where the others get `EPERM` because the file system has no hard links (the root, `fsa`, `hostfs`), as Emscripten itself answers. Older Emscripten builds (wasm-coreutils 9.12.0-5's `ln`, wasm-tar 1.35.0-5) answer `EMLINK` from their own C library, which never asks the kernel, but they see links other programs made: their open descriptors of one file's names share one buffer, and one that removes the name it has open while another link remains keeps writing to the file;
|
|
891
880
|
- `df` reports each mount from its driver's `statfs`;
|
|
892
881
|
- each mount has its own device number;
|
|
893
882
|
- without `chmod` support, `chmod` succeeds and changes nothing;
|
|
@@ -927,15 +916,15 @@ An `fsa` mount is a drive for a local folder, from the File System Access API, a
|
|
|
927
916
|
|
|
928
917
|
### Host folders: `hostfs`
|
|
929
918
|
|
|
930
|
-
A `hostfs` mount is a folder
|
|
919
|
+
A `hostfs` mount is a folder the host exports, such as a linked slicc CLI's `--mount` folder (`npx sliccy@next`), with no picker and no permission prompt. The protocol is specified in [slicc-node#13](https://github.com/ai-ecoverse/slicc-node/issues/13); slicc-node itself is retired, and its repository is archived.
|
|
931
920
|
|
|
932
|
-
- **Grants:** `kernel.mount({ type: 'hostfs', source: 'project', target: '/mnt/project' })` asks the page's `hostfs(source, { readonly })` hook for a grant.
|
|
933
|
-
- **Options:** `ro` mounts read-only, and `maxfile` caps file size as on any mount. There is no other size limit: reads and writes go in `maxIo` pieces (16 MiB unless the
|
|
921
|
+
- **Grants:** `kernel.mount({ type: 'hostfs', source: 'project', target: '/mnt/project' })` asks the page's `hostfs(source, { readonly })` hook for a grant. Over HTTP, the page holds the host's key and asks the host (`POST /api/hostfs/grant`) for a token scoped to that one folder. The kernel sees only `{ url, token }`, never the key, and the token never appears in mount options or `/proc/mounts`. When the host refuses a token, the kernel asks the hook once for a new one.
|
|
922
|
+
- **Options:** `ro` mounts read-only, and `maxfile` caps file size as on any mount. There is no other size limit: reads and writes go in `maxIo` pieces (16 MiB unless the host says otherwise), so a file of any size the host can hold works. A host that advertises `ranges` in its grant makes the mount ranged, so programs read and write its files in pages and truncate through `setattr` `size`. Without it files go whole, and a truncate on a ranged mount whose host was replaced by one without `ranges` is done by rewriting the file.
|
|
934
923
|
- **Changes on the host** arrive as invalidations on a watch stream (`POST /api/hostfs/watch`), so an edit made outside shows up on the next access without a remount.
|
|
935
924
|
- **Writes when the link drops:** after a mount's link drops, a write that returned success may not be on the host unless the program fsyncs or checks close, as on Linux with NFS: the shell ignores a redirection's close error ([#1062](https://github.com/ai-ecoverse/slicc-kernel/issues/1062)). How soon a write notices the loss is bounded: a folder that goes silent behind a live link fails the pending call after 20 s (see **Hanging folders** below), a port that dies fails it after 10 s without a ping answer, and a link that reports itself down (`ENOTCONN`) fails it at once. In every case the mount is then `nomedium` until it reconnects (see **Proxy loss** below), and until then each operation fails at once with `ENOMEDIUM`. A shell loop that keeps writing after a failed write (`for …; do echo …; done > file`) keeps running through its failing writes and exits non-zero at the end, as on Linux; stop at the first failed write (`echo … || exit 1`) to end at once ([#1077](https://github.com/ai-ecoverse/slicc-kernel/issues/1077)).
|
|
936
925
|
- **Proxy loss:** when the watch stream ends, or is silent for 45 s, the kernel reconnects once at once. If that fails, the mount is `nomedium`, as with [removable media](#removable-media-fsa): operations fail with `ENOMEDIUM` (`ENODEV` for WASI), and the kernel keeps reconnecting with backoff (1 s to 30 s), so the folder comes back by itself when the proxy does. A mount made while the proxy is down starts as `nomedium`, and so does one whose grant or first watch takes more than 15 s; it comes up by itself when they arrive.
|
|
937
926
|
- **Consistency:** a file that changes on the host while a program reads it fails with `ESTALE`, and the kernel restarts the whole read (up to 3 times) instead of stitching two versions together. On a ranged mount an open file keeps the version it was opened at (its ETag) until it writes, also when it is handed to a child process: a page fetched after the host changed the file fails the program's read with `ESTALE` ("Stale file handle") instead of mixing versions. A file deleted on the host while a program holds it open fails the next read that goes to the host with `ENOENT`, not with NFS's `ESTALE`.
|
|
938
|
-
- **Over a port:** the hook may answer `{ port, token?, capabilities? }` instead, for a folder that doesn't come
|
|
927
|
+
- **Over a port:** the hook may answer `{ port, token?, capabilities? }` instead, for a folder that doesn't come over HTTP: for example, a linked slicc CLI's export, which the page reaches over its own link. The page implements a `HostfsTransport` for the folder (`capabilities`, `token`, `maxIo`, `call(op, body)`, `read(fh, offset, size, ifMatch?)`, `write(fh, offset, bytes)`, `watch(signal, onEvent, onOpen)`, with the protocol's ops and answers) and serves it with `serveHostfsPort(port, transport)`, exported from both entries, which returns a function that stops serving and closes the port.
|
|
939
928
|
- **Slow links:** each read or write message carries up to `maxIo` bytes and has to finish within the 20 s call bound, so a page that serves a folder over a slow or remote link sets `capabilities.maxIo` to fit it: 4 MiB over 1 Mbit/s takes about 32 s and would fail every time, while 512 KiB takes about 4 s.
|
|
940
929
|
- The kernel moves the port into its worker with the grant. Calls, reads and writes are request and reply messages there, and the watch is a stream of messages that the kernel ends by aborting.
|
|
941
930
|
- Errors keep their `code`. A transport that throws `ENOTCONN` (its link is down) makes the mount `nomedium` and reconnecting, as a lost proxy does: the kernel asks the hook again, and the page answers with a new port.
|
|
@@ -943,7 +932,7 @@ A `hostfs` mount is a folder exported by the local proxy ([slicc-node](https://g
|
|
|
943
932
|
- **Hanging folders:** every request to a `hostfs` folder, over a port or over HTTP, gets 20 s: each call, and each read or write of up to `maxIo` bytes. The mount tells the kernel the folder's own `maxIo` (at most 16 MiB), so one file operation never puts more than that on the folder at once. One that takes longer fails with `ENOTCONN`, so the mount goes `nomedium` and reconnects by itself, instead of the driver watchdog (30 s) marking the mount `failed` until a remount. The timeout also retires that connection: its requests in flight are aborted (HTTP) or failed (a port), nothing more is sent on it, its open files are `ESTALE`, and a file whose open answers late is released on the folder. Over a port, the kernel keeps the port for another second for cleanup (a temporary write permission it added to open a read-only file is put back; over HTTP, where a request can still be running on the host after the restore, 30 s later the kernel checks it once more, and if a late chmod still left the same file (same inode, or same size and mtime) with just that bit added, it puts the mode back again), then tells `serveHostfsPort` it is going (`bye`, which Chrome can't signal by itself). The server then releases every file it opened through that port, including one whose open lands later. A request the host already received may still finish there: an operation that timed out may have happened, so check before retrying it (a retried `mkdir` or create can fail with `EEXIST`, a retried `rename` or `unlink` with `ENOENT`). The kernel itself retries only reads and whole-file rewrites after `ESTALE`, never an exclusive create.
|
|
944
933
|
- A read reply sends a copy of the transport's bytes, so a transport may return a view into storage it keeps (a cache, a pooled or shared buffer).
|
|
945
934
|
- `token` names the connection (a link id, say); without one, every port gets its own name. It doesn't decide whether open files survive a reconnect: a file a program holds open is opened again by path on the new connection, with the ETag it had (`ifMatch`). It goes on if the file is unchanged on the host, and fails with `ESTALE` if the host changed it meanwhile. Files opened through a port that is gone are `ESTALE` until they are opened again.
|
|
946
|
-
- **Node:** a page's requests carry its `Origin`, and
|
|
935
|
+
- **Node:** a page's requests carry its `Origin`, and a host may grant a token to that origin only. Node's `fetch` sends none, so a Node embedder passes `createNodeKernel` the origin its `hostfs` hook asked for the grant with, as `hostfsOrigin`, and the kernel adds it to every hostfs request. Without it the mount stays `nomedium`. `hostfsFetch(url, init)` replaces the `fetch` those requests use (for an agent or a test proxy), and gets the `Origin` too when both are given.
|
|
947
936
|
|
|
948
937
|
### Mounting from a program
|
|
949
938
|
|
|
@@ -1202,7 +1191,7 @@ printf '{"kind":"open.url","url":"%s"}\n' "$url" | cat > /dev/embedder
|
|
|
1202
1191
|
- **Revoking:** a result is kept until the file changes, so when `lookup`'s answers change (an updated hash list, a package revoked or reinstalled), call `await kernel.integrity.revoke(path?)` (on the page and in Node). It drops what the kernel verified for that path, or for everything under it when it's a directory (its real path and the name a link reached it by both count), or for every path without one, and rebuilds the command catalog from the manifests as checked again. A load that was under way when `revoke` was called keeps nothing. It takes effect at the next start: running processes keep what they loaded. `path` must be absolute; without `integrity` it does nothing.
|
|
1203
1192
|
- **Where `lookup` gets its hashes:** from a source independent of the installed files, such as the per-file hashes the embedder derives from the published package tarball when it builds its catalog. Hashes read back from the installed files prove nothing.
|
|
1204
1193
|
- **Links out of the paths:** a file reached through a protected path but whose real path lies outside every path (a symlink at or below a protected path, or one of its directories, pointing elsewhere, a command of the catalog included) is still checked, under the last protected name on the way: the kernel asks `lookup(name)` for the bytes of the real file, `unknown` applies when it has no hash, and `onDenial` reports the name as `path`. Writes outside the paths aren't counted, so such a result isn't cached: the file is hashed again at every start.
|
|
1205
|
-
- **Sources a program reads itself** ([#454](https://github.com/ai-ecoverse/slicc-kernel/issues/454)): a runtime that loads code on its own is checked too, when a process reads the file through the kernel. That covers jsh's
|
|
1194
|
+
- **Sources a program reads itself** ([#454](https://github.com/ai-ecoverse/slicc-kernel/issues/454)): a runtime that loads code on its own is checked too, when a process reads the file through the kernel. That covers jsh's modules and the `package.json` files whose `main`/`exports` they follow, python's `.py`, `.pyc`, `.pth` and zip imports, `dlopen`'s side modules, and a script's interpreter reading the script after its exec check (a file swapped in between is refused then).
|
|
1206
1195
|
- **Which files:** those under a path whose name ends in one of `integrity.sources`, plus every regular file with an execute bit (a `#!` script without a suffix, a sourced script). The default is `['.js', '.mjs', '.cjs', '.json', '.py', '.pyc', '.pth', '.zip', '.so', '.wasm', '.sh']`. An embedder can replace the list, and `sources: []` turns these checks off and leaves only the ones above. Data files aren't checked, since a package can hold files no `lookup` knows (lock files, caches), which `unknown: 'deny'` would make unreadable.
|
|
1207
1196
|
- **Where:** a whole-file read (a JS program's `ctx.fs.readFile` and `ctx.sync.fs.readFile`, a `.json` included, and the dynamic linker's) gets exactly the bytes that were hashed. An open (WASI and Emscripten programs, so ranged reads too) is checked when the file is opened. The result is cached per real path and version, as for programs, so an unchanged file costs a version check: OPFS's own `lastModified` and size, plus one metadata read.
|
|
1208
1197
|
- **Refused:** a read or an open fails with `EACCES`, and `onDenial` gets `{ op: 'read-integrity', … }`. A file with no known hash is reported once as `read-unverified`.
|
|
@@ -1313,7 +1302,7 @@ The kernel is ported from SLICC's `packages/webapp/src/kernel/` with the browser
|
|
|
1313
1302
|
- `src/realm/`: the synchronous bridge (`SharedArrayBuffer` + `Atomics.wait`) and the live Emscripten filesystem on top of it. When a program's file call gets no answer, the bridge treats it like an NFS mount (#990). A read-only call (`read`, `pread`, `stat`, `lstat`, `readdir`, `readlink`, `exists`, `access`, `statfs`) fails with `EIO` once nothing has come back for 25 s; the clock starts again with every chunk of a long answer, so a slow transfer that keeps answering never fails (the host keeps a long answer for 30 s after each chunk it hands over, not after the first), and a late answer is dropped, which is harmless because the call changed nothing. A call that changes the file system (writes, `rename`, `unlink`, `mkdir`, creates, `chmod`, holds…) waits for its answer like a hard mount, however long the backend is gone, so it never returns an error for an effect that lands later. Only a signal that ends the process (`SIGKILL`) or the kernel shutting down ends that wait. JavaScript programs' synchronous `ctx.sync.fs` calls get the same treatment on their own lane (#1033): a read-only call (`readFile`, `stat`, `lstat`, `readdir`, `readlink`, `exists`, `statfs`) throws `EIO` after 25 s without an answer, and every other call, as well as descriptor calls such as a read from a pipe or a terminal, waits for its answer.
|
|
1314
1303
|
- `src/fs/`: the OPFS filesystem and the virtual command directories.
|
|
1315
1304
|
- `src/launcher.ts`, `src/commands.ts`, `src/serve.ts`, `src/index.ts`, `src/users.ts`: command resolution, the kernel worker protocol, the page API and the user database; `src/node.ts`, `src/node-process-worker.ts` and `src/node/` the headless Node entry. An embedder that uses `Launcher` directly, without `createKernel`, calls `await launcher.prepare()` first: it creates `/tmp` and `/home` and writes the CA certificate.
|
|
1316
|
-
- `src/kernel/net/`: the proxy, HTTP/1.1, TLS termination and the local CA, and the bridge to the page's transport; `src/transport.ts`
|
|
1305
|
+
- `src/kernel/net/`: the proxy, HTTP/1.1, TLS termination and the local CA, and the bridge to the page's transport; `src/transport.ts` is the page side.
|
|
1317
1306
|
- `src/cdp/`: the CDP facade on `127.0.0.1:9222`, its WebSocket framing, the registry of hosts and the `MessagePort` bridge to them.
|
|
1318
1307
|
- `src/cron/`: the cron service (`service.ts`), the schedule and table parsers, the clock, and the `crontab` command with the kernel side of `ctx.crontab`. An embedder that uses `Launcher` directly passes `cron: { lock }` to share the firing lock among the kernels on one root; the default, `slicc-kernel-cron`, is one lock for the whole origin.
|
|
1319
1308
|
|
package/dist/index.js
CHANGED
|
@@ -217,14 +217,14 @@ function bodyOf(source, head, method) {
|
|
|
217
217
|
}
|
|
218
218
|
return counted(source, size);
|
|
219
219
|
}
|
|
220
|
-
function stream(
|
|
220
|
+
function stream(chunks2, socket, signal) {
|
|
221
221
|
return new ReadableStream({
|
|
222
222
|
start(controller) {
|
|
223
223
|
signal?.addEventListener("abort", () => controller.error(signal.reason), { once: true });
|
|
224
224
|
},
|
|
225
225
|
async pull(controller) {
|
|
226
226
|
try {
|
|
227
|
-
const next = await
|
|
227
|
+
const next = await chunks2.next();
|
|
228
228
|
if (next.done) {
|
|
229
229
|
controller.close();
|
|
230
230
|
socket.close();
|
|
@@ -274,9 +274,9 @@ async function loopbackFetch(dial, input, options) {
|
|
|
274
274
|
if (!raw) throw errorWithCode("ECONNRESET: the connection closed before a response");
|
|
275
275
|
head = parseHead(raw);
|
|
276
276
|
} while (head.status >= 100 && head.status < 200);
|
|
277
|
-
const
|
|
278
|
-
if (!
|
|
279
|
-
return new Response(
|
|
277
|
+
const chunks2 = bodyOf(source, head, request.method);
|
|
278
|
+
if (!chunks2) socket.close();
|
|
279
|
+
return new Response(chunks2 ? stream(chunks2, socket, options.signal) : null, {
|
|
280
280
|
status: head.status,
|
|
281
281
|
statusText: head.statusText,
|
|
282
282
|
headers: head.headers
|
|
@@ -1063,7 +1063,7 @@ function sendableClose(code) {
|
|
|
1063
1063
|
function blockedReason(url, e) {
|
|
1064
1064
|
const name = e?.name;
|
|
1065
1065
|
if (name === "SecurityError" && url.startsWith("ws:")) {
|
|
1066
|
-
return `${url}: ws:// is blocked from an https page; use wss
|
|
1066
|
+
return `${url}: ws:// is blocked from an https page; use wss://, or link a slicc CLI (npx sliccy@next)`;
|
|
1067
1067
|
}
|
|
1068
1068
|
return `${url}: ${e instanceof Error ? e.message : String(e)}`;
|
|
1069
1069
|
}
|
|
@@ -1186,7 +1186,7 @@ function requestBody(request) {
|
|
|
1186
1186
|
return request.body ? { body: request.body } : {};
|
|
1187
1187
|
}
|
|
1188
1188
|
function fetchTransport(options = {}) {
|
|
1189
|
-
const
|
|
1189
|
+
const send = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
1190
1190
|
const Socket = options.webSocket === true ? globalThis.WebSocket : options.webSocket || void 0;
|
|
1191
1191
|
const headers = options.webSocketHeaders === true;
|
|
1192
1192
|
const redirect = options.redirect ?? "follow";
|
|
@@ -1203,7 +1203,7 @@ function fetchTransport(options = {}) {
|
|
|
1203
1203
|
async fetch(request) {
|
|
1204
1204
|
let response;
|
|
1205
1205
|
try {
|
|
1206
|
-
response = await
|
|
1206
|
+
response = await send(request.url, {
|
|
1207
1207
|
method: request.method,
|
|
1208
1208
|
headers: joinHeaders(request.headers),
|
|
1209
1209
|
...requestBody(request),
|
|
@@ -1700,10 +1700,10 @@ async function holdLock(locks) {
|
|
|
1700
1700
|
await granted.promise;
|
|
1701
1701
|
return { name, release: () => held.resolve() };
|
|
1702
1702
|
}
|
|
1703
|
-
function concat(
|
|
1704
|
-
const out = new Uint8Array(
|
|
1703
|
+
function concat(chunks2) {
|
|
1704
|
+
const out = new Uint8Array(chunks2.reduce((n, c) => n + c.length, 0));
|
|
1705
1705
|
let at = 0;
|
|
1706
|
-
for (const chunk of
|
|
1706
|
+
for (const chunk of chunks2) {
|
|
1707
1707
|
out.set(chunk, at);
|
|
1708
1708
|
at += chunk.length;
|
|
1709
1709
|
}
|
|
@@ -1753,8 +1753,8 @@ var Channel = class {
|
|
|
1753
1753
|
}
|
|
1754
1754
|
request(call, handlers = {}) {
|
|
1755
1755
|
const id = ++this.nextId;
|
|
1756
|
-
const { failure:
|
|
1757
|
-
if (
|
|
1756
|
+
const { failure: failure2 } = this;
|
|
1757
|
+
if (failure2) return { id, done: Promise.reject(failure2) };
|
|
1758
1758
|
const done = new Promise((resolve, reject) => {
|
|
1759
1759
|
this.pending.set(id, { resolve, reject, ...handlers });
|
|
1760
1760
|
this.port.postMessage({ ...call, id });
|
|
@@ -1919,10 +1919,10 @@ function endedOf(done, signal) {
|
|
|
1919
1919
|
}
|
|
1920
1920
|
async function runOn(channel, argv, opts) {
|
|
1921
1921
|
const { onStdout, onStderr, ...rest } = opts;
|
|
1922
|
-
const
|
|
1922
|
+
const chunks2 = { 1: [], 2: [] };
|
|
1923
1923
|
const live = { 1: new TextDecoder(), 2: new TextDecoder() };
|
|
1924
1924
|
const tee = (fd, on) => (bytes) => {
|
|
1925
|
-
|
|
1925
|
+
chunks2[fd].push(bytes);
|
|
1926
1926
|
on?.(live[fd].decode(bytes, { stream: true }));
|
|
1927
1927
|
};
|
|
1928
1928
|
const child = await spawnOn(channel, argv, {
|
|
@@ -1931,7 +1931,7 @@ async function runOn(channel, argv, opts) {
|
|
|
1931
1931
|
onStderr: tee(2, onStderr)
|
|
1932
1932
|
});
|
|
1933
1933
|
const { status, signal } = await child.ended;
|
|
1934
|
-
const text = (fd) => new TextDecoder().decode(concat(
|
|
1934
|
+
const text = (fd) => new TextDecoder().decode(concat(chunks2[fd]));
|
|
1935
1935
|
return {
|
|
1936
1936
|
pid: child.pid,
|
|
1937
1937
|
status,
|
|
@@ -1960,7 +1960,7 @@ async function terminalOn(channel, argv, opts) {
|
|
|
1960
1960
|
);
|
|
1961
1961
|
done.catch(started.reject);
|
|
1962
1962
|
const pid = await started.promise;
|
|
1963
|
-
const
|
|
1963
|
+
const send = (action) => void channel.call({ op: "terminal", terminal: id, ...action }).catch(() => void 0);
|
|
1964
1964
|
return {
|
|
1965
1965
|
pid,
|
|
1966
1966
|
exited: done,
|
|
@@ -1972,10 +1972,10 @@ async function terminalOn(channel, argv, opts) {
|
|
|
1972
1972
|
listener = next;
|
|
1973
1973
|
if (next) for (const bytes of backlog.splice(0)) next(bytes);
|
|
1974
1974
|
},
|
|
1975
|
-
write: (data) =>
|
|
1976
|
-
resize: (cols, rows) =>
|
|
1977
|
-
signal: (name) =>
|
|
1978
|
-
close: () =>
|
|
1975
|
+
write: (data) => send({ action: "write", bytes: bytesOf2(data) }),
|
|
1976
|
+
resize: (cols, rows) => send({ action: "resize", cols, rows }),
|
|
1977
|
+
signal: (name) => send({ action: "signal", signal: name }),
|
|
1978
|
+
close: () => send({ action: "close" })
|
|
1979
1979
|
};
|
|
1980
1980
|
}
|
|
1981
1981
|
function fsOn(channel) {
|
|
@@ -2111,187 +2111,6 @@ async function attachKernel(port, options = {}) {
|
|
|
2111
2111
|
};
|
|
2112
2112
|
}
|
|
2113
2113
|
|
|
2114
|
-
// src/local-proxy-transport.ts
|
|
2115
|
-
var PATH = "/api/fetch-proxy";
|
|
2116
|
-
var RAW_CONTENT_TYPE = "application/vnd.slicc.raw-fetch";
|
|
2117
|
-
var MAX_HEAD = 1024 * 1024;
|
|
2118
|
-
var MAX_REQUEST_BODY2 = 64 * 1024 * 1024;
|
|
2119
|
-
var PERMISSIONS = ["loopback-network", "local-network-access"];
|
|
2120
|
-
var LOOPBACK_PAGE = /^(127\.\d+\.\d+\.\d+|localhost|\[::1\])$/;
|
|
2121
|
-
var failure2 = (message, status = 502) => Object.assign(new Error(`local proxy: ${message}`), { status });
|
|
2122
|
-
function encodeHead(url, method, headers) {
|
|
2123
|
-
return JSON.stringify({ url, method, headers }).replace(
|
|
2124
|
-
/[\u007f-]/g,
|
|
2125
|
-
(c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`
|
|
2126
|
-
);
|
|
2127
|
-
}
|
|
2128
|
-
function hopHeaders(options, extra) {
|
|
2129
|
-
return new Headers({ "X-Bridge-Token": options.key, ...extra });
|
|
2130
|
-
}
|
|
2131
|
-
function send(options) {
|
|
2132
|
-
return options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
2133
|
-
}
|
|
2134
|
-
async function refusalMessage(response) {
|
|
2135
|
-
const text = await response.text().catch(() => "");
|
|
2136
|
-
try {
|
|
2137
|
-
const parsed = JSON.parse(text);
|
|
2138
|
-
if (typeof parsed.error === "string") return parsed.error;
|
|
2139
|
-
} catch {
|
|
2140
|
-
}
|
|
2141
|
-
return text || response.statusText || `answered ${response.status}`;
|
|
2142
|
-
}
|
|
2143
|
-
async function refusal(response) {
|
|
2144
|
-
return failure2(await refusalMessage(response), response.ok ? 502 : response.status);
|
|
2145
|
-
}
|
|
2146
|
-
async function permission(permissions) {
|
|
2147
|
-
if (!permissions) return "unknown";
|
|
2148
|
-
for (const name of PERMISSIONS) {
|
|
2149
|
-
try {
|
|
2150
|
-
return (await permissions.query({ name })).state;
|
|
2151
|
-
} catch {
|
|
2152
|
-
}
|
|
2153
|
-
}
|
|
2154
|
-
return "unknown";
|
|
2155
|
-
}
|
|
2156
|
-
function isHead(value) {
|
|
2157
|
-
const head = value;
|
|
2158
|
-
return !!head && typeof head.status === "number" && typeof head.statusText === "string" && Array.isArray(head.headers) && head.headers.every(
|
|
2159
|
-
(pair) => Array.isArray(pair) && pair.length === 2 && typeof pair[0] === "string" && typeof pair[1] === "string"
|
|
2160
|
-
);
|
|
2161
|
-
}
|
|
2162
|
-
function concat2(a, b) {
|
|
2163
|
-
const out = new Uint8Array(a.byteLength + b.byteLength);
|
|
2164
|
-
out.set(a);
|
|
2165
|
-
out.set(b, a.byteLength);
|
|
2166
|
-
return out;
|
|
2167
|
-
}
|
|
2168
|
-
async function readHead(reader, idleMs) {
|
|
2169
|
-
let buffer = new Uint8Array(0);
|
|
2170
|
-
for (; ; ) {
|
|
2171
|
-
if (buffer.byteLength >= 4) {
|
|
2172
|
-
const length = new DataView(buffer.buffer, buffer.byteOffset, 4).getUint32(0);
|
|
2173
|
-
if (length > MAX_HEAD) throw failure2(`response head of ${length} bytes`);
|
|
2174
|
-
if (buffer.byteLength >= 4 + length) {
|
|
2175
|
-
let head;
|
|
2176
|
-
try {
|
|
2177
|
-
head = JSON.parse(new TextDecoder().decode(buffer.subarray(4, 4 + length)));
|
|
2178
|
-
} catch {
|
|
2179
|
-
}
|
|
2180
|
-
if (!isHead(head)) throw failure2("malformed response head");
|
|
2181
|
-
return { head, rest: buffer.subarray(4 + length) };
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
const next = await readWithin(reader, idleMs);
|
|
2185
|
-
if (next.done) throw failure2("closed before the response head");
|
|
2186
|
-
buffer = concat2(buffer, next.value);
|
|
2187
|
-
}
|
|
2188
|
-
}
|
|
2189
|
-
async function* chunks2(rest, reader, idleMs) {
|
|
2190
|
-
try {
|
|
2191
|
-
if (rest.byteLength > 0) yield rest;
|
|
2192
|
-
for (let next = await readWithin(reader, idleMs); !next.done; next = await readWithin(reader, idleMs)) {
|
|
2193
|
-
if (next.value.byteLength > 0) yield next.value;
|
|
2194
|
-
}
|
|
2195
|
-
} finally {
|
|
2196
|
-
await reader.cancel().catch(() => void 0);
|
|
2197
|
-
}
|
|
2198
|
-
}
|
|
2199
|
-
async function checkLocalProxy(options) {
|
|
2200
|
-
const onLoopback = LOOPBACK_PAGE.test(globalThis.location?.hostname ?? "");
|
|
2201
|
-
const permissions = onLoopback ? void 0 : options.permissions ?? globalThis.navigator?.permissions;
|
|
2202
|
-
if (await permission(permissions) === "denied") return { state: "blocked" };
|
|
2203
|
-
let response;
|
|
2204
|
-
try {
|
|
2205
|
-
response = await send(options)(new URL(PATH, options.url), {
|
|
2206
|
-
method: "POST",
|
|
2207
|
-
headers: hopHeaders(options, { "X-Slicc-Raw-Probe": "1" }),
|
|
2208
|
-
credentials: "omit",
|
|
2209
|
-
mode: "cors"
|
|
2210
|
-
});
|
|
2211
|
-
} catch {
|
|
2212
|
-
const now = await permission(permissions);
|
|
2213
|
-
if (now === "denied") return { state: "blocked" };
|
|
2214
|
-
return {
|
|
2215
|
-
state: "unreachable",
|
|
2216
|
-
permission: now === "granted" || now === "prompt" ? now : "unknown"
|
|
2217
|
-
};
|
|
2218
|
-
}
|
|
2219
|
-
if (!response.ok) {
|
|
2220
|
-
return { state: "refused", status: response.status, error: await refusalMessage(response) };
|
|
2221
|
-
}
|
|
2222
|
-
const reply = await response.json().catch(() => null);
|
|
2223
|
-
if (!reply || typeof reply.rawFetch !== "number" || reply.rawFetch < 1 || typeof reply.requestBodyStreaming !== "boolean" || typeof reply.maxRequestBodyBytes !== "number") {
|
|
2224
|
-
return { state: "incompatible" };
|
|
2225
|
-
}
|
|
2226
|
-
return {
|
|
2227
|
-
state: "ready",
|
|
2228
|
-
probe: {
|
|
2229
|
-
rawFetch: reply.rawFetch,
|
|
2230
|
-
requestBodyStreaming: reply.requestBodyStreaming,
|
|
2231
|
-
maxRequestBodyBytes: reply.maxRequestBodyBytes,
|
|
2232
|
-
...typeof reply.kernelTunnel === "number" ? { kernelTunnel: reply.kernelTunnel } : {},
|
|
2233
|
-
...typeof reply.kernelPort === "number" ? { kernelPort: reply.kernelPort } : {},
|
|
2234
|
-
...typeof reply.cdp === "number" ? { cdp: reply.cdp } : {}
|
|
2235
|
-
}
|
|
2236
|
-
};
|
|
2237
|
-
}
|
|
2238
|
-
async function probeLocalProxy(options) {
|
|
2239
|
-
const status = await checkLocalProxy(options);
|
|
2240
|
-
return status.state === "ready" ? status.probe : null;
|
|
2241
|
-
}
|
|
2242
|
-
function localProxyTransport(options) {
|
|
2243
|
-
const endpoint = new URL(PATH, options.url);
|
|
2244
|
-
return {
|
|
2245
|
-
traits: {
|
|
2246
|
-
manualRedirects: true,
|
|
2247
|
-
encodedBodies: false,
|
|
2248
|
-
maxRequestBody: options.maxRequestBody ?? MAX_REQUEST_BODY2
|
|
2249
|
-
},
|
|
2250
|
-
async fetch(request) {
|
|
2251
|
-
let response;
|
|
2252
|
-
try {
|
|
2253
|
-
response = await send(options)(endpoint, {
|
|
2254
|
-
method: "POST",
|
|
2255
|
-
headers: hopHeaders(options, {
|
|
2256
|
-
"X-Slicc-Raw-Request": encodeHead(request.url, request.method, request.headers)
|
|
2257
|
-
}),
|
|
2258
|
-
...request.body ? { body: request.body } : {},
|
|
2259
|
-
signal: request.signal,
|
|
2260
|
-
credentials: "omit",
|
|
2261
|
-
mode: "cors"
|
|
2262
|
-
});
|
|
2263
|
-
} catch (e) {
|
|
2264
|
-
if (request.signal.aborted) throw e;
|
|
2265
|
-
throw failure2(`${endpoint.origin} is unreachable or refuses this origin`);
|
|
2266
|
-
}
|
|
2267
|
-
const type = response.headers.get("content-type") ?? "";
|
|
2268
|
-
if (response.status !== 200 || !type.startsWith(RAW_CONTENT_TYPE) || !response.body) {
|
|
2269
|
-
throw await refusal(response);
|
|
2270
|
-
}
|
|
2271
|
-
const reader = response.body.getReader();
|
|
2272
|
-
const idleMs = options.bodyIdleMs ?? BODY_IDLE_MS;
|
|
2273
|
-
let framed;
|
|
2274
|
-
try {
|
|
2275
|
-
framed = await readHead(reader, idleMs);
|
|
2276
|
-
} catch (e) {
|
|
2277
|
-
await reader.cancel().catch(() => void 0);
|
|
2278
|
-
throw e;
|
|
2279
|
-
}
|
|
2280
|
-
const body = chunks2(framed.rest, reader, idleMs);
|
|
2281
|
-
return {
|
|
2282
|
-
status: framed.head.status,
|
|
2283
|
-
statusText: framed.head.statusText,
|
|
2284
|
-
headers: framed.head.headers,
|
|
2285
|
-
body,
|
|
2286
|
-
cancel: async () => {
|
|
2287
|
-
await body.return(void 0);
|
|
2288
|
-
await reader.cancel().catch(() => void 0);
|
|
2289
|
-
}
|
|
2290
|
-
};
|
|
2291
|
-
}
|
|
2292
|
-
};
|
|
2293
|
-
}
|
|
2294
|
-
|
|
2295
2114
|
// src/mount/driver.ts
|
|
2296
2115
|
function fsError2(code, message = code) {
|
|
2297
2116
|
return Object.assign(new Error(message), { code });
|
|
@@ -2313,7 +2132,7 @@ function serveHostfsPort(port, transport) {
|
|
|
2313
2132
|
});
|
|
2314
2133
|
handles.clear();
|
|
2315
2134
|
};
|
|
2316
|
-
const
|
|
2135
|
+
const send = (reply, transfer = []) => port.postMessage(reply, transfer);
|
|
2317
2136
|
const pending = /* @__PURE__ */ new Set();
|
|
2318
2137
|
const track = (work) => {
|
|
2319
2138
|
pending.add(work);
|
|
@@ -2333,7 +2152,7 @@ function serveHostfsPort(port, transport) {
|
|
|
2333
2152
|
data
|
|
2334
2153
|
}) => {
|
|
2335
2154
|
const { id, op } = data;
|
|
2336
|
-
if (op === "ping") return
|
|
2155
|
+
if (op === "ping") return send({ id, pong: true });
|
|
2337
2156
|
if (op === "bye") return stop();
|
|
2338
2157
|
if (op === "cleanup")
|
|
2339
2158
|
return void Promise.allSettled([...pending]).then(() => answer(data.name, data.body)).catch(() => {
|
|
@@ -2345,12 +2164,12 @@ function serveHostfsPort(port, transport) {
|
|
|
2345
2164
|
try {
|
|
2346
2165
|
await transport.watch(
|
|
2347
2166
|
stop2.signal,
|
|
2348
|
-
(event) =>
|
|
2349
|
-
() =>
|
|
2167
|
+
(event) => send({ id, event }),
|
|
2168
|
+
() => send({ id, open: true })
|
|
2350
2169
|
);
|
|
2351
|
-
|
|
2170
|
+
send({ id, end: true });
|
|
2352
2171
|
} catch (err) {
|
|
2353
|
-
|
|
2172
|
+
send({ id, error: errorOf2(err) });
|
|
2354
2173
|
} finally {
|
|
2355
2174
|
watches.delete(id);
|
|
2356
2175
|
}
|
|
@@ -2367,18 +2186,18 @@ function serveHostfsPort(port, transport) {
|
|
|
2367
2186
|
)
|
|
2368
2187
|
);
|
|
2369
2188
|
const bytes = read.slice();
|
|
2370
|
-
return
|
|
2189
|
+
return send({ id, bytes }, [bytes.buffer]);
|
|
2371
2190
|
}
|
|
2372
2191
|
if (op === "write") {
|
|
2373
2192
|
await track(
|
|
2374
2193
|
transport.write(data.fh, data.offset, data.bytes)
|
|
2375
2194
|
);
|
|
2376
|
-
return
|
|
2195
|
+
return send({ id });
|
|
2377
2196
|
}
|
|
2378
2197
|
if (op !== "call") throw fsError2("EINVAL", `unknown hostfs port op ${op}`);
|
|
2379
|
-
|
|
2198
|
+
send({ id, result: await answer(data.name, data.body) });
|
|
2380
2199
|
} catch (err) {
|
|
2381
|
-
|
|
2200
|
+
send({ id, error: errorOf2(err) });
|
|
2382
2201
|
}
|
|
2383
2202
|
};
|
|
2384
2203
|
const stop = () => {
|
|
@@ -2662,7 +2481,7 @@ async function createKernel(options = {}) {
|
|
|
2662
2481
|
const worker = new Worker(url, { type: "module", name: "slicc-kernel" });
|
|
2663
2482
|
const pending = /* @__PURE__ */ new Map();
|
|
2664
2483
|
let nextId = 0;
|
|
2665
|
-
let
|
|
2484
|
+
let failure2;
|
|
2666
2485
|
const dials = dialer((req) => call(req));
|
|
2667
2486
|
const files = fileWatches(
|
|
2668
2487
|
(req) => request(req),
|
|
@@ -2679,7 +2498,7 @@ async function createKernel(options = {}) {
|
|
|
2679
2498
|
facades.close();
|
|
2680
2499
|
sealed.close();
|
|
2681
2500
|
approvals.close();
|
|
2682
|
-
|
|
2501
|
+
failure2 = error;
|
|
2683
2502
|
bridge?.close();
|
|
2684
2503
|
uplinks?.close();
|
|
2685
2504
|
for (const call2 of pending.values()) call2.reject(error);
|
|
@@ -2739,7 +2558,7 @@ async function createKernel(options = {}) {
|
|
|
2739
2558
|
});
|
|
2740
2559
|
function request(req, handlers = {}) {
|
|
2741
2560
|
const id = ++nextId;
|
|
2742
|
-
if (
|
|
2561
|
+
if (failure2) return { id, done: Promise.reject(failure2) };
|
|
2743
2562
|
const done = new Promise((resolve, reject) => {
|
|
2744
2563
|
pending.set(id, { resolve, reject, ...handlers });
|
|
2745
2564
|
worker.postMessage({ ...req, id });
|
|
@@ -2761,7 +2580,7 @@ async function createKernel(options = {}) {
|
|
|
2761
2580
|
);
|
|
2762
2581
|
done.catch(started.reject);
|
|
2763
2582
|
const pid = await started.promise;
|
|
2764
|
-
const
|
|
2583
|
+
const send = (action) => void call({ op: "terminal", terminal: id, ...action }).catch(() => void 0);
|
|
2765
2584
|
return {
|
|
2766
2585
|
pid,
|
|
2767
2586
|
exited: quiet(done.then((r) => endedOf2(r).status)),
|
|
@@ -2773,13 +2592,13 @@ async function createKernel(options = {}) {
|
|
|
2773
2592
|
listener = next;
|
|
2774
2593
|
if (next) for (const bytes of backlog.splice(0)) next(bytes);
|
|
2775
2594
|
},
|
|
2776
|
-
write: (data) =>
|
|
2595
|
+
write: (data) => send({
|
|
2777
2596
|
action: "write",
|
|
2778
2597
|
bytes: typeof data === "string" ? new TextEncoder().encode(data) : data
|
|
2779
2598
|
}),
|
|
2780
|
-
resize: (cols, rows) =>
|
|
2781
|
-
signal: (name) =>
|
|
2782
|
-
close: () =>
|
|
2599
|
+
resize: (cols, rows) => send({ action: "resize", cols, rows }),
|
|
2600
|
+
signal: (name) => send({ action: "signal", signal: name }),
|
|
2601
|
+
close: () => send({ action: "close" })
|
|
2783
2602
|
};
|
|
2784
2603
|
}
|
|
2785
2604
|
const { dial } = dials;
|
|
@@ -2822,11 +2641,8 @@ export {
|
|
|
2822
2641
|
KernelCallError,
|
|
2823
2642
|
KernelGoneError,
|
|
2824
2643
|
attachKernel,
|
|
2825
|
-
checkLocalProxy,
|
|
2826
2644
|
createKernel,
|
|
2827
2645
|
fetchTransport,
|
|
2828
|
-
localProxyTransport,
|
|
2829
|
-
probeLocalProxy,
|
|
2830
2646
|
serveHostfsPort
|
|
2831
2647
|
};
|
|
2832
2648
|
//# sourceMappingURL=index.js.map
|