@ai-ecoverse/slicc-kernel 1.170.3 → 1.170.4

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
@@ -479,7 +479,7 @@ The module's default export (or its `main` export) is called with a context and
479
479
  - `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`;
480
480
  - `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`;
481
481
  - `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.
482
- - **What it does:** there's no handshake in the kernel. The connection carries plain HTTP/1.1 (and WebSocket upgrades) to the realm proxy's HTTP path for the origin `https://<servername or host>[:port]`, exactly as a `CONNECT` through `127.0.0.1:3128` does after the kernel terminates its TLS. The transport (the page's `fetch` or WebSocket, or the uplink's) makes the real TLS connection and verifies it. So `https.request` over a socket, keep-alive agents, `agent-base` and `wss://` clients work with the transport's semantics. That includes sealed placeholders in headers, which reach the transport exactly as from `ctx.fetch`.
482
+ - **What it does:** there's no handshake in the kernel. The connection carries plain HTTP/1.1 (and WebSocket upgrades) to the realm proxy's HTTP path for the origin `https://<servername or host>[:port]`, exactly as a `CONNECT` through `127.0.0.1:3128` does after the kernel terminates its TLS. The transport (the page's `fetch` or WebSocket, or the uplink's) makes the real TLS connection and verifies it. So `https.request` over a socket, keep-alive agents, `agent-base` and `wss://` clients work with the transport's semantics. A request with a sealed placeholder is refused there as from `ctx.fetch` (see **The transport** under [`/dev/sealed`](#devsealed)).
483
483
  - **The `tls` property:** the connection's `tls` is `{ path: 'transport', substitutes: true, protocol: 'transport', alpnProtocol: 'http/1.1', authorized: true, servername }`. `authorized` means the transport verified the server. There's no peer certificate chain and no TLS version of the kernel's own, so don't read it as a real handshake's result.
484
484
  - **Options:** the transport always verifies, so `rejectUnauthorized: false` is accepted and changes nothing. Outside the uplink, a custom `ca` is refused (`ENOTSUP`), and so is an `alpn` list without `http/1.1` (`EPROTONOSUPPORT`). The kernel's own loopback never goes to the transport. A `host` of `localhost` or `127.x` makes the handshake in the kernel instead (Loopback, below), so with nothing listening it's `ECONNREFUSED`. The rest of it (`*.localhost`, `0.0.0.0`, `::1`, and any loopback `servername` for another host) is `ENOTSUP` at connect, as `ctx.fetch` refuses `https:` and `wss:` there. `host.slicc.internal` goes to the transport under its policy, as for `ctx.fetch`.
485
485
  - **Non-HTTP protocols aren't carried:**
@@ -487,7 +487,7 @@ The module's default export (or its `main` export) is called with a context and
487
487
  - non-HTTP first bytes on any other port get a `400` and a close;
488
488
  - a server-speaks-first protocol on another port (the client sends nothing) waits for the proxy's 120 s idle limit.
489
489
  - For a destination the uplink routes, these cases take A2 instead (below).
490
- - **Which path** (A2 needs a destination the uplink routes, `net.destination(host) === 'uplink'`; an uplink-routed host on 443 with default options stays on A1, so its sealed placeholders keep working):
490
+ - **Which path** (A2 needs a destination the uplink routes, `net.destination(host) === 'uplink'`; an uplink-routed host on 443 with default options stays on A1, where a request with a sealed placeholder is refused):
491
491
 
492
492
  | The call | Uplink destination | Any other destination |
493
493
  |---|---|---|
