@ai-ecoverse/slicc-kernel 1.128.2 → 1.129.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -225,7 +225,7 @@ A WASI preview1 or WASIX program (Zig, Rust, Go, wasi-libc or wasix-libc C) has
225
225
  { "slicc": { "abi": "wasi", "commands": { "rg": { "wasm": "bin/rg.wasm" }, "rustc": { "script": "bin/rustc" } } } }
226
226
  ```
227
227
 
228
- A command's name is any single path component without whitespace or control characters, so coreutils' `[` is in `/usr/bin` too; a name that is empty, `.` or `..`, or has a `/`, a space, a tab, a newline, NUL or another control character is skipped. Entries may also set `argv0`, `args` and `env`, and a package can set `slicc.env` for all of its commands. A WASI entry with `"preopenRoot": true` also gets `/` as a preopen (see [WASI programs](#wasi-programs)). An entry with `"argv0Path": true` gets the absolute path it was run by as `argv[0]` when that path is a link outside `/bin` and `/usr/bin` to the command, as a venv's `bin/python` is (the `#!` line of a script naming such a link included), so it can find what sits beside that link; other entries keep their `argv0`. In `env`, `${package}` is the package directory, `${cwd}` the process's working directory, and `${NAME}` the caller's `NAME` when the program starts (a default naming an unset variable is left out); a relative path that exists in the package (`lib/python3.14`, `./`) becomes that absolute path. The caller's environment wins over these defaults; a `null` value removes the variable for the command, after the caller's environment is applied (a host-networked program can drop the kernel's proxy settings that way), and a command entry can set a variable its package's `slicc.env` removes. The catalog reads its roots in order, and the first root that provides a name wins it: a user's own global packages (for that user's processes), `/node_modules`, root's global packages, then each appended root. Within a root, packages are read by name and the first wins (compared as strings by UTF-16 code unit, so a scoped `@scope/name` comes before names that start with a letter, but after those that start with a digit), whatever order the file system lists them in; a `PNPM_HOME`'s install groups count as one root, read group by group (by the group directory's name, effectively arbitrary for pnpm's hashed names), each by package name. A variant can take over from the package it extends with `"slicc": { "replaces": ["@ai-ecoverse/wasm-imagemagick"] }`: when it and a package it names are in the same root, it wins every command name they share, whatever the order; names only the other provides stay the other's, a third package that provides the name and sorts first still wins it, and packages that replace each other fall back to the name order. A replace never reaches into an earlier root, so root's global packages can't take a name from `/node_modules` or a user's own packages. The field must be an array of package names; anything else is ignored. `sh` runs bash as `sh` unless a package provides its own. A package's `engines.slicc-kernel` is a floor the kernel holds it to: when the running kernel's version doesn't satisfy that npm range (comparators, `^`, `~`, x-ranges, hyphen ranges, `||`), or the range can't be read, its commands are refused. They stay in `/usr/bin`, and running one by name, by path or as a `#!` interpreter exits 126 with `<cmd>: <pkg>@<version> needs slicc-kernel <range>; this kernel is <version>` on stderr. Each refused package is logged once to the console. A non-string `engines.slicc-kernel`, or none, is no constraint. A development build reports the newest release tag as `<tag>+dev` and checks against that; a build of unknown version (`0.0.0`) checks nothing. Only commands are refused: a package without commands (a `py-*` library, a side module) isn't, and a refused package that replaces base commands makes those commands fail with 126 rather than fall back to the package it replaces. The installed commands appear as executables in a virtual `/usr/bin` and `/bin`, so `PATH` lookups, `command -v` and `ls /usr/bin` work without writing anything to OPFS. An entry reads as its program, as a PATH search that opens what it finds expects (`ruby -S rake`, `perl -S`, Pod::Usage looking itself up, bundler checking that `/usr/bin/ruby` isn't empty). A script command reads as its script, a JS command as its module, and a WASI or Emscripten command as its `.wasm`, so `cat /usr/bin/hello` prints the script and `head -c4 /usr/bin/cat` gives `\0asm`. The entry is a plain file and no link: it stays a root-owned `0755` regular file with its own inode, `readlink` of it is `EINVAL`, and `which` and `command -v` print `/usr/bin/<name>`. Its size and times are the program's when the caller may read the program (anyone, for a kernel-internal look or with users off, only when everyone may), and 0 and the epoch otherwise. Running it still runs the catalog command, with its `env`, `args` and `argv0`. The read is the caller's: with users on, reading an entry also needs `r` on its program, and that includes a user's own commands in `~/.local/share/pnpm/bin`, so an entry whose program the caller can't read is `EACCES`. A process can also run a program by the path of its glue when the `.wasm` sits next to it, a wasm module by its own path (as a WASI program; when it is the `wasm` of a WASI command in the caller's catalog, the user's own packages included, it runs as that command, with its `env` defaults, `args` and `argv0`, the command whose name matches the file's, without `.wasm`, if several share it, while a copy elsewhere runs bare), and a script through its `#!` line (including `#!/usr/bin/env name`). Executing any other file fails with `ENOEXEC`, so bash runs it as a shell script, as POSIX shells do. A `..` in a path that is run is resolved physically, as the file system resolves it: `pkg/../tools/x` after a symlinked `pkg` is the `tools/x` beside the link's target, and its exec permission is checked there. The walk is the caller's: with users on, each directory it passes needs search (`x`) permission (`EACCES`, 126 in bash, as on Linux), and a sudo process follows only the links it trusts, as for any other path. Commands also come from pnpm's global directory. Every process gets a `PNPM_HOME` and has `$PNPM_HOME/bin` on `PATH`: root's is `/usr/local/share/pnpm` (unless the embedder sets its own), a user's `~/.local/share/pnpm` (see [Users](#users)). The kernel scans each of pnpm's global install groups (`$PNPM_HOME/global/*/<group>/node_modules`) after `/node_modules`, which wins when both provide a command. The catalog is re-read whenever anything under either changes, so `pnpm add -g @ai-ecoverse/wasi-ripgrep` makes `rg` work in the same shell, and `pnpm remove -g` removes it again.
228
+ A command's name is any single path component without whitespace or control characters, so coreutils' `[` is in `/usr/bin` too; a name that is empty, `.` or `..`, or has a `/`, a space, a tab, a newline, NUL or another control character is skipped. Entries may also set `argv0`, `args` and `env`, and a package can set `slicc.env` for all of its commands. A WASI entry with `"preopenRoot": true` also gets `/` as a preopen (see [WASI programs](#wasi-programs)). An entry with `"argv0Path": true` gets the absolute path it was run by as `argv[0]` when that path is a link outside `/bin` and `/usr/bin` to the command, as a venv's `bin/python` is (the `#!` line of a script naming such a link included), so it can find what sits beside that link; other entries keep their `argv0`. In `env`, `${package}` is the package directory, `${cwd}` the process's working directory, and `${NAME}` the caller's `NAME` when the program starts (a default naming an unset variable is left out); a relative path that exists in the package (`lib/python3.14`, `./`) becomes that absolute path. The caller's environment wins over these defaults; a `null` value removes the variable for the command, after the caller's environment is applied (a host-networked program can drop the kernel's proxy settings that way), and a command entry can set a variable its package's `slicc.env` removes. The catalog reads its roots in order, and the first root that provides a name wins it: a user's own global packages (for that user's processes), `/node_modules`, root's global packages, then each appended root. Within a root, packages are read by name and the first wins (compared as strings by UTF-16 code unit, so a scoped `@scope/name` comes before names that start with a letter, but after those that start with a digit), whatever order the file system lists them in; a `PNPM_HOME`'s install groups count as one root, read group by group (by the group directory's name, effectively arbitrary for pnpm's hashed names), each by package name. A variant can take over from the package it extends with `"slicc": { "replaces": ["@ai-ecoverse/wasm-imagemagick"] }`: when it and a package it names are in the same root, it wins every command name they share, whatever the order; names only the other provides stay the other's, a third package that provides the name and sorts first still wins it, and packages that replace each other fall back to the name order. A replace never reaches into an earlier root, so root's global packages can't take a name from `/node_modules` or a user's own packages. The field must be an array of package names; anything else is ignored. `sh` runs bash as `sh` unless a package provides its own. A package's `engines.slicc-kernel` is a floor the kernel holds it to: when the running kernel's version doesn't satisfy that npm range (comparators, `^`, `~`, x-ranges, hyphen ranges, `||`), or the range can't be read, its commands are refused. They stay in `/usr/bin`, and running one by name, by path or as a `#!` interpreter exits 126 with `<cmd>: <pkg>@<version> needs slicc-kernel <range>; this kernel is <version>` on stderr. Each refused package is logged once to the console. A non-string `engines.slicc-kernel`, or none, is no constraint. A development build reports the newest release tag as `<tag>+dev` and checks against that; a build of unknown version (`0.0.0`) checks nothing. Only commands are refused: a package without commands (a `py-*` library, a side module) isn't, and a refused package that replaces base commands makes those commands fail with 126 rather than fall back to the package it replaces. The installed commands appear as executables in a virtual `/usr/bin` and `/bin`, so `PATH` lookups, `command -v` and `ls /usr/bin` work without writing anything to OPFS. An entry reads as its program, as a PATH search that opens what it finds expects (`ruby -S rake`, `perl -S`, Pod::Usage looking itself up, bundler checking that `/usr/bin/ruby` isn't empty). A script command reads as its script, a JS command as its module, and a WASI or Emscripten command as its `.wasm`, so `cat /usr/bin/hello` prints the script and `head -c4 /usr/bin/cat` gives `\0asm`. The entry is a plain file and no link: it stays a root-owned `0755` regular file with its own inode, `readlink` of it is `EINVAL`, and `which` and `command -v` print `/usr/bin/<name>`. Its size and times are the program's when the caller may read the program (anyone, for a kernel-internal look or with users off, only when everyone may), and 0 and the epoch otherwise. Running it still runs the catalog command, with its `env`, `args` and `argv0`. The read is the caller's: with users on, reading an entry also needs `r` on its program, and that includes a user's own commands in `~/.local/share/pnpm/bin`, so an entry whose program the caller can't read is `EACCES`. A process can also run a program by the path of its glue when the `.wasm` sits next to it, a wasm module by its own path (as a WASI program; when it is the `wasm` of a WASI command in the caller's catalog, the user's own packages included, it runs as that command, with its `env` defaults, `args` and `argv0`, the command whose name matches the file's, without `.wasm`, if several share it, while a copy elsewhere runs bare), and a script through its `#!` line (including `#!/usr/bin/env name`). Executing any other file fails with `ENOEXEC`, so bash runs it as a shell script, as POSIX shells do. A spawn by a bare name (no `/`) that the catalog doesn't have, and that didn't ask for no search (a WASIX `posix_spawn` or `execve`, as opposed to its `posix_spawnp`/`execvp`, keeps its bare name), searches the `PATH` of the spawn's own environment, in order, with an empty entry meaning the working directory; each candidate is run exactly as a spawn of that path would be. The first that runs wins; if one was found but refused and nothing else matched, the spawn fails with `EACCES` (126), otherwise `ENOENT` (127). A catalog command still wins over the same name on `PATH`, the reverse of a shell's order. The `PATH` searched is the new process's, from the spawn's environment; POSIX `posix_spawnp` uses the caller's, which is the same unless the caller hands the child another one. This is a stopgap for the Emscripten shim's `posix_spawnp`, which hands the kernel the bare name (`timeout 5 myscript`, [#1165](https://github.com/ai-ecoverse/slicc-kernel/issues/1165)); it goes once the shipped packages search `PATH` themselves ([#1168](https://github.com/ai-ecoverse/slicc-kernel/issues/1168)). A `..` in a path that is run is resolved physically, as the file system resolves it: `pkg/../tools/x` after a symlinked `pkg` is the `tools/x` beside the link's target, and its exec permission is checked there. The walk is the caller's: with users on, each directory it passes needs search (`x`) permission (`EACCES`, 126 in bash, as on Linux), and a sudo process follows only the links it trusts, as for any other path. Commands also come from pnpm's global directory. Every process gets a `PNPM_HOME` and has `$PNPM_HOME/bin` on `PATH`: root's is `/usr/local/share/pnpm` (unless the embedder sets its own), a user's `~/.local/share/pnpm` (see [Users](#users)). The kernel scans each of pnpm's global install groups (`$PNPM_HOME/global/*/<group>/node_modules`) after `/node_modules`, which wins when both provide a command. The catalog is re-read whenever anything under either changes, so `pnpm add -g @ai-ecoverse/wasi-ripgrep` makes `rg` work in the same shell, and `pnpm remove -g` removes it again.
229
229
 
230
230
  **More roots:** `createKernel({ commands: { append: ['/opt/user/node_modules'] } })` (and `createNodeKernel`) scans more directories, each absolute and normalised (anything else throws a `TypeError`). A name is looked up in a user's own global packages first, then in `modules`, then in root's global packages, then in the appended roots in the order given, and the first match wins, so an appended root never shadows another command (or `sudo`). Their commands are in `/usr/bin` for every user, like root's global packages, and their `binfmt` and file system entries count too. A package in an appended root also gets a command for each `bin` entry of its `package.json` (a string `bin` is named after the package, without its scope), which runs as a `#!` script; a name its `slicc.commands` gives wins. A `bin` entry is skipped when its name isn't a command name (see above), or its file, with symlinks resolved, isn't a regular file inside the package. `/node_modules` and the global packages keep reading only `slicc.commands`. A missing root counts as empty until it appears, and an install or removal in a root is picked up as it happens. With `users: 'enforce'` a root counts only when it and every directory above it are root's and not writable by group or others; another root is skipped without a word. Under `'report'` such a root is still scanned, and reported once to `onDenial` and the report log as `{ pid: 0, uid: 0, op: 'commands', path, errno: 'EACCES' }`. Running a `bin` command needs `r` and `x` on its file under `users` (see [Permissions](#permissions)), checked when it runs, so a `0644` file is `EACCES` under `'enforce'`.
231
231
 
@@ -465,13 +465,17 @@ The module's default export (or its `main` export) is called with a context and
465
465
 
466
466
  Then:
467
467
  - **HTTP/1.1:** if the **first request head** the program writes on the session (it starts with a standard method) has placeholders (`fnox_proxy_<24 hex>`) in exactly one header line, either in the value or in the `user:password` of `Basic` credentials, the kernel asks the vault once (`sessionMaterialize`, below) for that header's position.
468
+ - **SMTP:** a session whose first line is `EHLO` or `HELO` (implicit TLS, or after a STARTTLS upgrade; bytes written before the upgrade are never substituted) is read line by line, and only the `AUTH` exchange is a position:
469
+ - `AUTH PLAIN <blob>`, or the line after a bare `AUTH PLAIN`: the kernel decodes the blob, and asks the vault (`auth-plain`) only when the placeholders are in the **password field**. A placeholder in the user or authorization identity goes out as written. It substitutes there and re-encodes.
470
+ - `AUTH LOGIN`: the password line (the second line, or the first after `AUTH LOGIN <user>`) is decoded, substituted (`auth-login`) and re-encoded; the user line goes out as written.
471
+ - Every other line goes out as written. After the first `AUTH`, or once `MAIL`, `RCPT`, `DATA` or `BDAT` is seen, so is the rest of the session. A line still unfinished after 2 s, or longer than 4 KiB, is sent as written, and so is the rest.
468
472
  - **What goes out:** the placeholders the vault answers with a value of at least 8 bytes go out as that value (`Basic` decoded, substituted and re-encoded). Everything else goes out as written:
469
473
  - the request line and the body;
470
474
  - other headers;
471
475
  - a head with placeholders in two header lines;
472
476
  - later requests on the same connection;
473
477
  - anything once the head passes 16 KiB, or once the program pauses for 2 s in the middle of it;
474
- - SMTP and pg, which come next.
478
+ - pg, which comes next.
475
479
  - **What's read back:** once a process has had a value substituted for a host, everything its whole process tree reads from any A2 session to that host is scrubbed: the process itself, its ancestors and every descendant (forked, spawned or exec'd, before or after the substitution; a forked child's own new session included) with [slicc-scrub](https://github.com/ai-ecoverse/slicc-scrub), as S2 does: the value comes back as its placeholder, and a fragment or an encoded form (JSON, percent, HTML, base64, hex) as `[redacted:sealed]`. The scrub stays until the whole tree has ended. A process outside the tree isn't covered. A value shorter than 8 bytes is never substituted, as it couldn't be scrubbed.
476
480
  - **The bound:** the server the value goes to is the one the vault rule names, verified against the kernel's own roots. Substitution trusts that server not to reflect the value back in a form the scrub doesn't know (encrypted under a key the program chose, say). That's the same residual as A1's.
477
481
  - **Elsewhere:** a placeholder written into any other A2 session reaches the server as written. It isn't the secret, so nothing leaks.
@@ -603,6 +607,8 @@ In the browser, the page serves the uplink to the kernel worker over the same po
603
607
 
604
608
  `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.
605
609
 
610
+ `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.
611
+
606
612
  ### Browser automation (CDP)
607
613
 
608
614
  Programs drive a browser with the Chrome DevTools Protocol over a WebSocket, whichever host actually has the browser: slicc-extension's `chrome.debugger`, slicc-node's or slicc-swift's CDP proxy, or a test harness. The kernel relays; it does not launch a browser.
@@ -544,14 +544,14 @@ var Integrity = class {
544
544
  if (!this.root.version) return void 0;
545
545
  return `${real}\0${await this.root.version(real)}`;
546
546
  }
547
- async read(path, caller, plain2, asked) {
548
- return (await this.readKeyed(path, caller, plain2, asked)).bytes;
547
+ async read(path, caller, plain3, asked) {
548
+ return (await this.readKeyed(path, caller, plain3, asked)).bytes;
549
549
  }
550
- async readKeyed(path, caller, plain2, asked) {
550
+ async readKeyed(path, caller, plain3, asked) {
551
551
  const found = await this.real(path, asked);
552
- if (found === void 0) return { bytes: await plain2() };
552
+ if (found === void 0) return { bytes: await plain3() };
553
553
  const { real, name: name2 } = found;
554
- const read = name2 !== real ? { bytes: await plain2(), version: void 0 } : this.root.readVersioned ? await this.root.readVersioned(real) : { bytes: await this.root.readFileBuffer(real), version: void 0 };
554
+ const read = name2 !== real ? { bytes: await plain3(), version: void 0 } : this.root.readVersioned ? await this.root.readVersioned(real) : { bytes: await this.root.readFileBuffer(real), version: void 0 };
555
555
  const { bytes } = read;
556
556
  const keyed = read.version === void 0 ? { bytes } : { bytes, key: `${real}\0${read.version}` };
557
557
  await this.verify(name2, bytes, caller, EXEC_OPS);
@@ -560,9 +560,9 @@ var Integrity = class {
560
560
  get readsChecked() {
561
561
  return this.sources.length > 0;
562
562
  }
563
- async readSource(path, caller, plain2) {
563
+ async readSource(path, caller, plain3) {
564
564
  const found = await this.source(path);
565
- return found === void 0 ? plain2() : this.checked(found, caller, plain2);
565
+ return found === void 0 ? plain3() : this.checked(found, caller, plain3);
566
566
  }
567
567
  async openSource(path, caller, real) {
568
568
  const found = await this.source(path, real);
@@ -572,10 +572,10 @@ var Integrity = class {
572
572
  }
573
573
  await this.checked(found, caller, () => this.root.readFileBuffer(found.real));
574
574
  }
575
- async checked(found, caller, plain2) {
575
+ async checked(found, caller, plain3) {
576
576
  const { real, name: name2 } = found;
577
577
  if (name2 !== real || !this.root.readVersioned) {
578
- const bytes2 = await plain2();
578
+ const bytes2 = await plain3();
579
579
  await this.verify(name2, bytes2, caller, READ_OPS);
580
580
  return bytes2;
581
581
  }
@@ -3232,11 +3232,11 @@ function frame(opcode, payload) {
3232
3232
  return out;
3233
3233
  }
3234
3234
  function clientFrame(opcode, payload) {
3235
- const plain2 = frame(opcode, payload);
3236
- const head = plain2.length - payload.length;
3235
+ const plain3 = frame(opcode, payload);
3236
+ const head = plain3.length - payload.length;
3237
3237
  const mask = crypto.getRandomValues(new Uint8Array(4));
3238
- const out = new Uint8Array(plain2.length + 4);
3239
- out.set(plain2.subarray(0, head));
3238
+ const out = new Uint8Array(plain3.length + 4);
3239
+ out.set(plain3.subarray(0, head));
3240
3240
  out[1] = out[1] | 128;
3241
3241
  out.set(mask, head);
3242
3242
  for (let i = 0; i < payload.length; i++) {
@@ -7020,7 +7020,7 @@ function satisfies(version, range2) {
7020
7020
  }
7021
7021
 
7022
7022
  // src/version.ts
7023
- var KERNEL_VERSION = true ? "1.128.2" : "0.0.0-dev";
7023
+ var KERNEL_VERSION = true ? "1.129.0" : "0.0.0-dev";
7024
7024
  var OS_RELEASE = "6.0.0-slicc";
7025
7025
  function procVersion() {
7026
7026
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -12378,13 +12378,13 @@ async function chown(entry, path, req) {
12378
12378
  }
12379
12379
  async function runMkdir(entry, path, req) {
12380
12380
  const { fs } = entry;
12381
- const plain2 = plainDirMode(entry, req.mode);
12381
+ const plain3 = plainDirMode(entry, req.mode);
12382
12382
  const parentsMode = !req.exclusive ? plainParentsMode(entry) : void 0;
12383
12383
  const fixParents = parentsMode !== void 0 ? await missingParents(fs, path, parentsMode) : void 0;
12384
- const fresh2 = plain2 !== void 0 && !await fs.exists(path);
12384
+ const fresh2 = plain3 !== void 0 && !await fs.exists(path);
12385
12385
  await (req.exclusive ? mkdirExclusive(entry, path, req.mode) : fs.mkdir(path, mkdirOptions(entry, req.mode)));
12386
12386
  await fixParents?.();
12387
- if (fresh2) await fs.chmod(path, plain2);
12387
+ if (fresh2) await fs.chmod(path, plain3);
12388
12388
  return done2;
12389
12389
  }
12390
12390
  async function shownAs(entry, path, st) {
@@ -12392,8 +12392,8 @@ async function shownAs(entry, path, st) {
12392
12392
  return callerStat(guardOf(entry), await real?.catch(() => path) ?? path, st);
12393
12393
  }
12394
12394
  function readSource(entry, path) {
12395
- const plain2 = () => entry.fs.readFileBuffer(path);
12396
- return entry.source ? entry.source.read(path, plain2) : plain2();
12395
+ const plain3 = () => entry.fs.readFileBuffer(path);
12396
+ return entry.source ? entry.source.read(path, plain3) : plain3();
12397
12397
  }
12398
12398
  async function run(entry, path, req) {
12399
12399
  const { fs, cwd } = entry;
@@ -18490,6 +18490,95 @@ async function substituteHead(head, ask) {
18490
18490
  `, values };
18491
18491
  }
18492
18492
 
18493
+ // src/kernel/net/sealed-smtp.ts
18494
+ var SMTP_LINE_MAX = 4096;
18495
+ var GREETING = ["EHLO ", "HELO "];
18496
+ function sniffSmtp(start2) {
18497
+ const upper = start2.toUpperCase();
18498
+ for (const word of GREETING) {
18499
+ if (upper.startsWith(word)) return "yes";
18500
+ if (word.startsWith(upper)) return "more";
18501
+ }
18502
+ return "no";
18503
+ }
18504
+ function decoded(b64) {
18505
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(b64)) return void 0;
18506
+ try {
18507
+ return atob(b64);
18508
+ } catch {
18509
+ return void 0;
18510
+ }
18511
+ }
18512
+ async function swapped2(text, position, ask) {
18513
+ const placeholders = placeholdersIn(text);
18514
+ if (placeholders.length === 0 || placeholders.length > SESSION_PLACEHOLDERS) return void 0;
18515
+ const values = usable(await ask(position, placeholders));
18516
+ if (Object.keys(values).length === 0) return void 0;
18517
+ return { text: replaced(text, values), values };
18518
+ }
18519
+ async function plain2(b64, ask) {
18520
+ const text = decoded(b64);
18521
+ const fields = text?.split("\0");
18522
+ if (fields?.length !== 3) return { line: b64 };
18523
+ const [authz, user, pass2] = fields;
18524
+ if (placeholdersIn(authz + user).length > 0) return { line: b64 };
18525
+ const done5 = await swapped2(pass2, "auth-plain", ask);
18526
+ if (!done5) return { line: b64 };
18527
+ return { line: btoa(`${authz}\0${user}\0${done5.text}`), values: done5.values };
18528
+ }
18529
+ async function login(b64, ask) {
18530
+ const text = decoded(b64);
18531
+ const done5 = text === void 0 ? void 0 : await swapped2(text, "auth-login", ask);
18532
+ return done5 ? { line: btoa(done5.text), values: done5.values } : { line: b64 };
18533
+ }
18534
+ var SmtpClient = class {
18535
+ state = "command";
18536
+ ask;
18537
+ constructor(ask) {
18538
+ this.ask = ask;
18539
+ }
18540
+ get done() {
18541
+ return this.state === "done";
18542
+ }
18543
+ async line(text) {
18544
+ const state = this.state;
18545
+ if (state === "done") return { line: text };
18546
+ if (state === "plain" || state === "password") {
18547
+ this.state = "done";
18548
+ if (text === "*") return { line: text };
18549
+ return state === "plain" ? plain2(text, this.ask) : login(text, this.ask);
18550
+ }
18551
+ if (state === "user") {
18552
+ this.state = text === "*" ? "done" : "password";
18553
+ return { line: text };
18554
+ }
18555
+ return this.command(text);
18556
+ }
18557
+ async command(text) {
18558
+ const m = /^AUTH[ \t]+(PLAIN|LOGIN)(?:[ \t]+(\S+))?$/i.exec(text);
18559
+ if (!m) {
18560
+ if (/^(MAIL|RCPT|DATA|BDAT|AUTH)\b/i.test(text)) this.state = "done";
18561
+ return { line: text };
18562
+ }
18563
+ const mech = m[1].toUpperCase();
18564
+ const arg = m[2];
18565
+ if (mech === "LOGIN") {
18566
+ this.state = arg === void 0 ? "user" : "password";
18567
+ return { line: text };
18568
+ }
18569
+ if (arg === void 0) {
18570
+ this.state = "plain";
18571
+ return { line: text };
18572
+ }
18573
+ this.state = "done";
18574
+ const out = await plain2(arg, this.ask);
18575
+ return {
18576
+ line: `${text.slice(0, text.length - arg.length)}${out.line}`,
18577
+ ...out.values ? { values: out.values } : {}
18578
+ };
18579
+ }
18580
+ };
18581
+
18493
18582
  // src/kernel/net/sealed-conn.ts
18494
18583
  var HEAD_IDLE_MS = 2e3;
18495
18584
  function substitutes(s) {
@@ -18521,12 +18610,25 @@ function headEnd2(bytes) {
18521
18610
  }
18522
18611
  return -1;
18523
18612
  }
18524
- var Http1Writer = class {
18613
+ function modeOf(start2) {
18614
+ const http = sniffHttp1(start2);
18615
+ const smtp = sniffSmtp(start2);
18616
+ if (http === "yes") return "http1";
18617
+ if (smtp === "yes") return "smtp";
18618
+ return http === "no" && smtp === "no" ? "pass" : void 0;
18619
+ }
18620
+ function crlf(bytes) {
18621
+ for (let i = 0; i + 1 < bytes.length; i++) if (bytes[i] === 13 && bytes[i + 1] === 10) return i;
18622
+ return -1;
18623
+ }
18624
+ var SessionWriter = class {
18525
18625
  buffer = new Uint8Array(0);
18526
- passing = false;
18626
+ mode = "sniff";
18527
18627
  timer;
18528
18628
  chain = Promise.resolve();
18529
18629
  failed;
18630
+ session = sessionId();
18631
+ smtp;
18530
18632
  conn;
18531
18633
  s;
18532
18634
  scope;
@@ -18534,6 +18636,9 @@ var Http1Writer = class {
18534
18636
  this.conn = conn;
18535
18637
  this.s = s;
18536
18638
  this.scope = scope;
18639
+ this.smtp = new SmtpClient(
18640
+ (position, placeholders) => this.ask("smtp", position, placeholders)
18641
+ );
18537
18642
  }
18538
18643
  write(bytes) {
18539
18644
  const run2 = this.chain.then(() => this.take(bytes));
@@ -18543,30 +18648,78 @@ var Http1Writer = class {
18543
18648
  );
18544
18649
  return run2;
18545
18650
  }
18651
+ async ask(protocol, position, placeholders, head) {
18652
+ const s = this.s;
18653
+ const values = await s.broker.sessionMaterialize({
18654
+ stamp: await s.stamp(),
18655
+ session: this.session,
18656
+ host: s.host,
18657
+ port: s.port,
18658
+ protocol,
18659
+ position,
18660
+ verified: s.verified,
18661
+ handshakeAt: s.handshakeAt,
18662
+ exit: s.exit,
18663
+ placeholders,
18664
+ ...head ? { head } : {}
18665
+ });
18666
+ return values;
18667
+ }
18668
+ remember(values) {
18669
+ this.scope.add(
18670
+ Object.entries(values).map(([placeholder, value2]) => ({ name: "sealed", value: value2, placeholder }))
18671
+ );
18672
+ }
18546
18673
  async take(bytes) {
18547
18674
  if (this.failed !== void 0) throw this.failed;
18548
- if (this.passing) return this.conn.write(bytes);
18675
+ if (this.mode === "pass") return this.conn.write(bytes);
18549
18676
  clearTimeout(this.timer);
18550
18677
  const joined4 = new Uint8Array(this.buffer.length + bytes.length);
18551
18678
  joined4.set(this.buffer);
18552
18679
  joined4.set(bytes, this.buffer.length);
18553
18680
  this.buffer.fill(0);
18554
18681
  this.buffer = joined4;
18555
- const sniff = sniffHttp1(latin12(joined4.subarray(0, 8)));
18556
- const end = sniff === "yes" ? headEnd2(joined4) : -1;
18557
- if (sniff === "no" || end > HEAD_MAX || end < 0 && joined4.length > HEAD_MAX)
18558
- await this.release();
18559
- else if (end >= 0) await this.substitute(end);
18560
- else this.timer = setTimeout(() => this.idle(), HEAD_IDLE_MS);
18682
+ if (this.mode === "sniff") this.mode = modeOf(latin12(joined4.subarray(0, 8))) ?? "sniff";
18683
+ if (this.mode === "pass") await this.release();
18684
+ else if (this.mode === "http1") await this.http1();
18685
+ else if (this.mode === "smtp") await this.lines();
18686
+ if (this.mode !== "pass" && this.buffer.length > 0)
18687
+ this.timer = setTimeout(() => this.idle(), HEAD_IDLE_MS);
18561
18688
  return bytes.length;
18562
18689
  }
18690
+ async http1() {
18691
+ const end = headEnd2(this.buffer);
18692
+ if (end > HEAD_MAX || end < 0 && this.buffer.length > HEAD_MAX) await this.release();
18693
+ else if (end >= 0) await this.substitute(end);
18694
+ }
18695
+ async lines() {
18696
+ for (; ; ) {
18697
+ const at = crlf(this.buffer);
18698
+ if (at < 0) break;
18699
+ if (at > SMTP_LINE_MAX) return this.release();
18700
+ const all = this.buffer;
18701
+ this.buffer = all.slice(at + 2);
18702
+ const out = await this.smtp.line(latin12(all.subarray(0, at))).catch(() => ({ line: latin12(all.subarray(0, at)) }));
18703
+ all.fill(0);
18704
+ if ("values" in out && out.values) this.remember(out.values);
18705
+ const line = bytesOf(`${out.line}\r
18706
+ `);
18707
+ try {
18708
+ await writeAll4(this.conn, line);
18709
+ } finally {
18710
+ line.fill(0);
18711
+ }
18712
+ if (this.smtp.done) return this.release();
18713
+ }
18714
+ if (this.buffer.length > SMTP_LINE_MAX) await this.release();
18715
+ }
18563
18716
  idle() {
18564
18717
  this.chain = this.chain.then(() => this.release()).catch((err) => {
18565
18718
  this.failed ??= err;
18566
18719
  });
18567
18720
  }
18568
18721
  async release() {
18569
- this.passing = true;
18722
+ this.mode = "pass";
18570
18723
  const out = this.buffer;
18571
18724
  this.buffer = new Uint8Array(0);
18572
18725
  try {
@@ -18576,37 +18729,15 @@ var Http1Writer = class {
18576
18729
  }
18577
18730
  }
18578
18731
  async substitute(end) {
18579
- this.passing = true;
18732
+ this.mode = "pass";
18580
18733
  const all = this.buffer;
18581
18734
  this.buffer = new Uint8Array(0);
18582
- const s = this.s;
18583
- const session = sessionId();
18584
18735
  const done5 = await substituteHead(
18585
18736
  latin12(all.subarray(0, end)),
18586
- async (position, placeholders, head2) => s.broker.sessionMaterialize({
18587
- stamp: await s.stamp(),
18588
- session,
18589
- host: s.host,
18590
- port: s.port,
18591
- protocol: "http1",
18592
- position,
18593
- verified: s.verified,
18594
- handshakeAt: s.handshakeAt,
18595
- exit: s.exit,
18596
- placeholders,
18597
- head: head2
18598
- })
18737
+ (position, placeholders, head2) => this.ask("http1", position, placeholders, head2)
18599
18738
  ).catch(() => void 0);
18600
18739
  const head = done5 ? bytesOf(done5.head) : all.subarray(0, end);
18601
- if (done5) {
18602
- this.scope.add(
18603
- Object.entries(done5.values).map(([placeholder, value2]) => ({
18604
- name: "sealed",
18605
- value: value2,
18606
- placeholder
18607
- }))
18608
- );
18609
- }
18740
+ if (done5) this.remember(done5.values);
18610
18741
  try {
18611
18742
  await writeAll4(this.conn, head);
18612
18743
  await writeAll4(this.conn, all.subarray(end));
@@ -18617,14 +18748,14 @@ var Http1Writer = class {
18617
18748
  }
18618
18749
  flushed() {
18619
18750
  clearTimeout(this.timer);
18620
- this.chain = this.chain.then(() => this.passing ? void 0 : this.release());
18751
+ this.chain = this.chain.then(() => this.mode === "pass" ? void 0 : this.release());
18621
18752
  return this.chain;
18622
18753
  }
18623
18754
  drop() {
18624
18755
  clearTimeout(this.timer);
18625
18756
  this.buffer.fill(0);
18626
18757
  this.buffer = new Uint8Array(0);
18627
- this.passing = true;
18758
+ this.mode = "pass";
18628
18759
  }
18629
18760
  };
18630
18761
  var IDLE2 = /* @__PURE__ */ Symbol("idle");
@@ -18635,11 +18766,9 @@ var ScrubReader = class {
18635
18766
  held = false;
18636
18767
  ended = false;
18637
18768
  conn;
18638
- s;
18639
18769
  scope;
18640
- constructor(conn, s, scope) {
18770
+ constructor(conn, scope) {
18641
18771
  this.conn = conn;
18642
- this.s = s;
18643
18772
  this.scope = scope;
18644
18773
  }
18645
18774
  current() {
@@ -18698,8 +18827,8 @@ function idle() {
18698
18827
  }
18699
18828
  function sealedConn(conn, s) {
18700
18829
  const scope = s.broker.sessions.scope(s.pid, s.host);
18701
- const writer = substitutes(s) ? new Http1Writer(conn, s, scope) : void 0;
18702
- const reader = new ScrubReader(conn, s, scope);
18830
+ const writer = substitutes(s) ? new SessionWriter(conn, s, scope) : void 0;
18831
+ const reader = new ScrubReader(conn, scope);
18703
18832
  return {
18704
18833
  localAddr: conn.localAddr,
18705
18834
  remoteAddr: conn.remoteAddr,
@@ -19825,6 +19954,12 @@ function tlsOverTransport(req, proc) {
19825
19954
  return { fd: proc.fds.install(new OpenFile(mine), 3), alpnProtocol: "http/1.1", servername };
19826
19955
  }
19827
19956
 
19957
+ // src/kernel/test-hooks.ts
19958
+ var SYSCALL_HOOK = /* @__PURE__ */ Symbol.for("slicc-kernel.test.syscall");
19959
+ function testSyscallHook() {
19960
+ return globalThis[SYSCALL_HOOK];
19961
+ }
19962
+
19828
19963
  // src/kernel/vfs-file.ts
19829
19964
  var O_ACCMODE = 3;
19830
19965
  var O_WRONLY = 1;
@@ -21039,7 +21174,8 @@ var WasmProcess = class {
21039
21174
  cwd,
21040
21175
  ...moved2,
21041
21176
  ...group,
21042
- ...exec ? { exec } : {}
21177
+ ...exec ? { exec } : {},
21178
+ search: req.search
21043
21179
  };
21044
21180
  const pid = await this.children.spawn(spawned, stdio, inherit2);
21045
21181
  if (deathSignal !== void 0) this.onDeath(pgid === 0 || session ? -pid : pid, deathSignal);
@@ -21125,6 +21261,8 @@ var WasmProcess = class {
21125
21261
  return void 0;
21126
21262
  }
21127
21263
  async syscall(req, cancelled, block) {
21264
+ const hook = testSyscallHook();
21265
+ if (hook) await hook({ pid: this.pid, op: req.op });
21128
21266
  this.options.onSyscall?.(req);
21129
21267
  this.calls += 1;
21130
21268
  const started = performance.now();
@@ -21422,18 +21560,18 @@ var WasmProcess = class {
21422
21560
  }
21423
21561
  if (this.writesAt(req) && this.options.readonlyAt?.(path)) throw new KernelError("EROFS");
21424
21562
  const open2 = { ...req, path };
21425
- const plain2 = this.plainMode(req.mode);
21563
+ const plain3 = this.plainMode(req.mode);
21426
21564
  if (req.exclusive) {
21427
21565
  await nodes.createExclusive(path, this.newOwner(req.mode));
21428
- if (plain2 !== void 0) await fs.chmod?.(path, plain2);
21566
+ if (plain3 !== void 0) await fs.chmod?.(path, plain3);
21429
21567
  this.fds.installAt(fd, this.openVfsFile(open2, void 0, fs));
21430
21568
  } else if (req.create) {
21431
21569
  await nodes.onEntry(path, async () => {
21432
- const marks = this.options.owners || plain2 !== void 0;
21570
+ const marks = this.options.owners || plain3 !== void 0;
21433
21571
  const fresh2 = marks && !await present3(fs, path, true);
21434
21572
  this.fds.installAt(fd, this.openVfsFile(open2, await nodes.identify(path), fs));
21435
21573
  await this.fds.get(fd).file.stat?.();
21436
- if (fresh2 && plain2 !== void 0) await fs.chmod?.(path, plain2);
21574
+ if (fresh2 && plain3 !== void 0) await fs.chmod?.(path, plain3);
21437
21575
  else if (fresh2) {
21438
21576
  await fs.chown?.(path, { ...this.newOwner(req.mode), kind: "new" });
21439
21577
  }
@@ -23576,15 +23714,15 @@ function isRecord(value2) {
23576
23714
  return typeof value2 === "object" && value2 !== null && !Array.isArray(value2);
23577
23715
  }
23578
23716
  function serialized(reply) {
23579
- let plain2;
23717
+ let plain3;
23580
23718
  try {
23581
23719
  const text = JSON.stringify(reply);
23582
- plain2 = text === void 0 ? void 0 : JSON.parse(text);
23720
+ plain3 = text === void 0 ? void 0 : JSON.parse(text);
23583
23721
  } catch (err) {
23584
23722
  return JSON.stringify(errorReply("EIO", lineOf2(err)));
23585
23723
  }
23586
- if (!isRecord(plain2)) return JSON.stringify(errorReply("EIO", "the vault gave no reply"));
23587
- const { v: _, ...rest2 } = plain2;
23724
+ if (!isRecord(plain3)) return JSON.stringify(errorReply("EIO", "the vault gave no reply"));
23725
+ const { v: _, ...rest2 } = plain3;
23588
23726
  return JSON.stringify({ v: 1, ...rest2 });
23589
23727
  }
23590
23728
  function lineOf2(err) {
@@ -25546,13 +25684,13 @@ function splitWords(text) {
25546
25684
  while (piece.lastIndex < text.length) {
25547
25685
  const m = piece.exec(text);
25548
25686
  if (!m) return void 0;
25549
- const [, single, double, escaped, plain2, space] = m;
25687
+ const [, single, double, escaped, plain3, space] = m;
25550
25688
  if (space !== void 0) {
25551
25689
  if (word !== void 0) words2.push(word);
25552
25690
  word = void 0;
25553
25691
  continue;
25554
25692
  }
25555
- word = (word ?? "") + (single ?? (double !== void 0 ? unescaped(double) : escaped ?? plain2));
25693
+ word = (word ?? "") + (single ?? (double !== void 0 ? unescaped(double) : escaped ?? plain3));
25556
25694
  }
25557
25695
  if (word !== void 0) words2.push(word);
25558
25696
  return words2;
@@ -26745,16 +26883,52 @@ var Launcher = class {
26745
26883
  for (const bin of planned?.bins ?? []) await need(g, "exec", bin, R_OK | X_OK);
26746
26884
  if (planned?.target.copy) await need(g, "exec", planned.target.copy, R_OK | X_OK);
26747
26885
  }
26748
- async checkedPlan(req, { ppid, cred, scope }, fds) {
26749
- const refused3 = async (err) => {
26886
+ async checkedPlan(req, ctx, fds) {
26887
+ try {
26888
+ return await this.planOrSearch(req, ctx);
26889
+ } catch (err) {
26750
26890
  await fds.closeAll();
26751
- throw err.code === "EACCES" ? new SpawnError("EACCES") : err;
26891
+ throw err;
26892
+ }
26893
+ }
26894
+ async planOrSearch(req, ctx) {
26895
+ const bare = !req.file.includes("/") && !COMMAND2.test(req.file);
26896
+ try {
26897
+ return await this.planOne(req, ctx);
26898
+ } catch (err) {
26899
+ const code = codeOf7(err);
26900
+ const searched = bare && req.search !== false && (code === "ENOENT" || code === "ENOEXEC");
26901
+ if (!searched || await this.commandAt(req.file, ctx.scope)) throw err;
26902
+ const found = await this.onPath(req, ctx);
26903
+ if (found) return found;
26904
+ throw err;
26905
+ }
26906
+ }
26907
+ async onPath(req, ctx) {
26908
+ const path = req.env.PATH;
26909
+ if (typeof path !== "string") return void 0;
26910
+ let denied2 = false;
26911
+ for (const dir of path.split(":")) {
26912
+ const file = dir === "" ? `./${req.file}` : `${dir.replace(/\/+$/, "")}/${req.file}`;
26913
+ try {
26914
+ return await this.planOne({ ...req, file }, ctx);
26915
+ } catch (err) {
26916
+ const code = codeOf7(err);
26917
+ if (code === "EACCES") denied2 = true;
26918
+ else if (code !== "ENOENT" && code !== "ENOTDIR") throw err;
26919
+ }
26920
+ }
26921
+ if (denied2) throw new SpawnError("EACCES");
26922
+ return void 0;
26923
+ }
26924
+ async planOne(req, { ppid, cred, scope }) {
26925
+ const refused3 = (err) => {
26926
+ throw codeOf7(err) === "EACCES" ? new SpawnError("EACCES") : err;
26752
26927
  };
26753
26928
  const file = await this.execFile(req.file, req.cwd, this.execGuard(cred, ppid)).catch(refused3);
26754
26929
  const planned = await this.plan(file, req.argv, req.cwd, scope, req.argv0);
26755
26930
  await this.mayExec(cred, { ...req, file, pid: ppid }, planned).catch(refused3);
26756
26931
  if (planned) return planned;
26757
- await fds.closeAll();
26758
26932
  throw new SpawnError(await this.unrunnable(file, req.cwd, scope));
26759
26933
  }
26760
26934
  async enterable(g, cwd) {
@@ -26921,12 +27095,12 @@ var Launcher = class {
26921
27095
  return `${path}:${st.size}:${st.mtime.getTime()}`;
26922
27096
  }
26923
27097
  verified(path, caller, fs = this.base) {
26924
- const plain2 = () => fs.readFileBuffer(path);
26925
- return this.integrity ? this.integrity.read(path, caller, plain2) : plain2();
27098
+ const plain3 = () => fs.readFileBuffer(path);
27099
+ return this.integrity ? this.integrity.read(path, caller, plain3) : plain3();
26926
27100
  }
26927
27101
  keyed(path, caller, asked) {
26928
- const plain2 = async () => this.base.readFileBuffer(path);
26929
- return this.integrity ? this.integrity.readKeyed(path, caller, plain2, asked) : plain2().then((bytes) => ({ bytes }));
27102
+ const plain3 = async () => this.base.readFileBuffer(path);
27103
+ return this.integrity ? this.integrity.readKeyed(path, caller, plain3, asked) : plain3().then((bytes) => ({ bytes }));
26930
27104
  }
26931
27105
  cached(cache, key, load) {
26932
27106
  const hit = cache.get(key);
@@ -27123,7 +27297,7 @@ var Launcher = class {
27123
27297
  });
27124
27298
  return {
27125
27299
  source: {
27126
- read: (path, plain2) => integrity.readSource(path, caller(), plain2),
27300
+ read: (path, plain3) => integrity.readSource(path, caller(), plain3),
27127
27301
  open: (path, real) => integrity.openSource(path, caller(), real)
27128
27302
  }
27129
27303
  };
@@ -28030,9 +28204,9 @@ var Launcher = class {
28030
28204
  }
28031
28205
  );
28032
28206
  }
28033
- environment(cwd, extra, account, login = false) {
28207
+ environment(cwd, extra, account, login2 = false) {
28034
28208
  const env = {
28035
- ...login ? networkEnv(this.hostname) : this.env,
28209
+ ...login2 ? networkEnv(this.hostname) : this.env,
28036
28210
  ...userEnv(account, this.pnpmHome),
28037
28211
  PWD: cwd,
28038
28212
  ...extra
@@ -28259,6 +28433,9 @@ var Launcher = class {
28259
28433
  function cronLocks() {
28260
28434
  return globalThis.navigator?.locks;
28261
28435
  }
28436
+ function codeOf7(err) {
28437
+ return err?.code;
28438
+ }
28262
28439
 
28263
28440
  // src/pin.ts
28264
28441
  function pinScript(url) {