@ai-ecoverse/slicc-kernel 1.109.0 → 1.110.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
@@ -227,6 +227,8 @@ A command's name is any single path component without whitespace or control char
227
227
 
228
228
  **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'`.
229
229
 
230
+ **Variants by environment** ([homescoop#357](https://github.com/ai-ecoverse/homescoop/issues/357)): one package can carry more than one build of a wasm command and choose between them by the environment. `"variants": [{ "env": { "PYTHON_FORK": "true" }, "wasm": "bin/python-fork.wasm" }]` on a command entry (Emscripten or WASI) is matched in order against the environment the new process gets, at spawn and at exec. Every key must equal its value exactly, and the first match runs its `wasm`; no match runs the command's own `wasm`. This holds however the kernel reaches the entry: by name, by its path (`/usr/bin/python3`, as `sys.executable` gives it to `subprocess` and `multiprocessing`), or through a symlink to that path (a venv's `bin/python`). So `PYTHON_FORK=true python3`, `PYTHON_FORK=true exec /usr/bin/python3` and a child that inherits `PYTHON_FORK=true` all get the variant, and `PYTHON_FORK=1` doesn't. Commands of one package that share a build (`python`, `python3`, `pip`, `pip3`) each carry the same `variants`. A variant replaces only the module: the `abi`, the glue, `env`, `argv0`, `argv0Path` and the `/usr/bin` entry stay the command's. For an Emscripten command that means a variant swaps only the `.wasm` and keeps the command's JS glue, so it must be built against that same glue; a WASI or WASIX command has no glue, so there's no such constraint. Only the chosen module is read, checked and compiled, and compiled modules are cached by its path. A read or `stat` of `/usr/bin/<name>` still shows the default `wasm`. A `variants` that isn't an array, or has an entry without an `env` object of strings and a `wasm` inside the package, is ignored as a whole, with one warning per command, and the command runs its `wasm`. An older kernel ignores the field.
231
+
230
232
  A package can also name a command that runs files by their suffix, as `binfmt_misc` does: `"slicc": { "binfmt": { ".jsh": "jsh" } }`. Executing a file that has no `#!` line and is not a wasm module, whose name ends in a mapped suffix, runs that command with the file's path as invoked and the arguments after it, as a `#!` line naming the command would; a `#!` line still wins. The longest matching suffix wins, the whole name is never a suffix (`.jsh` alone is not one), and of two packages that map one suffix, the one whose name sorts first wins, as for commands; the kernel warns once (`console.warn`) when another package maps that suffix to a different command. A suffix starts with a dot and the command must be a name the catalog resolves when the file runs.
231
233
 
232
234
  ### WASI programs
@@ -893,6 +895,7 @@ The kernel is ported from SLICC's `packages/webapp/src/kernel/` with the browser
893
895
  - `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.
894
896
  - `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` and `src/local-proxy-transport.ts` are the page side.
895
897
  - `src/cdp/`: the CDP facade on `127.0.0.1:9222`, its WebSocket framing, the registry of hosts and the `MessagePort` bridge to them.
898
+ - `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.
896
899
 
897
900
 
898
901
  ## Installing from git
@@ -954,6 +957,21 @@ Releases are cut by semantic-release on every push to `main`, in two jobs, and o
954
957
 
955
958
  Moving the `certified` dist-tag stays manual. Automating it needs an npm token that may change dist-tags, in a step that re-hashes the tarball and tags only on a `certified` line.
956
959
 
960
+ ### Certification in the pull request (`cert-review-pr`)
961
+
962
+ `.github/workflows/cert-review-pr.yml` certifies a pull request before it merges, so `main`, and with it npm's `latest`, only gets certified changes. For now it reports as the `cert-review-pr` commit status and isn't a required check. It becomes the required `cert-review` once it has proved itself on real pull requests; the post-publish review above stays until then.
963
+
964
+ - **Which pull requests:** those that change shipped behaviour, by a conservative path filter (`behaviourFiles` in `lib.mjs`): `src/`, `build.mjs`, `package.json` and its lock, `tsconfig*.json`, `.releaserc*`, `tools/release-recover.mjs`, the workflows that build or publish (`release.yml`, `ci.yml`), and the reviewer's own code (`tools/cert-review/`, `cert-review*.yml`, `.github/runner.mjs`). **Main's code decides:** every job checks out the pull request's base and fetches only the trees under test, so the path filter, pack, suite, findings, the instructions and record all run from `main`. A pull request that changes the reviewer is reviewed by `main`'s reviewer. The workflow file of a `pull_request` run still comes from the pull request, so **pull requests that touch the reviewer always go to the Kernel Coordinator before they are queued.** The pull request that adds this workflow can't pass its own check, because its base has no tools yet. Any other pull request gets a green status that says `not behaviour: <files>`, so the skip is recorded.
965
+ - **When:** only for a same-repo pull request with `merge-ok`, when the label is added and on every push while it is set. Each pull request has its own concurrency group, and a newer push cancels the older review. A behaviour change without `merge-ok` gets a red status, `waits for merge-ok to be reviewed`. Other labels don't start or cancel anything. Fork code never reaches the pools or the key.
966
+ - **What:** the checkout is the event's default `refs/pull/N/merge`, the pull request merged onto `main` at review time, so a review is of what would land, without pushing to the branch. Both sides are packed in the same run, on a GitHub-hosted runner, with no secrets: the candidate (the merge ref, `x.y.z-pr.N.<run>`) and the base (its first parent, `main`). Then:
967
+ - the candidate's whole suite, and the base's;
968
+ - the pull request's new or changed test files on the base build (the NEGATIVE: a fix's test must fail on `main` and pass on the candidate);
969
+ - kernel-compat on the candidate's artifact (#812), against npm's `latest`;
970
+ - the review (Claude through `claude-code-action/base-action`, with the Bedrock key in that step only, no issue filing);
971
+ - and record, which re-hashes the candidate's artifact against the pack job's sha256.
972
+ - **The verdict** goes into the commit status on the pull request's head and a comment on the pull request. Certified is green. A FAIL is red with the line, and the pull request needs a fix. **INCOMPLETE** (a job that didn't succeed, no verdict, a suite without results) is red with `re-run the failed jobs`, never a silent pass. Main moving on after a review doesn't trigger another one; a push does.
973
+ - **Merge groups only look up.** On `merge_group`, the job lists the group's pull requests (its merge commits) and requires, for each one, a green status on the head being merged, or no behaviour change. Otherwise it's red, naming the pull request. It runs in seconds, with no secrets and no model. The group's `ci / test` covers the combined tree.
974
+
957
975
  Not part of the run yet: bios's real setup (the base-set sweep in bios, the upgrade boot, the sudo rows under `enforce`). It would plug in as one more job before review that runs slicc-bios's `npm run test:cert` with `CERT_COMMANDS` and the release's tarball, and leaves its results next to the others.
