@ai-ecoverse/slicc-kernel 1.174.0 → 1.174.2

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
@@ -39,6 +39,7 @@ TypeScript declarations ship in `dist/types/`, and each entry (`.`, `./node`, `.
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)) |
42
+ | `timeZone` | the browser's (`Intl.DateTimeFormat().resolvedOptions().timeZone`) | the system time zone, an IANA name such as `'Europe/Berlin'`: `TZ` in every environment, and the zone cron reads schedules in (see [Time zone](#time-zone)). A name `Intl` doesn't know rejects. `createNodeKernel` takes it too |
42
43
  | `users` | `'enforce'` | `'off'`, `'report'` or `'enforce'`: whether files have owners and their modes are checked (see [File owners](#file-owners) and [Permissions](#permissions)), and whether `sudo` elevates (see [Sudo](#sudo)). `'off'` behaves exactly as kernels before owners did; any other value rejects. `createNodeKernel` takes it too, with the same default. Kernels up to 1.73 defaulted to `'off'` |
43
44
  | `onDenial` | none | `({ pid, uid, op, path, errno, tag? }) => void`: called for every permission check that fails, under `'report'` and `'enforce'` (see [Permissions](#permissions)). `createNodeKernel` takes it too |
44
45
  | `sealed` | none | `(req, { signal }) => Promise<object>`, or `{ request, fetch? }` with that function as `request`: `request` answers requests written to [`/dev/sealed`](#devsealed), and `fetch` fills requests that carry a sealed placeholder (see **The fetch hook** under [`/dev/sealed`](#devsealed)). `createNodeKernel` takes it too. An exported `SEALED_FETCH_VERSION`, the number `1`, says that this kernel takes the object |
@@ -58,7 +59,7 @@ Runs `argv[0]` with `argv` as its arguments and resolves when it exits. `stdout`
58
59
  | `cwd` | working directory, default `/`; created in OPFS if missing. Under `users: 'enforce'`, a `cwd` the user can't search rejects with `code: 'EACCES'` (`cannot change to <cwd>: Permission denied`) and starts nothing, as a `chdir` there fails on Linux; `openTerminal` does the same. A program's spawn with its own `cwd` (`ctx.spawn`, `ctx.sync.run`, a WASIX `posix_spawn` chdir action) is checked the same way and fails with `EACCES`, while a child that inherits its parent's cwd keeps it unchecked, as on Linux |
59
60
  | `user` | the user to run as, by name or uid (see [Users](#users)); default `root`. An unknown user rejects with `code: 'ENOENT'` |
60
61
  | `tag` | a string the kernel keeps with the process and every process it starts (fork, spawn and exec), and hands to `approve` with a [`sudo`](#sudo) request, so the embedder knows which agent asks; nothing a program does changes it. Anything but a string rejects with a `TypeError` |
61
- | `env` | extra environment, over the user's defaults: `HOME`, `USER`, `LOGNAME`, `PNPM_HOME` and `PATH=$PNPM_HOME/bin:/usr/local/bin:/usr/bin:/bin` (see [Users](#users)), and `PWD=<cwd>` |
62
+ | `env` | extra environment, over the user's defaults: `HOME`, `USER`, `LOGNAME`, `PNPM_HOME` and `PATH=$PNPM_HOME/bin:/usr/local/bin:/usr/bin:/bin` (see [Users](#users)), `TZ=<system time zone>` (see [Time zone](#time-zone)), and `PWD=<cwd>` |
62
63
  | `stdin` | a string or `Uint8Array`; without it stdin is `/dev/null` |
63
64
  | `onStdout`, `onStderr` | called with each chunk of text as it is written |
64
65
 
@@ -476,7 +477,8 @@ The module's default export (or its `main` export) is called with a context and
476
477
  - **Scope:** preview1 only, as Node's `node:wasi`. There are no WASIX calls, and no `thread-spawn` from the guest: emnapi starts its threads as JS workers, which are `thread`s here.
477
478
  - `spawn({ argv, argv0, env, cwd, pgid, session, parentDeathSignal, stdin, stdout, stderr, fds })` starts a command (found as `kernel.run` finds it) as a child process and resolves with `{ pid, stdin, stdout, stderr, wait(), kill(signal) }`. Each stdio is `'inherit'` (the default: the program's own), `'pipe'` (a web stream on a kernel pipe), `'null'`, or one of the program's descriptors. `env` is the child's whole environment (the program's by default), and `cwd` defaults to the program's. The command run is `argv[0]`; `argv0`, when given, is the `argv[0]` the child sees instead, as given (no basename; an empty string too), as with Node's `options.argv0`: `spawn({ argv: ['/bin/sh', '-c', 'echo $0'], argv0: '/bin/sh' })` prints `/bin/sh`, where without it the child sees the command's own name (`sh`). A command whose package manifest sets its `argv0` (a multi-call program's applets, such as coreutils') keeps that one. An `argv0` that isn't a string is `EINVAL`. The child is in the program's process group and session, as after `fork` (Node's `detached: false`), so the terminal's `^C` and `^Z` reach it along with the program. `pgid: 0` makes it lead a new group in the program's session, set before it runs, so a `kill(-pid)` right after the call reaches it and everything it starts (`npx`, then `node`); on the program's terminal that group is in the background, so a read from the terminal stops it with `SIGTTIN` (`EIO` when it ignores `SIGTTIN`), as on Linux. `pgid: N` joins group N, which must be in the program's session: `EPERM` for another session's group, `ESRCH` when there is none. `session: true` makes it lead a new session and a new group (`setsid`, what Node's `detached: true` does on POSIX): its pid is its `pgid` and its `sid`, it has no controlling terminal (`/dev/tty` is `ENXIO`) and the terminal's keys don't signal it. `session` together with `pgid`, or a `pgid` that isn't a non-negative integer, is `EINVAL`. A command that does not exist rejects with `ENOENT`. `parentDeathSignal` (a number or `'SIGTERM'`, as Linux's `PR_SET_PDEATHSIG`) is sent to the child when the program ends, killed or not: to the child's whole group when it leads one (`pgid: 0` or `session: true`), else to the child alone. It's sent with the program's credentials, so a child the program may no longer signal (one that gained privilege) doesn't get it. If the program has already ended by the time the spawn completes, it's sent at once. A child of its own (not a group leader) that has been waited for gets nothing, and `sync.run` has no such option. A bad signal is `EINVAL`;
478
479
  - `wait(pid, { untraced })` resolves with `{ status }` once a child ends, or `{ status: 128 + signal, signal }` when a signal ended it, without blocking the program; with `untraced: true` it also resolves when the child stops, with `{ status: 128 + signal, signal, stopped: true }`, as `waitpid` with `WUNTRACED` (a child's `wait({ untraced })` is the same), and options that aren't an object are `EINVAL`; `kill(pid, signal)` signals any process, by number or as `'SIGTERM'` (the default);
479
- - `sudo(args)`, what the [`sudo`](#sudo) command calls: `args` are its arguments after `sudo`, and it resolves with `{ pid }`, the command the kernel started as the program's child, to `wait` for; `{ run }`, an argv to run as the caller (with `users: 'off'`); `{ list }`, the text of `sudo -l`; or `{ error, errno }`, the message to print;
480
+ - `sudo(args)`, what the [`sudo`](#sudo) command calls: `args` are its arguments after `sudo`, and it resolves with `{ pid }`, the command the kernel started as the program's child, to `wait` for; `{ run }`, an argv to run as the caller (with `users: 'off'`); `{ list }`, the text of `sudo -l`; or `{ error, errno }`, the message to print. A policy line or file the kernel skipped is reported on the program's standard error, by the kernel, before the call resolves;
481
+ - `visudo({ files? })`, what the [`visudo -c`](#sudo) command calls: `files` is `[{ name, text }]`, the files the program read, checked as policy text, or left out to check the policy itself (an effective uid of 0 needed under `users: 'enforce'`). It resolves with `{ lines, ok }`: `lines` is `[fd, text]` pairs to print in order (`1` for `parsed OK`, `2` for a problem), and `ok` is whether nothing was wrong. A malformed call (at most 64 files of 1 MiB each) is `EINVAL`;
480
482
  - `crontab({ action, user?, text? })`, what the [`crontab`](#cron) command calls: `action` is `'list'`, `'remove'` or `'install'` (with the table as `text`), on the caller's own table or, for an effective uid of 0, on `user`'s. It resolves with `{ text }` (the table for `list`, `''` otherwise) or `{ error }`, the message to print. A malformed call is `EINVAL`;
481
483
  - `net`: `listen({ port, host, backlog })` and `connect({ host, port, signal })` (`host` defaults to `127.0.0.1`) on the kernel's loopback, the same sockets WASIX programs use, so a JS server is reached by other processes, by the page as `<port>.kernel.localhost` (`loopbackFetch`, `dial`) and by attached clients. A listener has its `port` (as bound, so port `0` picks a free one), `accept({ signal })`, `close()` and `for await` over its connections. A connection has `local` and `remote` addresses, `readable` and `writable` web streams (closing the writable shuts down the sending side, so the peer reads the end), `close()`, and `reset()`, which closes it with an RST as Node's `resetAndDestroy()` does ([#1530](https://github.com/ai-ecoverse/slicc-kernel/issues/1530)): on the kernel's loopback, the peer first reads the bytes it has already received, as on Linux and BSD ([#1577](https://github.com/ai-ecoverse/slicc-kernel/issues/1577)), and then its reads fail with `ECONNRESET` instead of seeing the end; its writes fail with `ECONNRESET` at once. A TLS connection (`secure()`, a `tls` connect on the loopback) resets the same way. On an uplink connection `reset()` closes at once with a FIN, as the transport can't send an RST: bytes still buffered for the peer are discarded instead of sent. The loopback is IPv4 (`127.0.0.1` by default, or `0.0.0.0`), so an IPv6 host such as `::1` fails with `EADDRNOTAVAIL` for both `listen` and `connect`, and binding and connecting fail as they would for a WASIX program (`EADDRINUSE`, `ECONNREFUSED`, `EADDRNOTAVAIL`). `sync.net.listen` takes the same options and gives the same listener, bound and listening when it returns, so its `port` is known at once, as Node's `listen()` needs ([#1398](https://github.com/ai-ecoverse/slicc-kernel/issues/1398)); it needs the sync lane. A blocked accept, read or write waits without holding anything in the kernel, so aborting it or closing the socket leaves nothing behind. A write to a connection the peer closed fails with `EPIPE` and no `SIGPIPE`;
482
484
  - `net.connect({ host, port, tls: { servername, alpn, rejectUnauthorized, ca, path, cert, key, passphrase, sni, checkHostname, keylog } })` takes one of two paths. By default it's **A1, TLS through the transport** ([#914](https://github.com/ai-ecoverse/slicc-kernel/issues/914)), described first below. For a destination the uplink routes it can also be **A2, a TLS handshake in the kernel** ([#922](https://github.com/ai-ecoverse/slicc-kernel/issues/922)), described after it. `conn.tls.path` (`'transport'` or `'kernel'`) and `conn.tls.substitutes` say which one was taken.
@@ -670,6 +672,15 @@ The kernel has one node name, `slicc` unless `createKernel({ hostname })` (or `c
670
672
  - **The trade-off:** a raw connection to the real `wasmer.sh` resolves to loopback too. HTTP(S) to it still goes through the realm proxy and the transport, which resolve names themselves; `wasmer.sh` is deliberately not in `no_proxy`.
671
673
  - **A real file wins:** a root that has its own `/etc/hostname` (or `/etc/hosts`, `/etc/passwd`) keeps it, and programs read that file rather than the synthetic one. The kernel's name still drives `uname`, `gethostname` and name resolution. A program that opens one of these synthetic files for writing (as root, since `/etc` is root's) gets a real one first: a root-owned `0644` copy of the defaults. So `echo '10.0.0.9 db' >> /etc/hosts` keeps `localhost` and the other default entries, `>` replaces them as on Linux, and `rm` returns to the synthetic file. The copy is `0644` whatever the umask. Programs that read `/etc/hosts` themselves (WASIX/musl ones, such as python's `socket.gethostbyname`) resolve an added name. So does the kernel's own resolver, which Emscripten programs' `getaddrinfo`, WASIX `resolve` and `ctx.net` use ([#898](https://github.com/ai-ecoverse/slicc-kernel/issues/898)). It reads a real `/etc/hosts` as glibc does (`#` comments, aliases, IPv4 and IPv6, first entries first), re-reads it when its inode, size or mtime change, and answers a name the file lists before its built-in names. HTTP that goes through the transport by URL is resolved there, so `curl http://db/` doesn't use the file. A root-written `/etc/hosts` entry for `localhost` applies to `ctx.net` and `getaddrinfo`, as with glibc. The WASI imports' `net.connect` maps the literal name `localhost` to `127.0.0.1`.
672
674
 
675
+ ### Time zone
676
+
677
+ The kernel has one time zone, the browser's unless `createKernel({ timeZone })` (or `createNodeKernel`) names another IANA zone. It is fixed when the kernel starts, and everything that tells the time uses it:
678
+ - **`TZ`** is the zone's name (`TZ=Europe/Berlin`) in the kernel's base environment and in every login environment (cron jobs, `sudo`), so `kernel.run`, terminals and their programs have it. An embedder's own `env.TZ` still wins in the base environment, as any key of `env` does, and a program keeps whatever `TZ` its caller passes.
679
+ - **Cron** reads schedules in it (see [Cron](#cron)), so a job set for `22 18 * * *` runs when `date` in the same environment says 18:22.
680
+ - **Programs:** coreutils (wasm-coreutils 9.12.0-12 on) honours `TZ`; with no `TZ` it would be UTC (homescoop#1626), which is why the kernel sets it. Other Emscripten programs (bash's `printf '%(…)T'`, git, sqlite) and JS programs (`new Date()`) use the browser's zone whatever `TZ` says, which is the same zone unless `timeZone` names another. WASIX programs (python, perl, ruby) read `TZ` through musl, which finds a zone name only in `/usr/share/zoneinfo`; with no such tree in the root they use UTC, as they did before.
681
+
682
+ Until this, `TZ` was unset, so `date` printed UTC while cron read schedules in the browser's zone.
683
+
673
684
  ### Uplink
674
685
 
675
686
  `createKernel({ network: { uplink } })` (or `createNodeKernel`) gives the kernel a page-owned way into another network, such as a tailnet. It is the whole kernel's: while the page passes an uplink, every process gets its names. The uplink is an object with:
@@ -731,7 +742,7 @@ In the browser, the page serves the uplink to the kernel worker over the same po
731
742
  - `reset(code)` (default `ECONNRESET`) breaks the connection both ways: writes the peer has not read fail, the program's next read fails with `ECONNRESET`, and its writes with `EPIPE`.
732
743
  - `closed` resolves when the kernel closes the connection.
733
744
 
734
- `fakeCronClock(start?, utc?)` is a clock for the kernel's cron to run on in tests, so a test moves time instead of waiting for it. It starts at `start` (default 2026-01-01T00:00Z) and reads schedules in UTC unless `utc` is `false`. `now()` is the fake time. `sleep(until, signal)` resolves when `advance(ms)` moves time to `until` or past it, or when `signal` aborts. `set(ms)` sets the wall clock, forward or back, and wakes every sleeper, as a scheduler's next periodic wake would after a real clock change. `sleepers` counts the sleeps still waiting. `flush()` resolves once every sleeper that a time move woke has gone back to sleep for a later time, which is the point where a scheduler has done what was due. It never resolves while nothing sleeps.
745
+ `fakeCronClock(start?, zone?)` is a clock for the kernel's cron to run on in tests, so a test moves time instead of waiting for it. It starts at `start` (default 2026-01-01T00:00Z). `zone` sets the [time zone](#time-zone) of a kernel started on it that names no `timeZone` of its own: UTC by default or with `true`, an IANA name such as `'Europe/Berlin'`, or the browser's with `false`. Its `timeZone` is that name (none for `false`) and `utc` is whether it is UTC. `now()` is the fake time. `sleep(until, signal)` resolves when `advance(ms)` moves time to `until` or past it, or when `signal` aborts. `set(ms)` sets the wall clock, forward or back, and wakes every sleeper, as a scheduler's next periodic wake would after a real clock change. `sleepers` counts the sleeps still waiting. `flush()` resolves once every sleeper that a time move woke has gone back to sleep for a later time, which is the point where a scheduler has done what was due. It never resolves while nothing sleeps.
735
746
 
736
747
  `setTestSyscallHook(hook)` is **test-only**: while set, every process kernel call (the `WasmSyscall` requests: process, fd, socket, signal and job calls; not the path-only file system requests such as `read`, `write` or `rename`) of a process in this Node process first awaits `hook({ pid, op })`. `pid` is the kernel's own pid of the process, which for an exec'd image is not the pid the program sees (`getpid()`, `ps` show the pid it took over). A test can then act at an exact point (write a ^C when a shell forks) or hold a call (delay a child's `proc-setpgid`). `setTestSyscallHook(undefined)` clears it. Unset, which is the default, it does nothing.
737
748
 
@@ -1031,7 +1042,7 @@ Lowering a soft resource limit (`ulimit -S -n 64`) still fails with `EPERM` in E
1031
1042
  Every process has credentials in the kernel, as on Linux: real, effective and saved user and group ids, and supplementary groups. A process gets them from the user it was started as (`run`, `openTerminal`, a client's `spawn`, or the client's own user; root by default) and keeps its parent's across fork, spawn and exec. Setuid and setgid bits are never honoured on exec. Files may have owners (see [File owners](#file-owners)), checked against the credentials with `users: 'enforce'` (see [Permissions](#permissions)); the credentials also decide who may signal whom, and what `/proc` and `ps` show.
1032
1043
 
1033
1044
  - **The database:** `/etc/passwd` and `/etc/group` are real files. On first start the kernel writes them when they're missing, with `root` (uid 0, home `/root`, created `0700`) and `nobody` (65534), and the groups `root`, `sudo` (27), `users` (100) and `nogroup` (65534); files already there are kept as they are. The kernel reads them for every lookup, so an edit counts at once. [`kernel.users`](#kernelusers) adds and removes users.
1034
- - **Environment:** a process started as a user gets `HOME`, `USER`, `LOGNAME`, `PNPM_HOME`, `NPM_CONFIG_PREFIX` and `PATH`. A user's `PNPM_HOME` is `~/.local/share/pnpm` and its npm prefix `~/.local`; root's are `/usr/local/share/pnpm` (or the embedder's `env.PNPM_HOME`) and `/usr/local`. `PATH` is `$PNPM_HOME/bin:/usr/local/bin:/usr/bin:/bin`. pnpm makes only `$PNPM_HOME/bin`, so the kernel makes root's `PNPM_HOME` when it is missing, just before a process starts: once at boot, and again after any mount or unmount, a removable medium inserted, a `hostfs` folder coming back, or a change at that directory or above it (removing it, say), and after a failed try (no medium yet). With owners on, the directories it makes there get root's owner and `0755`, as the boot layout does. A process already running when a medium arrives gets it at its next child launch. Writes inside it cost nothing extra.
1045
+ - **Environment:** a process started as a user gets `HOME`, `USER`, `LOGNAME`, `PNPM_HOME`, `NPM_CONFIG_PREFIX` and `PATH`, and `TZ` (the kernel's [time zone](#time-zone)) with the rest of the base or login environment. A user's `PNPM_HOME` is `~/.local/share/pnpm` and its npm prefix `~/.local`; root's are `/usr/local/share/pnpm` (or the embedder's `env.PNPM_HOME`) and `/usr/local`. `PATH` is `$PNPM_HOME/bin:/usr/local/bin:/usr/bin:/bin`. pnpm makes only `$PNPM_HOME/bin`, so the kernel makes root's `PNPM_HOME` when it is missing, just before a process starts: once at boot, and again after any mount or unmount, a removable medium inserted, a `hostfs` folder coming back, or a change at that directory or above it (removing it, say), and after a failed try (no medium yet). With owners on, the directories it makes there get root's owner and `0755`, as the boot layout does. A process already running when a medium arrives gets it at its next child launch. Writes inside it cost nothing extra.
1035
1046
  - **Commands per user:** root's global packages (`pnpm add -g` as root) are commands every user finds in `/usr/bin`. A user's own global packages are commands for that user only: they show up in the user's `~/.local/share/pnpm/bin`, first on its `PATH`, and a program the user starts finds them by name before the system's; another user, root included, doesn't. A command that only a dependency of those packages provides shows up there only when no other root has the name (see [Commands](#commands)). A user's packages can map file suffixes (`binfmt`) for that user too. The kernel picks up an install, a removal and a new user as they happen.
1036
1047
  - **Changing credentials:** POSIX `setresuid`/`setresgid` rules. Root (effective uid 0) may set any id and the group list; anyone else may only swap among their own real, effective and saved ids, and gets `EPERM` otherwise.
1037
1048
  - **Signals:** a process may signal another if its effective uid is 0, or if its real or effective uid is the target's real or saved uid; otherwise `kill` fails with `EPERM`. A group kill signals the members it may, and fails with `EPERM` only when it may signal none.
@@ -1075,10 +1086,13 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
1075
1086
 
1076
1087
  ### Sudo
1077
1088
 
1078
- `sudo` is a command of the kernel itself, in `/usr/bin` (a package's `sudo` never wins over it): `sudo [-n] [-u user] [--] command [args…]` runs `command` as `user` (root by default), and `sudo -l` lists what the caller may run. `-n` never asks: a command that needs approval fails with `sudo: a password is required`. The decision, the credentials and the environment are all made in the kernel; the command only passes its arguments on (`ctx.sudo`, see [JS programs](#js-programs)), so no program can grant itself more by calling the kernel some other way.
1089
+ `sudo` is a command of the kernel itself, in `/usr/bin` (a package's `sudo` never wins over it): `sudo [-n] [-u user] [--] command [args…]` runs `command` as `user` (root by default), and `sudo -l` lists what the caller may run (`visudo -c` checks the policy, below). `-n` never asks: a command that needs approval fails with `sudo: a password is required`. The decision, the credentials and the environment are all made in the kernel; the command only passes its arguments on (`ctx.sudo`, see [JS programs](#js-programs)), so no program can grant itself more by calling the kernel some other way.
1079
1090
 
1080
1091
  - **`sudo` elevates only under `users: 'enforce'`.** With `'off'` and `'report'`, `sudo command` just runs `command`, as the caller, with its environment. Under `'report'` nothing is refused, so anyone could rewrite the policy or a command; `sudo` grants nothing until the kernel enforces.
1081
- - **The policy** is `/etc/sudoers` and the files in `/etc/sudoers.d/` (in name order, skipping names that start with `.` or end in `~`), which the kernel writes at start when they're missing, with owners on: `/etc/sudoers` root's `0440` with the one line `%sudo Cmnd *`, and `/etc/sudoers.d` root's `0750`. There is no API: an admin (bios, as root) edits `/etc/sudoers.d/<user>`. A line is `[NOPASSWD] <user|%group|ALL> [(<target>)] [Cwd <dir>] [Stdin none] Cmnd <argv…|*>`: who (a user name, a group, or anyone), the user it may run as (`root` by default, or `ALL`), the directory it may run in, whether it needs a standard input that carries nothing the approver didn't see, and the command, or `*` for any command. The command is shell-quoted words whose first is an absolute path, matched exactly against the resolved path (a `*` in it is a literal `*`). The arguments follow sudoers(5): **none written** (`Cmnd /usr/bin/mcpcat`) allows the program with any arguments, none included; **`""` as the only argument** (`Cmnd /usr/bin/mcpcat ""`) allows it with no arguments (`''` is one empty argument); **otherwise** each word matches one argument, exactly, except that an unquoted `*` is a wildcard. A word that is `*` alone matches any number of arguments, none included (`Cmnd /usr/bin/mcpcat list *`), and a `*` inside a word matches any text within that one argument (`--log=*.txt`). A quoted or escaped `*` (`\*`, `'*'`, `"*"`) is a literal `*`, and `*` is the only wildcard: `?` and `[` are literal. **Unlike sudo**, which matches the arguments joined by spaces as one string, so that `/bin/cat /var/log/*` also allows `cat /var/log/x /etc/shadow`, a `*` here never reaches past the argument it is in: that line allows one argument under `/var/log/`, while `/bin/cat *` allows any arguments. `sudo -l` shows the command as it reads, `*` unquoted and a literal `*` as `\*`. **Upgrading:** before this, a command with no arguments allowed only a bare call and `*` was literal, so a hand-written line without arguments now allows any arguments, and an unquoted `*` now matches. Narrow such a line with `""` or the exact arguments if a bare call is what it meant. A hand-written `""` used to be one empty argument and is now no arguments; write `''` for the empty argument. A line in the exact format an `'always'` answer wrote for a bare call in a kernel from before, `NOPASSWD <user> (<target>) [Cwd <dir>] Cmnd <path>` in `/etc/sudoers.d/<user>`, character for character (trailing whitespace on the line ignored), is always read as if it ended in `""`, so it keeps allowing only that bare call, and `sudo -l` shows it with the `""`. That holds for a line an older kernel appends later, on the same root, too. A hand-written line that is character for character in that format is read the same way, so add arguments or `*`, or drop the `(<target>)`, to make it wider. Recorded lines with arguments are unchanged, since the kernel quoted each `*` it wrote. `Cwd` takes one shell-quoted absolute path, and the line then matches only when the caller's directory, resolved to its real path (the one the request shows and the command runs in), is that path, so write it without symlinks: one that has a symlink in it never matches. A line without `Cwd` matches in any directory. `sudo -l` shows the clause as `CWD=<dir>`. `Stdin none` makes the line match only when the caller's standard input is a terminal, `/dev/null` or closed, so a rule for `tee /etc/hosts` typed at a terminal never covers `echo … | sudo tee /etc/hosts` or `< file`. A terminal is any terminal, which a program can also drive through a pseudo-terminal of its own (`/dev/ptmx`, as `script` and `expect` do), so `Stdin none` keeps piped, redirected and file input out, not every input a person didn't type; `none` is the only value, and any other makes the line not parse. A line without `Stdin` matches whatever the input, as before. `sudo -l` shows it as `STDIN=none`, after `CWD=`. A line in the bare-call format below with `Stdin none ` before `Cmnd` is read as ending in `""` too. `#` lines and blank lines are ignored. A line that doesn't parse is skipped, and so is a policy file that isn't root's, is a symlink, or is writable by group or others, or one in a directory that is (each logged once with `console.warn`): what's skipped grants nothing. Groups are the caller's process groups, as `id` shows them.
1092
+ - **The policy** is `/etc/sudoers` and the files in `/etc/sudoers.d/` (in name order, skipping names that start with `.` or end in `~`), which the kernel writes at start when they're missing, with owners on: `/etc/sudoers` root's `0440` with the one line `%sudo Cmnd *`, and `/etc/sudoers.d` root's `0750`. There is no API: an admin (bios, as root) edits `/etc/sudoers.d/<user>`. A line is `[NOPASSWD] <user|%group|ALL> [(<target>)] [Cwd <dir>] [Stdin none] Cmnd <argv…|*>`: who (a user name, a group, or anyone), the user it may run as (`root` by default, or `ALL`), the directory it may run in, whether it needs a standard input that carries nothing the approver didn't see, and the command, or `*` for any command. The command is shell-quoted words whose first is an absolute path, matched exactly against the resolved path (a `*` in it is a literal `*`). The arguments follow sudoers(5): **none written** (`Cmnd /usr/bin/mcpcat`) allows the program with any arguments, none included; **`""` as the only argument** (`Cmnd /usr/bin/mcpcat ""`) allows it with no arguments (`''` is one empty argument); **otherwise** each word matches one argument, exactly, except that an unquoted `*` is a wildcard. A word that is `*` alone matches any number of arguments, none included (`Cmnd /usr/bin/mcpcat list *`), and a `*` inside a word matches any text within that one argument (`--log=*.txt`). A quoted or escaped `*` (`\*`, `'*'`, `"*"`) is a literal `*`, and `*` is the only wildcard: `?` and `[` are literal. **Unlike sudo**, which matches the arguments joined by spaces as one string, so that `/bin/cat /var/log/*` also allows `cat /var/log/x /etc/shadow`, a `*` here never reaches past the argument it is in: that line allows one argument under `/var/log/`, while `/bin/cat *` allows any arguments. `sudo -l` shows the command as it reads, `*` unquoted and a literal `*` as `\*`. **Upgrading:** before this, a command with no arguments allowed only a bare call and `*` was literal, so a hand-written line without arguments now allows any arguments, and an unquoted `*` now matches. Narrow such a line with `""` or the exact arguments if a bare call is what it meant. A hand-written `""` used to be one empty argument and is now no arguments; write `''` for the empty argument. A line in the exact format an `'always'` answer wrote for a bare call in a kernel from before, `NOPASSWD <user> (<target>) [Cwd <dir>] Cmnd <path>` in `/etc/sudoers.d/<user>`, character for character (trailing whitespace on the line ignored), is always read as if it ended in `""`, so it keeps allowing only that bare call, and `sudo -l` shows it with the `""`. That holds for a line an older kernel appends later, on the same root, too. A hand-written line that is character for character in that format is read the same way, so add arguments or `*`, or drop the `(<target>)`, to make it wider. Recorded lines with arguments are unchanged, since the kernel quoted each `*` it wrote. `Cwd` takes one shell-quoted absolute path, and the line then matches only when the caller's directory, resolved to its real path (the one the request shows and the command runs in), is that path, so write it without symlinks: one that has a symlink in it never matches. A line without `Cwd` matches in any directory. `sudo -l` shows the clause as `CWD=<dir>`. `Stdin none` makes the line match only when the caller's standard input is a terminal, `/dev/null` or closed, so a rule for `tee /etc/hosts` typed at a terminal never covers `echo … | sudo tee /etc/hosts` or `< file`. A terminal is any terminal, which a program can also drive through a pseudo-terminal of its own (`/dev/ptmx`, as `script` and `expect` do), so `Stdin none` keeps piped, redirected and file input out, not every input a person didn't type; `none` is the only value, and any other makes the line not parse. A line without `Stdin` matches whatever the input, as before. `sudo -l` shows it as `STDIN=none`, after `CWD=`. A line in the bare-call format below with `Stdin none ` before `Cmnd` is read as ending in `""` too. `#` lines and blank lines are ignored. Groups are the caller's process groups, as `id` shows them.
1093
+ - **The sudoers(5) form:** a line can also be written the way sudoers(5) writes one, for the common subset: `<user|%group|ALL> ALL=[(<target>[,<target>…][:<group>…])] [NOPASSWD:|PASSWD:] <command…|ALL>`, with optional spaces around `=`. The host must be `ALL`. The run-as list is user names or `ALL`, root when it is left out, and a line with several (`cone ALL=(cone-32,cone-33) …`) is one rule per user, as if written once per user in the kernel form; a `:<group>` part is accepted and ignored: the kernel's `sudo` has no `-g`, and the rule runs with the target's own groups, so `(root:restricted)` can differ from sudo, which would run it with that group. `NOPASSWD:` is the kernel's `NOPASSWD`, `PASSWD:` (the default) asks `approve`, and the command `ALL` is the kernel's `*`. So `cone ALL=(cone-32,cone-33) /usr/local/bin/agent send cone --file /tmp/msg-*.md` reads as `cone (cone-32) Cmnd /usr/local/bin/agent send cone --file /tmp/msg-*.md` and the same for `cone-33`, and `%sudo ALL=(ALL:ALL) ALL` as `%sudo (ALL) Cmnd *`. **The command keeps the kernel's stricter meaning, not sudo's:** an unquoted `*` stays within the argument it is in (the line above allows `--file /tmp/msg-a.md` but not `--file /tmp/msg-a.md /etc/shadow.md`, which sudo's `*` would), `?` and `[` are literal, no arguments allows any, and `""` allows none. A `\` escapes the next character (`\,` for a comma in an argument), as in sudoers. Anything else of sudoers is refused rather than guessed at, so it grants nothing: another host, a `%group`, `#uid` or `!user` to run as, `(:group)` alone, a tag other than `NOPASSWD:`/`PASSWD:` (`NOEXEC:`, `SETENV:`, …), an option such as `CWD=` (use the kernel form's `Cwd`, which limits where the caller may run it rather than choosing where it runs), several commands separated by `,` or a `:` that starts another entry (write one line each), a negated `!command`, a wildcard or a directory as the command path, quotes (write a quoted argument in the kernel form), a `#` comment after the rule, a line continued with `\`, a user list, `Defaults`, `*_Alias`, a `#uid` as who, and `@include`/`#include` (`#includedir /etc/sudoers.d` is ignored, since the kernel reads that directory anyway). **Upgrading:** kernels before this skipped every line in this form (with only a `console.warn`), so such a line already in `/etc/sudoers.d` starts to count; `visudo -c` as root lists what the policy holds.
1094
+ - **A line that doesn't parse is never dropped silently:** it is skipped, so it grants nothing, while the rest of the file still counts (as sudo's error recovery does), and every `sudo` and `sudo -l` says so on standard error before anything else. A caller with an effective uid of 0 gets one line per problem, with the file, the line number and what was expected: `sudo: /etc/sudoers.d/memory-handoff:1: line skipped: the SETENV: tag isn't supported: only NOPASSWD: and PASSWD: are`, or, for a line in neither form, `expected '[NOPASSWD] <user|%group|ALL> [(<target>)] [Cwd <dir>] [Stdin none] Cmnd <command…|*>' or '<user|%group|ALL> ALL=[(<target>[,<target>…])] [NOPASSWD:] <command…|ALL>'`. A policy file that isn't root's, is a symlink, or is writable by group or others, and every file in a directory that is, is skipped the same way and reported as `sudo: <path> skipped: it must be root's, no symlink, and writable by root only`. Any other caller can't read the policy, so it gets only a count, without file names or anything from the lines: `sudo: 2 policy lines or files were skipped and grant nothing; ask an administrator to run sudo visudo -c`, and nothing when nothing was skipped. To check a line before installing it, run `visudo -c -f <file>` on your own copy, which reports it in full. Each problem is also logged once, in full, with `console.warn`.
1095
+ - **`visudo -c`** is a command of the kernel itself, in `/usr/bin`, that checks the policy without running anything: `visudo -c -f <file>` (or `visudo -c <file>`, several allowed, `-` for standard input, shown as `stdin`) checks a file the caller can read, before it is installed, and prints `<file>: parsed OK` on standard output, or `visudo: <file>:<line>: <what was expected>` on standard error for each bad line, and exits with 1. `visudo -c` alone checks `/etc/sudoers` and the files in `/etc/sudoers.d` as `sudo` reads them, the ownership and mode included (`visudo: <path>: sudo skips it: …`), and needs an effective uid of 0, as reading those files does on Linux: `sudo visudo -c`. `-q` prints nothing and only sets the status, `-s` is accepted (the check is always strict), and `visudo` without `-c` refuses to edit: write the file as root with mode `0440` (`install -m 0440 -o root -g root <file> /etc/sudoers.d/<name>`).
1082
1096
  - **The embedders' recipe** puts cones in `sudo` (`groups: ['sudo']`) and scoops not, so a cone may ask for any command and a scoop for none.
1083
1097
  - **The command** is found once, on `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin` for a bare name or against the caller's directory for a path, and its real path (symlinks resolved) is what runs and what the policy sees. That file, and every file that will really run for it (a catalog command's glue, module and imports module, and the `package.json` that defines it, a script and the interpreter its `#!` line or `binfmt` suffix names), must be root's and writable by root only, and so must every directory above each, or `sudo` refuses (`EPERM`), whatever the policy says. A command that wouldn't start as the target user (a `#!` line naming an interpreter that is only the caller's, say) fails with `command not found` before anything is asked. Other files a program loads at run time (a package's libraries) are the admin's to keep safe, as on Linux. The caller's directory, which the program passes on, must be a directory the caller may enter, or `sudo` refuses (`EACCES`). The kernel resolves it to its real path once, shows that in the request, and runs the command there; if it no longer resolves the same, or the caller can no longer enter it, when the answer comes or when the command starts, nothing runs.
1084
1098
  - **The decision:** a caller whose effective uid is 0 runs anything (the check on the command's path still applies). Otherwise a matching `NOPASSWD` line runs the command at once; any other matching line asks `approve`; nothing matching is refused (`sudo: cone is not allowed to run /usr/bin/ls as root`).
@@ -1093,9 +1107,10 @@ With `users: 'enforce'` the kernel checks every file call that reaches it agains
1093
1107
  The kernel runs scheduled jobs itself, with no daemon process: a cron service starts with the kernel and stops with `terminate()`. It reads `/etc/crontab` and the per-user tables in `/var/spool/cron/crontabs/<user>`, and runs each due job as `$SHELL -c '<command>'` (`/bin/bash` unless the table sets `SHELL`) as the table's user, in that user's home (`/` if the home is missing), with no terminal and `/dev/null` as standard input.
1094
1108
 
1095
1109
  - **`crontab`** is a command of the kernel itself, in `/usr/bin` (a package's `crontab` never wins over it), with Vixie's options: `crontab -l` prints the caller's table, `crontab -r` removes it, `crontab <file>` and `crontab -` (standard input) install one, and `crontab -e` copies the table to `/tmp`, runs `$VISUAL` (or `$EDITOR`) on the copy through `/bin/bash -c '<editor> "$1"'`, and installs it if it changed and the editor exited with 0. If the edit doesn't parse, the copy is kept and its path is printed. `-u <user>` works on another user's table and needs an effective uid of 0 (`sudo crontab -u cone -l`). An install is checked first: a table with a line that doesn't parse is refused with every error (`"-":<line>: <message>`), and the old one stays. The table becomes the user's `0600` file in the spool, written to a dotfile of a random name and renamed, so the service, which skips dotfiles, never reads half a table. The command only reads the file it is given and passes it on (`ctx.crontab`, see [JS programs](#js-programs)). The kernel decides whose table it is from the caller's real uid, so a user reaches only their own table, and `/etc/crontab` stays root's to edit.
1096
- - **Format:** Vixie's. Five fields (minute, hour, day of month, month, weekday) with lists, ranges, steps and the names `jan`–`dec` and `sun`–`sat` (7 is Sunday too), or one of `@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`, `@hourly` and `@reboot`. As in cronie, when the day of month or the weekday starts with `*`, a day must match both fields; when both are restricted, it may match either. `/etc/crontab` has a user field after the schedule; a user's table doesn't. `NAME=value` lines set the environment for the jobs after them, `#` lines are comments, and `# name: <id>` on the line above a job gives it a stable id (letters, digits, `_`, `.`, `-`), which names its log. Without one, the id is a hash of the table, the user, the schedule and the command. `%` is not special. Times are the kernel's local time.
1097
- - **Environment:** only what a login of that user gets, and nothing from the page or the embedder's `env`. That is `HOME`, `USER`, `LOGNAME`, `PATH`, `PNPM_HOME`, `NPM_CONFIG_PREFIX`, the kernel's network settings (`http_proxy`, `SSL_CERT_FILE`, …), `SHELL`, `PWD`, the table's `NAME=value` lines, and `SLICC_CRON=1`, `SLICC_CRON_JOB=<id>`, `SLICC_CRON_DUE=<ISO time of the due run>` and `SLICC_CRON_MISSED=<runs folded into this one>`. An `agent send` from a job therefore knows it was scheduled.
1098
- - **Missed runs:** when the kernel wasn't running (a closed tab) or time jumped forward, every due run since the last one is folded into **one** run, with `SLICC_CRON_MISSED` saying how many more were due (counted up to 100 000) and `SLICC_CRON_DUE` naming the latest due time, even past the count's cap ([#843](https://github.com/ai-ecoverse/slicc-kernel/issues/843)). The next run is then computed from now. A clock set back more than two minutes runs nothing until the next due time after the new time.
1110
+ - **Format:** Vixie's. Five fields (minute, hour, day of month, month, weekday) with lists, ranges, steps and the names `jan`–`dec` and `sun`–`sat` (7 is Sunday too), or one of `@yearly`/`@annually`, `@monthly`, `@weekly`, `@daily`/`@midnight`, `@hourly` and `@reboot`. As in cronie, when the day of month or the weekday starts with `*`, a day must match both fields; when both are restricted, it may match either. `/etc/crontab` has a user field after the schedule; a user's table doesn't. `NAME=value` lines set the environment for the jobs after them, `#` lines are comments, and `# name: <id>` on the line above a job gives it a stable id (letters, digits, `_`, `.`, `-`), which names its log. Without one, the id is a hash of the table, the user, the schedule and the command. `%` is not special.
1111
+ - **Time zone:** schedules are read in the kernel's [time zone](#time-zone), the one `TZ` names in the job's environment and in `kernel.run`, so `date` shows the time a schedule names. As in cronie, `CRON_TZ=<zone>` sets another IANA zone for the schedules of the jobs after it (and is in their environment); a zone `Intl` doesn't know makes each of those lines an error, so `crontab` refuses the table. A `TZ=` line only sets the jobs' `TZ`, as in cronie; set both to have `date` in the job agree with its `CRON_TZ` schedule. Across daylight saving changes it follows cronie: a job whose minute and hour are both fixed runs once on a day the clock goes back, and one due in the hour the clock skips runs right after the change. A job with `*` in the minute or hour runs by the clock, so it runs in the repeated hour too, and the skipped one has nothing to run. Until this change, schedules were read in the browser's zone while `date` showed UTC.
1112
+ - **Environment:** only what a login of that user gets, and nothing from the page or the embedder's `env`. That is `HOME`, `USER`, `LOGNAME`, `PATH`, `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `TZ` (the kernel's [time zone](#time-zone)), the kernel's network settings (`http_proxy`, `SSL_CERT_FILE`, …), `SHELL`, `PWD`, the table's `NAME=value` lines, and `SLICC_CRON=1`, `SLICC_CRON_JOB=<id>`, `SLICC_CRON_DUE=<ISO time of the due run>` and `SLICC_CRON_MISSED=<runs folded into this one>`. An `agent send` from a job therefore knows it was scheduled.
1113
+ - **Missed runs:** when the kernel wasn't running (a closed tab) or time jumped forward, every due run since the last one is folded into **one** run, with `SLICC_CRON_MISSED` saying how many more were due (counted up to 100 000) and `SLICC_CRON_DUE` naming the latest due time, even past the count's cap ([#843](https://github.com/ai-ecoverse/slicc-kernel/issues/843)). The next run is then computed from now. A line the service hasn't seen before, and a line whose schedule or `CRON_TZ` changed (a `# name:` job given a new schedule), counts from when the service first sees it, so nothing due before it was installed is folded in; a named job whose command alone changed keeps counting. A clock set back more than two minutes runs nothing until the next due time after the new time.
1099
1114
  - **At most once:** the service writes each due run to `/var/spool/cron/stamp` before it starts the job. A kernel that ends mid-job (a reload, a crash) never runs that due again; a stamp that can't be read counts every job as run just now.
1100
1115
  - **`@reboot`** runs once per kernel boot, when that kernel first fires jobs.
1101
1116
  - **Overlap:** a job's runs don't wait for each other, so a slow job can still be running when it is due again. At most 16 jobs of one user run at once; a run past that is skipped, with a line in the job's log and in `/var/log/cron/cron.err`.
@@ -1336,6 +1351,8 @@ The kernel is ported from SLICC's `packages/webapp/src/kernel/` with the browser
1336
1351
  - `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.
1337
1352
  - `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.
1338
1353
  - `src/cdp/`: the CDP facade on `127.0.0.1:9222`, its WebSocket framing, the registry of hosts and the `MessagePort` bridge to them.
1354
+ - `src/sudo.ts`, `src/sudoers.ts`, `src/sudo-command.ts`: the kernel side of `sudo` and `visudo -c`, the policy parser (both line forms and their diagnostics), and the two commands.
1355
+ - `src/time-zone.ts`: the system time zone (`timeZone`, or the browser's) and UTC offsets of a zone, which cron's schedules and `TZ` both come from.
1339
1356
  - `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.
1340
1357
 
1341
1358
 
package/dist/index.js CHANGED
@@ -1149,6 +1149,37 @@ function sealedAnswerer(port, hook, fetchHook) {
1149
1149
  };
1150
1150
  }
1151
1151
 
1152
+ // src/time-zone.ts
1153
+ var MINUTE_MS = 6e4;
1154
+ var HOUR_MS = 60 * MINUTE_MS;
1155
+ var BUCKET_MS = 15 * MINUTE_MS;
1156
+ function canonicalZone(name) {
1157
+ if (typeof name !== "string" || name === "" || name.length > 64) return void 0;
1158
+ try {
1159
+ return new Intl.DateTimeFormat("en-US", { timeZone: name }).resolvedOptions().timeZone;
1160
+ } catch {
1161
+ return void 0;
1162
+ }
1163
+ }
1164
+ function hostTimeZone() {
1165
+ let name;
1166
+ try {
1167
+ name = new Intl.DateTimeFormat().resolvedOptions().timeZone;
1168
+ } catch {
1169
+ name = void 0;
1170
+ }
1171
+ return canonicalZone(name) ?? "UTC";
1172
+ }
1173
+ function systemTimeZone(given, fallback) {
1174
+ if (given !== void 0) {
1175
+ const zone = canonicalZone(given);
1176
+ if (zone === void 0) throw new Error(`not a time zone: ${String(given)}`);
1177
+ return zone;
1178
+ }
1179
+ return canonicalZone(fallback) ?? hostTimeZone();
1180
+ }
1181
+ var MAX_SHIFT_MS = 3 * HOUR_MS;
1182
+
1152
1183
  // src/kernel/net/ws-queue.ts
1153
1184
  var INBOUND_LIMIT = 16 * 1024 * 1024;
1154
1185
  var MESSAGE_LIMIT = 64 * 1024 * 1024;
@@ -2498,6 +2529,7 @@ async function initCall(options, transport) {
2498
2529
  ...options.cdp ? { cdp: true } : {},
2499
2530
  ...facadesInit(options.facades),
2500
2531
  ...options.hostname !== void 0 ? { hostname: options.hostname } : {},
2532
+ ...options.timeZone !== void 0 ? { timeZone: options.timeZone } : {},
2501
2533
  users: options.users === void 0 ? DEFAULT_USERS : options.users,
2502
2534
  ...options.onDenial ? { denials: true } : {},
2503
2535
  ...hooks.request ? { sealed: true } : {},
@@ -2669,6 +2701,7 @@ function checkOptions(options) {
2669
2701
  if (options.hostname !== void 0 && !isHostname(options.hostname)) {
2670
2702
  throw new Error(`not a host name: ${String(options.hostname)}`);
2671
2703
  }
2704
+ if (options.timeZone !== void 0) systemTimeZone(options.timeZone);
2672
2705
  if (options.protect) protectInit(options.protect);
2673
2706
  integrityOptions(options.protect?.integrity);
2674
2707
  facadesInit(options.facades);