@ai-ecoverse/slicc-kernel 1.179.0 → 1.181.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
@@ -95,7 +95,11 @@ Makes a port for another client of the same kernel. Hand it to a dedicated worke
95
95
 
96
96
  **`requests`** binds the port's [`/dev/embedder`](#devembedder) requests when the port is made: a hook (`{ kinds, handle }`, as `createKernel`'s) answers for every process the port's client starts, in the embedder's own realm, so a worker client (an agent) never answers or registers requests itself. `'attach'` lets the client that attaches to the port bring its own hook (`attachKernel(port, { requests })`, protocol 1.13); without it, an attach with `requests` fails with `EPERM`. A port with neither answers nothing: its processes' requests are `ENOTSUP`.
97
97
 
98
- **`user`** (a name or uid) binds the client to that user; without it the client is root. The binding is a capability: the client's `spawn`, `run` and `openTerminal` run as that user, its `ps` lists only that user's processes, and its `kill` follows POSIX permission. A bound client can't run anything as another user (`code: 'EPERM'`). A root client may pass `user` to `spawn`, `run` or `openTerminal` to run one command as any user. An unknown user rejects with `code: 'ENOENT'`. **`tag`** is the tag (see `kernel.run`) of every process the client starts; its own `spawn`, `run` and `openTerminal` cannot name another. They may add one with their own `tag` option (protocol 1.12; an older kernel answers `ENOSYS`): the process then has both tags, the connection's first, and so does everything it starts. A per-spawn tag only adds; no client, program or child can remove or replace a tag ([#1380](https://github.com/ai-ecoverse/slicc-kernel/issues/1380)).
98
+ **`user`** (a name or uid) binds the client to that user; without it the client is root. The binding is a capability: the client's `spawn`, `run` and `openTerminal` run as that user, its `ps` lists only that user's processes, and its `kill` follows POSIX permission. A bound client can't run anything as another user (`code: 'EPERM'`). A root client may pass `user` to `spawn`, `run` or `openTerminal` to run one command as any user. An unknown user rejects with `code: 'ENOENT'`. **`tag`** is the tag (see `kernel.run`) of every process the client starts; its own `spawn`, `run` and `openTerminal` cannot name another. They may add one with their own `tag` option (protocol 1.12; an older kernel answers `ENOSYS`): the process then has both tags, the connection's first, and so does everything it starts. The option may also be a list of at most 8 tags, added in order (protocol 1.15; against an older kernel the client rejects with `code: 'ENOSYS'` before sending anything), so a caller can mark one call's tree with a tag of its own for [`trace`](#clienttracetag-onevents--limit---promise-close-) beside a tag that means something else. A per-spawn tag only adds; no client, program or child can remove or replace a tag ([#1380](https://github.com/ai-ecoverse/slicc-kernel/issues/1380)).
99
+
100
+ ### `kernel.sudoRun(request, { stdout, stderr, tag }?) → Promise<{ pid, pgid, ended }>`
101
+
102
+ Runs a `sudo` request that nobody answered in time (or that was denied or withdrawn) as if the answer were `'once'`, after checking that what was asked still holds, detached from any terminal or process: see [Running a request later](#running-a-request-later-sudorun). An attached root client has the same call, `client.sudoRun` (protocol 1.16).
99
103
 
100
104
  ### `kernel.users`
101
105
 
@@ -187,12 +191,28 @@ Attaches to a kernel over a port from `kernel.connect()`, in any realm: a worker
187
191
  | `fs.watch(paths, { recursive }, onChange)` | resolves with `{ close() }`; `onChange` gets `{ paths }`, the changed paths at, below (or, without `recursive`, directly in) the watched ones, batched per task, or `{ overflow: true }` when there were too many to list, to rescan; it covers every change made through this kernel, by its processes and its clients, and a removal or rename that failed or found nothing to do is none. It also covers what another kernel on the same OPFS root changes (another tab, a worker page) and what a writer outside the kernel reports with [`postFsNotice`](#metadata), as those notices name them: a directory another kernel creates, removes or renames, the file it writes back, and the records of its symlinks, modes, owners and times, and a notice's `trees` as their roots. A peer's notices come only once the kernels share the root (`/proc/sys/kernel/slicc_shared_root` reads `1` from the join handshake on) and only with the shared metadata (not with `metadata: false`), and a writer outside the kernel that posts no notice is never heard. So a client that has to catch a page writing OPFS directly needs that page to call `postFsNotice` after each write. Kernels before this one didn't pass peer and outside changes to a client's watch; they reached only `ctx.fs.watch`. A watch is on names, not files: a watched path need not exist, nor its directories, and one under a regular file is a watch too (every kernel with `watch` does this, protocol 1.1 on), so its creation, by any `mkdir -p` above it, a write or a rename to it, is reported as its path, and so are a removal or rename of it or of a directory above it; the watch goes on hearing the name after that. So a client that waits for a directory to appear watches it, rather than polling `stat` and `readdir`. Under `users`, a watched path the client may not read is `EACCES`, and so is a missing one under a directory the client may not search. Changes are reported by the name they were made under (a file written through a symlink also by its real path), so a client that watches a path under a symlink watches its real path too, for a missing path the real path of its nearest existing directory with the rest of the path after it |
188
192
  | `serveCdp(hook, { runtime })` | offers this client's browser to the kernel's programs (protocol 1.5): `hook` is the `cdp` hook of `createKernel`, asked once per program connection; resolves with `{ close() }`, and closing or detaching takes the offer back and closes its connections (see [Browser automation](#browser-automation-cdp)) |
189
193
  | `serveFacade(name, hook) → { close() }` | serves a facade that `createKernel({ facades })` declared, the same way as its `hook` (protocol 1.11); the latest registration wins, over the embedder's hook too, and `close()` hands it back. An undeclared name is `EINVAL` |
194
+ | `trace(tag, onEvents, { limit }?)` | reports, as events, every program that a process carrying `tag` runs and every such process's end, with nothing missed (protocol 1.15; against an older kernel the client rejects with `code: 'ENOSYS'`); resolves with `{ close() }` (see [below](#clienttracetag-onevents--limit---promise-close-)) |
195
+ | `sudoRun(request, { stdout, stderr, tag }?)` | runs a `sudo` request again, as `kernel.sudoRun` does, and resolves with `{ pid, pgid, ended }` once it has started (protocol 1.16; against an older kernel the client rejects with `code: 'ENOSYS'` before sending anything). Only a root client may: a client bound to a user rejects with `code: 'EPERM'` (`sudo: only a root client may run a sudo request`). `ended` is tied to the client, as `spawn`'s is, while the process is not (see [Running a request later](#running-a-request-later-sudorun)) |
190
196
  | `onTraits(listener) → () => void` | calls `listener(traits)` each time the kernel announces new transport traits (`kernel.updateTransportTraits()`, protocol 1.10), after `client.transport.traits` has been updated; the function it returns removes the listener |
191
197
  | `close({ kill })` | detaches: the client's processes keep running unless `kill` is set, which ends their process groups with `SIGKILL`; its terminals are hung up |
192
198
  | `closed` | resolves with the error that ended the client |
193
199
  | `users` | the kernel's `users` mode, `'off'`, `'report'` or `'enforce'`; `'off'` from kernels that don't say |
194
200
 
195
- When the kernel goes away (the page closed or reloaded, `terminate()`), pending calls and `exited` reject with `KernelGoneError`, and so do later calls. Each side holds a Web Lock and waits on the other's, since a `MessagePort` reports no close in browsers; in Node, the port's `close` event does the same. The first message is a handshake on the protocol version, `1.12` (`1.0` had no `watch`, `1.1` no mounts, `1.2` no `spawn` into a group, `1.3` no `dial` and `1.4` no `serveCdp`, which reject with `code: 'ENOSYS'` on such a kernel; a `1.5` kernel has no [uplink](#uplink), a `1.6` kernel resolves names through one but connects nothing to it, a `1.7` kernel has no users, a `1.8` kernel has no `sudo` and no tags, a `1.9` kernel never announces new transport traits, and a `1.11` kernel has no per-spawn `tag`): a client or kernel of another major version is refused with an error naming both.
201
+ When the kernel goes away (the page closed or reloaded, `terminate()`), pending calls and `exited` reject with `KernelGoneError`, and so do later calls. Each side holds a Web Lock and waits on the other's, since a `MessagePort` reports no close in browsers; in Node, the port's `close` event does the same. The first message is a handshake on the protocol version, `1.16` (`1.0` had no `watch`, `1.1` no mounts, `1.2` no `spawn` into a group, `1.3` no `dial` and `1.4` no `serveCdp`, which reject with `code: 'ENOSYS'` on such a kernel; a `1.5` kernel has no [uplink](#uplink), a `1.6` kernel resolves names through one but connects nothing to it, a `1.7` kernel has no users, a `1.8` kernel has no `sudo` and no tags, a `1.9` kernel never announces new transport traits, a `1.11` kernel has no per-spawn `tag`, a `1.12` kernel takes no `requests` at attach, a `1.13` kernel has no `cwdMissing`, a `1.14` kernel has no `trace` and takes one tag per spawn, not a list, and a `1.15` kernel has no `sudoRun`, which the client rejects with `code: 'ENOSYS'` before sending anything): a client or kernel of another major version is refused with an error naming both.
202
+
203
+ #### `client.trace(tag, onEvents, { limit }?) → Promise<{ close() }>`
204
+
205
+ Tells a client which programs a command really ran, without polling `ps()`, which misses a `seq` or a `head` that lives for a millisecond. The kernel runs real bash, so builtins and keywords (`cd`, `for`, `printf`, `echo`) never start a program, and what does is what the command was about (`seq`, `sed`, `node`, the program behind `timeout`). Give each call a tag of its own (the spawn's `tag`, which may be a list, see [`kernel.connect`](#kernelconnect-user-tag-requests---promisemessageport)), subscribe to it before the spawn, and `onEvents` gets every process that carries the tag, in its whole tree, background jobs that outlive the command included:
206
+
207
+ - **`exec`** `{ type, seq, pid, ppid, pgid, argv, exe, uid, at, replaced? }`: a program started, for the first image of a spawn, a child a program spawned (`posix_spawn`, as `timeout` runs its command) and each successful `execve`. `replaced: true` marks an `execve`, whose program keeps the `pid` of the image it replaced (bash runs its last command that way, so `bash -c '…; node x'` ends with an `exec` of `node` under bash's pid). An `execve` that fails (`command not found`) has no event.
208
+ - **`fork`** `{ type, seq, pid, ppid, pgid, argv, exe, uid, at }`: a `fork()`. Its `argv` and `exe` are the parent's, a forked bash for a subshell, a pipeline element or a command it is about to run. A fork that then runs a program has an `exec` with the same `pid` next; one that never has an `exec` ran only builtins, so `type` tells a forked `bash` from a real program.
209
+ - **`exit`** `{ type, seq, pid, status, signal?, utime?, stime?, at }`: a process ended, once per `pid`, after its last image: `status` as `waitpid` would report it (127 for a command that wasn't found, 141 for an unhandled `SIGPIPE`), `signal` the name of the signal that killed it (`'SIGKILL'`), `utime`/`stime` the milliseconds of CPU it used, its images together, as in `ps()` (absent for a JS program).
210
+
211
+ `pid`, `ppid` and `pgid` are the ids `ps()` shows, `ppid` 1 for a spawn's first process; `uid` is the effective uid; `at` is the time in ms since the epoch. The kernel reports from the path every process takes to start and end, so a process that execs and exits within a millisecond is reported like any other. Events come in order: `seq` is one kernel-wide counter, increasing in the order the kernel saw the events (with gaps for events only other subscriptions got), so it orders the execs and exits across a tree more finely than `at`, and for one `pid` the `fork` or `exec` comes before its `exit`. A process that started before the subscription reports only its exit.
212
+
213
+ A client bound to a user sees only the processes of that user it would see in `ps()` (its own real or effective uid; root sees all), so another user's processes with the same tag never reach it, and a program that `sudo` runs as root drops out of its view. A tag matches exactly, any of a process's tags.
214
+
215
+ **Batches and overflow:** `onEvents` gets `{ events, dropped }`, the events since the last batch. The next batch waits until the call returns, or, when it returns a promise, until that settles, so a slow consumer holds the kernel back instead of piling up messages. While it waits, at most `limit` events (4096 by default, at most 65536) wait in the kernel for that subscription; any more are dropped and counted, and the next batch says how many in `dropped` (0 otherwise), so a fork bomb costs a bounded amount of memory, and a consumer that sees `dropped > 0` knows its list is incomplete. `close()` ends the subscription, and so does closing the client. An empty tag or a bad `limit` rejects with `code: 'EINVAL'`.
196
216
 
197
217
  ### Headless in Node, for tests
198
218
 
@@ -1129,6 +1149,25 @@ As in sudo(8), a person's approval is remembered for a few minutes: until then,
1129
1149
  - **In tests,** `createNodeKernel({ sudo: { clock } })` reads the time of timestamps from `clock.now()`, such as `fakeCronClock()` from `@ai-ecoverse/slicc-kernel/testing`; otherwise it is the monotonic `performance.now()`.
1130
1150
  - **Upgrading:** kernels before this asked for every `sudo` that needed approval. An approver that answers `{ answer, timestamp: true }` now opens a 5-minute window, so a second `sudo` in the same shell, script or terminal doesn't ask, and `sudo -n` succeeds inside it. Put `Defaults timestamp_timeout=0` in the policy to keep asking every time. Plain string answers behave as before. `visudo -c` now ends with the timestamp values.
1131
1151
 
1152
+ ### Running a request later: `sudoRun`
1153
+
1154
+ A request can end without an answer: nobody was there within the 5 minutes, and `sudo` failed with `sudo: the request expired`. The owner may still want it to run. `kernel.sudoRun(request, options)`, and `client.sudoRun` on a root client, run it then, as if the answer had been `'once'`, without the caller's shell, which has moved on.
1155
+
1156
+ - **The request** is the object `approve` got, passed back as it is (`SudoReplay`: its `id`, `user`, `uid`, `target`, `argv`, `path`, `cwd`, `stdin`, `rootOnly`, `userWritable`, `tag`, `tags` and `validate`; other fields are ignored). The kernel keeps nothing of a request, so a request saved before a reload still runs, and every call runs it once more: running it at most once is the caller's to keep. It trusts none of the fields: it runs the decision again and refuses whatever has changed. Something that isn't such a request is `EINVAL` (`sudo: that isn't a sudo request this kernel can run`): a field missing or of the wrong type (`rootOnly` included, `{}` for none), an `argv` that is empty, holds a NUL or is over 4096 words or 1 MiB, and a `path` or `cwd` that isn't absolute, holds a NUL or is longer than 4096 characters. A kernel whose `users` isn't `'enforce'` refuses it too.
1157
+ - **What it refuses outright** (`EINVAL`): a `sudo -v` request (`sudo: a sudo -v request runs no command`), and one whose `stdin` was `'pipe'` or `'file'`, since that input is gone (`sudo: the request read a pipe, and that input is gone`).
1158
+ - **What it checks again,** in this order, before anything runs:
1159
+ - `user` still exists, with `uid` (`ENOENT`, `sudo: unknown user <user>`; `EPERM`, `sudo: <user> is no longer uid <uid>`), and so does `target` (`ENOENT`).
1160
+ - A policy line still lets `user` ask for `path` with these arguments as `target`, in `cwd`, with the contents in `userWritable`, `NOPASSWD` or not (`EACCES`, `sudo: <user> may no longer ask to run <command> as <target>`). There is no caller process any more, so its groups are the ones `/etc/group` gives the user now: a user taken out of `sudo` since can't have its old request run. A root user's request passes, as root's `sudo` does.
1161
+ - `cwd` resolves to the same real path (`EACCES`, `sudo: <cwd> changed since it was requested`), and `user` may still enter it (`EACCES`, `sudo: cannot run in <cwd>: it isn't a directory <user> can enter`). Both are checked once more just before the command starts, as for a command `sudo` starts, and a directory that no longer resolves to itself then (a symlink swapped in meanwhile) is refused with the first sentence, so the command never runs somewhere else.
1162
+ - The first word of `argv` still finds `path`, by the same search as `sudo`'s (`ENOENT`, `sudo: <word>: command not found`; `EPERM`, `sudo: <word> now runs <path now>, not <path>`).
1163
+ - The command's user-writable files are the same files with the same sha256 as in `userWritable` (`EPERM`, `sudo: <file> changed since it was requested`).
1164
+ - No entry of `rootOnly` that was `true` is false now (`EPERM`, `sudo: <path> is no longer root-only`).
1165
+ - **How it runs:** as `sudo` would have run it, as `target` with all six ids and its groups, with `sudo`'s environment (`SUDO_USER`, `SUDO_COMMAND`, the fixed `PATH` and the rest) and the caller's umask, in `cwd`, with the [symlink rule](#sudo) for it and everything it starts, and the binding of its user-writable files checked once more when the kernel loads them. It isn't the child of any process: it leads a session and a process group of its own (`pgid` and its session are its `pid`, its parent is pid 1), has no terminal, and reads `/dev/null`. It has every tag of the request, in their order, after the tag of the client's connection, and then `tag` (a string or a list of at most 8), so `ps` and [`trace`](#clienttracetag-onevents--limit---promise-close-) show it as the agent's and the run's: give each run a tag of its own (`sudo-run:<id>`) and subscribe to it before the call.
1166
+ - **Output:** `stdout` and `stderr` are absolute paths that the kernel opens as `user`, as the caller's shell opens `sudo command > file`: the caller must be able to write the file, or create it in its directory (`EACCES`, `sudo: <path>: Permission denied`; a missing directory is `ENOENT`, `sudo: <path>: No such file or directory`, since the kernel makes none), a symlink on the way must be root's or the caller's, and the file is truncated, or created as the caller's with mode `0600`. Once the kernel holds the file, it checks the path again, and refuses one that changed meanwhile (a symlink in its place: `EACCES`, `sudo: <path> changed while it was opened`). The same path for both opens one file, as `> file 2>&1` does. Without them, output goes to `/dev/null`. Because the output goes to files, not to the client, the run doesn't depend on the client: it goes on when the client detaches or its worker restarts, and nothing kills it for writing to a reader that is gone.
1167
+ - **The answer:** the call (`SudoRunOptions` in, `SudoRun` out) resolves with `{ pid, pgid, ended }` once the command has started, and `ended` with `{ status, signal? }` as for `spawn`, `128 + n` and the signal's name when a signal ended it. A client's `ended` rejects with `KernelGoneError` when the client goes away, while the process runs on: a new client finds it by its tag in `ps()` and `trace`. To stop it, signal its group (`client.kill(-pgid)`). A refusal rejects before anything runs, with the errno as `code` and the sentence above as the message, ready to show.
1168
+ - **Nothing else changes:** no [timestamp](#sudo-timestamps) is written or used, nothing is recorded in `/etc/sudoers.d`, and no request is pending for the user. The run is logged on the kernel's console like a run a timestamp allowed, with `REPLAY=<request id>` where that line has `TIMESTAMP=`: `sudo: cone : TTY=unknown ; PWD=/home/cone ; USER=root ; REPLAY=sudo-1-… ; COMMAND=/usr/bin/systemctl restart nginx`.
1169
+ - **Who may call it:** the embedder, and a root client, which may already run anything as anyone; the checks are there so that a run late is the run that was asked for, not to decide who may run it. That's the embedder's to decide, typically only on a person's click.
1170
+
1132
1171
  ### Cron
1133
1172
 
1134
1173
  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.
@@ -824,7 +824,7 @@ async function loopbackFetch(dial, input, options) {
824
824
  }
825
825
 
826
826
  // src/client/protocol.ts
827
- var PROTOCOL = [1, 14];
827
+ var PROTOCOL = [1, 16];
828
828
  function locksOf(scope = globalThis) {
829
829
  const locks = scope.navigator?.locks;
830
830
  return typeof locks?.request === "function" ? locks : void 0;
@@ -889,6 +889,7 @@ var Channel = class {
889
889
  failure;
890
890
  pending = /* @__PURE__ */ new Map();
891
891
  watching = /* @__PURE__ */ new Map();
892
+ tracing = /* @__PURE__ */ new Map();
892
893
  cdpHooks = /* @__PURE__ */ new Map();
893
894
  cdpOpen = /* @__PURE__ */ new Set();
894
895
  facadeHooks = /* @__PURE__ */ new Map();
@@ -956,6 +957,10 @@ var Channel = class {
956
957
  this.watching.get(data.watch)?.(data.change);
957
958
  return;
958
959
  }
960
+ if (data.trace !== void 0 && data.id === void 0) {
961
+ this.traced(data.trace, data.events ?? [], data.dropped ?? 0);
962
+ return;
963
+ }
959
964
  if (data.bye !== void 0) {
960
965
  this.end(new KernelGoneError(data.bye));
961
966
  return;
@@ -984,6 +989,23 @@ var Channel = class {
984
989
  if (reply.error !== void 0) call.reject(new KernelCallError(reply.error, reply.code));
985
990
  else call.resolve(reply.result);
986
991
  }
992
+ traced(trace, events, dropped) {
993
+ const listener = this.tracing.get(trace);
994
+ if (!listener) return;
995
+ const ack = () => {
996
+ if (this.tracing.has(trace)) this.post({ traceAck: trace });
997
+ };
998
+ let consumed;
999
+ try {
1000
+ consumed = listener({ events, dropped });
1001
+ } finally {
1002
+ if (typeof consumed?.then === "function") {
1003
+ void Promise.resolve(consumed).then(ack, ack);
1004
+ } else {
1005
+ ack();
1006
+ }
1007
+ }
1008
+ }
987
1009
  openCdp({ cdpOpen: { registration, ...request }, port }) {
988
1010
  const hook = registration === void 0 ? void 0 : this.cdpHooks.get(registration);
989
1011
  if (!hook || this.failure) {
@@ -1046,6 +1068,29 @@ async function serveCdpOn(channel, hook, options) {
1046
1068
  }
1047
1069
  };
1048
1070
  }
1071
+ async function traceOn(channel, tag, onEvents, options) {
1072
+ if (typeof onEvents !== "function") throw new TypeError("trace: onEvents must be a function");
1073
+ await since(channel, 15, "trace", async () => {
1074
+ });
1075
+ const { id, done } = channel.request({
1076
+ op: "trace",
1077
+ tag,
1078
+ ...options.limit !== void 0 ? { limit: options.limit } : {}
1079
+ });
1080
+ channel.tracing.set(id, onEvents);
1081
+ try {
1082
+ await done;
1083
+ } catch (error) {
1084
+ channel.tracing.delete(id);
1085
+ throw error;
1086
+ }
1087
+ return {
1088
+ close: () => {
1089
+ if (!channel.tracing.delete(id)) return;
1090
+ void channel.call({ op: "untrace", trace: id }).catch(() => void 0);
1091
+ }
1092
+ };
1093
+ }
1049
1094
  async function dialOn(channel, { port, host }) {
1050
1095
  const req = { op: "dial", port, ...host ? { host } : {} };
1051
1096
  return dialStream(await since(channel, 4, "dial", () => channel.call(req)));
@@ -1059,6 +1104,8 @@ async function spawnOn(channel, argv, opts) {
1059
1104
  });
1060
1105
  if (opts.tag !== void 0) await since(channel, 12, "a tag per spawn", async () => {
1061
1106
  });
1107
+ if (Array.isArray(opts.tag)) await since(channel, 15, "several tags per spawn", async () => {
1108
+ });
1062
1109
  if (opts.cwdMissing === "error") await since(channel, 14, "cwdMissing: 'error'", async () => {
1063
1110
  });
1064
1111
  const started = Promise.withResolvers();
@@ -1083,6 +1130,19 @@ async function spawnOn(channel, argv, opts) {
1083
1130
  signal: async (name = "SIGTERM") => void await channel.call({ op: "kill", pid: -pgid, signal: name })
1084
1131
  };
1085
1132
  }
1133
+ async function sudoRunOn(channel, replay, { stdout, stderr, tag }) {
1134
+ await since(channel, 16, "sudoRun", async () => {
1135
+ });
1136
+ const started = Promise.withResolvers();
1137
+ let endedBy;
1138
+ const { done } = channel.request(
1139
+ { op: "sudo-run", replay, options: { stdout, stderr, tag } },
1140
+ { started: started.resolve, ended: (signal) => endedBy = signal }
1141
+ );
1142
+ done.catch(started.reject);
1143
+ const pid = await started.promise;
1144
+ return { pid, pgid: pid, ended: endedOf(done, () => endedBy) };
1145
+ }
1086
1146
  function endedOf(done, signal) {
1087
1147
  return quiet(
1088
1148
  done.then((status) => {
@@ -1120,6 +1180,10 @@ async function terminalOn(channel, argv, opts) {
1120
1180
  });
1121
1181
  if (opts.tag !== void 0) await since(channel, 12, "a tag per terminal", async () => {
1122
1182
  });
1183
+ if (Array.isArray(opts.tag)) {
1184
+ await since(channel, 15, "several tags per terminal", async () => {
1185
+ });
1186
+ }
1123
1187
  if (opts.cwdMissing === "error") await since(channel, 14, "cwdMissing: 'error'", async () => {
1124
1188
  });
1125
1189
  let listener = onData ?? null;
@@ -1279,6 +1343,8 @@ async function attachKernel(port, options = {}) {
1279
1343
  channel.traitsListeners.add(listener);
1280
1344
  return () => channel.traitsListeners.delete(listener);
1281
1345
  },
1346
+ trace: (tag, onEvents, options2 = {}) => traceOn(channel, tag, onEvents, options2),
1347
+ sudoRun: (replay, options2 = {}) => sudoRunOn(channel, replay, options2),
1282
1348
  async close(o = {}) {
1283
1349
  if (channel.failure) return;
1284
1350
  await channel.call({ op: "detach", ...o.kill ? { kill: true } : {} }).catch(() => void 0);