@ai-ecoverse/slicc-kernel 1.177.0 → 1.178.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 CHANGED
@@ -602,7 +602,7 @@ The module's default export (or its `main` export) is called with a context and
602
602
  - **Limits:** no revocation checking (neither OCSP nor CRLs, as with Node's default), no session resumption, and the clock is the page's `Date.now()`.
603
603
  - `net.resolve(name, { family, signal })` asks the kernel's resolver, which WASIX `resolve` and Emscripten `getaddrinfo` use too: entries in a real `/etc/hosts`, the kernel's own names (`localhost`, the node name, `host.slicc.internal`) and then the uplink's DNS ([#913](https://github.com/ai-ecoverse/slicc-kernel/issues/913)). It answers the addresses, in the resolver's order, for `family` `4`, `6` or `0` (both, IPv4 first; the default). A name with no answer rejects with a `JsCallError` of code `ENOTFOUND`, as Node's `dns.lookup` does. A lookup the uplink fails or doesn't answer within 10 s rejects with `EAI_AGAIN` (the kernel's `sock-resolve` is `ETIMEDOUT` there, `ENOENT` for no answer), as Node's does for a transient failure, so a client can retry; the failure isn't cached ([#1363](https://github.com/ai-ecoverse/slicc-kernel/issues/1363)). Another family rejects with `EAI_FAMILY`, and an aborted `signal` with its reason.
604
604
  - `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;
605
- - `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. On a transport with `traits.streamingBodies` (below), a `ReadableStream` body (with `duplex: 'half'`, which `Request` requires) streams to it as the program produces it, with no size limit of the kernel's, and a redirect that would send it again (a 307 or 308) rejects with a `TypeError`, as the fetch standard has it. Other bodies (a string, a buffer, a `Blob`, a `FormData`) can be sent again, so they go whole even there. Otherwise the request body is read whole first, 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 request the transport itself could not make** ([#1714](https://github.com/ai-ecoverse/slicc-kernel/issues/1714)) has `cause.detail` `'transport'` as well: that's `fetchTransport()` in a page whose `fetch` rejected (the host is unreachable, or CORS doesn't allow the request; a page can't tell which). `cause.code` stays `ECONNREFUSED` and `cause.message` stays `net-head: ECONNREFUSED`, so programs that check them see no change. A refused connection has no `detail`, whether on the kernel's own loopback or behind `nodeTransport()`, whose `fetch` gives the reason in its own error. So a program can tell "the browser wouldn't let the page fetch this" from "the server refused" and suggest a transport that CORS doesn't bind. The detail is that literal and nothing else: no reason text and nothing else from the transport's error reaches the program. WASI and Emscripten programs keep what they had: the proxy answers `502` with the transport's reason, and `Module.sliccKernel.http` fails with `ECONNREFUSED`. 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. A URL on one of the fetch standard's bad ports (25, 6000 and the rest) rejects too, as Node's `fetch` does: a `TypeError` `fetch failed` whose `cause` is an `Error` `bad port` (no `code`), at the start and as a redirect target, on the kernel's own loopback too ([#822](https://github.com/ai-ecoverse/slicc-kernel/issues/822)). `redirect: 'error'` rejects with a `TypeError` whose `cause` is an `Error` `unexpected redirect`, and a body whose connection ends early fails its read with `TypeError` `terminated` whose `cause` is undici's: `name` `SocketError`, `message` `other side closed`, `code` `UND_ERR_SOCKET`. The request goes out with its headers in the order the program gave them (an object's or an array's order; a `Headers` object's is sorted), lowercase as `Headers` keeps them. The global `fetch` stays withheld;
605
+ - `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. On a transport with `traits.streamingBodies` (below), a `ReadableStream` body (with `duplex: 'half'`, which `Request` requires) streams to it as the program produces it, with no size limit of the kernel's, and a redirect that would send it again (a 307 or 308) rejects with a `TypeError`, as the fetch standard has it. Other bodies (a string, a buffer, a `Blob`, a `FormData`) can be sent again, so they go whole even there. Otherwise the request body is read whole first, 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 request the transport itself could not make** ([#1714](https://github.com/ai-ecoverse/slicc-kernel/issues/1714)) has `cause.detail` `'transport'` as well: that's `fetchTransport()` in a page whose `fetch` rejected (the host is unreachable, or CORS doesn't allow the request; a page can't tell which). `cause.code` stays `ECONNREFUSED` and `cause.message` stays `net-head: ECONNREFUSED`, so programs that check them see no change. A refused connection has no `detail`, whether on the kernel's own loopback or behind `nodeTransport()`, whose `fetch` gives the reason in its own error. So a program can tell "the browser wouldn't let the page fetch this" from "the server refused" and suggest a transport that CORS doesn't bind. The detail is that literal and nothing else: no reason text and nothing else from the transport's error reaches the program. WASI and Emscripten programs keep what they had: the proxy answers `502` with the transport's reason, and `Module.sliccKernel.http` fails with `ECONNREFUSED`. 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. A URL on one of the fetch standard's bad ports (25, 6000 and the rest) rejects too, as Node's `fetch` does: a `TypeError` `fetch failed` whose `cause` is an `Error` `bad port` (no `code`), at the start and as a redirect target, on the kernel's own loopback too ([#822](https://github.com/ai-ecoverse/slicc-kernel/issues/822)). `redirect: 'error'` rejects with a `TypeError` whose `cause` is an `Error` `unexpected redirect`, and a body whose connection ends early fails its read with `TypeError` `terminated` whose `cause` is undici's: `name` `SocketError`, `message` `other side closed`, `code` `UND_ERR_SOCKET`. The request goes out with its headers in the order the program gave them (an object's or an array's order; a `Headers` object's is sorted), lowercase as `Headers` keeps them. **Forbidden request headers** ([#1796](https://github.com/ai-ecoverse/slicc-kernel/issues/1796)): `Cookie`, `Referer`, `Origin` and the rest of the fetch standard's forbidden list go out too, as `curl` sends them: a browser's `Request` drops them from `init.headers` without a word, so `fetch` takes the program's `init.headers` as data and hands them to the transport as given (a `Request` object the program built itself has already lost them in a browser, so pass such headers in `init`). Whether they reach the server is the route's to say (see **Forbidden request headers** under [Network](#network)): the kernel's own loopback, `nodeTransport()` and a transport that takes headers as data (a linked `slicc` exit, an extension's `fetch`) send them, and the page's own `fetch`, which can't, makes `fetch` reject with a `TypeError` `fetch failed` whose `cause.code` is `ENOTSUP`, `cause.detail` is `'forbidden-header'` and `cause.message` names the headers (`net-head: fetch <url> refused: the page's fetch can't send the request headers cookie, referer, so it was not sent: ENOTSUP`); the request is never sent. The global `fetch` stays withheld;
606
606
  - **`fetch` and `websocket` to the kernel's own loopback:** a URL whose host is `127.0.0.0/8`, `0.0.0.0`, `[::1]`, `localhost` or `*.localhost` goes to the kernel's own loopback, as a guest's sockets do (`<port>.kernel.localhost` takes its port from the name). So it reaches whatever listens on that port in the kernel, the CDP facade at `SLICC_CDP_URL` and its `/json` included. It never goes to the transport: with nothing listening, it rejects with a `TypeError` whose `cause.code` is `ECONNREFUSED`, with or without a transport. The kernel speaks HTTP/1.1 and WebSocket (RFC 6455) to the listener itself, and `fetch` handles the redirects and compressed bodies it answers, as for any server. Its request head starts as undici's does, `host` then `connection`, then the program's headers in their order, then `content-length`. It sends `connection: close` where undici sends `keep-alive`, because each request is a connection of its own; a server sees no other difference. A WebSocket's `closed` settles after the closing handshake once the server has closed the connection (RFC 6455 §7.1.4), as undici's `close` event does, or at the latest 5 s after the Close frames. A refused upgrade rejects with a `TypeError` whose `cause` is an `Error` naming the status, `the server answered the upgrade with 503 Service Unavailable`, with `code` `EPROTO`. `https:` and `wss:` there reject with `ENOTSUP`. The listener sees a connection from `127.0.0.1` whose `SO_PEERCRED` is the program's pid, uid and gid, as if the program had connected itself. `host.slicc.internal` still goes to the transport as the real machine's loopback, for `fetch` too, under the transport's policy (`nodeTransport({ hostLoopback })`). A redirect from any other URL into the kernel's loopback rejects with `cause.code` `EACCES`, so a server elsewhere can't steer a program's request to the kernel's own servers; `redirect: 'manual'` still returns it;
607
607
  - `tty`, for programs on a terminal: `getAttr(fd)` and `setAttr(fd, termios)` read and set its termios (`{ c_iflag, c_oflag, c_cflag, c_lflag, c_cc }`, Linux values), `setRaw(fd, raw)` switches raw mode on as libuv (and Node's `setRawMode`) does (no echo, no line editing, no `^C` signal, each key read as it comes) and off again to the termios it had before (per terminal: after raw mode on fds 0 and 1 of one terminal, turning it off on either restores the termios from before the first), `size(fd)` answers `{ columns, rows }`, and `foreground(fd)` and `setForeground(fd, pgrp)` get and set the foreground process group, under the same job-control rules as other programs. A window resize arrives as `SIGWINCH` through `signals`. A terminal the program left raw is set back when the program ends, whether it returns, exits or throws (a program killed by a signal cannot do so). On a descriptor that is not a terminal, each call fails with `ENOTTY`; `sync.tty` has `getAttr`, `setAttr` and `setRaw` in synchronous form, sharing the saved modes with `tty`, so a raw mode set either way is set back when the program ends. `sync.tty.setRaw(fd, raw)` returns only once the terminal's line discipline has the new mode, so input that arrives after it returns is never cooked or echoed under the old mode, as Node's synchronous `setRawMode` promises (#1109);
608
608
  - `exit(status)`, which ends the program once its pending writes are done;
@@ -639,7 +639,8 @@ WASIX programs get `AF_INET`/`AF_INET6` `SOCK_DGRAM` sockets, and `AF_UNIX` ones
639
639
  - Emscripten programs have no datagram sockets yet.
640
640
  - **The proxy** listens on `127.0.0.1:3128` from the first connection on. It takes absolute-form `http:` requests and `CONNECT` tunnels, which it terminates with a certificate for the requested host, issued by the kernel's own CA, then hands each request to the transport. Programs start with `http_proxy`, `https_proxy` (and the upper-case names) pointing at it, `no_proxy` covering loopback and the [node name](#node-name), and `SSL_CERT_FILE`, `CURL_CA_BUNDLE` and `GIT_SSL_CAINFO` pointing at the CA bundle in `/etc/ssl/certs/slicc-kernel-ca.pem`; variables in `env` override them. The CA's key never leaves WebCrypto; it is kept in IndexedDB, in `<metadata>-ca`, or in `slicc-kernel-ca` when `metadata` is the default or `false`, so kernels on one origin share one CA whether or not they keep metadata. `ca` names another database, and `ca: false` keeps a CA in memory for the kernel's lifetime. Each kernel adds its CA to the bundle instead of replacing it, so every kernel on the origin stays trusted; the bundle keeps the newest 16.
641
641
  - **TLS** needs [`@ai-ecoverse/wasm-tls-engine`](https://www.npmjs.com/package/@ai-ecoverse/wasm-tls-engine) installed under `modules`, like a command. Without it, `CONNECT` to port 443 is answered `501` with the reason. A tunnel to another port is accepted, and what the program sends first decides: plain HTTP is served as `http://host:port`, without TLS, and a TLS handshake is terminated as above, or the tunnel closes when it cannot be.
642
- - **The transport** is an object on the page with `traits` (`manualRedirects`, `encodedBodies`, `maxRequestBody`, and `crossOrigin`: `'cors'` or `'any'`) and `fetch(request)`, which answers `{ status, statusText, headers, body, cancel }` with `body` an async iterable of `Uint8Array`, and optionally `url` and `redirected: true` when it followed redirects itself (`fetchTransport()` does), so a JS program's `fetch` reports where it ended. The kernel pulls the body one chunk at a time, so a slow program slows the download. A rejection with a numeric `status` is answered with that status; any other with `502`. A rejection with `detail: 'transport'` says the transport could not make the request at all. `fetchTransport()` sets it when its `fetch` rejects without a `cause.code`, as a browser's does, and a JS program's `fetch` gets it as `cause.detail` (above). Any other `detail` is dropped.
642
+ - **The transport** is an object on the page with `traits` (`manualRedirects`, `encodedBodies`, `maxRequestBody`, and `crossOrigin`: `'cors'` or `'any'`) and `fetch(request)`, which answers `{ status, statusText, headers, body, cancel }` with `body` an async iterable of `Uint8Array`, and optionally `url` and `redirected: true` when it followed redirects itself (`fetchTransport()` does), so a JS program's `fetch` reports where it ended. The kernel pulls the body one chunk at a time, so a slow program slows the download. A rejection with a numeric `status` is answered with that status; any other with `502`. A rejection with `detail: 'transport'` says the transport could not make the request at all. `fetchTransport()` sets it when its `fetch` rejects without a `cause.code`, as a browser's does, and a JS program's `fetch` gets it as `cause.detail` (above). `detail: 'forbidden-header'` says the route can't send a header the request has (below). Any other `detail` is dropped.
643
+ - **Forbidden request headers** ([#1796](https://github.com/ai-ecoverse/slicc-kernel/issues/1796)): a request's headers reach the transport as data, `Cookie`, `Referer`, `Origin` and the rest of the fetch standard's forbidden request headers included, from the proxy (curl, git) as from a JS program's `fetch`. A transport that sends them itself (a linked `slicc` exit, an extension, `nodeTransport()`) passes them on. `fetchTransport()` with the realm's own `fetch` in a browser can't: the page's `fetch` drops them without telling anyone. So it refuses the request before sending it, and rejects with `status: 502`, `code: 'ENOTSUP'`, `detail: 'forbidden-header'`, `headers` (the names, lowercase) and the message `fetch <url> refused: the page's fetch can't send the request header(s) <names>, so it was not sent`, with its `hint` appended. curl and other programs behind the proxy get that as a `502` whose body is the message; a JS program's `fetch` gets it as above; an attached client's `client.fetch` and `client.transport.fetch` reject with the same `message`, `status`, `code` and `detail`. Which headers the page drops is asked of the platform's own `Request`, so it matches the browser. The ones the HTTP stack sets itself from the request are left to it and aren't refused: `Accept-Encoding` (the transport decodes the body), `Connection`, `Content-Length`, `Expect`, `Host` (from the URL), `Keep-Alive`, `TE`, `Trailer`, `Transfer-Encoding` and `Upgrade`; the proxy already drops most of them, as proxies do. `fetchTransport({ fetch })` hands every header to the `fetch` it's given (a `Headers` object built in the page keeps them all), since such a `fetch`, an extension's, may send them; a `fetch` you pass in that ends up in the page's own (a wrapper that only drops `User-Agent`) passes `forbiddenHeaders: 'refuse'` too, so it refuses instead of losing them. `forbiddenHeaders: 'send'` turns the check off. Under Node the platform's `fetch` drops none, so nothing is refused there; it takes `Host` from the URL.
643
644
  - **`User-Agent` in a browser:** `fetchTransport()` leaves the header out when it uses the realm's own `fetch` in a page or a worker, or anywhere else `location` exists. Chromium drops it anyway, while WebKit sends it and preflights the request, which a registry refuses. A `fetch` you pass in gets it, and so does Node.
644
645
  - **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.
645
646
  - **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()`.
@@ -1149,10 +1150,28 @@ The kernel runs scheduled jobs itself, with no daemon process: a cron service st
1149
1150
 
1150
1151
  A character device (`0666`) through which a program talks to the embedder's `sealed` hook (`createKernel({ sealed })`), the secrets vault on the page. Emscripten programs, WASI programs (`std::fs`) and JS programs (`ctx.open`, `ctx.sync.open`, listed by `ctx.fs.readdir('/dev')`) all open it, through the same kernel device, with the same framing, errnos and stamp: a JS program's stamp is built in the kernel from its own process, as for any other. The kernel holds the mode a descriptor was opened with, in every runtime, for its `dup`s and for the copies children inherit through fork, spawn and exec: one opened read-only refuses writes with `EBADF`, and one opened write-only refuses reads. Emscripten programs see it in `/dev` and open it by any path that leads to it (`./sealed` from `/dev`, `..`, a symlink to it or to `/dev`), and `fstat` and `/proc/self/fd` show it as `/dev/sealed`, a character device, after fork and exec too; WASI and JS programs open it by a path with no symlink on the way, and WASI programs by one without `..`.
1151
1152
  - **State per open:** each `open()` is independent; `dup` and fork share one, as on Linux.
1152
- - **One write is one request:** a single UTF-8 JSON object of at most 64 KiB with `v` 1 and a known `op` (`ask`, `seal`, `placeholder`, `list`, `test`, `request`, `status`, `cancel`, `materialize`). The write takes all the bytes or fails: `EMSGSIZE` over 64 KiB, `EINVAL` for anything else that isn't such an object. These are the only errnos; every other outcome is a JSON reply. The kernel zeroes the bytes it was given once it has parsed them.
1153
+ - **One write is one request:** a single UTF-8 JSON object of at most 64 KiB with `v` 1 and a known `op` (`ask`, `seal`, `placeholder`, `list`, `test`, `request`, `status`, `cancel`, `materialize`, `capabilities`, `oauthGrant`, `oauthStatus`, `oauthRefresh`, `oauthRemove`). The write takes all the bytes or fails: `EMSGSIZE` over 64 KiB, `EINVAL` for anything else that isn't such an object, including an invalid managed OAuth request shape. Managed OAuth also returns `ENOTSUP` at the write for reserved unsupported authentication or field-capture inputs, below. Every other outcome is a JSON reply. The kernel zeroes the bytes it was given once it has parsed them.
1153
1154
  - **The stamp:** the hook gets the request with `caller: { pid, ppid, uid, gid, user?, groups, users, tag? }`, from the process that writes (at the write; not the one that opened it): `uid` and `gid` the effective ids, `user` the `/etc/passwd` name of `uid` read at that moment (left out when there is none), `groups` the process's supplementary groups (only root can change them), `users` the kernel's `users` mode, since a uid means more under `'enforce'`, and `tag` the process's tag (see `kernel.run`) when it has one. A `caller` in the request is replaced.
1154
1155
  - **Reading:** the reply comes from the same descriptor, as JSON (`{ v: 1, … }`). A read waits for it, drains it, then returns end-of-file; with no request outstanding it returns end-of-file at once. A signal ends the wait with `EINTR` and the request stays outstanding, `O_NONBLOCK` gives `EAGAIN`, and `poll`/`select` report the descriptor readable once the reply is there. A new write drops an unread reply. The kernel zeroes its copy of a reply once it has been read to the end, dropped or closed.
1155
- - **The hook:** `signal` aborts with `'timeout'` after 10 s (the reply is then `{ v: 1, error: { code: 'ETIMEDOUT' } }`; an `ask` has 305 s, below), `'replaced'` when a new write on the descriptor comes first, and `'withdrawn'` when the writer exits or the descriptor is closed. A throw or a reply that isn't an object becomes an `EIO` error reply, with the first line of the message; no hook is `ENOTCONN`. On the page, `createKernel` relays the request from the kernel worker and back, and the abort too, with no time limit of its own, so an `ask` held open for minutes comes back as it was answered.
1156
+ - **The hook:** `signal` aborts with `'timeout'` after 10 s (the reply is then `{ v: 1, error: { code: 'ETIMEDOUT' } }`; an `ask` has 305 s and the four managed OAuth operations have 45 s, below), `'replaced'` when a new write on the descriptor comes first, and `'withdrawn'` when the writer exits or the descriptor is closed. A throw or a reply that isn't an object becomes an `EIO` error reply, with the first line of the message for legacy operations; managed OAuth and `capabilities` use fixed safe errors instead. No hook is `ENOTCONN`. On the page, `createKernel` relays the request from the kernel worker and back, and the abort too, with no time limit of its own, so an `ask` held open for minutes comes back as it was answered.
1157
+
1158
+ **Vault-managed OAuth (v1).** `capabilities`, `oauthGrant`, `oauthStatus`, `oauthRefresh` and `oauthRemove` carry public-client Bearer credentials to the vault; the kernel neither stores a refresh grant nor performs refresh network requests. `SealedOAuthRequest`, `SealedOAuthConfig`, `SealedOAuthIdentity`, `SealedOAuthStatus`, `SealedOAuthState`, `SealedOAuthError` and `SealedOAuthReply` are exported types from both the browser and Node entries. They describe device payloads; the existing hook adds its authoritative `caller`. No raw-grant or direct sealed request API is added to an attached client.
1159
+
1160
+ Probe before beginning a managed sign-in with `{ v: 1, op: 'capabilities' }`. The only successful shapes are `{ v: 1, capabilities: { kernelOAuth: 1 } }` and `{ v: 1, capabilities: {} }`. This is an end-to-end capability, not a kernel promise: compatible bios relays these five operations as the page envelope `{ op: 'sealed', req, stamp: caller, kernelOAuth: 1 }`, and the vault advertises or accepts managed OAuth only with that **outer** compatibility assertion. The assertion is not authorization. A process cannot supply it: nested `kernelOAuth`, `stamp`, `source`, `kind`, grant references and other unexpected request fields are `EINVAL`. A submitted `caller` is ignored and overwritten, including on inherited descriptors. An old kernel or vault can return `EINVAL` for the probe; offline, malformed and version-skewed replies never mean support. Deploy the supporting vault and compatible bios with a published supporting kernel before enabling the managed path; do not silently fall back to access-only sealing after a failed probe.
1161
+
1162
+ A registration is one device write, for example, with fake credentials:
1163
+
1164
+ ```json
1165
+ {"v":1,"op":"oauthGrant","name":"EXAMPLE_TOKEN","provider":"example","account":"default","accessToken":"fake-access-token","refreshToken":"fake-refresh-token","tokenType":"Bearer","expiresAt":1800003600000,"oauth":{"tokenUrl":"https://auth.example.invalid/token","clientId":"example-public-client","authMethod":"none","scope":"read","resource":"https://api.example.invalid/"}}
1166
+ ```
1167
+
1168
+ - Required fields are `name`, `provider`, `account`, `accessToken`, and `oauth` with `tokenUrl`, `clientId`, `authMethod`. `name` is an environment name, at most 128 characters; `provider` matches `^[a-z0-9][a-z0-9-]{0,127}$`; `account` is a nonempty selector of at most 256 characters with no C0/C1 controls, not an attested human identity. Tokens are nonempty strings. Optional `tokenType` defaults to Bearer and is compared case-insensitively without trimming; another type is `ENOTSUP`. Optional `expiresAt` is a nonnegative safe-integer epoch millisecond number, with the vault enforcing its lifetime limits. Optional `oauth.scope` is a string and `oauth.resource` a nonempty string. The vault validates canonical HTTPS token endpoints, client IDs, resource URLs, metadata secrecy and immutable binding; the kernel validates structure and does not choose endpoints.
1169
+ - Only `authMethod: 'none'` is supported. A different string auth method, or any `auth`, `clientSecret` or `fields` field at the request or OAuth-config level, is `ENOTSUP`, never processed or echoed. Other unexpected fields and wrong types are `EINVAL`. Confidential-client refresh, arbitrary auth headers/templates and captured fields are not supported. Existing confidential/access-only/field-capture paths remain legacy operations; this API does not widen their authority.
1170
+ - Without `refreshToken`, registration makes a managed non-refreshable access record and the vault atomically removes its previous owned grant. Without `expiresAt`, expiry is unknown, not guaranteed infinite validity. The vault owns atomic storage, protected-record replacement, enforced-user management checks, immutable provider/account/endpoint binding, linked-grant ownership and refresh rotation; caller claims never substitute for those checks.
1171
+ - Status, refresh and removal take only `{ v: 1, op: 'oauthStatus' | 'oauthRefresh' | 'oauthRemove', name, provider, account }`. Status does no network I/O; refresh forces at most one vault attempt or shares a concurrent successful refresh; removal soft-deletes the access record and its owned grant, not the upstream provider grant. Neither lookup nor refresh accepts new OAuth metadata. Legacy `placeholder`/`list` replies can carry `oauthManaged: true` for managed access records; the kernel preserves that marker unchanged, so clients must not treat such a record as legacy when the managed API is unavailable.
1172
+ - Grant/status/refresh replies have exactly `{ v: 1, placeholder, state, expiresAt, refreshable }`: a `fnox_proxy_` placeholder followed by 24 lowercase hex digits, state `ready`, `refreshing`, `expired`, `reauth-required`, `removed` or `locked`, `expiresAt` always a nonnegative safe integer or `null`, and boolean `refreshable`. `refreshing` means expired with a refresh source, not proof of current I/O; `locked` promises no usability, and `refreshable` describes structural availability, not network reachability. Removal returns exactly `{ v: 1, removed: true }`. No refresh placeholder, credential, OAuth configuration or provider response body can be returned.
1173
+ - An error is `{ v: 1, error: { code, cause?, message } }`, with code `EINVAL`, `ENOENT`, `EEXIST`, `EACCES`, `EAGAIN`, `ENOTSUP`, `ENOTCONN`, `ETIMEDOUT` or `EIO`, and optional cause `vault-locked`, `oauth-reauth-required` or `oauth-refresh-failed`. The kernel requires own `code` and string `message` fields, ignores supplied message text and generates fixed safe text. A missing, inherited or non-string message is malformed. Unknown codes/causes, unexpected fields, malformed types and a reply whose actual `v` is not 1 become fixed `EIO`; unlike legacy operations, a wrong reply version is never normalized into success. `ENOENT` alone means missing/non-managed; identity conflicts, lock, unavailable transport or other errors cannot justify legacy fallback.
1174
+ - These four operations have a 45-second kernel deadline; `capabilities` retains 10 seconds. Vault has a 40-second receipt-based budget and a bounded refresh lock. Aborts and late-reply discarding do not roll back a committed mutation: the current bios may only withdraw its local pending reply, without cancelling vault work. Never replay initial credentials after lost completion; reconcile later with exact-identity status. A blocked raw read can return `EINTR` without cancelling the request (JS reads restart after handled signals). The vault must reject stale refresh/drop/re-auth commits and publish scrub state before replying; bios must propagate that state before releasing successful mutation completion. These are embedder/vault obligations, not guarantees the kernel can infer from a reply. Old sign-ins whose refresh token was discarded need re-authentication.
1156
1175
 
1157
1176
  **Asking the user (`ask`).** A program asks the user for a secret with `{ v: 1, op: 'ask', name, prompt?, reveal?, replace? }`, and the vault answers it from a dialog. `name` matches `^[A-Za-z_][A-Za-z0-9_]*$` and is at most 128 characters, `prompt` is a string of at most 200 characters (code points), shown to the user as the program's text, and `reveal` and `replace` are booleans; any other field, or a field of another shape, is `EINVAL` at the write (a `caller` or `requester` is replaced, as below).
1158
1177
  - **What the hook gets:** `{ v: 1, op: 'ask', name, prompt?, reveal, replace, caller, requester }`, `reveal` and `replace` always there (`false` unless the program set them), `caller` the stamp above, and `requester: { exe, argv, cwd, tty, stdin }`, the kernel's facts about the writing process when the hook is asked: `exe` is `/usr/bin/<name>` for a command of the catalog, a kernel command such as `sealed` included, and otherwise the file `/proc/<pid>/exe` names, with its links resolved; `argv` the process's arguments, as `/proc/<pid>/cmdline` shows them; `cwd` its working directory, with its links resolved; `tty` the terminal it started on (`/dev/ttyN` or `/dev/pts/N`), or `null`; and `stdin` what its standard input is, as for [`sudo`](#sudo): `tty`, `none` (closed or `/dev/null`), `pipe` or `file`. A program can't supply or change any of it. A process the kernel has no record of (none of its own, so only under a custom embedding) gets an `EIO` reply and the hook isn't asked.
@@ -223,6 +223,9 @@ function kindsOf(hook) {
223
223
  );
224
224
  }
225
225
 
226
+ // src/kernel/net/transport.ts
227
+ var FORBIDDEN_HEADER = "forbidden-header";
228
+
226
229
  // src/kernel/net/ws-queue.ts
227
230
  var INBOUND_LIMIT = 16 * 1024 * 1024;
228
231
  var MESSAGE_LIMIT = 64 * 1024 * 1024;
@@ -300,7 +303,7 @@ var TransportError = class extends Error {
300
303
  super(message);
301
304
  this.status = status;
302
305
  this.code = code;
303
- this.detail = detail === "transport" ? detail : void 0;
306
+ this.detail = detail === "transport" || detail === FORBIDDEN_HEADER ? detail : void 0;
304
307
  }
305
308
  };
306
309
  function unfollowed(redirect) {