UNPKG

kestrel.markets

Version:

A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.

491 lines (459 loc) 25.8 kB
#!/usr/bin/env node /** * # bin/kestrel — the node-safe launcher (bead: kestrel-mkn) * * Owner directive (2026-07-14): bun is not widely deployed and the site examples are npx-only, so * `npx kestrel.markets run/day/agent` must work under plain node. This launcher is the published * bin entry; it imports ONLY node builtins before the light/heavy re-exec decision — never * Bun-only code. * * ## Contract * - LIGHT verbs (`version help parse validate print frame percept sim prove replay verify`), any `-h/--help/-V/--version`, * or an argv with NO leading verb at all → load `dist/cli.js` in-process under the current runtime, * exactly as the pre-mkn bin did. Light stays node-runnable with zero bun anywhere. An UNRECOGNIZED * leading verb FAILS CLOSED to heavy (kestrel-jnd7): a verb the router grew but these sets have not * must re-exec onto bun, never silently skip it and die under node with a misleading exit-4. * - HEAVY verbs (`run day paper runs lineage leaderboard agent`) need `bun:sqlite`/`Bun.CryptoHasher`. * Already under bun (`process.versions.bun`) → continue in-process. Under node → RE-EXEC * `bun dist/cli.js <identical argv>` with stdio inherited, propagating the child's exit code * (a signalled child → 128+signo, so SIGINT/SIGTERM/SIGSEGV surface as 130/143/139, not a * flattened 1). Certified paths stay pinned-Bun by construction: the re-exec IS bun. * * ## Discovery — ONE gate, and it is behavioural * A candidate is accepted iff `bun --version` exits 0 with a semver at or above {@link BUN_FLOOR} * ({@link probeBun}). NOTHING else gates: no size sniff, no name heuristic. A file-size heuristic was * tried and REVERTED — it rejected legitimate small wrappers (asdf/mise shims, a pnpm `.bin` script * entry, a 42-byte `exec …/bun "$@"`) while adding nothing the probe does not already do: bun's own * ~450-byte "postinstall did not run" placeholder exits 1 with no semver, so the probe rejects it * anyway. The probe is also the only SOUND test available — on POSIX `execvp` hands an ENOEXEC file to * `/bin/sh`, so a wrong-arch binary or a stray script would "launch" and exit nonzero, indistinguish- * able from a genuine command failure, if we trusted the exec alone. The version floor matters because * an old `bun` earlier on PATH would otherwise outrank the pinned bundled one and die deep inside the * registry with a misleading `no such table` instead of a loud refusal here. * * Order: * 1. `$KESTREL_BUN` — an EXPLICIT operator pin. If it does not probe clean the launcher fails LOUD * (exit 4) and NEVER substitutes another bun: silently re-execing onto a runtime other than the * one an operator pinned would void exactly the pinned-Bun certification this re-exec preserves. * 2. Every `bun` on `$PATH`, in order. * 3. The SHARED `$KESTREL_HOME/.kestrel/runtime` cache (else `~/.kestrel/runtime`) — one per-USER * bun that all worktrees on a shared dev box reuse, mirroring the `$KESTREL_HOME/.kestrel` idiom * `credentials.ts` uses. The first working bun this launcher resolves is MATERIALIZED here once by * copying its BYTES (a hardlink when same-filesystem, a real copy on EXDEV — NEVER a symlink), so * the cache is INDEPENDENT of any install's `node_modules`: it survives that tree being deleted * (worktree cleanup) or absent (`--omit=optional`), the two scenarios it exists for. Best-effort and * fail-quiet; subsequent runs — in this or any sibling worktree — find it before re-probing (or * re-installing) a per-worktree copy (bead kestrel-2e2e). * 4. The bundled `bun` optionalDependency — its postinstall-materialized `bun/bin/bun.exe` FIRST, * then the `@oven/bun-*` packages filtered by platform+arch+libc (non-baseline before baseline), * because npm installs every wrong-libc/baseline variant and bun's postinstall MOVES the correct * one out of `@oven`. * A candidate that fails the probe (or the exec) is SKIPPED and the next tried. `KESTREL_NO_BUNDLED_BUN=1` * excludes the bundled PACKAGE entirely — by REALPATH, so its `.bin/bun` symlink (which npx puts first * on PATH) cannot smuggle it back in. The flag targets that per-worktree package; the shared cache is a * separate per-USER tier holding INDEPENDENT bytes (a hardlink/copy, not a symlink into the package), so * a bun materialized there is its own artifact, no longer the bundled package the flag excludes. * * Only when EVERY candidate fails does the launcher fall through in-process: the heavy verb then faults * through `loadHeavy` into the existing loud exit-4 RUNTIME_UNAVAILABLE refusal, rendered in the * caller's mode — never a raw stack. */ "use strict"; const { spawnSync } = require("node:child_process"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const { pathToFileURL } = require("node:url"); // TEST-ISOLATION BUNDLE OVERRIDE (bead kestrel-viz2). By default the launcher loads/re-execs the shipped // repo-root `dist/cli.js`. The spawn-driver e2e suites drive THIS launcher but each rebuilds that one // shared bundle, so a peer's rebuild races another's `node bin/kestrel.cjs <verb>` → a non-deterministic // exit-1 that looks like net-new breakage. `KESTREL_CLI` lets such a suite point the launcher at a PRIVATE // bundle it built (via `KESTREL_CLI_OUTDIR`), so no concurrent repo-root rebuild can prune it out from // under the spawn. Opt-in and absent from every production/npx path, so the default is unchanged. const CLI = process.env.KESTREL_CLI ? path.resolve(process.env.KESTREL_CLI) : path.join(__dirname, "..", "dist", "cli.js"); /** The floor `package.json#engines.bun` promises. An older bun ships an older `bun:sqlite`. */ const BUN_FLOOR = [1, 1, 0]; // `sim` is LIGHT by construction (kestrel-585): it drives the hosted funnel over fetch only // (no bun:sqlite/chdb), so the site's hero snippet runs under plain node via npx. // `prove`/`replay`/`verify` (kestrel-markets-n04e.1) are LIGHT like `sim`: they drive the hosted // funnel over fetch only (verify adds node:crypto — still a node built-in), so the zero-credential // front door runs under plain node via npx with no bun anywhere. // `certify` (kestrel-8kvs, gate G10) is LIGHT too: it fetches the evidence bundle over fetch and // re-projects the Blotter with pure `project`/`serialize` + a node:crypto-free sha256 — no bun/chdb, // no engine drive — so open recomputation runs under plain node via npx. // `whoami`/`refresh` (kestrel-p7th) are LIGHT: `whoami` is a pure-local credential read (node // built-ins + fs only, zero network); `refresh` renews the durable capability over fetch against the // credential's own host (no bun/chdb) — so credential self-inspection + renewal run under plain node. // `secrets` (kestrel-jh9w.2) is LIGHT: the operator/BYOK secret store is a node built-ins + fs // read/write of `~/.kestrel/.env` with ZERO network and no bun/chdb — so credential residency is // manageable under plain node via npx, like the rest of the credential surface. const LIGHT_VERBS = new Set(["version", "help", "parse", "validate", "print", "frame", "percept", "sim", "register", "whoami", "refresh", "secrets", "card", "prove", "replay", "verify", "certify"]); // `paper` (kestrel-7o2.12) is HEAVY: it dynamic-imports the same `src/cli/heavy.ts` barrel as // `run`/`day` (bun:sqlite registry + ledger) to drive a live-feed session against the venue's // paper gate. Under node it would otherwise fail open to LIGHT and hit the exit-4 refusal. // `mcp` (kestrel-0f7i) is classified HEAVY like `agent`, for the same reason: its `--local` arm // dynamic-imports the in-process engine (Bun.CryptoHasher et al.), so when a bun exists the re-exec // serves BOTH transports. Its DEFAULT (remote) transport is pure fetch: on a genuinely bun-less host // the launcher's fall-through runs it in-process under node and the stdio drop-in still works, while // `--local` there faults loud (exit 4) through loadHeavy — never a raw Bun fault on the MCP wire. const HEAVY_VERBS = new Set(["run", "day", "paper", "runs", "lineage", "leaderboard", "agent", "mcp"]); /** Global flags that CONSUME the next token — so `--api run` can never be misread as the `run` verb. */ const VALUE_FLAGS = new Set(["--format", "--color", "--api"]); /** * Scan argv in order; the FIRST known verb decides. Help/version flags anywhere force LIGHT (the * router's meta fast-path serves them without any heavy import), so `run --help` needs no bun. A value * flag swallows its value; other dashed tokens are skipped. The first non-flag token IS the command: a * LIGHT verb goes in-process, ANYTHING else (a HEAVY verb, or a verb these sets do not know) FAILS CLOSED * to heavy. An argv with no leading verb at all stays LIGHT (the in-process router renders the usage * error). The two sets are pinned set-equal to the router's verb inventory by tests/cli/heavy.test.ts, * so a known verb classifies correctly; unknown→heavy is the authoring-time backstop behind that pin. */ function isHeavyInvocation(args) { for (let i = 0; i < args.length; i += 1) { const tok = args[i]; if (VALUE_FLAGS.has(tok)) { i += 1; // a flag VALUE is never a flag continue; } if (tok === "-h" || tok === "--help" || tok === "-V" || tok === "--version") return false; } for (let i = 0; i < args.length; i += 1) { const tok = args[i]; if (VALUE_FLAGS.has(tok)) { i += 1; // consume the flag's VALUE — never classify it as a verb continue; } if (tok.startsWith("-")) continue; if (LIGHT_VERBS.has(tok)) return false; // The first non-flag token IS the command. A LIGHT verb went home above; anything else — a HEAVY // verb OR a verb the launcher does not know (present in the router, absent from these sets) — FAILS // CLOSED to heavy. Unknown→heavy is the SAFE direction: it only ever costs a bun re-exec (which the // heavy path certifies anyway), whereas unknown→light silently skips the re-exec and dies under node // with a misleading exit-4 RUNTIME_UNAVAILABLE. The drift pin (tests/cli/heavy.test.ts) still couples // the sets to the router; this is the authoring-time backstop so a new verb can never fail open. return true; } return false; } /** * The render mode the CLI itself will resolve, by the SAME precedence as `src/cli/context.ts`: * explicit `--json`/`--format` > `--agent`/env > piped/CI → text, TTY → human. The launcher must agree * with it: in `json`/`text` mode stderr is a machine channel an agent parses, so a bare advisory line * there would corrupt it. */ function resolveMode(args, env, stdoutTTY) { for (let i = 0; i < args.length; i += 1) { if (args[i] === "--json") return "json"; if (args[i] === "--format") { const v = args[i + 1]; if (v === "json" || v === "text" || v === "human") return v; } if (VALUE_FLAGS.has(args[i])) i += 1; } const agent = args.includes("--agent") || env.KESTREL_AGENT === "1" || env.AGENT === "1"; const isCI = !!(env.CI || env.GITHUB_ACTIONS || env.BUILDKITE || env.GITLAB_CI); if (agent) return "text"; return !stdoutTTY || isCI ? "text" : "human"; } /** Render a launcher-level failure in the caller's mode, mirroring `src/cli/errors.ts#fail`, and exit 4. */ function failRuntime(mode, message, hint) { if (mode === "json") { process.stderr.write(JSON.stringify({ error: { code: "RUNTIME_UNAVAILABLE", message, hint } }) + "\n"); } else if (mode === "text") { process.stderr.write(`error\tcode=RUNTIME_UNAVAILABLE\tmessage=${message}\thint=${hint}\n`); } else { process.stderr.write(`error: ${message}\nhint: ${hint}\n`); } process.exit(4); } /** * The ONE gate: does this path BEHAVE like a bun at or above the floor? `bun --version` must exit 0 and * print a semver. Costs one ~20ms spawn on a verb that is about to run a whole graded session. */ function probeBun(p) { const res = spawnSync(p, ["--version"], { encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] }); if (res.error !== undefined && res.error !== null) return false; if (res.status !== 0) return false; const m = /^(\d+)\.(\d+)\.(\d+)/.exec((res.stdout ?? "").trim()); if (m === null) return false; const v = [Number(m[1]), Number(m[2]), Number(m[3])]; for (let i = 0; i < 3; i += 1) { if (v[i] > BUN_FLOOR[i]) return true; if (v[i] < BUN_FLOOR[i]) return false; } return true; // exactly the floor } /** True on a musl libc (Alpine): node reports no glibc runtime version there. */ function isMusl() { try { const report = typeof process.report?.getReport === "function" ? process.report.getReport() : undefined; const header = report !== undefined && report !== null ? report.header : undefined; if (header !== undefined && header !== null && typeof header === "object") { return header.glibcVersionRuntime === undefined; } } catch { /* fall through to the filesystem probe */ } return fs.existsSync("/etc/alpine-release"); } /** * The `@oven/bun-*` package names that can actually RUN here, best first. `@oven/bun-linux-*` declares * no `libc` field, so npm installs EVERY linux variant for the arch (a Debian tree really does carry * both `bun-linux-aarch64` and `bun-linux-aarch64-musl`) and bun's postinstall MOVES the correct one * into `bun/bin/`, leaving the wrong-libc siblings behind. An unfiltered readdir therefore hands back * an unrunnable binary on Linux, and coin-flips onto the slower `-baseline` build on x64. */ function ovenPackageNames() { const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x64" : null; if (arch === null) return []; const { platform } = process; if (platform === "darwin") { return arch === "aarch64" ? ["bun-darwin-aarch64"] : ["bun-darwin-x64", "bun-darwin-x64-baseline"]; } if (platform === "win32") { return arch === "aarch64" ? ["bun-windows-aarch64"] : ["bun-windows-x64", "bun-windows-x64-baseline"]; } if (platform === "linux") { const libc = isMusl() ? "-musl" : ""; return arch === "aarch64" ? [`bun-linux-aarch64${libc}`] : [`bun-linux-x64${libc}`, `bun-linux-x64${libc}-baseline`]; } return []; } function exists(p) { try { return fs.statSync(p).isFile(); // follows symlinks (.bin entries are symlinks) } catch { return false; } } /** The installed `bun` package dir, or null when the optional dependency is absent. */ function bunPackageDir() { try { return path.dirname(require.resolve("bun/package.json", { paths: [__dirname] })); } catch { return null; // installed with --omit=optional, or this platform has no bun build } } /** Ranked bun binaries inside the bun package dir: the materialized bin, THEN the @oven fallbacks. */ function bundledCandidates(pkgDir) { const out = []; // 1) The postinstall-materialized bin — bun MOVED the right platform binary here, so it is the one // location guaranteed correct for this libc/arch. Try it BEFORE any @oven scan. for (const bin of ["bun.exe", "bun"]) { const p = path.join(pkgDir, "bin", bin); if (exists(p) && !out.includes(p)) out.push(p); } // 2) The @oven platform packages — real even when install scripts were skipped (--ignore-scripts, // pnpm), but only the ones whose platform+arch+libc match this host. for (const root of [path.join(pkgDir, "..", "@oven"), path.join(pkgDir, "node_modules", "@oven")]) { for (const name of ovenPackageNames()) { for (const bin of ["bun", "bun.exe"]) { const p = path.join(root, name, "bin", bin); if (exists(p) && !out.includes(p)) out.push(p); } } } return out; } function realpathOrSelf(p) { try { return fs.realpathSync(p); } catch { return p; } } /** Every `bun` on PATH, in order. Names only — {@link probeBun} is the gate. */ function bunFromPath() { const names = process.platform === "win32" ? ["bun.exe", "bun.cmd", "bun"] : ["bun"]; const out = []; for (const dir of (process.env.PATH ?? "").split(path.delimiter)) { if (dir === "") continue; for (const name of names) { const p = path.join(dir, name); if (exists(p) && !out.includes(p)) out.push(p); } } return out; } /** * The SHARED runtime-cache home — `$KESTREL_HOME/.kestrel/runtime` (else `~/.kestrel/runtime`), * mirroring the `$KESTREL_HOME/.kestrel` idiom `src/cli/credentials.ts` established for the credential * store. This is the ONE per-USER (not per-worktree, not per-install) location a bun is materialized * into and reused from — the machine-health fix for a shared dev box where every worktree's * `node_modules` would otherwise carry its own ~60MB bundled bun (bead kestrel-2e2e). An empty * `KESTREL_HOME` is treated as unset (a bare relative `.kestrel` would be worse than useless). */ function sharedRuntimeDir() { const home = process.env.KESTREL_HOME && process.env.KESTREL_HOME !== "" ? process.env.KESTREL_HOME : os.homedir(); return path.join(home, ".kestrel", "runtime"); } /** The canonical shared-cache bun path this platform materializes/reads (`…/runtime/bin/bun[.exe]`). */ function sharedRuntimeBunPath() { return path.join(sharedRuntimeDir(), "bin", process.platform === "win32" ? "bun.exe" : "bun"); } /** The shared-cache bun binaries that actually exist right now (empty until first promotion). */ function sharedRuntimeCandidates() { const out = []; const dir = path.join(sharedRuntimeDir(), "bin"); for (const bin of ["bun.exe", "bun"]) { const p = path.join(dir, bin); if (exists(p) && !out.includes(p)) out.push(p); } return out; } /** * A per-PROCESS counter that, with {@link process.pid}, names the temp file each promotion writes before * its atomic rename. Uniqueness is derived from pid + this incrementing counter — NEVER `Math.random`/ * `Date.now` on the launcher path (they are banned on the runtime path, and pid+counter is already unique * within and across the concurrent agents that share this box). */ let promoteCounter = 0; /** * Materialize a just-discovered bun into the shared cache, ONCE, so every OTHER worktree finds it here * first and never re-probes (or re-installs) its own copy. It COPIES THE ACTUAL BYTES so the cache is * INDEPENDENT of any `node_modules`: a HARDLINK (same-filesystem — instant, shares the inode, survives * deletion of the source path as long as this cache link exists), falling back to a real COPY on EXDEV * (cross-device). NEVER a symlink — a symlink into some install's `node_modules` DANGLES the moment that * tree is deleted (worktree cleanup — the exact disk-churn this cache targets) or absent * (`npm install --omit=optional` — the exact survival case), making the shared cache useless in precisely * the two scenarios it exists for (bead kestrel-2e2e). * * ATOMICITY (50-100 concurrent agents may promote at once): the bytes land in a UNIQUE temp name in the * SAME directory as `dest`, then a single {@link fs.renameSync} publishes it — rename is atomic on POSIX, * so a concurrent reader NEVER observes a half-materialized binary. Benign races are swallowed: if `dest` * already exists a peer won — clean up the temp and return; an `EEXIST` on the rename (Windows, where * rename does not replace) is the same peer-won case. * * Strictly BEST-EFFORT and fail-quiet: a read-only home, a sandbox, a cross-device copy that runs out of * disk, or a race with a peer must NEVER turn caching a runtime into a failed session (fail-closed: * caching is an optimization, never a gate). Any temp file is cleaned up on every failure path, and the * run continues on the bun it already resolved. */ function promoteToSharedRuntime(bun) { let tmp = null; try { const dest = sharedRuntimeBunPath(); if (exists(dest)) return; // already materialized (this very bun, or a peer's) — reuse, don't churn const src = realpathOrSelf(bun); const destDir = path.dirname(dest); fs.mkdirSync(destDir, { recursive: true, mode: 0o700 }); // A UNIQUE temp in the SAME dir as dest, so publishing is a same-directory (atomic) rename. promoteCounter += 1; tmp = dest + "." + process.pid + "." + promoteCounter + ".tmp"; // Copy the BYTES: a hardlink when same-fs, a real copy on EXDEV. Either way the cache no longer // depends on the source path surviving — that is the whole fix. try { fs.linkSync(src, tmp); } catch (e) { if (e && e.code === "EXDEV") fs.copyFileSync(src, tmp); else throw e; } fs.chmodSync(tmp, 0o755); // executable — before the rename, so the published entry is ready to run try { fs.renameSync(tmp, dest); // atomic publish — a reader sees the whole binary or nothing tmp = null; // renamed into place; nothing left to clean up } catch (e) { if (e && e.code === "EEXIST") return; // a peer worktree won the race (Windows) — cache is populated throw e; // any other rename failure falls to the fail-quiet catch; the temp is cleaned in finally } } catch { /* best-effort ONLY — never let caching a runtime break the run */ } finally { if (tmp !== null) { try { fs.unlinkSync(tmp); // clean up the temp on every failure/peer-won path } catch { /* nothing to clean — already gone */ } } } } /** * The ranked candidate list for the IMPLICIT tiers: PATH, then the SHARED `~/.kestrel/runtime` cache, * then the per-worktree bundled package. The shared cache outranks the bundled copy so a machine that * has materialized bun once reuses it instead of every worktree re-probing its own. `KESTREL_NO_BUNDLED_BUN=1` * drops the bundled package by REALPATH — `node_modules/.bin/bun` is a symlink INTO that package and * npx puts it first on PATH, so a name-only exclusion would smuggle the bundled bun straight back in * and make the escape hatch a lie. The realpath filter still drops any PATH entry that resolves into the * bundled package; it does NOT reach the shared cache, because the cache now holds independent bytes (a * hardlink/copy), not a symlink back into the package — a materialized cache entry is its own artifact. */ function bunCandidates() { const pkgDir = bunPackageDir(); const excludeBundled = process.env.KESTREL_NO_BUNDLED_BUN === "1" && pkgDir !== null; const bundledRoot = pkgDir === null ? null : realpathOrSelf(pkgDir); const ovenRoot = pkgDir === null ? null : realpathOrSelf(path.join(pkgDir, "..", "@oven")); const fromBundle = pkgDir === null || excludeBundled ? [] : bundledCandidates(pkgDir); const out = []; for (const p of [...bunFromPath(), ...sharedRuntimeCandidates(), ...fromBundle]) { if (excludeBundled) { const real = realpathOrSelf(p); if (bundledRoot !== null && real.startsWith(bundledRoot + path.sep)) continue; if (ovenRoot !== null && real.startsWith(ovenRoot + path.sep)) continue; } if (!out.includes(p)) out.push(p); } return out; } /** Load the dist entry under the CURRENT runtime; it runs `main(process.argv.slice(2))` and exits. */ function runInProcess() { import(pathToFileURL(CLI).href).catch((err) => { const msg = err instanceof Error ? err.message : String(err); process.stderr.write(`error: failed to load ${CLI}: ${msg}\n`); process.exit(1); }); } /** Re-exec the CLI under `bun`, propagating the child's exit code. Returns only if it did NOT launch. */ function reExec(bun, argv) { const res = spawnSync(bun, [CLI, ...argv], { stdio: "inherit" }); if (res.error !== undefined && res.error !== null) return; if (res.signal !== null && res.signal !== undefined) { const signo = os.constants.signals[res.signal]; process.exit(128 + (typeof signo === "number" ? signo : 1)); // SIGINT→130, SIGTERM→143, SIGSEGV→139 } process.exit(res.status ?? 1); } function launch(argv) { if (!isHeavyInvocation(argv) || process.versions.bun !== undefined) { runInProcess(); return; } // Tier 1 — the EXPLICIT pin. An operator who names a bun gets THAT bun or a loud refusal; quietly // falling back to another runtime would void the pinned-Bun guarantee the re-exec exists to keep. const pin = process.env.KESTREL_BUN; if (pin !== undefined && pin !== "") { if (probeBun(pin)) reExec(pin, argv); failRuntime( resolveMode(argv, process.env, process.stdout.isTTY === true), `KESTREL_BUN=${pin} is not a working Bun >= ${BUN_FLOOR.join(".")}`, "point KESTREL_BUN at a Bun binary, or unset it to use PATH / the bundled runtime", ); return; // unreachable (failRuntime exits) — belt and braces } // Tiers 2-4 — PATH, then the shared `~/.kestrel/runtime` cache, then the bundled dependency. A // candidate that fails the probe or the exec is skipped for the next one. The FIRST working bun is // promoted into the shared cache (best-effort) so every OTHER worktree resolves it there first. for (const bun of bunCandidates()) { if (!probeBun(bun)) continue; promoteToSharedRuntime(bun); // materialize once, reuse across worktrees (fail-quiet) reExec(bun, argv); // exits, unless the exec itself failed } // Nothing anywhere → fall through to the loud exit-4 RUNTIME_UNAVAILABLE refusal. runInProcess(); } if (require.main === module) launch(process.argv.slice(2)); module.exports = { BUN_FLOOR, LIGHT_VERBS, HEAVY_VERBS, isHeavyInvocation, resolveMode, probeBun, ovenPackageNames, bundledCandidates, bunCandidates, sharedRuntimeDir, sharedRuntimeBunPath, sharedRuntimeCandidates, promoteToSharedRuntime, };