958
976
 
959
977
  ## Test fixtures
@@ -1086,9 +1104,9 @@ A threaded WASIX C program for `test/integration/thread-signal.test.mjs` (#520).
1086
1104
 
1087
1105
  A WASIX C program for `test/integration/long-timers.test.mjs` (#767).
1088
1106
  - `longwait alarm` sets `alarm(3000000)` (about 35 days), sleeps 0.5 s, and prints how many alarms came and how many seconds were left.
1089
- - `longwait select|ppoll|nanosleep` waits 3,000,000 s on a pipe that never gets data, until a 300 ms interval timer interrupts the wait, and prints `not before it` when the wait lasted at least 300 ms (`early` otherwise). It sets no upper bound, so a loaded CI host can't fail the row; a wait that never ends times the test out.
1107
+ - `longwait select|ppoll|nanosleep` waits 3,000,000 s on a pipe that never gets data, until a 300 ms interval timer interrupts the wait, and prints `not before it` when the wait lasted at least 300 ms (`early` otherwise), measured from before `setitimer` arms the timer, so the time the arming call takes can't shorten it (#858). It sets no upper bound, so a loaded CI host can't fail the row; a wait that never ends times the test out.
1090
1108
 
1091
- **Build:** `WASIX_SYSROOT_28=/path/to/wasix-sysroot ./build.sh`, with `wasixcc` 0.4.7 and binaryen's `wasm-opt` on `PATH`. `bin/longwait.wasm` is 44,004 bytes, sha256 `e69d03f01479693666078297e781f5373719700c8534d1f2933749f5b3cee903`.
1109
+ **Build:** `WASIX_SYSROOT_28=/path/to/wasix-sysroot ./build.sh`, with `wasixcc` 0.4.7 and binaryen's `wasm-opt` on `PATH`. `bin/longwait.wasm` is 44,002 bytes, sha256 `24acd996068ea04c526b74c93b896986eef782ec96230e19fb939d1b00a1bad0`.
1092
1110
 
1093
1111
  ### `epollmask`
1094
1112
 
@@ -2645,14 +2645,14 @@ var WsReader = class {
2645
2645
  }
2646
2646
  };
2647
2647
  function switchingHead(accept2, protocol = "") {
2648
- const chosen = protocol ? `Sec-WebSocket-Protocol: ${protocol}\r
2648
+ const chosen2 = protocol ? `Sec-WebSocket-Protocol: ${protocol}\r
2649
2649
  ` : "";
2650
2650
  return latin1Bytes(
2651
2651
  `HTTP/1.1 101 Switching Protocols\r
2652
2652
  Upgrade: websocket\r
2653
2653
  Connection: Upgrade\r
2654
2654
  Sec-WebSocket-Accept: ${accept2}\r
2655
- ${chosen}\r
2655
+ ${chosen2}\r
2656
2656
  `
2657
2657
  );
2658
2658
  }
@@ -6108,7 +6108,7 @@ function satisfies(version, range2) {
6108
6108
  }
6109
6109
 
6110
6110
  // src/version.ts
6111
- var KERNEL_VERSION = true ? "1.109.0" : "0.0.0-dev";
6111
+ var KERNEL_VERSION = true ? "1.110.0" : "0.0.0-dev";
6112
6112
  var OS_RELEASE = "6.0.0-slicc";
6113
6113
  function procVersion() {
6114
6114
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -6156,6 +6156,37 @@ function argsOf(pkg, raw) {
6156
6156
  if (!raw.every((arg) => typeof arg === "string")) return void 0;
6157
6157
  return raw.map((arg) => arg.replace(PACKAGE, pkg));
6158
6158
  }
6159
+ var warnedVariants = /* @__PURE__ */ new Set();
6160
+ function variantOf(pkg, raw) {
6161
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
6162
+ const { env, wasm } = raw;
6163
+ const path = inside(pkg, wasm);
6164
+ if (!path || !env || typeof env !== "object" || Array.isArray(env)) return void 0;
6165
+ const pairs = Object.entries(env);
6166
+ if (pairs.length === 0) return void 0;
6167
+ if (!pairs.every(([key, value2]) => ENV_KEY.test(key) && typeof value2 === "string")) {
6168
+ return void 0;
6169
+ }
6170
+ return { env: Object.fromEntries(pairs), wasm: path };
6171
+ }
6172
+ function variantsOf(pkg, name2, raw) {
6173
+ if (raw === void 0) return void 0;
6174
+ const variants = Array.isArray(raw) ? raw.map((one) => variantOf(pkg, one)) : [void 0];
6175
+ if (variants.every((one) => one !== void 0)) {
6176
+ return variants.length > 0 ? variants : void 0;
6177
+ }
6178
+ const key = `${pkg}:${name2}:${JSON.stringify(raw)}`;
6179
+ if (!warnedVariants.has(key)) {
6180
+ warnedVariants.add(key);
6181
+ console.warn(`slicc-kernel: ${pkg}: ${name2}: ignoring malformed "variants", running its wasm`);
6182
+ }
6183
+ return void 0;
6184
+ }
6185
+ function variantFor(variants, env) {
6186
+ return variants?.find(
6187
+ (one) => Object.entries(one.env).every(([key, value2]) => env[key] === value2)
6188
+ )?.wasm;
6189
+ }
6159
6190
  function abiOf(raw, fallback) {
6160
6191
  const abi = raw ?? fallback;
6161
6192
  return abi === "emscripten" || abi === "wasi" || abi === "js" ? abi : void 0;
@@ -6189,6 +6220,7 @@ function commandOf(pkg, name2, raw, packageAbi, shared) {
6189
6220
  const glue = abi === "wasi" ? wasm : inside(pkg, raw.glue);
6190
6221
  if (!abi || !glue || !wasm) return void 0;
6191
6222
  const imports = abi === "wasi" ? inside(pkg, raw.imports) : void 0;
6223
+ const variants = variantsOf(pkg, name2, raw.variants);
6192
6224
  return {
6193
6225
  name: name2,
6194
6226
  abi,
@@ -6200,7 +6232,8 @@ function commandOf(pkg, name2, raw, packageAbi, shared) {
6200
6232
  ...abi === "wasi" && raw.preopenRoot === true ? { preopenRoot: true } : {},
6201
6233
  ...args ? { args } : {},
6202
6234
  ...withEnv,
6203
- ...imports ? { imports } : {}
6235
+ ...imports ? { imports } : {},
6236
+ ...variants ? { variants } : {}
6204
6237
  };
6205
6238
  }
6206
6239
  function commandsOf(pkg, manifest) {
@@ -6520,13 +6553,14 @@ var CRONTAB_SOURCE = `export default async function crontab(ctx) {
6520
6553
  let text;
6521
6554
  try {
6522
6555
  if (file === "-") {
6556
+ const stream = new TextDecoder();
6523
6557
  const parts = [];
6524
6558
  for (;;) {
6525
6559
  const chunk = await ctx.read(0);
6526
6560
  if (chunk.length === 0) break;
6527
- parts.push(decode(chunk));
6561
+ parts.push(stream.decode(chunk, { stream: true }));
6528
6562
  }
6529
- text = parts.join("");
6563
+ text = parts.join("") + stream.decode();
6530
6564
  } else text = decode(await ctx.fs.readFile(ctx.resolve(file)));
6531
6565
  } catch (err) {
6532
6566
  await say(2, "crontab: " + file + ": " + (err && err.code ? err.code : String(err)) + "\\n");
@@ -7294,7 +7328,10 @@ var CronService = class {
7294
7328
  const account = await this.host.lookup(job.user);
7295
7329
  if (account) out.push({ source: "system", account, job });
7296
7330
  }
7297
- const names = await fs.readdir(SPOOL_DIR).catch(() => []);
7331
+ const names = await fs.readdir(SPOOL_DIR).catch((err) => {
7332
+ if (err?.code === "ENOENT") return [];
7333
+ throw err;
7334
+ });
7298
7335
  for (const name2 of names.sort()) {
7299
7336
  if (name2.startsWith(".") || name2.endsWith("~")) continue;
7300
7337
  const path = `${SPOOL_DIR}/${name2}`;
@@ -21976,9 +22013,18 @@ function targetOf(command) {
21976
22013
  ...command.env ? { env: command.env } : {},
21977
22014
  ...command.unset ? { unset: command.unset } : {},
21978
22015
  ...command.imports ? { imports: command.imports } : {},
21979
- ...command.preopenRoot ? { preopenRoot: true } : {}
22016
+ ...command.preopenRoot ? { preopenRoot: true } : {},
22017
+ ...command.variants ? { variants: command.variants } : {}
21980
22018
  };
21981
22019
  }
22020
+ function chosen(planned, env) {
22021
+ const { target } = planned;
22022
+ const wasm = variantFor(target.variants, env);
22023
+ if (wasm === void 0) return planned;
22024
+ const { asked: _asked, ...rest2 } = target;
22025
+ const glue = target.abi === "wasi" ? wasm : target.glue;
22026
+ return { ...planned, target: { ...rest2, wasm, glue } };
22027
+ }
21982
22028
  function withScriptEnv(target, command) {
21983
22029
  if (!command) return target;
21984
22030
  return {
@@ -22889,14 +22935,15 @@ var Launcher = class {
22889
22935
  ...target.preopenRoot ? { preopenRoot: true } : {}
22890
22936
  };
22891
22937
  }
22892
- async launch(planned, req) {
22893
- const { target, args } = planned;
22938
+ async launch(asked, req) {
22894
22939
  const cwd = await physicalPathAsync(this.base, req.cwd);
22895
22940
  const seen = { ...req.env, HOSTNAME: this.hostname, cwd };
22896
- const env = { ...expandDefaults(target.env, seen), ...req.env };
22897
- for (const key of target.unset ?? []) delete env[key];
22941
+ const env = { ...expandDefaults(asked.target.env, seen), ...req.env };
22942
+ for (const key of asked.target.unset ?? []) delete env[key];
22898
22943
  delete env[TICKET_ENV];
22899
22944
  env.HOSTNAME = this.hostname;
22945
+ const planned = chosen(asked, env);
22946
+ const { target, args } = planned;
22900
22947
  let program;
22901
22948
  await this.pnpmReady();
22902
22949
  try {