@@ -1133,6 +1133,12 @@ The kernel then asks the hook `{ v: 1, op: 'materializeConsume', ticket, stamp,
1133
1133
 
1134
1134
  The kernel never asks for a session that isn't `verified`, whose handshake is more than 60 s old (or in the future), or whose fields aren't of that shape. The reply is `{ values: { [placeholder]: value } }` within 10 s; the kernel takes only the placeholders it asked for, with a non-empty value free of CR, LF and NUL, and anything else (an error such as `{ v: 1, error: { code } }`, a timeout, a malformed reply, no hook) means nothing is substituted: the placeholders go out as written.
1135
1135
 
1136
+ **The transport.** Nothing fills a placeholder in a request that leaves through the transport, so the kernel refuses that request itself and never hands it to the transport. This covers the realm proxy (`curl`, `CONNECT`, TLS on A1), `ctx.fetch`, `ctx.websocket` and a client's `fetch` and `transport.websocket`. The check needs no vault and no `sealed` hook: anything of the form `fnox_proxy_<24 hex>` is refused, whether or not the vault knows it.
1137
+
1138
+ - **Where it looks:** the URL (host, user info, path, query and fragment, percent-encoded or not), header names and values, WebSocket subprotocols, and `Basic` credentials, decoded for the check (the user, the password, or a credential that is malformed or not UTF-8). A `Basic` credential without a placeholder goes out byte for byte. Bodies aren't read: a placeholder in a body goes out as written.
1139
+ - **What a refusal looks like:** the realm proxy answers `403` with the reason in the body, for a WebSocket upgrade too. `ctx.fetch` and `ctx.websocket` reject with a `TypeError` whose `cause.code` is `EACCES` and, for `ctx.fetch`, whose `cause.detail` is the reason. A client's `fetch` and `transport.websocket` reject with `code` `EACCES` and `status` `403`. Nothing is sent, retried or sent another way, and a held redirect isn't replayed for it.
1140
+ - **What stays as it was:** the kernel's own loopback, A2 sessions (above) and raw sockets never reach this check.
1141
+
1136
1142
  ### `/dev/clipboard`
1137
1143
 
1138
1144
  A character device (`0666`), there in every `users` mode, through which programs read and write the embedder's clipboard (`createKernel({ clipboard: { read, write } })`; on the page, bios answers with `navigator.clipboard`). Emscripten programs (`cat /dev/clipboard`, `cat > /dev/clipboard`, see it in `/dev`, reach it by any path that leads to it, and see it as a character device after fork and exec), WASI programs (`std::fs`) and JS programs (`ctx.fs.readFile`, `ctx.fs.writeFile`, `ctx.open`) all open it.
@@ -8498,7 +8498,7 @@ function satisfies(version, range2) {
8498
8498
  }
8499
8499
 
8500
8500
  // src/version.ts
8501
- var KERNEL_VERSION = true ? "1.170.3" : "0.0.0-dev";
8501
+ var KERNEL_VERSION = true ? "1.170.4" : "0.0.0-dev";
8502
8502
  var OS_RELEASE = "6.0.0-slicc";
8503
8503
  function procVersion() {
8504
8504
  return `Linux version ${OS_RELEASE} (slicc-kernel ${KERNEL_VERSION}) #1 SMP PREEMPT_DYNAMIC
@@ -10392,6 +10392,78 @@ var CronService = class {
10392
10392
  }
10393
10393
  };
10394
10394
 
