@ai-ecoverse/slicc-kernel 1.125.12 → 1.126.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
@@ -354,7 +354,7 @@ The module's default export (or its `main` export) is called with a context and
354
354
 
355
355
  - `kernel.version`, the slicc-kernel version, as in `/proc/version` (also `sync.kernel.version`; read-only);
356
356
  - `argv` (with `argv[0]`), `env`, `pid` (the pid `ps` shows, also after an `exec`), `ppid()` (asked each time, so an orphan sees 1) and `cwd`, and `resolve(path)` to make a path absolute against `cwd`;
357
- - `stdin`, `stdout` and `stderr` as web streams, and `read(fd, max?)` (empty at end of file), `write(fd, bytes | string)` and `close(fd)` on any descriptor it inherited. A write waits until the pipe has room, so a slow reader slows the program down;
357
+ - `stdin`, `stdout` and `stderr` as web streams, and `read(fd, max?)` (empty at end of file), `write(fd, bytes | string, { nonblock }?)` and `close(fd)` on any descriptor it inherited. A write waits until the pipe has room, so a slow reader slows the program down, and answers the byte count. With `{ nonblock: true }` (also `sync.write`), it doesn't wait: it takes what fits now (at most 1 MiB) and answers that count, possibly fewer bytes than given, or fails with `EAGAIN` when nothing fits. The descriptor's own flags don't change. A regular file takes it all, as on Linux. A pipe with no reader is still `EPIPE` with `SIGPIPE`, and a socket is `EPIPE` alone ([#1124](https://github.com/ai-ecoverse/slicc-kernel/issues/1124));
358
358
  - `fdStatus(fd)` (`{ type, tty, seekable }`, `type` being `file`, `pipe`, `tty`, `socket`, `device` or `directory`) and `isatty(fd)`, to tell `< file` from a pipe;
359
359
  - `open(path, { read, write, append, create, exclusive, truncate, nonblock, signal })`, which returns a file with `read(position, length)`, `write(position, bytes)`, `size()`, `truncate(size)`, `sync()` and `close()`. Reads and writes go through the kernel's paged files at those positions, so a large file is never read whole; **A FIFO** ([#935](https://github.com/ai-ecoverse/slicc-kernel/issues/935)) opens through the kernel's FIFO open, as fifo(7) says. A read-open waits for a writer and a write-open for a reader, unless the open is `read` and `write` both. `nonblock: true` gives a read-open at once and a write-open with no reader `ENXIO`, and `signal` aborts a waiting open with its reason. Its file reads and writes the pipe: `read(0, length)` and `write(0, bytes)`, any other position is `ESPIPE`, `size()` is `0`, and `truncate` is `EINVAL`. A socket node is `ENXIO`.
