seisin 0.1.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/src/scan.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Finds credential-shaped content that is NOT under a declared key directory.
3
+ *
4
+ * seisin scopes what a role may read from the directories you named. Every
5
+ * secret living somewhere else is readable by every role, because reads outside
6
+ * the denied paths stay open — an agent has to be able to read the repo.
7
+ *
8
+ * So the honest completion of "seisin protects your keys" is a command that
9
+ * says which keys it is not protecting. This is that command. It reports and
10
+ * never edits: moving a credential breaks whatever reads it today, and that is
11
+ * a decision, not a cleanup.
12
+ *
13
+ * ── Why this file is mostly about precision ──
14
+ * The first version was run against a real 18,000-file tree and returned 200
15
+ * findings, of which 5 had a specific shape and 2 were actual credentials. A
16
+ * scanner at that ratio does not get read a second time, which makes it worse
17
+ * than no scanner: it converts a real risk into a wall of noise someone learns
18
+ * to skip. Three things were wrong and all three are handled below.
19
+ */
20
+ import { readdirSync, readFileSync, statSync, realpathSync } from "node:fs";
21
+ import { join, relative, sep } from "node:path";
22
+ import { covers } from "./owners.js";
23
+
24
+ /**
25
+ * A match is `certain` when the shape alone is enough — these strings are
26
+ * issued by a provider and do not occur by accident. It is `review` when the
27
+ * shape only says "this line talks about a secret", which is true of a great
28
+ * deal of ordinary code.
29
+ *
30
+ * The split is the point. Mixing them produces one list nobody triages; keeping
31
+ * them apart lets `certain` be the thing that fails a build.
32
+ */
33
+ export const SHAPES = [
34
+ ["certain", "private key block", /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/],
35
+ ["certain", "Anthropic/OpenAI key", /\bsk-[A-Za-z0-9_-]{20,}/],
36
+ ["certain", "GitHub token", /\bgh[pousr]_[A-Za-z0-9]{20,}/],
37
+ ["certain", "AWS access key", /\bAKIA[0-9A-Z]{16}\b/],
38
+ ["certain", "Google API key", /\bAIza[0-9A-Za-z_-]{30,}/],
39
+ ["certain", "Slack token", /\bxox[abposr]-[A-Za-z0-9-]{10,}/],
40
+ ["certain", "Stripe live key", /\b[sr]k_live_[A-Za-z0-9]{20,}/],
41
+ ["certain", "JSON Web Token", /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/],
42
+ ["review", "assigned secret", /\b[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY)[A-Z0-9_]*\s*[=:]\s*["']?([^\s"'#,;)]{12,})/i],
43
+ ];
44
+
45
+ /**
46
+ * The value is code, not a secret.
47
+ *
48
+ * `TOKEN = os.environ["TOKEN"]` matches every naive secret pattern ever
49
+ * written, and it is the single most common line in any codebase that handles
50
+ * secrets *correctly*. Measured on the real tree: 46 of 159 loose findings were
51
+ * exactly this — the scanner was flagging the good practice.
52
+ */
53
+ const REFERENCE = /(os\.environ|process\.env|import\.meta\.env|getenv|ENV\[|Deno\.env|config\(|settings\.|secrets\.|\$\{|\$\(|%\(|\{\{|<%|%s|%d|\.get\(|request\.|req\.|headers\[|params\[|argv|prompt\(|input\()/i;
54
+
55
+ /** Values that are obviously not secrets, however secret-shaped the name is. */
56
+ const PLACEHOLDER = /^(?:changeme|change_me|xxx+|your[-_a-z]*|example|placeholder|dummy|sample|redacted|null|none|true|false|test|todo|fixme|\.{3}|-+|\*+)$/i;
57
+
58
+ /**
59
+ * Paths skipped by default.
60
+ *
61
+ * Two kinds, and both were learned the same way. Machinery nobody wrote
62
+ * (`node_modules`, `dist`) has always been obvious. What was not: caches of
63
+ * pages fetched from other people's sites, and `.bak` copies of a config, which
64
+ * between them produced most of the noise in the first real run — the same
65
+ * finding repeated five times, and three keys belonging to a company that is
66
+ * not the one running the scan.
67
+ */
68
+ export const DEFAULT_IGNORE = [
69
+ "**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.next/**",
70
+ "**/vendor/**", "**/__pycache__/**", "**/.venv/**", "**/venv/**", "**/coverage/**",
71
+ "**/_cache/**", "**/.cache/**", "**/.angular/**", "**/.parcel-cache/**",
72
+ "**/.turbo/**", "**/.nuxt/**", "**/target/**", "**/out/**",
73
+ "**/*.bak", "**/*.bak-*", "**/*.orig",
74
+ "**/*.min.js", "**/*.min.css", "**/*-lock.json", "**/*.lock",
75
+ ];
76
+
77
+ const SKIP_EXT = /\.(png|jpe?g|gif|webp|ico|svg|pdf|zip|gz|tgz|bz2|xz|mp[34]|mov|wav|woff2?|ttf|otf|eot|so|dylib|a|o|class|jar|wasm|sqlite3?|db)$/i;
78
+ const MAX_BYTES = 512 * 1024;
79
+ // Written as an escape, not as the byte itself. A literal NUL in the source
80
+ // makes git classify this file as binary, and a file with no readable diff
81
+ // cannot be reviewed — in a repo whose whole argument is that you should read
82
+ // it before you trust it. Same value at runtime, same test.
83
+ const NUL = "\u0000";
84
+
85
+ /**
86
+ * Walks `root` and returns [{file, line, shape, level}].
87
+ *
88
+ * `protectedDirs` are the declared key directories: a hit inside one is the
89
+ * system working, not a finding. `ignore` adds to DEFAULT_IGNORE rather than
90
+ * replacing it, so a user's list stays short.
91
+ */
92
+ export function scan(root, protectedDirs = [], ignore = [], limit = 500) {
93
+ const safe = protectedDirs.map((d) => join(root, d));
94
+ const patterns = [...DEFAULT_IGNORE, ...ignore];
95
+ const hits = [];
96
+ const skipped = { ignored: 0, protectedDirs: 0, reference: 0, placeholder: 0 };
97
+
98
+ const isIgnored = (rel) => patterns.some((p) => covers(p, rel));
99
+
100
+ (function walk(dir) {
101
+ if (hits.length >= limit) return;
102
+ let entries;
103
+ try {
104
+ entries = readdirSync(dir, { withFileTypes: true });
105
+ } catch {
106
+ return; // an unreadable directory is a permission, not a finding
107
+ }
108
+ for (const e of entries) {
109
+ if (hits.length >= limit) return;
110
+ const full = join(dir, e.name);
111
+ const rel = relative(root, full);
112
+
113
+ if (safe.some((s) => full === s || full.startsWith(s + sep))) { skipped.protectedDirs++; continue; }
114
+ if (isIgnored(rel)) { skipped.ignored++; continue; }
115
+
116
+ /**
117
+ * A symlink pointing out of the repo, reported without being followed.
118
+ *
119
+ * `scan` is the command that answers "do I have credentials outside the
120
+ * declared key directories", and a link is the one way a credential is
121
+ * present in the tree without being a file in it. It was skipped
122
+ * silently, because a symlink is not `isFile()` — so `loose-link ->
123
+ * ~/.ssh/id_rsa` scanned clean.
124
+ *
125
+ * The destination is named, not read. Opening it would put whatever is
126
+ * there into a report that people paste into issues, and the point is
127
+ * that it exists, not what it says.
128
+ */
129
+ if (e.isSymbolicLink()) {
130
+ let real;
131
+ try {
132
+ real = realpathSync(full);
133
+ } catch {
134
+ continue; // a broken link has nothing to leak
135
+ }
136
+ if (!real.startsWith(root + sep) && real !== root)
137
+ hits.push({
138
+ file: rel, line: 0, level: "link", shape: real,
139
+ });
140
+ continue;
141
+ }
142
+
143
+ if (e.isDirectory()) { walk(full); continue; }
144
+ if (!e.isFile() || SKIP_EXT.test(e.name)) continue;
145
+
146
+ let text;
147
+ try {
148
+ if (statSync(full).size > MAX_BYTES) continue;
149
+ text = readFileSync(full, "utf8");
150
+ } catch {
151
+ continue;
152
+ }
153
+ if (text.includes(NUL)) continue; // binary
154
+
155
+ const lines = text.split(/\r?\n/);
156
+ for (let i = 0; i < lines.length && hits.length < limit; i++) {
157
+ const line = lines[i];
158
+ for (const [level, shape, re] of SHAPES) {
159
+ const m = re.exec(line);
160
+ if (!m) continue;
161
+ if (level === "review") {
162
+ // A captured value that is code, or that is plainly a stand-in, is
163
+ // the pattern working correctly on a line that holds no secret.
164
+ if (REFERENCE.test(line)) { skipped.reference++; break; }
165
+ if (m[1] && PLACEHOLDER.test(m[1])) { skipped.placeholder++; break; }
166
+ }
167
+ hits.push({ file: rel, line: i + 1, shape, level });
168
+ break; // one finding per line; the first shape is the most specific
169
+ }
170
+ }
171
+ }
172
+ })(root);
173
+
174
+ return { hits, skipped, truncated: hits.length >= limit };
175
+ }
package/src/serve.js ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * A local server for the console, and the one place seisin keeps a process alive.
3
+ *
4
+ * Everything else here is a launcher: it runs, it exits, there is no state to
5
+ * corrupt and nothing to restart. That property is worth protecting, so this is
6
+ * scoped hard — the server is a **foreground command you start and stop**, it
7
+ * serves one page and one JSON document, and nothing in the enforcement path
8
+ * touches it. If it is not running, seisin loses a window, not a boundary.
9
+ *
10
+ * It binds to loopback only, and that is not decoration: the page shows which
11
+ * key each role can read, which is a map of where the credentials live.
12
+ */
13
+ import { createServer } from "node:http";
14
+ import { randomBytes } from "node:crypto";
15
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { loadConfig } from "./config.js";
19
+ import { read, logPath } from "./log.js";
20
+ import { settingsFor } from "./srt.js";
21
+ import { pending, requestsPath, settle, applyGrant } from "./requests.js";
22
+
23
+ const HERE = dirname(fileURLToPath(import.meta.url));
24
+
25
+ /**
26
+ * Everything the page needs, read fresh on each request.
27
+ *
28
+ * Read fresh rather than cached because the file on disk is the truth and the
29
+ * page is a view of it — someone editing seisin.toml in an editor should see
30
+ * the console change, not wonder why it disagrees.
31
+ */
32
+ function state(configPath) {
33
+ const cfg = loadConfig(configPath);
34
+ const entries = read(logPath(cfg.root), { limit: 200 });
35
+
36
+ const roles = Object.values(cfg.roles).map((r) => ({
37
+ name: r.name,
38
+ writes: r.writes,
39
+ keys: r.keys,
40
+ env: r.env,
41
+ blocks: entries.filter((e) => e.role === r.name && e.verdict === "denied").length,
42
+ settings: settingsFor(cfg, r.name),
43
+ }));
44
+
45
+ return {
46
+ root: cfg.root,
47
+ config: cfg.path,
48
+ requests: pending(requestsPath(cfg.root)),
49
+ // The file itself, not a regeneration of it. The page can render a policy
50
+ // from its own model, and that model has no comments — so showing it under
51
+ // the heading "seisin.toml" next to "copy this back" invites someone to
52
+ // paste away the provenance a grant just wrote.
53
+ toml: readFileSync(cfg.path, "utf8"),
54
+ keyDirs: cfg.keyDirs,
55
+ allowedDomains: cfg.allowedDomains,
56
+ roles,
57
+ log: entries.slice(-60).reverse(),
58
+ live: true,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Approve or refuse one pending request, as a person.
64
+ *
65
+ * This is the only thing in seisin that writes policy, and it lives here rather
66
+ * than in the MCP server for one reason: an agent cannot reach this. Measured,
67
+ * not assumed — a confined role curling this port gets the same nothing it gets
68
+ * from a domain outside its allowlist, because the egress proxy does not make
69
+ * an exception for loopback. That is what lets the console hold the half the
70
+ * MCP server deliberately does not.
71
+ */
72
+ function decide(configPath, { number, decision, reason }) {
73
+ const cfg = loadConfig(configPath);
74
+ const file = requestsPath(cfg.root);
75
+ const req = pending(file)[Number(number) - 1];
76
+ if (!req) throw new Error(`no pending request #${number}`);
77
+ if (decision !== "granted" && decision !== "denied")
78
+ throw new Error(`decision must be granted or denied`);
79
+
80
+ if (decision === "granted") {
81
+ // The config is edited as text, so comments and order survive. Written
82
+ // before the queue is settled: if this throws, the request is still open
83
+ // rather than marked done against a file that never changed.
84
+ const { toml, changed } = applyGrant(readFileSync(cfg.path, "utf8"), req, reason);
85
+ if (changed) writeFileSync(cfg.path, toml);
86
+ }
87
+ settle(file, req.key, decision, reason ?? "");
88
+ return { ok: true, role: req.role, grant: req.grant, decision };
89
+ }
90
+
91
+ /** The body of a POST, capped: this endpoint takes three short fields. */
92
+ function readBody(req) {
93
+ return new Promise((ok, fail) => {
94
+ let s = "";
95
+ req.on("data", (c) => {
96
+ s += c;
97
+ if (s.length > 4096) { fail(new Error("body too large")); req.destroy(); }
98
+ });
99
+ req.on("end", () => ok(s));
100
+ });
101
+ }
102
+
103
+ export function serve(configPath, port = 4178) {
104
+ const page = join(HERE, "..", "ui", "index.html");
105
+ if (!existsSync(page)) throw new Error("the console is missing from this install");
106
+
107
+ /**
108
+ * One secret per run, handed to the page and demanded back on every write.
109
+ *
110
+ * Loopback is not a boundary against the browser: any site the operator has
111
+ * open can POST to 127.0.0.1. It cannot read this token, and asking for it in
112
+ * a custom header also forces a preflight that this server never answers. So
113
+ * the page can approve and a tab from somewhere else cannot.
114
+ */
115
+ const token = randomBytes(24).toString("hex");
116
+
117
+ const server = createServer(async (req, res) => {
118
+ if (req.method === "POST" && req.url === "/api/decide") {
119
+ if (req.headers["x-seisin-token"] !== token) {
120
+ res.writeHead(403, { "content-type": "application/json" });
121
+ return res.end(JSON.stringify({ error: "bad or missing token" }));
122
+ }
123
+ try {
124
+ const out = decide(configPath, JSON.parse((await readBody(req)) || "{}"));
125
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
126
+ return res.end(JSON.stringify(out));
127
+ } catch (e) {
128
+ res.writeHead(400, { "content-type": "application/json" });
129
+ return res.end(JSON.stringify({ error: e.message }));
130
+ }
131
+ }
132
+ if (req.method !== "GET" && req.method !== "HEAD") {
133
+ res.writeHead(405).end("method not allowed");
134
+ return;
135
+ }
136
+ if (req.url === "/api/state") {
137
+ let body;
138
+ try {
139
+ body = JSON.stringify(state(configPath));
140
+ } catch (e) {
141
+ res.writeHead(500, { "content-type": "application/json" });
142
+ return res.end(JSON.stringify({ error: e.message }));
143
+ }
144
+ // The page polls, so tell every cache in between to stay out of it.
145
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
146
+ return res.end(body);
147
+ }
148
+ if (req.url === "/" || req.url === "/index.html") {
149
+ // Inlined rather than put in the URL: a fragment survives in history, in
150
+ // a screenshot, and in whatever the operator pastes into a chat.
151
+ const html = readFileSync(page, "utf8")
152
+ .replace("</head>", `<script>window.SEISIN_TOKEN=${JSON.stringify(token)}</script></head>`);
153
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
154
+ return res.end(html);
155
+ }
156
+ res.writeHead(404).end("not found");
157
+ });
158
+
159
+ return new Promise((ok, fail) => {
160
+ // Loopback only. The page maps where every credential lives; it has no
161
+ // business being reachable from the network the laptop happens to be on.
162
+ server.listen(port, "127.0.0.1", () => ok(server));
163
+ server.on("error", (e) =>
164
+ fail(e.code === "EADDRINUSE"
165
+ ? new Error(`port ${port} is busy — pass another with: seisin ui --port <n>`)
166
+ : e));
167
+ });
168
+ }
package/src/spool.js ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The audit trail, held by the parent instead of by the thing being audited.
3
+ *
4
+ * ── Why this exists ──
5
+ * `.seisin/` used to be in every role's allowWrite, because the hook runs
6
+ * inside the box and has to record what it decided. That made the log and the
7
+ * request queue writable by the process they are a record of: an agent could
8
+ * truncate `log.jsonl`, rewrite a decision, or invent a request that had never
9
+ * been refused. The files said "append-only" in their own comments and nothing
10
+ * enforced it. An external review named this and was right.
11
+ *
12
+ * So the file moves out of reach and a socket takes its place:
13
+ *
14
+ * agent ─X─► .seisin/ (no longer writable)
15
+ * hook ──line──► unix socket ──► seisin run ──append──► .seisin/log.jsonl
16
+ *
17
+ * The only verb reachable from inside the sandbox is "send one line". There is
18
+ * no seek, no truncate, no unlink, because there is no file descriptor — and
19
+ * `allowUnixSockets` is granted for exactly this one socket path, so it is not
20
+ * a general hole either.
21
+ *
22
+ * ── What this does not fix, stated plainly ──
23
+ * A hostile agent can still *send* lines, so it can add noise to its own
24
+ * record. What it can no longer do is erase or alter what is already there.
25
+ * Forgery and tampering are different problems and only the second one is
26
+ * solved here; the log is evidence of what happened, not proof that nothing
27
+ * else did.
28
+ */
29
+ import { createServer, createConnection } from "node:net";
30
+ import { unlinkSync, existsSync, mkdtempSync, chmodSync } from "node:fs";
31
+ import { tmpdir } from "node:os";
32
+ import { join, dirname } from "node:path";
33
+
34
+ /** The env var a confined hook looks for. Absent means "write the file". */
35
+ export const SOCK_ENV = "SEISIN_SPOOL";
36
+
37
+ /**
38
+ * Where the socket lives.
39
+ *
40
+ * Not under the repo: the point is a path the role's territory does not cover.
41
+ * Kept short because a unix socket path is capped near 104 bytes on macOS and
42
+ * the failure when it is too long is an unhelpful EINVAL.
43
+ */
44
+ export function spoolPath() {
45
+ // A random directory, 0700, rather than a name anyone can guess from the pid.
46
+ // The socket itself is already denyWrite so the agent cannot unlink it, but
47
+ // any other process running as this user could otherwise connect and add
48
+ // lines. That does not let them erase anything — forgery is the part that
49
+ // stays open — but a predictable path invites it for free.
50
+ const dir = mkdtempSync(join(tmpdir(), "seisin-"));
51
+ chmodSync(dir, 0o700);
52
+ return join(dir, "spool.sock");
53
+ }
54
+
55
+ /**
56
+ * Listens for lines from inside the box and hands each to `sink`.
57
+ *
58
+ * One line is one JSON object with a `to` field naming which file it belongs
59
+ * in. The parent decides what that means; the sender does not get to pick a
60
+ * path, which is the other half of why this is not just a slower file write.
61
+ */
62
+ export function spool(sink, path = spoolPath()) {
63
+ if (existsSync(path)) unlinkSync(path);
64
+
65
+ const server = createServer((conn) => {
66
+ let buffer = "";
67
+ conn.setEncoding("utf8");
68
+ conn.on("data", (chunk) => {
69
+ buffer += chunk;
70
+ // A 1 MB line is not a log entry, it is something trying to fill a disk.
71
+ if (buffer.length > 1_000_000) return conn.destroy();
72
+ let nl;
73
+ while ((nl = buffer.indexOf("\n")) !== -1) {
74
+ const line = buffer.slice(0, nl);
75
+ buffer = buffer.slice(nl + 1);
76
+ if (!line.trim()) continue;
77
+ try {
78
+ const msg = JSON.parse(line);
79
+ if (msg && (msg.to === "log" || msg.to === "requests")) sink(msg.to, msg.entry);
80
+ } catch {
81
+ // Garbage from inside the box is not the parent's emergency.
82
+ }
83
+ }
84
+ });
85
+ conn.on("error", () => {});
86
+ });
87
+
88
+ /**
89
+ * A failed `listen` has to reject, and it used to do nothing at all.
90
+ *
91
+ * The error handler swallowed everything and the promise only ever settled
92
+ * from the `listen` callback, so any failure to bind — a denied path, a name
93
+ * too long for a unix socket, a directory that is not there — left an awaited
94
+ * promise pending forever. What the user saw was Node's "Detected unsettled
95
+ * top-level await" and rc=13: no error, no path, nothing naming the spool.
96
+ * Measured while running seisin inside seisin, where the outer box refuses
97
+ * the inner bind; but nothing about it is particular to that case, which is
98
+ * the reason it is fixed here rather than beside the nesting check.
99
+ *
100
+ * After a successful bind the handler goes back to swallowing, because by
101
+ * then an error belongs to one connection and must not take down the run
102
+ * that the audit trail is only observing.
103
+ */
104
+ return new Promise((ok, fail) => {
105
+ server.once("error", fail);
106
+ server.listen(path, () => {
107
+ server.removeListener("error", fail);
108
+ server.on("error", () => {});
109
+ ok({
110
+ path,
111
+ close() {
112
+ server.close();
113
+ // Only the socket. The first version removed `dirname(path)` too,
114
+ // reasoning that spoolPath() had just made that directory — but a
115
+ // caller passing its own path makes dirname the system temp directory,
116
+ // and closing the spool deleted it. That is a caller's whole scratch
117
+ // space gone, for a cleanup. Whoever created the directory removes it:
118
+ // see `run`, which owns the one spoolPath() makes.
119
+ try { unlinkSync(path); } catch {}
120
+ },
121
+ });
122
+ });
123
+ });
124
+ }
125
+
126
+ /**
127
+ * Sends one entry to the parent, if there is one listening.
128
+ *
129
+ * Returns a promise and registers it, because the hook is a short-lived
130
+ * process whose last act is `process.exit` — and an exit does not wait for a
131
+ * socket to drain. The write would be lost exactly when it matters, silently,
132
+ * which is the shape of the original bug this file replaces. `flush()` is what
133
+ * the hook awaits before it answers.
134
+ */
135
+ const inFlight = new Set();
136
+
137
+ export function send(to, entry, path = process.env[SOCK_ENV]) {
138
+ if (!path) return false;
139
+ const p = new Promise((done) => {
140
+ let settled = false;
141
+ const finish = () => { if (!settled) { settled = true; done(true); } };
142
+ try {
143
+ const conn = createConnection(path);
144
+ // Never hang the agent's turn on bookkeeping. If the parent is gone, the
145
+ // entry is lost and the run continues — the boundary does not depend on
146
+ // this succeeding.
147
+ const guard = setTimeout(() => { conn.destroy(); finish(); }, 1000);
148
+ conn.on("error", () => { clearTimeout(guard); finish(); });
149
+ conn.on("close", () => { clearTimeout(guard); finish(); });
150
+ conn.write(JSON.stringify({ to, entry: { at: new Date().toISOString(), ...entry } }) + "\n");
151
+ conn.end();
152
+ } catch {
153
+ finish();
154
+ }
155
+ });
156
+ inFlight.add(p);
157
+ p.then(() => inFlight.delete(p));
158
+ return true;
159
+ }
160
+
161
+ /** Waits for everything `send` started. Call it before exiting. */
162
+ export async function flush() {
163
+ await Promise.all([...inFlight]);
164
+ }