10395
+ // src/kernel/net/placeholder-floor.ts
10396
+ var PLACEHOLDER = /fnox_proxy_[0-9a-f]{24}/;
10397
+ var ESCAPE = /%([0-9a-f]{2})/gi;
10398
+ var BASIC = /\bbasic\b(.*)/is;
10399
+ var PERCENT_LAYERS = 8;
10400
+ var BASE64_GROUP = 4;
10401
+ var PLACEHOLDER_REFUSED = "refused: this request has a sealed placeholder in its URL, a header or a Basic credential, and nothing fills it, so it was not sent";
10402
+ var PlaceholderRefused = class extends Error {
10403
+ status = 403;
10404
+ code = "EACCES";
10405
+ constructor() {
10406
+ super(PLACEHOLDER_REFUSED);
10407
+ }
10408
+ };
10409
+ function carries(text) {
10410
+ let layer = text;
10411
+ for (let i = 0; i < PERCENT_LAYERS; i++) {
10412
+ if (PLACEHOLDER.test(layer)) return true;
10413
+ const next3 = layer.replace(
10414
+ ESCAPE,
10415
+ (_, hex4) => String.fromCharCode(Number.parseInt(hex4, 16))
10416
+ );
10417
+ if (next3 === layer) return false;
10418
+ layer = next3;
10419
+ }
10420
+ return PLACEHOLDER.test(layer);
10421
+ }
10422
+ function basicCarries(value2) {
10423
+ const credential = BASIC.exec(value2)?.[1];
10424
+ if (credential === void 0) return false;
10425
+ const letters = credential.replace(/[^A-Za-z0-9+/_-]/g, "").replaceAll("-", "+").replaceAll("_", "/");
10426
+ for (let shift = 0; shift < BASE64_GROUP; shift++) {
10427
+ const part = letters.slice(shift);
10428
+ const whole = part.length % BASE64_GROUP === 1 ? part.slice(0, -1) : part;
10429
+ if (carries(atob(whole))) return true;
10430
+ }
10431
+ return false;
10432
+ }
10433
+ function headersCarry(headers) {
10434
+ return headers.some(
10435
+ ([name2, value2]) => carries(name2.toLowerCase()) || carries(value2) || basicCarries(value2)
10436
+ );
10437
+ }
10438
+ function urlCarries(url) {
10439
+ return carries(url) || URL.canParse(url) && carries(new URL(url).href);
10440
+ }
10441
+ function refuse(request3) {
10442
+ if (urlCarries(request3.url) || headersCarry(request3.headers)) throw new PlaceholderRefused();
10443
+ }
10444
+ function refuseSocket(request3) {
10445
+ refuse(request3);
10446
+ if (request3.protocols.some(carries)) throw new PlaceholderRefused();
10447
+ }
10448
+ function placeholderFloor(inner) {
10449
+ const open2 = inner.websocket?.bind(inner);
10450
+ return {
10451
+ get traits() {
10452
+ return inner.traits;
10453
+ },
10454
+ ...open2 ? {
10455
+ websocket: async (request3) => {
10456
+ refuseSocket(request3);
10457
+ return open2(request3);
10458
+ }
10459
+ } : {},
10460
+ async fetch(request3, owner) {
10461
+ refuse(request3);
10462
+ return inner.fetch(request3, owner);
10463
+ }
10464
+ };
10465
+ }
10466
+
10395
10467
  // src/kernel/net/proxy-service.ts
10396
10468
  var SEND_BUFFER2 = 1024 * 1024;
10397
10469
  var TLS_HANDSHAKE = 22;
@@ -10898,7 +10970,8 @@ var RealmProxy = class {
10898
10970
  });
10899
10971
  } catch (e) {
10900
10972
  waiting.abort();
10901
- throw new HttpError(502, e instanceof Error ? e.message : String(e));
10973
+ const status = e instanceof PlaceholderRefused ? e.status : 502;
10974
+ throw new HttpError(status, e instanceof Error ? e.message : String(e));
10902
10975
  }
10903
10976
  ctx.idle.ms = Number.POSITIVE_INFINITY;