360
360
  - `fs`: `stat`, `lstat`, `exists`, `readdir`, `mkdir` (with parents, or, with `{ recursive: false }`, as Node's `fs.mkdir` without `recursive`: `EEXIST` for an existing entry and `ENOENT` for a missing parent, atomically among the processes of one kernel, so it can serve as a lock there, but not between two kernels on one origin, such as two tabs ([#519](https://github.com/ai-ecoverse/slicc-kernel/issues/519)); `sync.fs.mkdir` takes the same option), `rm` (recursive), `unlink`, `rename`, `symlink`, `readlink`, `statfs`, `chmod(path, mode)`, and `readFile`/`writeFile` for small files. `chmod` takes the permission bits (`0` to `0o7777`, `EINVAL` otherwise) and follows a symlink; as `chmod(2)`, only the file's owner or root may (`EPERM`), so a program can make a script it wrote runnable by path. `sync.fs.chmod` is the same, synchronously. `statfs(path)` answers `{ type, bsize, blocks, bfree, bavail, files, ffree, quota, usage }`: the fields of Node's `StatFs`, plus `quota` and `usage` in bytes (what jsh 0.17 reads). The values come from the same source as an Emscripten program's `statfs`/`df`: the driver of the mount the path is on, or, on the root file system, the origin's storage estimate (`navigator.storage.estimate()`). `bsize` is 4096, `bfree` and `bavail` are what's left of the quota, and `type`, `files` and `ffree` are 0, since the kernel doesn't know them. Where there's no estimate (the Node entry's root), every count is 0. A missing path is `ENOENT`. `sync.fs` has it too. `rm` removes a whole tree in one kernel call (on the OPFS root, one recursive removal and one metadata delete: 50,000 files take about 3 s in Chrome, where `rm -r` takes about 25 s) with the result of a file-by-file removal: symlinks inside the tree are removed, never followed; a tree holding a protected path is refused with `EROFS` and nothing is removed; a mount inside the tree is emptied and its mount point answers `EBUSY`, while the rest is removed; under `users`, what the user may remove is removed and the call then fails with the first `EACCES`/`EPERM`; files a process holds open stay readable to it;
@@ -386,6 +386,7 @@ The module's default export (or its `main` export) is called with a context and
386
386
  - `sync.spawn({ argv, argv0, env, cwd, pgid, session, parentDeathSignal, stdin, stdout, stderr, fds })` starts a child as `spawn` below does, with the same options and the same child, but synchronously: it returns `{ pid, stdin, stdout, stderr, wait(), kill(signal) }` at once, so a runtime can set Node's `child.pid` before `spawn()` returns. A spawn error (`ENOENT`, `EACCES`, `ESRCH` or `EPERM` for a group, `EINVAL` for a bad option, `EBADF` for a descriptor the program doesn't have) is thrown at once, with its `code`. The streams, `wait()` and `kill()` are the asynchronous ones, used afterwards from async code; the child doesn't wait for the program to read, since its output goes to a kernel pipe, which holds what it writes until the program reads it (a child that fills the pipe waits, as with `spawn`);
387
387
  - `sync.kill(pid, signal)`, a synchronous `kill`. When the signal reaches the program itself (its own pid, `0` for its process group, or `-pgid` of its own group) and its action is to end the program (no handler, not ignored), the call never returns: the program's writes reach their descriptors and its terminal modes are put back, as for `sync.exit`, then the rest of the group is signalled and the program ends by the signal, so a parent's `wait` and `sync.run` report `signal`, as Node does for `process.kill(process.pid)`. A signal with a handler runs it when the call returns, an ignored one does nothing, and a stop signal stops the program. A signal whose default action dumps core (`SIGQUIT`, `SIGABRT`, `SIGSEGV`, …) ends it the same way; the kernel writes no core and sets no core flag in the wait status. `-1` signals every process the program may signal except itself and pid 1, as on Linux, and returns (`ESRCH` when there was none, `EPERM` when there were some but it may signal none of them); a process that exec'd gets the signal once. Without users, every process runs as root, so `kill -9 -1` from any program ends every other process; with users it reaches only what the sender may signal (its own uid's processes, or all of them as root);
388
388
  - `sync.exit(status)`, which ends the process at once, as Node's `process.exit()` does: no `catch`, `finally`, microtask, timer or signal handler runs after it, because the call never returns. First the writes the program has made reach their descriptors, in order (a broken pipe drops the rest for that descriptor), and terminal modes the program set with `tty` are put back. Then the process ends the way any exit does: its descriptors are closed and written back, its parent gets `SIGCHLD`, and `wait` reports `status`. Children keep running. A JS program has no threads of its own (`Worker` is withheld), so nothing of it outlives the call. `exit(status)` (below) unwinds instead, so the program's `catch` and `finally` blocks run;