10904
10977
  try {
@@ -11112,7 +11185,7 @@ async function relay(socket, conn) {
11112
11185
  // src/fs/attest.ts
11113
11186
  var ATTEST_MAX = 1024 * 1024;
11114
11187
  var UNOWNED = 1e3;
11115
- function refuse(code, path, why) {
11188
+ function refuse2(code, path, why) {
11116
11189
  throw Object.assign(new Error(`attest ${path}: ${why}`), { code });
11117
11190
  }
11118
11191
  function chainOf(path) {
@@ -11154,11 +11227,11 @@ async function trustedParents(fs, path, o) {
11154
11227
  const parents = [];
11155
11228
  for (const dir of chainOf(path).slice(0, -1)) {
11156
11229
  const st = await fs.lstat(dir);
11157
- if (st.isSymbolicLink) refuse("EACCES", path, `${dir} is a symlink`);
11158
- if (!st.isDirectory) refuse("ENOTDIR", path, `${dir} is not a directory`);
11230
+ if (st.isSymbolicLink) refuse2("EACCES", path, `${dir} is a symlink`);
11231
+ if (!st.isDirectory) refuse2("ENOTDIR", path, `${dir} is not a directory`);
11159
11232
  const perm = owned(dir, st);
11160
11233
  if (o.users !== "off" && (perm.uid !== 0 || (perm.mode & 18) !== 0)) {
11161
- refuse("EACCES", path, `${dir} is not root-owned or is writable by group or others`);
11234
+ refuse2("EACCES", path, `${dir} is not root-owned or is writable by group or others`);
11162
11235
  }
11163
11236
  parents.push(perm);
11164
11237
  }
@@ -11166,27 +11239,27 @@ async function trustedParents(fs, path, o) {
11166
11239
  }
11167
11240
  async function attest(fs, asked, o) {
11168
11241
  if (typeof asked !== "string" || !asked.startsWith("/")) {
11169
- refuse("EINVAL", String(asked), "not an absolute path");
11242
+ refuse2("EINVAL", String(asked), "not an absolute path");
11170
11243
  }
11171
11244
  const path = normalizePath(asked);
11172
11245
  const mount = o.mounts.find((target) => {
11173
11246
  const at = normalizePath(target);
11174
11247
  return at === "/" || under2(path, at);
11175
11248
  });
11176
- if (mount !== void 0) refuse("EACCES", path, `under the mount ${mount}`);
11249
+ if (mount !== void 0) refuse2("EACCES", path, `under the mount ${mount}`);
11177
11250
  const parents = await trustedParents(fs, path, o);
11178
11251
  for (let tries = 0; ; tries++) {
11179
11252
  const before = await fs.lstat(path);
11180
- if (before.isSymbolicLink) refuse("EACCES", path, "is a symlink");
11253
+ if (before.isSymbolicLink) refuse2("EACCES", path, "is a symlink");
11181
11254
  if (!before.isFile)
11182
- refuse(before.isDirectory ? "EISDIR" : "EINVAL", path, "is not a regular file");
11183
- if (before.size > ATTEST_MAX) refuse("EFBIG", path, `is over ${ATTEST_MAX} bytes`);
11255
+ refuse2(before.isDirectory ? "EISDIR" : "EINVAL", path, "is not a regular file");
11256
+ if (before.size > ATTEST_MAX) refuse2("EFBIG", path, `is over ${ATTEST_MAX} bytes`);
11184
11257
  const bytes = (await fs.readFileBuffer(path)).slice();
11185
11258
  const after = await fs.lstat(path);
11186
11259
  if (unchanged(before, after, bytes)) {
11187
11260
  return attested(path, after, bytes, await sha2562(bytes), o, parents);
11188
11261
  }
11189
- if (tries >= 1) refuse("EAGAIN", path, "changed while it was read");
11262
+ if (tries >= 1) refuse2("EAGAIN", path, "changed while it was read");
11190
11263
  }
11191
11264
  }
11192
11265
  var WATCH_DEBOUNCE_MS = 50;
@@ -15754,7 +15827,7 @@ function encodeSabResult(result) {
15754
15827
  message: result.message,
15755
15828
  ...result.direct ? { direct: true } : {},
15756
15829
  ...result.dev !== void 0 ? { dev: result.dev } : {},
15757
- ...result.detail === "transport" ? { detail: result.detail } : {}
15830
+ ...result.detail ? { detail: result.detail } : {}
15758
15831
  })
15759
15832
  )
15760
15833
  };
@@ -19216,22 +19289,28 @@ var HttpHandles = class {
19216
19289
  return response.redirected ? { ...head, json: { ...head.json, redirected: true } } : head;
19217
19290
  } catch (e) {
19218
19291
  entry.upload?.cancel();
19219
- const text = message2(e);
19220
- if (port !== void 0) return { ok: false, errno: codeOf2(e), message: text };
19221
- const status = e.status;
19222
- if (text === NO_TRANSPORT || typeof status !== "number" || status === 502) {
19223
- const code = codeOf2(e);
19224
- const errno3 = text === NO_TRANSPORT ? "ENETUNREACH" : code !== "EIO" ? code : "ECONNREFUSED";
19225
- const failed3 = e.detail === "transport";
19226
- return { ok: false, errno: errno3, message: text, ...failed3 ? { detail: "transport" } : {} };
19227
- }
19228
- entry.body = once(new TextEncoder().encode(text))[Symbol.asyncIterator]();
19229
- return this.headOf(handle, req.url, {
19230
- status,
19231
- statusText: "",
19232
- headers: [["content-type", "text/plain; charset=utf-8"]]
19233
- });
19292
+ if (port !== void 0) return { ok: false, errno: codeOf2(e), message: message2(e) };
19293
+ return this.failure(handle, req, e, entry);
19294
+ }
19295
+ }
19296
+ failure(handle, req, e, entry) {
19297
+ const text = message2(e);
19298
+ if (e instanceof PlaceholderRefused) {
19299
+ return { ok: false, errno: e.code, message: text, detail: "placeholder" };
19234
19300
  }
19301
+ const status = e.status;
19302
+ if (text === NO_TRANSPORT || typeof status !== "number" || status === 502) {
19303
+ const code = codeOf2(e);
19304
+ const errno3 = text === NO_TRANSPORT ? "ENETUNREACH" : code !== "EIO" ? code : "ECONNREFUSED";
19305
+ const failed3 = e.detail === "transport";
19306
+ return { ok: false, errno: errno3, message: text, ...failed3 ? { detail: "transport" } : {} };
19307
+ }
19308
+ entry.body = once(new TextEncoder().encode(text))[Symbol.asyncIterator]();
19309
+ return this.headOf(handle, req.url, {
19310
+ status,
19311
+ statusText: "",
19312
+ headers: [["content-type", "text/plain; charset=utf-8"]]
19313
+ });
19235
19314
  }
19236
19315
  headOf(handle, url, response) {
19237
19316
  const { status, statusText, headers } = response;
@@ -22442,7 +22521,7 @@ var HEAD_MAX = 16 * 1024;
22442
22521
  var ANY_PLACEHOLDER = /fnox_proxy_[0-9a-f]{24}/g;
22443
22522
  var REQUEST_LINE2 = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+) ([\x21-\x7e]+) HTTP\/1\.[01]$/;
22444
22523
  var HEADER_LINE = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*(.*?)[ \t]*$/;
22445
- var BASIC = /^(Basic[ \t]+)([A-Za-z0-9+/]+={0,2})$/i;
22524
+ var BASIC2 = /^(Basic[ \t]+)([A-Za-z0-9+/]+={0,2})$/i;
22446
22525
  var encoder8 = new TextEncoder();
22447
22526
  function binary(text) {
22448
22527
  let out = "";
@@ -22458,7 +22537,7 @@ function sniffHttp1(start2) {
22458
22537
  return "no";
22459
22538
  }
22460
22539
  function basicOf(value2) {
22461
- const m = BASIC.exec(value2);
22540
+ const m = BASIC2.exec(value2);
22462
22541
  if (!m) return void 0;
22463
22542
  try {
22464
22543
  return { scheme: m[1], decoded: atob(m[2]) };
@@ -29031,7 +29110,8 @@ function replayingHops(inner, heldMs = HELD_MS) {
29031
29110
  return { ...inner.traits, manualRedirects: true };
29032
29111
  },
29033
29112
  ...inner.websocket ? { websocket: inner.websocket.bind(inner) } : {},
29034
- async fetch(request3, owner = KERNEL) {
29113
+ async fetch(request3, caller) {
29114
+ const owner = caller ?? KERNEL;
29035
29115
  const key = keyOf3(owner, request3.method, request3.url);
29036
29116
  const waiting = held.get(key);
29037
29117
  if (waiting && waiting.fingerprint === fingerprint(request3.headers)) {
@@ -29040,7 +29120,7 @@ function replayingHops(inner, heldMs = HELD_MS) {
29040
29120
  return replay(waiting);
29041
29121
  }
29042
29122
  if (waiting) drop(key);
29043
- const response = await inner.fetch(request3);
29123
+ const response = await inner.fetch(request3, caller);
29044
29124
  if (!response.redirects?.length) return response;
29045
29125
  return replay({
29046
29126
  owner,
@@ -31883,7 +31963,7 @@ var Launcher = class {
31883
31963
  ...options.env
31884
31964
  };
31885
31965
  this.ca = kernelCa(options.caStore ?? memoryCaStore());
31886
- this.transport = replayingHops(options.transport ?? missingTransport());
31966
+ this.transport = placeholderFloor(replayingHops(options.transport ?? missingTransport()));
31887
31967
  this.announcedTraits = JSON.stringify(this.transport.traits);
31888
31968
  this.uplink = options.uplink;
31889
31969
  this.routes = new Routes(options.uplink?.traits.ipv6 === true);