389
+ - `select({ read?, write?, timeoutMs? }, { signal }?)` waits until a descriptor of either list is readable or writable, and answers `{ read, write }` with the ready ones; `timeoutMs` (default: no limit) bounds the wait, and `0` only checks. After a nonblock write's `EAGAIN`, `select({ write: [fd] })` says when room returns ([#1124](https://github.com/ai-ecoverse/slicc-kernel/issues/1124)).
389
390
  - `socketpair()` (and `sync.socketpair()`) answers `[a, b]`, two descriptors of the program on a connected `AF_UNIX` stream pair, and `pipe()` (and `sync.pipe()`) answers `[readFd, writeFd]` ([#1093](https://github.com/ai-ecoverse/slicc-kernel/issues/1093)).
390
391
  - **The same descriptors either way:** the async and `sync` forms give the program's own kernel descriptors, which `read`, `write`, `close` and their `sync` forms all take, until the program closes them or ends.
391
392
  - **Reading** a socket or pipe whose peer has closed gives an empty `Uint8Array` (end of file). A `read` still pending when the program `close`s that descriptor rejects with `EBADF` instead of waiting. A `read` and a `write` on one socket can be pending at the same time, as a socket is full duplex.
@@ -427,8 +428,9 @@ The module's default export (or its `main` export) is called with a context and
427
428
  - **A refusal** rejects the connect with Node's code: `ERR_TLS_CERT_ALTNAME_INVALID`, `UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, `CERT_HAS_EXPIRED`, `CERT_NOT_YET_VALID` or `INVALID_PURPOSE`, with the engine's reason as `detail`. A failed handshake otherwise is `ERR_SSL_HANDSHAKE_FAILURE`.
428
429
  - **`rejectUnauthorized: false`** finishes the handshake whatever the chain says and reports `authorized: false` with `authorizationError`.
429
430
  - **`servername`** must be a host name (letters, digits and `-` in labels of at most 63, at most 253 in all, one trailing dot allowed) or an IP literal, else `EINVAL` before anything is dialled. An IP literal sends no SNI and must match an iPAddress subjectAltName.
430
- - **The `tls` property** is the handshake's: `{ path: 'kernel', substitutes: false, protocol: 'TLSv1.3' or 'TLSv1.2', alpnProtocol (the agreed protocol, or false), authorized, authorizationError?, servername, peerCertificates, cipher, clientCertificate, sni, hostnameChecked, hostnameMatches? }`.
431
+ - **The `tls` property** is the handshake's: `{ path: 'kernel', substitutes: false, verified, protocol: 'TLSv1.3' or 'TLSv1.2', alpnProtocol (the agreed protocol, or false), authorized, authorizationError?, servername, peerCertificates, cipher, clientCertificate, sni, hostnameChecked, hostnameMatches? }`.
431
432
  - `peerCertificates` is the server's chain as DER, leaf first.
433
+ - `verified` is the kernel's own verdict on the session, whatever the caller's flags: the chain verifies against the kernel's **bundled roots** (never a `ca` the caller passed, which only decides `authorized`) and the leaf names `servername`, which is a host name, not an IP literal. `rejectUnauthorized: false`, `checkHostname: false` and `sni: false` don't change it; a custom `ca` makes it false even when the chain would pass both. It's what sealed substitution in an A2 session will be gated on ([#1090](https://github.com/ai-ecoverse/slicc-kernel/issues/1090)); until then `substitutes` stays false.
432
434
  - `cipher` is the negotiated suite as Node's `getCipher()` gives it: `{ name, standardName, version }`, where `name` is OpenSSL's (`ECDHE-RSA-AES128-GCM-SHA256`) and `standardName` IANA's. As in Node, `version` is the suite's own version, not the session's: `TLSv1.0` for a CBC-SHA suite on a TLS 1.2 session.
433
435
  - `clientCertificate` says whether a client certificate was offered. `sni` says whether the server name was sent: it's false with `sni: false` and for an IP-literal `servername`, which TLS never sends.
434
436
  - `hostnameChecked` says whether the kernel checked the leaf against `servername`. `hostnameMatches` says whether it names it.
@@ -458,7 +460,7 @@ The module's default export (or its `main` export) is called with a context and
458
460
  - `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;
459
461
  - `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 `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;
460
462
  - **`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;
461
- - `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`;
463
+ - `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);
462
464
  - `exit(status)`, which ends the program once its pending writes are done;
463
465
  - `onUncaught(listener)` and `onUnhandledRejection(listener)`, which take the errors the program throws outside any call and the promises it leaves rejected (`listener(error, promise)`), as Node's `uncaughtException` and `unhandledRejection` events do. While a listener is registered, the program decides what such an error means; each call answers a function that removes its listener. A listener that throws ends the program as an unhandled error does.
464
466
 
@@ -646,6 +648,8 @@ The proxy answers `200` with `Content-Type: application/vnd.slicc.raw-fetch`: a
646
648
 
647
649
  `/` 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.
648
650
 
651
+ **Renaming a directory on the OPFS root** ([#1122](https://github.com/ai-ecoverse/slicc-kernel/issues/1122)): Chrome's OPFS can move a file but not a directory. So the kernel renames a directory by creating the new tree and moving each file into it (copying a file only if it can't move, e.g. while it's open elsewhere), then removing the old tree. Within the kernel the rename is atomic to readers: a lookup or listing under the old or the new name, or of either parent, waits until the whole tree has moved. So a stage-then-rename updater (pnpm's, an atomic config write) is never seen half there. Another tab sharing the root reads OPFS directly and can see the tree mid-move. A root whose directories can move (the Node memory root) renames them in one step. **If the page reloads or crashes mid-rename**, some files are already in the new tree and the rest are still in the old one. So before the first move, the kernel writes the rename's intent (`{ from, to }`) to its metadata store, and clears it after the last. At the next boot, when no other tab shares the root (the same rule as for the boot's other repair of stale metadata), the kernel finishes every recorded rename: it moves what's left into the new tree, removes the old name, and clears the record. While another tab shares the root, the split tree stays as it is (both names visible, each with part of the files) until a boot that's alone.
652
+
649
653
  A lookup follows at most 40 symlinks, as Linux does: the 41st is `ELOOP`, for WASI programs, JS programs and the kernel API alike, with or without `..` in a link's target. Emscripten programs resolve paths in their glue, which stops one link earlier, at 40.
650
654
 
651
655
  Directories are renamed with `FileSystemHandle.move()` where available, else by copy and delete.
@@ -936,6 +940,13 @@ The kernel then asks the hook `{ v: 1, op: 'materializeConsume', ticket, stamp,
936
940
 
937
941
  **The child's output is scrubbed (S2).** The kernel compiles a [slicc-scrub](https://github.com/ai-ecoverse/slicc-scrub) scrubber (1.0.0, bundled; the published package has no runtime dependency) over exactly the values it swapped in, and gives the child new open file descriptions for fd 1 and fd 2, for every other descriptor it holds on the same descriptions (a `dup` of them), and for every descriptor on a terminal (fd 0 of a terminal session is one), that pass every write through it into the originals (one wrapper per original description, so `2>&1` keeps its order). A value appears as its placeholder, and a fragment or encoded form of it (JSON, percent, HTML, base64, hex, wrapped) as `[redacted:NAME]`. Bytes go through at once unless they may still be the start of a match; such a tail is let out after 50 ms without a write when it's shorter than 16 bytes, and otherwise at the next write or at close. The child's descendants inherit the scrubbed descriptions through fork, spawn, exec and `dup`, and a descendant that opens its controlling terminal (`/dev/tty`, or a terminal by name) gets one scrubbed too, as does a pseudo-terminal slave (`/dev/pts/N`) or [`/dev/clipboard`](#devclipboard) it opens, and a `/dev/clipboard` descriptor it inherited is wrapped at the redeem like fd 1 and 2, so the page's clipboard gets placeholders, never the values. Files, sockets and pipes the child or a descendant opens itself are not scrubbed, so `printf %s "$T" > f` writes the value. A scrubbed fd 1 or 2 is a stream: it doesn't seek. Under `O_NONBLOCK` it gives `EAGAIN` when the pipe or terminal under it would. A write that finds room returns at once, taking at most 64 KiB, and the scrubbed bytes the file couldn't take yet go out before anything written later. Until they have, `poll` reports it unwritable and a nonblocking write gets `EAGAIN`, taking nothing. A blocking write that waits for them, or for room, ends with `EINTR` at a signal, taking nothing; one interrupted after it began writing returns its whole count, and the rest follows. A descendant that redeems a ticket of its own adds its values: its output is scrubbed for both sets. A value under 8 bytes is swapped but can't be scrubbed. The scrubber goes when the last process and description holding it do.
938
942
 
943
+ **A2 sessions (`sessionMaterialize`, [#1090](https://github.com/ai-ecoverse/slicc-kernel/issues/1090)).** The request is in place but not sent yet: the next #1090 steps send it from the http1, SMTP and pg writers. For a kernel-terminated TLS session, the kernel will ask the hook `{ v: 1, op: 'sessionMaterialize', caller, stamp, session, host, port, protocol, position, verified, handshakeAt, exit, placeholders, head? }` (slicc-vault 1.10.0, Sessions), once per session:
944
+ - `stamp` (also `caller`) is the stamp of the process that owns the socket; `session` the kernel's id for it (16 to 128 of `A-Za-z0-9_-`); `host` the name the leaf was verified against, lower-cased without a trailing dot; `handshakeAt` when the handshake finished (epoch ms); `exit` the exit the session leaves by (`page`, `proxy`, `tailscale` or `device:<name>`);
945
+ - `protocol` is `http1`, `smtp` or `pg`, and `position` one of its own: `header:<lower-cased name>`, `auth-plain` or `auth-login`, or `password-cleartext`; `head` (`{ method, path }`) is there for `http1` only, and required there;
946
+ - `placeholders` are the 1 to 16 distinct `fnox_proxy_<24 hex>` placeholders found at that position.
947
+
948
+ The kernel never asks for a session that isn't `verified`, whose handshake is more than 60 s old (or in the future), or whose fields aren't of that shape. The reply is `{ values: { [placeholder]: value } }` within 10 s; the kernel takes only the placeholders it asked for, with a non-empty value free of CR, LF and NUL, and anything else (an error such as `{ v: 1, error: { code } }`, a timeout, a malformed reply, no hook) means nothing is substituted: the placeholders go out as written.
949
+
939
950
  ### `/dev/clipboard`
940
951
 
941
952
  A character device (`0666`), there in every `users` mode, through which programs read and write the embedder's clipboard (`createKernel({ clipboard: { read, write } })`; on the page, bios answers with `navigator.clipboard`). Emscripten programs (`cat /dev/clipboard`, `cat > /dev/clipboard`, see it in `/dev`, reach it by any path that leads to it, and see it as a character device after fork and exec), WASI programs (`std::fs`) and JS programs (`ctx.fs.readFile`, `ctx.fs.writeFile`, `ctx.open`) all open it.