@wenathlan/saddle 2.0.150 → 2.0.152
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/Dockerfile +43 -23
- package/README.md +4 -4
- package/biome.jsonc +134 -0
- package/dist/App.d.ts +3 -0
- package/dist/App.js +50 -0
- package/dist/Sol/Sol.d.ts +3 -0
- package/dist/Sol/Sol.js +46 -0
- package/dist/Sol/agentbrowser/agentbrowser.d.ts +9 -0
- package/dist/Sol/agentbrowser/agentbrowser.js +121 -0
- package/dist/Sol/architecture/architecture.d.ts +9 -0
- package/dist/Sol/architecture/architecture.js +93 -0
- package/dist/Sol/compute/compute.d.ts +9 -0
- package/dist/Sol/compute/compute.js +22 -0
- package/dist/Sol/console/console.d.ts +15 -0
- package/dist/Sol/console/console.js +292 -0
- package/dist/Sol/dashboard/dashboard.d.ts +10 -0
- package/dist/Sol/dashboard/dashboard.js +181 -0
- package/dist/Sol/docs/docs.d.ts +9 -0
- package/dist/Sol/docs/docs.js +28 -0
- package/dist/Sol/home/home.d.ts +35 -0
- package/dist/Sol/home/home.js +239 -0
- package/dist/Sol/integrations/integrations.d.ts +9 -0
- package/dist/Sol/integrations/integrations.js +83 -0
- package/dist/Sol/intro/intro.d.ts +7 -0
- package/dist/Sol/intro/intro.js +102 -0
- package/dist/Sol/login/login.d.ts +16 -0
- package/dist/Sol/login/login.js +49 -0
- package/dist/Sol/notfound/notfound.d.ts +9 -0
- package/dist/Sol/notfound/notfound.js +15 -0
- package/dist/Sol/onboarding/onboarding.d.ts +25 -0
- package/dist/Sol/onboarding/onboarding.js +60 -0
- package/dist/Sol/playground/playground.d.ts +9 -0
- package/dist/Sol/playground/playground.js +127 -0
- package/dist/Sol/register/register.d.ts +16 -0
- package/dist/Sol/register/register.js +193 -0
- package/dist/Sol/shell/Shell.d.ts +124 -0
- package/dist/Sol/shell/Shell.js +335 -0
- package/dist/Sol/wingets/ComputeBootladder.d.ts +6 -0
- package/dist/Sol/wingets/ComputeBootladder.js +71 -0
- package/dist/Sol/wingets/ComputeModalitycatalog.d.ts +7 -0
- package/dist/Sol/wingets/ComputeModalitycatalog.js +71 -0
- package/dist/Sol/wingets/ConsoleApibadge.d.ts +7 -0
- package/dist/Sol/wingets/ConsoleApibadge.js +5 -0
- package/dist/Sol/wingets/ConsoleEdgelaw.d.ts +6 -0
- package/dist/Sol/wingets/ConsoleEdgelaw.js +36 -0
- package/dist/Sol/wingets/ConsoleEventstimeline.d.ts +19 -0
- package/dist/Sol/wingets/ConsoleEventstimeline.js +12 -0
- package/dist/Sol/wingets/ConsoleSnapshotcontrols.d.ts +8 -0
- package/dist/Sol/wingets/ConsoleSnapshotcontrols.js +8 -0
- package/dist/Sol/wingets/ConsoleSpecpanel.d.ts +21 -0
- package/dist/Sol/wingets/ConsoleSpecpanel.js +75 -0
- package/dist/Sol/wingets/ConsoleTerminal.d.ts +22 -0
- package/dist/Sol/wingets/ConsoleTerminal.js +77 -0
- package/dist/Sol/wingets/DashboardAccountpanel.d.ts +18 -0
- package/dist/Sol/wingets/DashboardAccountpanel.js +65 -0
- package/dist/Sol/wingets/DashboardAdminpanel.d.ts +6 -0
- package/dist/Sol/wingets/DashboardAdminpanel.js +90 -0
- package/dist/Sol/wingets/DashboardAuditlog.d.ts +8 -0
- package/dist/Sol/wingets/DashboardAuditlog.js +50 -0
- package/dist/Sol/wingets/DashboardCreatesandboxform.d.ts +10 -0
- package/dist/Sol/wingets/DashboardCreatesandboxform.js +71 -0
- package/dist/Sol/wingets/DashboardEventstimeline.d.ts +8 -0
- package/dist/Sol/wingets/DashboardEventstimeline.js +83 -0
- package/dist/Sol/wingets/DashboardGlobalsandboxestable.d.ts +8 -0
- package/dist/Sol/wingets/DashboardGlobalsandboxestable.js +57 -0
- package/dist/Sol/wingets/DashboardMeshnodestable.d.ts +8 -0
- package/dist/Sol/wingets/DashboardMeshnodestable.js +103 -0
- package/dist/Sol/wingets/DashboardSandboxlist.d.ts +10 -0
- package/dist/Sol/wingets/DashboardSandboxlist.js +163 -0
- package/dist/Sol/wingets/DashboardUserstable.d.ts +8 -0
- package/dist/Sol/wingets/DashboardUserstable.js +78 -0
- package/dist/Sol/wingets/HomeEntrygate.d.ts +23 -0
- package/dist/Sol/wingets/HomeEntrygate.js +73 -0
- package/dist/Sol/wingets/HomeMetricstrip.d.ts +9 -0
- package/dist/Sol/wingets/HomeMetricstrip.js +4 -0
- package/dist/Sol/wingets/HomeRuntimediagram.d.ts +1 -0
- package/dist/Sol/wingets/HomeRuntimediagram.js +22 -0
- package/dist/Sol/wingets/LoginForm.d.ts +19 -0
- package/dist/Sol/wingets/LoginForm.js +141 -0
- package/dist/Sol/wingets/RegisterPasswordconfirm.d.ts +13 -0
- package/dist/Sol/wingets/RegisterPasswordconfirm.js +16 -0
- package/dist/Sol/wingets/RegisterPasswordmeter.d.ts +19 -0
- package/dist/Sol/wingets/RegisterPasswordmeter.js +65 -0
- package/dist/Sol/wingets/RegisterUsernamerules.d.ts +11 -0
- package/dist/Sol/wingets/RegisterUsernamerules.js +16 -0
- package/dist/Sol/wingets/index.d.ts +39 -0
- package/dist/Sol/wingets/index.js +39 -0
- package/dist/alternatives.d.ts +508 -0
- package/dist/alternatives.js +950 -0
- package/dist/api.d.ts +154 -0
- package/dist/api.js +174 -0
- package/dist/auth.d.ts +268 -0
- package/dist/auth.js +476 -0
- package/dist/automation.d.ts +425 -0
- package/dist/automation.js +566 -0
- package/dist/browser.d.ts +561 -0
- package/dist/browser.js +1058 -0
- package/dist/capacitor.config.d.ts +45 -0
- package/dist/capacitor.config.js +48 -0
- package/dist/catalog.d.ts +43 -0
- package/dist/catalog.js +44 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +168 -0
- package/dist/communication.d.ts +181 -0
- package/dist/communication.js +376 -0
- package/dist/compute.d.ts +1160 -0
- package/dist/compute.js +2272 -0
- package/dist/dashboardlib.d.ts +58 -0
- package/dist/dashboardlib.js +161 -0
- package/dist/db.d.ts +569 -0
- package/dist/db.js +1080 -0
- package/dist/distribution.d.ts +528 -0
- package/dist/distribution.js +810 -0
- package/dist/drizzle.config.d.ts +20 -0
- package/dist/drizzle.config.js +18 -0
- package/dist/edgelandes.d.ts +70 -0
- package/dist/edgelandes.host.d.ts +26 -0
- package/dist/edgelandes.host.js +1089 -0
- package/dist/edgelandes.inproc.d.ts +27 -0
- package/dist/edgelandes.inproc.js +952 -0
- package/dist/edgelandes.js +1133 -0
- package/dist/edgelandes.ninety.d.ts +31 -0
- package/dist/edgelandes.ninety.js +174 -0
- package/dist/edgelandes.virtual.d.ts +25 -0
- package/dist/edgelandes.virtual.js +629 -0
- package/dist/edgeplane.d.ts +114 -0
- package/dist/edgeplane.js +305 -0
- package/dist/examplelocaljob.d.ts +1 -0
- package/{docs/example-localjob.ts → dist/examplelocaljob.js} +0 -1
- package/dist/execution.d.ts +529 -0
- package/dist/execution.js +760 -0
- package/dist/family.d.ts +16 -0
- package/dist/family.js +42 -0
- package/dist/familyurl.d.ts +21 -0
- package/dist/familyurl.js +24 -0
- package/dist/format.d.ts +18 -0
- package/dist/format.js +59 -0
- package/dist/foundation.d.ts +172 -0
- package/dist/foundation.js +312 -0
- package/dist/index.d.ts +735 -0
- package/dist/index.js +1020 -0
- package/dist/integration.d.ts +235 -0
- package/dist/integration.js +228 -0
- package/dist/intelligence.d.ts +93 -0
- package/dist/intelligence.js +144 -0
- package/dist/isolation.d.ts +128 -0
- package/dist/isolation.js +95 -0
- package/dist/labels.d.ts +665 -0
- package/dist/labels.js +751 -0
- package/dist/localauth.d.ts +127 -0
- package/dist/localauth.js +413 -0
- package/dist/media.d.ts +343 -0
- package/dist/media.js +985 -0
- package/dist/mesh.d.ts +177 -0
- package/dist/mesh.js +468 -0
- package/dist/modalityregistry.d.ts +109 -0
- package/dist/modalityregistry.js +1386 -0
- package/dist/modes.d.ts +358 -0
- package/dist/modes.js +133 -0
- package/dist/onboardingstate.d.ts +79 -0
- package/dist/onboardingstate.js +282 -0
- package/dist/orchestrator.d.ts +1338 -0
- package/dist/orchestrator.js +4310 -0
- package/dist/packager/targetcli.js +4 -0
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +14 -0
- package/dist/performance.d.ts +415 -0
- package/dist/performance.js +826 -0
- package/dist/quantum.d.ts +809 -0
- package/dist/quantum.js +2072 -0
- package/dist/render.d.ts +380 -0
- package/dist/render.js +1072 -0
- package/dist/runall.d.ts +2 -0
- package/dist/runall.js +39 -0
- package/dist/runtimeprobe.d.ts +4 -0
- package/dist/runtimeprobe.js +8 -0
- package/dist/sandbox.d.ts +355 -0
- package/dist/sandbox.js +1721 -0
- package/dist/sandboxenvprofile.d.ts +29 -0
- package/dist/sandboxenvprofile.js +89 -0
- package/dist/sandboxprofile.d.ts +137 -0
- package/dist/sandboxprofile.js +241 -0
- package/dist/sandboxqueue.d.ts +108 -0
- package/dist/sandboxqueue.js +201 -0
- package/dist/sandboxquota.d.ts +121 -0
- package/dist/sandboxquota.js +236 -0
- package/dist/scheduler.d.ts +595 -0
- package/dist/scheduler.js +1258 -0
- package/dist/scriptlanes.d.ts +182 -0
- package/dist/scriptlanes.js +660 -0
- package/dist/scripts.buildengine.d.ts +1 -0
- package/dist/scripts.buildengine.js +39 -0
- package/dist/scripts.securitygate.d.ts +1 -0
- package/dist/scripts.securitygate.js +68 -0
- package/dist/scripts.validateflatnative.d.ts +1 -0
- package/dist/scripts.validateflatnative.js +72 -0
- package/dist/security.d.ts +500 -0
- package/dist/security.js +1169 -0
- package/dist/seedcatalog.d.ts +13 -0
- package/dist/seedcatalog.js +46 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +53 -0
- package/dist/speccatalog.d.ts +31 -0
- package/dist/speccatalog.js +163 -0
- package/dist/tiers.d.ts +1001 -0
- package/dist/tiers.js +2169 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +5 -0
- package/dist/vaulthandoff.d.ts +141 -0
- package/dist/vaulthandoff.js +444 -0
- package/dist/virtual.d.ts +640 -0
- package/dist/virtual.js +1284 -0
- package/dist/virtualcpu.d.ts +315 -0
- package/dist/virtualcpu.js +1180 -0
- package/dist/virtualgpu.d.ts +344 -0
- package/dist/virtualgpu.js +1000 -0
- package/dist/virtualization.d.ts +606 -0
- package/dist/virtualization.js +1186 -0
- package/dist/virtualmemory.d.ts +817 -0
- package/dist/virtualmemory.js +1795 -0
- package/dist/vite.config.d.ts +3 -0
- package/dist/vite.config.js +43 -0
- package/dist/vitest.config.d.ts +2 -0
- package/dist/vitest.config.js +25 -0
- package/dist/webserver.d.ts +166 -0
- package/dist/webserver.js +2226 -0
- package/dist/workloadlaw.d.ts +133 -0
- package/dist/workloadlaw.js +193 -0
- package/docker.config +13 -6
- package/docs/artifactavailability.md +2 -2
- package/docs/{containerplatforms-2.1.0.md → containerplatforms.2.1.0.md} +1 -1
- package/docs/{documentmap-1.8.12.md → documentmap.1.8.12.md} +1 -1
- package/docs/ninety.modalities.md +108 -0
- package/docs/planning.ninety.md +391 -0
- package/docs/{releasenotes-1.8.13.md → releasenotes.1.8.13.md} +2 -2
- package/docs/{releasenotes-1.8.14.md → releasenotes.1.8.14.md} +1 -1
- package/docs/{repo-synthesis-1.8.16.md → repo.synthesis.1.8.16.md} +1 -1
- package/docs/{todo-1.8.15.md → todo.1.8.15.md} +1 -1
- package/docs/{todo-1.8.16.md → todo.1.8.16.md} +5 -5
- package/docs/todo.md +2 -2
- package/docs/workloadlaw.md +104 -0
- package/index.ts +4 -3
- package/manifest.json +1 -1
- package/modalityregistry.ts +1493 -0
- package/orchestrator.ts +1 -1
- package/package.json +76 -37
- package/docs/CONVERSA.txt +0 -1523
- package/docs/architecture-1.8.19.png +0 -0
- package/docs/assets/architecture.svg +0 -45
- package/docs/assets/saddle-browser-trace.webp +0 -0
- package/docs/assets/saddle-hero-bridge.webp +0 -0
- package/docs/assets/saddle-mark.webp +0 -0
- package/docs/assets/saddle-runtime-map.webp +0 -0
- package/docs/assets/saddlemark.svg +0 -13
- package/docs/example-publicapi.ts +0 -7
- package/docs/saddle.archive.1.8.17.tar.gz.gpg +0 -0
- /package/docs/{architecture-1.8.19.md → architecture.1.8.19.md} +0 -0
- /package/docs/{architecture-1.8.19.mmd → architecture.1.8.19.mmd} +0 -0
- /package/docs/{artifactresearch-1.8.9.md → artifactresearch.1.8.9.md} +0 -0
- /package/docs/{batches-1816-1820.md → batches.1816.to.1820.md} +0 -0
- /package/docs/{brancharchive-2026-08-18.md → brancharchive.2026.08.18.md} +0 -0
- /package/docs/{containerplatforms-1.8.17.md → containerplatforms.1.8.17.md} +0 -0
- /package/docs/{e2ugh-engine.md → e2ugh.engine.md} +0 -0
- /package/docs/{flatbuildresearch-1.8.11.md → flatbuildresearch.1.8.11.md} +0 -0
- /package/docs/{licenseaudit-1.8.12.md → licenseaudit.1.8.12.md} +0 -0
- /package/docs/{mobileconversionresearch-1.8.10.md → mobileconversionresearch.1.8.10.md} +0 -0
- /package/docs/{native-wrappers.md → native.wrappers.md} +0 -0
- /package/docs/{nativeidentityresearch-1.8.11.md → nativeidentityresearch.1.8.11.md} +0 -0
- /package/docs/{releasenotes-1.8.12.md → releasenotes.1.8.12.md} +0 -0
- /package/docs/{releasenotes-1.8.15.md → releasenotes.1.8.15.md} +0 -0
- /package/docs/{releasenotes-1.8.16.md → releasenotes.1.8.16.md} +0 -0
- /package/docs/{releasenotes-1.8.17.md → releasenotes.1.8.17.md} +0 -0
- /package/docs/{releasenotes-1.8.18.md → releasenotes.1.8.18.md} +0 -0
- /package/docs/{releasenotes-1.8.19.md → releasenotes.1.8.19.md} +0 -0
- /package/docs/{reorganization-1.8.8.md → reorganization.1.8.8.md} +0 -0
- /package/docs/{reorganization-1.8.9.md → reorganization.1.8.9.md} +0 -0
- /package/docs/{repo-analysis-1.8.21.json → repo.analysis.1.8.21.json} +0 -0
- /package/docs/{repo-candidates-1.8.21.jsonl → repo.candidates.1.8.21.jsonl} +0 -0
- /package/docs/{repo-research-1.8.16.md → repo.research.1.8.16.md} +0 -0
- /package/docs/{repo-research-1.8.21.md → repo.research.1.8.21.md} +0 -0
- /package/docs/{repo-selected-1.8.21.json → repo.selected.1.8.21.json} +0 -0
- /package/docs/{repo-synthesis-1.8.21.md → repo.synthesis.1.8.21.md} +0 -0
- /package/docs/{research-1.8.18-isolation.md → research.1.8.18.isolation.md} +0 -0
- /package/docs/{research-1.8.19-virtual-browser.md → research.1.8.19.virtual.browser.md} +0 -0
- /package/docs/{scanningresearch-1.8.11.md → scanningresearch.1.8.11.md} +0 -0
- /package/docs/{securityaudit-1.8.7.md → securityaudit.1.8.7.md} +0 -0
- /package/docs/{securityreferences-1.8.11.md → securityreferences.1.8.11.md} +0 -0
- /package/docs/{securityresearch-1.8.11.md → securityresearch.1.8.11.md} +0 -0
- /package/docs/{signingresearch-1.8.12.md → signingresearch.1.8.12.md} +0 -0
- /package/docs/{tauri-glib-risk-1.8.11.md → tauri.glib.risk.1.8.11.md} +0 -0
- /package/docs/{todo-1.8.18.md → todo.1.8.18.md} +0 -0
- /package/docs/{todo-1.8.19.md → todo.1.8.19.md} +0 -0
- /package/docs/{toolchainresearch-1.8.9.md → toolchainresearch.1.8.9.md} +0 -0
- /package/docs/{web-duplicatecleanup.md → web.duplicatecleanup.md} +0 -0
- /package/docs/{web-ideas.md → web.ideas.md} +0 -0
- /package/docs/{workflowimprovements-2026-08-19.md → workflowimprovements.2026.08.19.md} +0 -0
|
@@ -0,0 +1,1089 @@
|
|
|
1
|
+
// # edgelandes.host.ts — the host binary lane executors of the ninety
|
|
2
|
+
// modalities wave, group a: twenty-five planned sandbox modalities
|
|
3
|
+
// wired as honest host-binary execution lanes — the sandbox jails, the
|
|
4
|
+
// container extensions, the runtimes, the screens, the mobile seat, the
|
|
5
|
+
// builds, the ci runners, the network tunnels and the storage daemons
|
|
6
|
+
// the engine host may carry. Every lane follows the spawn pattern of
|
|
7
|
+
// edgelandes.ts: the runtime is probed first (an absent runtime answers
|
|
8
|
+
// the coded HOST_RUNTIME_ABSENT naming the runtime — a lane never fakes
|
|
9
|
+
// a run), the code field lands in a private temp directory, the inputs
|
|
10
|
+
// ride inputs.json through the SADDLE_EDGE_INPUTS env, the runtime
|
|
11
|
+
// spawns under the caller's hard deadline with both streams capped at
|
|
12
|
+
// 256 kb, and the temp directory dies in the finally no matter how the
|
|
13
|
+
// run answered (the owner's law: the lanes act for the system
|
|
14
|
+
// server-side, the visitor browser, gpu, cpu and storage are never
|
|
15
|
+
// touched, and no lane leaves bytes behind on the host). The shared
|
|
16
|
+
// helpers are local on purpose: edgelandes.ts exports its contract
|
|
17
|
+
// pieces only, so this file carries its own spawn, deadline and temp
|
|
18
|
+
// surface — the same law with its own seat. All twenty-five lanes are
|
|
19
|
+
// kind live: the executor exists and drives the runtime when present,
|
|
20
|
+
// exactly like language-ruby rides live though a host may not carry it.
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { existsSync } from "node:fs";
|
|
23
|
+
import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import { connect } from "node:net";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { basename, dirname, join } from "node:path";
|
|
28
|
+
import process from "node:process";
|
|
29
|
+
import { edgelaneerror, probehostbinary } from "./edgelandes.ts";
|
|
30
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
31
|
+
/* Section 2: the shared host execution surface — spawn, deadline, temp. */
|
|
32
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
33
|
+
/** the per stream capture ceiling: 256 kb of stdout or stderr, the honest
|
|
34
|
+
* bound every host lane reports under. */
|
|
35
|
+
const OUTPUTCAPBYTES = 262144;
|
|
36
|
+
/** spawns one host runtime under a hard deadline, captures both streams
|
|
37
|
+
* (capped), optionally feeds a stdin payload, kills on timeout and
|
|
38
|
+
* answers the coded failures honestly: a missing binary answers
|
|
39
|
+
* HOST_RUNTIME_ABSENT, a nonzero exit answers RUN_EXITED_NONZERO
|
|
40
|
+
* carrying the stderr, a blown deadline answers RUN_DEADLINE_EXCEEDED. */
|
|
41
|
+
function runhostprocess(command, args, options) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
// detached makes the child a process group leader: the deadline kill
|
|
44
|
+
// takes the whole group down (podman spawns conmon and the container
|
|
45
|
+
// shims that would otherwise outlive the cli and hold the pipes open —
|
|
46
|
+
// the close event would never fire and the lane would hang past its
|
|
47
|
+
// own budget)
|
|
48
|
+
const child = spawn(command, args, {
|
|
49
|
+
cwd: options.cwd,
|
|
50
|
+
env: options.env === undefined ? process.env : { ...process.env, ...options.env },
|
|
51
|
+
stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
52
|
+
detached: true,
|
|
53
|
+
});
|
|
54
|
+
if (options.stdin !== undefined && child.stdin !== null) {
|
|
55
|
+
// a payload through /dev/stdin: an early exit must not crash the
|
|
56
|
+
// engine over a broken pipe, the run answers the exit it got
|
|
57
|
+
child.stdin.on("error", () => {
|
|
58
|
+
// the stream error is the child's own honest outcome
|
|
59
|
+
});
|
|
60
|
+
child.stdin.end(options.stdin);
|
|
61
|
+
}
|
|
62
|
+
const outchunks = [];
|
|
63
|
+
const errchunks = [];
|
|
64
|
+
let timedout = false;
|
|
65
|
+
const makecollector = (chunks) => {
|
|
66
|
+
let used = 0;
|
|
67
|
+
return (chunk) => {
|
|
68
|
+
if (used < OUTPUTCAPBYTES) {
|
|
69
|
+
chunks.push(chunk);
|
|
70
|
+
used += chunk.byteLength;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
child.stdout?.on("data", makecollector(outchunks));
|
|
75
|
+
child.stderr?.on("data", makecollector(errchunks));
|
|
76
|
+
const timer = setTimeout(() => {
|
|
77
|
+
timedout = true;
|
|
78
|
+
try {
|
|
79
|
+
process.kill(-child.pid, "SIGKILL");
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
child.kill("SIGKILL");
|
|
83
|
+
}
|
|
84
|
+
}, options.deadline);
|
|
85
|
+
child.on("error", (error) => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
reject(edgelaneerror("HOST_RUNTIME_ABSENT", `the host runtime ${command} is absent: ${errormessage(error)}`));
|
|
88
|
+
});
|
|
89
|
+
child.on("close", (code, signal) => {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
const stdout = Buffer.concat(outchunks).toString("utf8");
|
|
92
|
+
const stderr = Buffer.concat(errchunks).toString("utf8");
|
|
93
|
+
if (timedout) {
|
|
94
|
+
reject(edgelaneerror("RUN_DEADLINE_EXCEEDED", `the lane exceeded its ${options.deadline} ms deadline and was killed`, {
|
|
95
|
+
stderr,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
else if (code !== 0) {
|
|
99
|
+
reject(edgelaneerror("RUN_EXITED_NONZERO", `the lane process ${command} exited with code ${code ?? signal}`, {
|
|
100
|
+
stderr,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
// the hard cap law: the collector admits chunks under the ceiling
|
|
105
|
+
// but one large chunk may ride past the line — the joined buffer
|
|
106
|
+
// truncates to the ceiling so the capture never exceeds it
|
|
107
|
+
resolve({
|
|
108
|
+
stdout: stdout.length > OUTPUTCAPBYTES ? stdout.slice(0, OUTPUTCAPBYTES) : stdout,
|
|
109
|
+
stderr: stderr.length > OUTPUTCAPBYTES ? stderr.slice(0, OUTPUTCAPBYTES) : stderr,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** runs fn inside one private temp directory (mkdtemp under the os
|
|
116
|
+
* tmpdir, the saddleedgehost prefix) and removes it no matter how fn
|
|
117
|
+
* answers — the host lanes never leave workspace bytes behind. */
|
|
118
|
+
async function withtempdirhost(fn) {
|
|
119
|
+
const dir = await mkdtemp(join(tmpdir(), "saddleedgehost"));
|
|
120
|
+
try {
|
|
121
|
+
return await fn(dir);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await rm(dir, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** writes the run inputs as inputs.json beside the program and answers
|
|
128
|
+
* the env overlay pointing at it (SADDLE_EDGE_INPUTS); host lane
|
|
129
|
+
* programs read their inputs from that file when the caller sent any. */
|
|
130
|
+
async function writeinputsfile(dir, inputs) {
|
|
131
|
+
if (inputs === undefined)
|
|
132
|
+
return undefined;
|
|
133
|
+
const path = join(dir, "inputs.json");
|
|
134
|
+
await writeFile(path, JSON.stringify(inputs ?? null), "utf8");
|
|
135
|
+
return { SADDLE_EDGE_INPUTS: path };
|
|
136
|
+
}
|
|
137
|
+
/** writes the code as script.sh inside the private dir and runs one
|
|
138
|
+
* command with the inputs surface and the extra env merged in: the
|
|
139
|
+
* shared recipe of the bash-shaped host lanes. */
|
|
140
|
+
async function runscript(command, args, dir, code, inputs, deadline, extraenv = {}) {
|
|
141
|
+
await writeFile(join(dir, "script.sh"), code, "utf8");
|
|
142
|
+
const inputenv = (await writeinputsfile(dir, inputs)) ?? {};
|
|
143
|
+
const run = await runhostprocess(command, args, { cwd: dir, deadline, env: { ...inputenv, ...extraenv } });
|
|
144
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
145
|
+
}
|
|
146
|
+
/** the safe message of an unknown thrown value (no stack ever rides a lane answer). */
|
|
147
|
+
function errormessage(error) {
|
|
148
|
+
return error instanceof Error ? error.message : String(error ?? "unknown lane failure");
|
|
149
|
+
}
|
|
150
|
+
/** the coded absence of one host runtime: the lane never fakes a run
|
|
151
|
+
* over a binary the host does not carry — the message names the runtime
|
|
152
|
+
* and the lane, the code rides the machine surface. */
|
|
153
|
+
function absentruntime(laneid, runtime, note = "") {
|
|
154
|
+
throw edgelaneerror("HOST_RUNTIME_ABSENT", `the host runtime ${runtime} is absent: the lane ${laneid} requires it on PATH${note.length > 0 ? ` ${note}` : ""}`);
|
|
155
|
+
}
|
|
156
|
+
/** whether the engine process runs as root: the chroot and the oci
|
|
157
|
+
* runtimes need the seat for their namespace work, and the lane answers
|
|
158
|
+
* the coded privilege refusal instead of a wrapped errno. */
|
|
159
|
+
function isroot() {
|
|
160
|
+
return typeof process.getuid === "function" && process.getuid() === 0;
|
|
161
|
+
}
|
|
162
|
+
/** the monotonic lane id counter: deterministic container ids without
|
|
163
|
+
* crypto — the ids only need uniqueness per engine process. */
|
|
164
|
+
let laneidseat = 0;
|
|
165
|
+
function nextlaneid(prefix) {
|
|
166
|
+
laneidseat += 1;
|
|
167
|
+
return `${prefix}-${process.pid}-${laneidseat}`;
|
|
168
|
+
}
|
|
169
|
+
/** waits for one loopback tcp port to accept a connection within the
|
|
170
|
+
* budget: the honest readiness probe of the tunnel lanes (a port that
|
|
171
|
+
* never opens answers the deadline refusal, never a fake tunnel). */
|
|
172
|
+
function waitforport(port, budgetms) {
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
const endsat = Date.now() + budgetms;
|
|
175
|
+
const attempt = () => {
|
|
176
|
+
if (Date.now() > endsat) {
|
|
177
|
+
resolve(false);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const socket = connect(port, "127.0.0.1");
|
|
181
|
+
const settle = (answer) => {
|
|
182
|
+
socket.removeAllListeners();
|
|
183
|
+
socket.destroy();
|
|
184
|
+
if (answer) {
|
|
185
|
+
resolve(true);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (Date.now() >= endsat) {
|
|
189
|
+
resolve(false);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
setTimeout(attempt, 100);
|
|
193
|
+
};
|
|
194
|
+
socket.once("connect", () => settle(true));
|
|
195
|
+
socket.once("error", () => settle(false));
|
|
196
|
+
};
|
|
197
|
+
attempt();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/** waits for one spawned child to exit within the budget (the daemon
|
|
201
|
+
* teardown of the tunnel lanes; a false answer hands the caller the
|
|
202
|
+
* kill decision). */
|
|
203
|
+
function waitforexit(child, budgetms) {
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
206
|
+
resolve(true);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
let settled = false;
|
|
210
|
+
const finish = (answer) => {
|
|
211
|
+
if (settled)
|
|
212
|
+
return;
|
|
213
|
+
settled = true;
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
resolve(answer);
|
|
216
|
+
};
|
|
217
|
+
const timer = setTimeout(() => finish(false), budgetms);
|
|
218
|
+
child.once("exit", () => finish(true));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/** probes the docker surface of the host: the binary on PATH or the
|
|
222
|
+
* daemon socket (the same honest seat the engine's docker lane rides). */
|
|
223
|
+
function probedockersurface() {
|
|
224
|
+
return probehostbinary("docker") || existsSync("/var/run/docker.sock");
|
|
225
|
+
}
|
|
226
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
227
|
+
/* Section 3: the sandbox jail lanes — bubblewrap, nsjail and chroot. */
|
|
228
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
229
|
+
/** sandbox-bubblewrap: the user-namespace jail lane. The code rides a
|
|
230
|
+
* temp script.sh executed inside bwrap with the host root bound
|
|
231
|
+
* read-only, every namespace unshared and the jail dying with the
|
|
232
|
+
* engine parent — the honest bubblewrap seat this host may carry. */
|
|
233
|
+
async function sandboxbubblewraplane(code, inputs, deadline) {
|
|
234
|
+
if (!probehostbinary("bwrap")) {
|
|
235
|
+
absentruntime("sandbox-bubblewrap", "bwrap");
|
|
236
|
+
}
|
|
237
|
+
return withtempdirhost((dir) => runscript("bwrap", ["--ro-bind", "/", "/", "--unshare-all", "--die-with-parent", "/bin/bash", "script.sh"], dir, code, inputs, deadline));
|
|
238
|
+
}
|
|
239
|
+
/** sandbox-nsjail: the process jail lane. The lane writes a minimal
|
|
240
|
+
* nsjail config (mode ONCE, the deadline as time_limit, the temp cwd,
|
|
241
|
+
* a read-only root bind and a writable tmpfs scratch) and runs the
|
|
242
|
+
* script through the jail — the config and the script live and die in
|
|
243
|
+
* the private temp dir. */
|
|
244
|
+
async function sandboxnsjaillane(code, inputs, deadline) {
|
|
245
|
+
if (!probehostbinary("nsjail")) {
|
|
246
|
+
absentruntime("sandbox-nsjail", "nsjail");
|
|
247
|
+
}
|
|
248
|
+
return withtempdirhost(async (dir) => {
|
|
249
|
+
const config = [
|
|
250
|
+
"# the saddle edge nsjail lane: one process under the jail, the",
|
|
251
|
+
"# deadline rides time_limit, the root bind keeps the system visible",
|
|
252
|
+
"# read-only and the tmpfs at /scratch is the writable seat.",
|
|
253
|
+
'name: "saddle-edge-nsjail"',
|
|
254
|
+
"mode: ONCE",
|
|
255
|
+
`time_limit: ${Math.max(1, Math.ceil(deadline / 1000))}`,
|
|
256
|
+
`cwd: "${dir}"`,
|
|
257
|
+
"mount {",
|
|
258
|
+
' src: "/"',
|
|
259
|
+
' dst: "/"',
|
|
260
|
+
" is_bind: true",
|
|
261
|
+
"}",
|
|
262
|
+
"mount {",
|
|
263
|
+
' dst: "/scratch"',
|
|
264
|
+
' fstype: "tmpfs"',
|
|
265
|
+
" rw: true",
|
|
266
|
+
"}",
|
|
267
|
+
"",
|
|
268
|
+
].join("\n");
|
|
269
|
+
await writeFile(join(dir, "config.nsjail"), config, "utf8");
|
|
270
|
+
return await runscript("nsjail", ["--config", "config.nsjail", "--", "/bin/bash", "script.sh"], dir, code, inputs, deadline);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
/** builds the minimal chroot tree: /bin/bash with its ldd dependency
|
|
274
|
+
* set (best effort — fakechroot maps the real filesystem through its
|
|
275
|
+
* preload shim, the copies serve the real chroot seat), the lane script
|
|
276
|
+
* at /script.sh and the inputs at /inputs.json so the jailed program
|
|
277
|
+
* reads them at their in-tree paths. */
|
|
278
|
+
async function buildchroottree(root, code, inputs) {
|
|
279
|
+
if (!existsSync("/bin/bash")) {
|
|
280
|
+
throw edgelaneerror("RUNTIME_BUNDLE_UNAVAILABLE", "the lane sandbox-chroot cannot build its tree: /bin/bash is absent from the host (the lane never fakes a root)");
|
|
281
|
+
}
|
|
282
|
+
await mkdir(join(root, "bin"), { recursive: true });
|
|
283
|
+
await mkdir(join(root, "tmp"), { recursive: true });
|
|
284
|
+
await copyFile("/bin/bash", join(root, "bin", "bash"));
|
|
285
|
+
try {
|
|
286
|
+
const deps = await runhostprocess("ldd", ["/bin/bash"], { cwd: root, deadline: 2000 });
|
|
287
|
+
for (const line of deps.stdout.split("\n")) {
|
|
288
|
+
for (const token of line.split(/\s+/)) {
|
|
289
|
+
if (!token.startsWith("/") || !token.includes(".so"))
|
|
290
|
+
continue;
|
|
291
|
+
try {
|
|
292
|
+
await mkdir(join(root, dirname(token)), { recursive: true });
|
|
293
|
+
await copyFile(token, join(root, token));
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// an optional dependency copy that fails leaves the runtime's
|
|
297
|
+
// own honest loader error at run time — the tree never fakes a lib
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
// ldd absent or failing: the fakechroot seat maps the real fs and
|
|
304
|
+
// needs no copies, the real chroot seat answers its own loader error
|
|
305
|
+
}
|
|
306
|
+
await writeFile(join(root, "script.sh"), code, "utf8");
|
|
307
|
+
if (inputs !== undefined) {
|
|
308
|
+
await writeFile(join(root, "inputs.json"), JSON.stringify(inputs ?? null), "utf8");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** sandbox-chroot: the chroot jail lane with the honest privilege law:
|
|
312
|
+
* a real chroot needs root, so a non-root engine requires fakechroot —
|
|
313
|
+
* a host carrying neither root nor fakechroot answers the coded
|
|
314
|
+
* privilege refusal, never a wrapped errno or a fake root. */
|
|
315
|
+
async function sandboxchrootlane(code, inputs, deadline) {
|
|
316
|
+
const chrootpresent = probehostbinary("chroot");
|
|
317
|
+
const fakepresent = probehostbinary("fakechroot");
|
|
318
|
+
if (!chrootpresent && !fakepresent) {
|
|
319
|
+
absentruntime("sandbox-chroot", "chroot", "(and fakechroot, the unprivileged seat, is absent too)");
|
|
320
|
+
}
|
|
321
|
+
if (!fakepresent && !isroot()) {
|
|
322
|
+
throw edgelaneerror("HOST_PRIVILEGE_REQUIRED", "the lane sandbox-chroot requires root for a real chroot and the host carries no fakechroot (the lane never fakes a root)");
|
|
323
|
+
}
|
|
324
|
+
return withtempdirhost(async (dir) => {
|
|
325
|
+
const tree = join(dir, "root");
|
|
326
|
+
await buildchroottree(tree, code, inputs);
|
|
327
|
+
const env = {};
|
|
328
|
+
if (inputs !== undefined)
|
|
329
|
+
env.SADDLE_EDGE_INPUTS = "/inputs.json";
|
|
330
|
+
const command = fakepresent ? "fakechroot" : "chroot";
|
|
331
|
+
const args = fakepresent ? ["chroot", tree, "/bin/bash", "/script.sh"] : [tree, "/bin/bash", "/script.sh"];
|
|
332
|
+
const run = await runhostprocess(command, args, { cwd: dir, deadline, env });
|
|
333
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
337
|
+
/* Section 4: the container extension lanes — podman to ghcr. */
|
|
338
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
339
|
+
/** container-podman: the rootless container lane. The script mounts at
|
|
340
|
+
* /work and runs through the busybox image — the pull happens inside
|
|
341
|
+
* the runtime itself under the caller's deadline, the lane never
|
|
342
|
+
* pre-pulls anything outside its own process. */
|
|
343
|
+
async function containerpodmanlane(code, inputs, deadline) {
|
|
344
|
+
if (!probehostbinary("podman")) {
|
|
345
|
+
absentruntime("container-podman", "podman");
|
|
346
|
+
}
|
|
347
|
+
return withtempdirhost(async (dir) => {
|
|
348
|
+
await writeFile(join(dir, "script.sh"), code, "utf8");
|
|
349
|
+
const inputenv = await writeinputsfile(dir, inputs);
|
|
350
|
+
const env = inputenv === undefined ? undefined : { ...inputenv, SADDLE_EDGE_INPUTS: "/work/inputs.json" };
|
|
351
|
+
const run = await runhostprocess("podman", [
|
|
352
|
+
"run",
|
|
353
|
+
"--rm",
|
|
354
|
+
"-v",
|
|
355
|
+
`${dir}:/work`,
|
|
356
|
+
"-w",
|
|
357
|
+
"/work",
|
|
358
|
+
"quay.io/libpod/busybox:latest",
|
|
359
|
+
"/bin/sh",
|
|
360
|
+
"/work/script.sh",
|
|
361
|
+
], { cwd: dir, deadline, env });
|
|
362
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
/** container-containerd: the containerd supervisor lane. The code field
|
|
366
|
+
* is the shell command echoed through busybox's /bin/sh inside one
|
|
367
|
+
* containerd task under the native snapshotter — the task id rides the
|
|
368
|
+
* monotonic counter, deterministic without crypto. */
|
|
369
|
+
async function containercontainerdlane(code, inputs, deadline) {
|
|
370
|
+
if (!probehostbinary("ctr")) {
|
|
371
|
+
absentruntime("container-containerd", "ctr");
|
|
372
|
+
}
|
|
373
|
+
return withtempdirhost(async (dir) => {
|
|
374
|
+
const inputenv = await writeinputsfile(dir, inputs);
|
|
375
|
+
const run = await runhostprocess("ctr", [
|
|
376
|
+
"run",
|
|
377
|
+
"--rm",
|
|
378
|
+
"--snapshotter",
|
|
379
|
+
"native",
|
|
380
|
+
"docker.io/library/busybox:latest",
|
|
381
|
+
nextlaneid("saddleedge-ctr"),
|
|
382
|
+
"/bin/sh",
|
|
383
|
+
"-c",
|
|
384
|
+
code,
|
|
385
|
+
], { cwd: dir, deadline, env: inputenv });
|
|
386
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
/** builds one minimal oci bundle for the runc and crun seats: a
|
|
390
|
+
* spec.json (process args /bin/sh -c, cwd /, a PATH env, the pid,
|
|
391
|
+
* network, ipc, uts and mount namespaces, a read-only root) and a
|
|
392
|
+
* rootfs seeded with busybox — without busybox on PATH there is no
|
|
393
|
+
* honest bundle and the lane answers RUNTIME_BUNDLE_UNAVAILABLE. */
|
|
394
|
+
async function buildocibundle(dir, laneid, code) {
|
|
395
|
+
if (!probehostbinary("busybox")) {
|
|
396
|
+
throw edgelaneerror("RUNTIME_BUNDLE_UNAVAILABLE", `the lane ${laneid} cannot build its rootfs: busybox is absent from PATH (the lane never fakes a bundle)`);
|
|
397
|
+
}
|
|
398
|
+
const bundle = join(dir, "bundle");
|
|
399
|
+
const rootfs = join(bundle, "rootfs");
|
|
400
|
+
await mkdir(join(rootfs, "bin"), { recursive: true });
|
|
401
|
+
await mkdir(join(rootfs, "tmp"), { recursive: true });
|
|
402
|
+
const seat = await runhostprocess("bash", ["-c", "command -v busybox"], { cwd: dir, deadline: 2000 });
|
|
403
|
+
const busybox = seat.stdout.trim();
|
|
404
|
+
await copyFile(busybox, join(rootfs, "bin", "sh"));
|
|
405
|
+
await copyFile(busybox, join(rootfs, "bin", "busybox"));
|
|
406
|
+
const spec = {
|
|
407
|
+
ociVersion: "1.0.2",
|
|
408
|
+
process: {
|
|
409
|
+
terminal: false,
|
|
410
|
+
user: { uid: 0, gid: 0 },
|
|
411
|
+
args: ["/bin/sh", "-c", code],
|
|
412
|
+
env: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
|
|
413
|
+
cwd: "/",
|
|
414
|
+
},
|
|
415
|
+
root: { path: "rootfs", readonly: true },
|
|
416
|
+
hostname: "saddleedge",
|
|
417
|
+
linux: {
|
|
418
|
+
namespaces: [{ type: "pid" }, { type: "network" }, { type: "ipc" }, { type: "uts" }, { type: "mount" }],
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
await writeFile(join(bundle, "spec.json"), `${JSON.stringify(spec, null, 2)}\n`, "utf8");
|
|
422
|
+
return bundle;
|
|
423
|
+
}
|
|
424
|
+
/** the shared oci runtime seat of container-runc and container-crun:
|
|
425
|
+
* the probe, the root privilege law (the minimal spec carries no user
|
|
426
|
+
* namespace mappings, so the namespace work needs root), the bundle
|
|
427
|
+
* build and the run under the deadline. */
|
|
428
|
+
async function runocilane(laneid, runtime, code, inputs, deadline) {
|
|
429
|
+
if (!probehostbinary(runtime)) {
|
|
430
|
+
absentruntime(laneid, runtime);
|
|
431
|
+
}
|
|
432
|
+
if (!isroot()) {
|
|
433
|
+
throw edgelaneerror("HOST_PRIVILEGE_REQUIRED", `the lane ${laneid} requires root to create its ${runtime} namespaces (the lane never fakes a container)`);
|
|
434
|
+
}
|
|
435
|
+
return withtempdirhost(async (dir) => {
|
|
436
|
+
const bundle = await buildocibundle(dir, laneid, code);
|
|
437
|
+
const inputenv = await writeinputsfile(dir, inputs);
|
|
438
|
+
const run = await runhostprocess(runtime, ["run", nextlaneid(`saddleedge-${runtime}`)], {
|
|
439
|
+
cwd: bundle,
|
|
440
|
+
deadline,
|
|
441
|
+
env: inputenv,
|
|
442
|
+
});
|
|
443
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/** container-runc: the oci process lane — spec.json plus a busybox
|
|
447
|
+
* rootfs, run by runc under the deadline. */
|
|
448
|
+
async function containerrunclane(code, inputs, deadline) {
|
|
449
|
+
return runocilane("container-runc", "runc", code, inputs, deadline);
|
|
450
|
+
}
|
|
451
|
+
/** container-crun: the same oci bundle law through the crun runtime. */
|
|
452
|
+
async function containercrunlane(code, inputs, deadline) {
|
|
453
|
+
return runocilane("container-crun", "crun", code, inputs, deadline);
|
|
454
|
+
}
|
|
455
|
+
/** runs the code through one docker profile: the image and the
|
|
456
|
+
* hardening flags ride the profile args, the script mounts at /work and
|
|
457
|
+
* the inputs ride /work/inputs.json inside the container. */
|
|
458
|
+
async function rundockerprofile(dir, code, inputs, deadline, profileargs) {
|
|
459
|
+
await writeFile(join(dir, "script.sh"), code, "utf8");
|
|
460
|
+
const inputenv = await writeinputsfile(dir, inputs);
|
|
461
|
+
const env = inputenv === undefined ? undefined : { ...inputenv, SADDLE_EDGE_INPUTS: "/work/inputs.json" };
|
|
462
|
+
const run = await runhostprocess("docker", ["run", "--rm", "-v", `${dir}:/work`, "-w", "/work", ...profileargs, "/bin/bash", "/work/script.sh"], { cwd: dir, deadline, env });
|
|
463
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
464
|
+
}
|
|
465
|
+
/** container-max: the docker max profile — no network, every capability
|
|
466
|
+
* dropped, the pid limit raised to 256 and the memory swap left open:
|
|
467
|
+
* more resources than the lite seat, the same honest spawn law. */
|
|
468
|
+
async function containermaxlane(code, inputs, deadline) {
|
|
469
|
+
if (!probedockersurface()) {
|
|
470
|
+
absentruntime("container-max", "docker", "(no docker binary and no /var/run/docker.sock)");
|
|
471
|
+
}
|
|
472
|
+
return withtempdirhost((dir) => rundockerprofile(dir, code, inputs, deadline, [
|
|
473
|
+
"--network",
|
|
474
|
+
"none",
|
|
475
|
+
"--cap-drop",
|
|
476
|
+
"ALL",
|
|
477
|
+
"--pids-limit",
|
|
478
|
+
"256",
|
|
479
|
+
"--memory-swap",
|
|
480
|
+
"-1",
|
|
481
|
+
"docker.io/library/bash:5",
|
|
482
|
+
]));
|
|
483
|
+
}
|
|
484
|
+
/** container-lite: the docker lite profile — no network, every
|
|
485
|
+
* capability dropped, a read-only root, the pid limit at 64, 256 mb of
|
|
486
|
+
* memory and a tmpfs at /tmp: the tightest honest container seat. */
|
|
487
|
+
async function containerlitelane(code, inputs, deadline) {
|
|
488
|
+
if (!probedockersurface()) {
|
|
489
|
+
absentruntime("container-lite", "docker", "(no docker binary and no /var/run/docker.sock)");
|
|
490
|
+
}
|
|
491
|
+
return withtempdirhost((dir) => rundockerprofile(dir, code, inputs, deadline, [
|
|
492
|
+
"--network",
|
|
493
|
+
"none",
|
|
494
|
+
"--cap-drop",
|
|
495
|
+
"ALL",
|
|
496
|
+
"--read-only",
|
|
497
|
+
"--pids-limit",
|
|
498
|
+
"64",
|
|
499
|
+
"--memory",
|
|
500
|
+
"256m",
|
|
501
|
+
"--tmpfs",
|
|
502
|
+
"/tmp",
|
|
503
|
+
"docker.io/library/bash:5",
|
|
504
|
+
]));
|
|
505
|
+
}
|
|
506
|
+
/** container-ghcr: the immutable ghcr lane. The image rides the inputs
|
|
507
|
+
* field {image, script} — without it the lane runs the distroless
|
|
508
|
+
* default ghcr.io/distroless/cc:latest and echoes the script through
|
|
509
|
+
* the container's /dev/stdin (docker -i): the lane feeds the payload,
|
|
510
|
+
* the image answers what it answers, nothing is faked. */
|
|
511
|
+
async function containerghcrlane(code, inputs, deadline) {
|
|
512
|
+
if (!probedockersurface()) {
|
|
513
|
+
absentruntime("container-ghcr", "docker", "(no docker binary and no /var/run/docker.sock)");
|
|
514
|
+
}
|
|
515
|
+
return withtempdirhost(async (dir) => {
|
|
516
|
+
const record = (inputs ?? {});
|
|
517
|
+
const image = typeof record.image === "string" && record.image.length > 0 ? record.image : "ghcr.io/distroless/cc:latest";
|
|
518
|
+
const script = typeof record.script === "string" ? record.script : code;
|
|
519
|
+
const inputenv = await writeinputsfile(dir, inputs);
|
|
520
|
+
const run = await runhostprocess("docker", ["run", "--rm", "-i", "--network", "none", "--cap-drop", "ALL", "--read-only", "--pull=missing", image], { cwd: dir, deadline, env: inputenv, stdin: script });
|
|
521
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
525
|
+
/* Section 5: the runtime lanes — the clr and workerd seats. */
|
|
526
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
527
|
+
/** runtime-clr: the dotnet lane. When the dotnet-script tool rides the
|
|
528
|
+
* host the program runs as program.csx; otherwise the lane falls back
|
|
529
|
+
* to a minimal net8.0 console csproj whose Program.cs is the code field
|
|
530
|
+
* — both seats spawn under the deadline, the compile answers honestly. */
|
|
531
|
+
async function runtimeclrlane(code, inputs, deadline) {
|
|
532
|
+
if (!probehostbinary("dotnet")) {
|
|
533
|
+
absentruntime("runtime-clr", "dotnet");
|
|
534
|
+
}
|
|
535
|
+
const scripttool = probehostbinary("dotnet-script");
|
|
536
|
+
return withtempdirhost(async (dir) => {
|
|
537
|
+
const inputenv = await writeinputsfile(dir, inputs);
|
|
538
|
+
if (scripttool) {
|
|
539
|
+
await writeFile(join(dir, "program.csx"), code, "utf8");
|
|
540
|
+
const run = await runhostprocess("dotnet", ["script", "program.csx"], { cwd: dir, deadline, env: inputenv });
|
|
541
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
542
|
+
}
|
|
543
|
+
await writeFile(join(dir, "Program.cs"), code, "utf8");
|
|
544
|
+
await writeFile(join(dir, "lane.csproj"), [
|
|
545
|
+
'<Project Sdk="Microsoft.NET.Sdk">',
|
|
546
|
+
" <PropertyGroup>",
|
|
547
|
+
" <OutputType>Exe</OutputType>",
|
|
548
|
+
" <TargetFramework>net8.0</TargetFramework>",
|
|
549
|
+
" <ImplicitUsings>disable</ImplicitUsings>",
|
|
550
|
+
" <Nullable>disable</Nullable>",
|
|
551
|
+
" </PropertyGroup>",
|
|
552
|
+
"</Project>",
|
|
553
|
+
"",
|
|
554
|
+
].join("\n"), "utf8");
|
|
555
|
+
const run = await runhostprocess("dotnet", ["run", "--project", "lane.csproj"], {
|
|
556
|
+
cwd: dir,
|
|
557
|
+
deadline,
|
|
558
|
+
env: inputenv,
|
|
559
|
+
});
|
|
560
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
/** runtime-workerd: the cloudflare runtime lane. The lane writes the
|
|
564
|
+
* worker as worker.js plus a minimal capnp config (one loopback http
|
|
565
|
+
* socket, one worker whose module tree is the temp dir, sourceUrl
|
|
566
|
+
* workerd:main) and serves it under the deadline. workerd serve is a
|
|
567
|
+
* server: a healthy worker outlives the run window and answers the
|
|
568
|
+
* honest RUN_DEADLINE_EXCEEDED with its startup logs riding the
|
|
569
|
+
* streams — a broken config answers RUN_EXITED_NONZERO, never a fake. */
|
|
570
|
+
async function runtimeworkerdlane(code, inputs, deadline) {
|
|
571
|
+
if (!probehostbinary("workerd")) {
|
|
572
|
+
absentruntime("runtime-workerd", "workerd");
|
|
573
|
+
}
|
|
574
|
+
return withtempdirhost(async (dir) => {
|
|
575
|
+
await writeFile(join(dir, "worker.js"), code, "utf8");
|
|
576
|
+
await writeFile(join(dir, "workerd.capnp"), [
|
|
577
|
+
"# the saddle edge lane workerd config: one loopback http socket and",
|
|
578
|
+
'# one worker whose module tree is this directory (sourceUrl "workerd:main").',
|
|
579
|
+
'using Workerd = import "/workerd/io/workerd.capnp";',
|
|
580
|
+
"",
|
|
581
|
+
"const config :Workerd.Server = (",
|
|
582
|
+
' services = [ (name = "main", worker = .mainWorker ) ],',
|
|
583
|
+
' sockets = [ (name = "http", address = "127.0.0.1:0", http = (), service = "main" ) ],',
|
|
584
|
+
");",
|
|
585
|
+
"",
|
|
586
|
+
"const mainWorker :Workerd.Worker = (",
|
|
587
|
+
' compatibilityDate = "2024-04-03",',
|
|
588
|
+
' sourceUrl = "workerd:main",',
|
|
589
|
+
' modules = "./",',
|
|
590
|
+
");",
|
|
591
|
+
"",
|
|
592
|
+
].join("\n"), "utf8");
|
|
593
|
+
const inputenv = await writeinputsfile(dir, inputs);
|
|
594
|
+
const run = await runhostprocess("workerd", ["serve", "workerd.capnp"], { cwd: dir, deadline, env: inputenv });
|
|
595
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
599
|
+
/* Section 6: the screen lanes — the virtual displays, never the visitor's. */
|
|
600
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
601
|
+
/** screen-xvfb: the virtual x display lane. The script runs under
|
|
602
|
+
* xvfb-run with an auto-picked display and a 1280x800x24 screen — the
|
|
603
|
+
* display is a virtual server the lane spawns and kills, the visitor's
|
|
604
|
+
* screen is never touched (the engine host owns this surface). */
|
|
605
|
+
async function screenxvfblane(code, inputs, deadline) {
|
|
606
|
+
if (!probehostbinary("xvfb-run")) {
|
|
607
|
+
absentruntime("screen-xvfb", "xvfb-run");
|
|
608
|
+
}
|
|
609
|
+
return withtempdirhost((dir) => runscript("xvfb-run", ["-a", "--server-args=-screen 0 1280x800x24", "/bin/bash", "script.sh"], dir, code, inputs, deadline));
|
|
610
|
+
}
|
|
611
|
+
/** screen-wayland: the headless compositor lane. cage is the first seat
|
|
612
|
+
* (the wlroots headless backend, the runtime dir as the xdg seat so the
|
|
613
|
+
* socket lands inside the private temp dir); weston is the fallback —
|
|
614
|
+
* one bash spawn drives the compositor and the script in sequence, the
|
|
615
|
+
* deadline covers the whole session and the compositor dies with the
|
|
616
|
+
* script. A host carrying neither answers the coded absence. */
|
|
617
|
+
async function screenwaylandlane(code, inputs, deadline) {
|
|
618
|
+
const cagepresent = probehostbinary("cage");
|
|
619
|
+
const westonpresent = probehostbinary("weston");
|
|
620
|
+
if (!cagepresent && !westonpresent) {
|
|
621
|
+
throw edgelaneerror("HOST_RUNTIME_ABSENT", "the host runtime cage is absent: the lane screen-wayland requires it on PATH (weston, the alternative seat, is absent too)");
|
|
622
|
+
}
|
|
623
|
+
return withtempdirhost(async (dir) => {
|
|
624
|
+
if (cagepresent) {
|
|
625
|
+
return await runscript("cage", ["--", "/bin/bash", "script.sh"], dir, code, inputs, deadline, {
|
|
626
|
+
XDG_RUNTIME_DIR: dir,
|
|
627
|
+
WLR_BACKENDS: "headless",
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
await writeFile(join(dir, "script.sh"), code, "utf8");
|
|
631
|
+
const inputenv = (await writeinputsfile(dir, inputs)) ?? {};
|
|
632
|
+
const session = [
|
|
633
|
+
`XDG_RUNTIME_DIR="${dir}" weston --backend=headless-backend.so --socket=wayland-0 >/dev/null 2>&1 &`,
|
|
634
|
+
"westonpid=$!",
|
|
635
|
+
"sleep 0.3",
|
|
636
|
+
`WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR="${dir}" /bin/bash script.sh`,
|
|
637
|
+
"status=$?",
|
|
638
|
+
'kill "$westonpid" 2>/dev/null',
|
|
639
|
+
'wait "$westonpid" 2>/dev/null',
|
|
640
|
+
"exit $status",
|
|
641
|
+
].join("\n");
|
|
642
|
+
const run = await runhostprocess("bash", ["-c", session], { cwd: dir, deadline, env: inputenv });
|
|
643
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
647
|
+
/* Section 7: the mobile lane — the termux seat. */
|
|
648
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
649
|
+
/** mobile-termux: the honest mobile lane. A linux engine host never
|
|
650
|
+
* carries termux — the lane answers the coded absence; a termux host
|
|
651
|
+
* (the engine running inside the android sandbox) runs the script
|
|
652
|
+
* through the termux prefix environment (TERMUX_PREFIX with the prefix
|
|
653
|
+
* bin on PATH), the honest emulation-free seat. */
|
|
654
|
+
async function mobiletermuxlane(code, inputs, deadline) {
|
|
655
|
+
if (!probehostbinary("termux")) {
|
|
656
|
+
absentruntime("mobile-termux", "termux", "(the termux host prefix exists only inside a termux device host)");
|
|
657
|
+
}
|
|
658
|
+
return withtempdirhost(async (dir) => {
|
|
659
|
+
const record = (inputs ?? {});
|
|
660
|
+
const prefix = typeof record.termuxprefix === "string" && record.termuxprefix.length > 0
|
|
661
|
+
? record.termuxprefix
|
|
662
|
+
: "/data/data/com.termux/files/usr";
|
|
663
|
+
const path = process.env.PATH ?? "";
|
|
664
|
+
return await runscript("bash", ["script.sh"], dir, code, inputs, deadline, {
|
|
665
|
+
TERMUX_PREFIX: prefix,
|
|
666
|
+
PATH: `${prefix}/bin:${path}`,
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
671
|
+
/* Section 8: the build lanes — bazel, buck2 and turborepo. */
|
|
672
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
673
|
+
/** build-bazel: the bazel lane. A minimal BUILD file wires the script
|
|
674
|
+
* as one sh_binary and bazel runs it in batch mode with the system and
|
|
675
|
+
* home rc files refused, HOME pointed at the temp dir and the output
|
|
676
|
+
* user root inside it — the build caches the engine host may own are
|
|
677
|
+
* never touched, nothing leaks outside the private dir. */
|
|
678
|
+
async function buildbazellane(code, inputs, deadline) {
|
|
679
|
+
if (!probehostbinary("bazel")) {
|
|
680
|
+
absentruntime("build-bazel", "bazel");
|
|
681
|
+
}
|
|
682
|
+
return withtempdirhost(async (dir) => {
|
|
683
|
+
await writeFile(join(dir, "MODULE.bazel"), "", "utf8");
|
|
684
|
+
await writeFile(join(dir, "BUILD"), 'sh_binary(\n name = "lane",\n srcs = ["script.sh"],\n)\n', "utf8");
|
|
685
|
+
return await runscript("bazel", ["--batch", "--nosystem_rc", "--nohome_rc", `--output_user_root=${join(dir, "out")}`, "run", "//:lane"], dir, code, inputs, deadline, { HOME: dir });
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
/** build-buck: the buck2 lane. A minimal BUCK file wires the script as
|
|
689
|
+
* one sh_binary and buck2 runs it with HOME at the temp dir so the
|
|
690
|
+
* buck caches stay inside the private seat. */
|
|
691
|
+
async function buildbucklane(code, inputs, deadline) {
|
|
692
|
+
if (!probehostbinary("buck2")) {
|
|
693
|
+
absentruntime("build-buck", "buck2");
|
|
694
|
+
}
|
|
695
|
+
return withtempdirhost(async (dir) => {
|
|
696
|
+
await writeFile(join(dir, ".buckconfig"), "[repositories]\nroot = .\n", "utf8");
|
|
697
|
+
await writeFile(join(dir, "BUCK"), 'sh_binary(\n name = "lane",\n srcs = ["script.sh"],\n)\n', "utf8");
|
|
698
|
+
return await runscript("buck2", ["run", "//:lane"], dir, code, inputs, deadline, { HOME: dir });
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
/** the turbo seat: the binary on PATH or the engine module graph's
|
|
702
|
+
* turbo package (the bin shim it ships) — the honest probe answers
|
|
703
|
+
* present only when one of the two seats resolves. */
|
|
704
|
+
function turboseat() {
|
|
705
|
+
if (probehostbinary("turbo")) {
|
|
706
|
+
return { present: true, binpath: "turbo", viabinary: true };
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
const resolved = createRequire(import.meta.url).resolve("turbo");
|
|
710
|
+
let dir = dirname(resolved);
|
|
711
|
+
while (dir !== dirname(dir)) {
|
|
712
|
+
if (basename(dir) === "turbo" && basename(dirname(dir)) === "node_modules") {
|
|
713
|
+
const binpath = join(dir, "bin", "turbo");
|
|
714
|
+
if (existsSync(binpath)) {
|
|
715
|
+
return { present: true, binpath, viabinary: false };
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
dir = dirname(dir);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
// the module seat is absent: the binary seat already answered
|
|
723
|
+
}
|
|
724
|
+
return { present: false, binpath: "", viabinary: false };
|
|
725
|
+
}
|
|
726
|
+
/** build-turborepo: the turbo lane. A minimal package.json whose build
|
|
727
|
+
* script is the code field plus a minimal turbo.json, then turbo run
|
|
728
|
+
* build — through the binary seat when the host carries it, through the
|
|
729
|
+
* engine module seat's bin shim (spawned under node) when the repo
|
|
730
|
+
* carries turbo only as a dependency. */
|
|
731
|
+
async function buildturborepolane(code, inputs, deadline) {
|
|
732
|
+
const seat = turboseat();
|
|
733
|
+
if (!seat.present) {
|
|
734
|
+
throw edgelaneerror("HOST_RUNTIME_ABSENT", "the host runtime turbo is absent: the lane build-turborepo requires it on PATH (neither the turbo binary nor the engine module seat resolved)");
|
|
735
|
+
}
|
|
736
|
+
return withtempdirhost(async (dir) => {
|
|
737
|
+
const manifest = {
|
|
738
|
+
name: "saddle-edge-turbo-lane",
|
|
739
|
+
private: true,
|
|
740
|
+
version: "0.0.0",
|
|
741
|
+
scripts: { build: code },
|
|
742
|
+
};
|
|
743
|
+
await writeFile(join(dir, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
744
|
+
await writeFile(join(dir, "turbo.json"), `${JSON.stringify({ $schema: "https://turbo.build/schema.json", tasks: { build: { outputs: [] } } }, null, 2)}\n`, "utf8");
|
|
745
|
+
const inputenv = (await writeinputsfile(dir, inputs)) ?? {};
|
|
746
|
+
const command = seat.viabinary ? "turbo" : "node";
|
|
747
|
+
const args = seat.viabinary ? ["run", "build"] : [seat.binpath, "run", "build"];
|
|
748
|
+
const run = await runhostprocess(command, args, { cwd: dir, deadline, env: { ...inputenv, HOME: dir } });
|
|
749
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
753
|
+
/* Section 9: the ci runner lanes — the actions and gitlab seats. */
|
|
754
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
755
|
+
/** the actions runner root seat: the ACTIONS_RUNNER_ROOT env pointing
|
|
756
|
+
* at a real directory, or the empty string when the host carries none. */
|
|
757
|
+
function actionsrunnerroot() {
|
|
758
|
+
const root = process.env.ACTIONS_RUNNER_ROOT;
|
|
759
|
+
return typeof root === "string" && root.length > 0 && existsSync(root) ? root : "";
|
|
760
|
+
}
|
|
761
|
+
/** ci-actions: the github actions runner lane. The probe is the
|
|
762
|
+
* Runner.Listener binary on PATH or the ACTIONS_RUNNER_ROOT directory;
|
|
763
|
+
* when present the lane runs the listener in once mode through its own
|
|
764
|
+
* root and answers its streams — the job the listener pulls answers the
|
|
765
|
+
* configured service, the code and the inputs ride the env surface for
|
|
766
|
+
* the job side to pick up, nothing is faked. */
|
|
767
|
+
async function ciactionslane(code, inputs, deadline) {
|
|
768
|
+
const root = actionsrunnerroot();
|
|
769
|
+
if (root.length === 0 && !probehostbinary("Runner.Listener")) {
|
|
770
|
+
absentruntime("ci-actions", "actions-runner", "(no Runner.Listener on PATH and no ACTIONS_RUNNER_ROOT directory)");
|
|
771
|
+
}
|
|
772
|
+
return withtempdirhost(async (dir) => {
|
|
773
|
+
let runnerroot = root;
|
|
774
|
+
if (runnerroot.length === 0) {
|
|
775
|
+
const seat = await runhostprocess("bash", ["-c", "command -v Runner.Listener"], {
|
|
776
|
+
cwd: dir,
|
|
777
|
+
deadline: Math.min(deadline, 2000),
|
|
778
|
+
});
|
|
779
|
+
runnerroot = dirname(seat.stdout.trim());
|
|
780
|
+
}
|
|
781
|
+
await writeFile(join(dir, "script.sh"), code, "utf8");
|
|
782
|
+
const inputenv = await writeinputsfile(dir, inputs);
|
|
783
|
+
const run = await runhostprocess("./Runner.Listener", ["run", "--once"], {
|
|
784
|
+
cwd: runnerroot,
|
|
785
|
+
deadline,
|
|
786
|
+
env: inputenv,
|
|
787
|
+
});
|
|
788
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
/** ci-gitlab: the gitlab runner lane. The job script rides a minimal
|
|
792
|
+
* .gitlab-ci.yml (one job, one block scalar script) and the runner
|
|
793
|
+
* executes it through `exec docker`; a modern runner that removed the
|
|
794
|
+
* exec command answers RUNTIME_CAPABILITY_ABSENT with the message
|
|
795
|
+
* naming the removal — the runtime exists, the capability is gone. */
|
|
796
|
+
async function cigitlablane(code, inputs, deadline) {
|
|
797
|
+
if (!probehostbinary("gitlab-runner")) {
|
|
798
|
+
absentruntime("ci-gitlab", "gitlab-runner");
|
|
799
|
+
}
|
|
800
|
+
return withtempdirhost(async (dir) => {
|
|
801
|
+
const body = code
|
|
802
|
+
.split("\n")
|
|
803
|
+
.map((line) => ` ${line}`)
|
|
804
|
+
.join("\n");
|
|
805
|
+
await writeFile(join(dir, ".gitlab-ci.yml"), `lane:\n script:\n - |\n${body}\n`, "utf8");
|
|
806
|
+
const inputenv = await writeinputsfile(dir, inputs);
|
|
807
|
+
try {
|
|
808
|
+
const run = await runhostprocess("gitlab-runner", ["exec", "docker", "lane"], {
|
|
809
|
+
cwd: dir,
|
|
810
|
+
deadline,
|
|
811
|
+
env: inputenv,
|
|
812
|
+
});
|
|
813
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
814
|
+
}
|
|
815
|
+
catch (error) {
|
|
816
|
+
const stderr = error.stderr ?? "";
|
|
817
|
+
if (typeof error.code === "string" && /unknown command/i.test(stderr)) {
|
|
818
|
+
throw edgelaneerror("RUNTIME_CAPABILITY_ABSENT", "the host runtime gitlab-runner refused the exec command: exec was removed from the modern runners (the lane ci-gitlab requires a runner that still carries exec)", { stderr });
|
|
819
|
+
}
|
|
820
|
+
throw error;
|
|
821
|
+
}
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
825
|
+
/* Section 10: the network tunnel lanes — tor, i2p and wireguard. */
|
|
826
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
827
|
+
/** network-tor: the onion lane. The lane starts tor inside the private
|
|
828
|
+
* temp dir (its data directory and logs stay there), waits up to three
|
|
829
|
+
* seconds for the socks port to accept a connection, runs the code as
|
|
830
|
+
* a bash script with ALL_PROXY pointing at the circuit and tears tor
|
|
831
|
+
* down in the finally — no daemon outlives the lane, and a circuit that
|
|
832
|
+
* never opens answers the deadline refusal, never a fake tunnel. */
|
|
833
|
+
async function networktorlane(code, inputs, deadline) {
|
|
834
|
+
if (!probehostbinary("tor")) {
|
|
835
|
+
absentruntime("network-tor", "tor");
|
|
836
|
+
}
|
|
837
|
+
return withtempdirhost(async (dir) => {
|
|
838
|
+
const tordata = join(dir, "tordata");
|
|
839
|
+
await mkdir(tordata, { recursive: true });
|
|
840
|
+
const daemon = spawn("tor", [
|
|
841
|
+
"--SocksPort",
|
|
842
|
+
"9050",
|
|
843
|
+
"--DataDirectory",
|
|
844
|
+
tordata,
|
|
845
|
+
"--ControlPort",
|
|
846
|
+
"0",
|
|
847
|
+
"--Log",
|
|
848
|
+
`notice file ${join(dir, "tor.log")}`,
|
|
849
|
+
], { cwd: dir, stdio: "ignore", env: process.env });
|
|
850
|
+
try {
|
|
851
|
+
const opened = await waitforport(9050, Math.min(3000, Math.max(250, deadline)));
|
|
852
|
+
if (!opened) {
|
|
853
|
+
throw edgelaneerror("RUN_DEADLINE_EXCEEDED", "the tor socks port 9050 never opened within its 3 s wait (the lane network-tor refuses to run a script without the circuit)");
|
|
854
|
+
}
|
|
855
|
+
return await runscript("bash", ["script.sh"], dir, code, inputs, deadline, {
|
|
856
|
+
ALL_PROXY: "socks5://127.0.0.1:9050",
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
daemon.kill("SIGTERM");
|
|
861
|
+
const exited = await waitforexit(daemon, 1500);
|
|
862
|
+
if (!exited) {
|
|
863
|
+
daemon.kill("SIGKILL");
|
|
864
|
+
await waitforexit(daemon, 1500);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
/** network-i2p: the i2p tunnel lane. The runtime seat is the i2prouter
|
|
870
|
+
* binary: when the router console (7657) already answers, the script
|
|
871
|
+
* rides the existing tunnel; otherwise the lane starts the router,
|
|
872
|
+
* waits up to five seconds for the console and runs the script with
|
|
873
|
+
* the i2p proxy env (I2P_PROXY_HOST/I2P_PROXY_PORT at the 4444 http
|
|
874
|
+
* proxy). The router the lane started gets its graceful stop in the
|
|
875
|
+
* finally — a console that never opens answers the deadline refusal. */
|
|
876
|
+
async function networki2plane(code, inputs, deadline) {
|
|
877
|
+
if (!probehostbinary("i2prouter")) {
|
|
878
|
+
absentruntime("network-i2p", "i2p", "(the i2prouter binary is absent and the router console 7657 cannot be managed)");
|
|
879
|
+
}
|
|
880
|
+
return withtempdirhost(async (dir) => {
|
|
881
|
+
let startedrouter = false;
|
|
882
|
+
try {
|
|
883
|
+
const consoleopen = await waitforport(7657, 300);
|
|
884
|
+
if (!consoleopen) {
|
|
885
|
+
await runhostprocess("i2prouter", ["start"], { cwd: dir, deadline: Math.min(deadline, 8000) });
|
|
886
|
+
startedrouter = true;
|
|
887
|
+
const opened = await waitforport(7657, Math.min(5000, Math.max(250, deadline)));
|
|
888
|
+
if (!opened) {
|
|
889
|
+
throw edgelaneerror("RUN_DEADLINE_EXCEEDED", "the i2p router console 7657 never opened within its 5 s wait (the lane network-i2p refuses to run a script without the tunnel)");
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
return await runscript("bash", ["script.sh"], dir, code, inputs, deadline, {
|
|
893
|
+
I2P_PROXY_HOST: "127.0.0.1",
|
|
894
|
+
I2P_PROXY_PORT: "4444",
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
finally {
|
|
898
|
+
if (startedrouter) {
|
|
899
|
+
try {
|
|
900
|
+
await runhostprocess("i2prouter", ["graceful"], { cwd: dir, deadline: Math.min(deadline, 8000) });
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
// the graceful stop is the honest best effort: the router owns
|
|
904
|
+
// its own daemon lifecycle and answers its own error here
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
/** network-wireguard: the interface handshake lane. An empty code field
|
|
911
|
+
* answers the honest report — the wg show capture plus one wg genkey |
|
|
912
|
+
* wg pubkey pair generation on stdout; a non-empty code field runs as a
|
|
913
|
+
* bash script with the wg tools on PATH, and a script that asks for a
|
|
914
|
+
* wg-quick interface bring-up needs root: a non-root engine answers
|
|
915
|
+
* the coded privilege refusal, never a wrapped errno. */
|
|
916
|
+
async function networkwireguardlane(code, inputs, deadline) {
|
|
917
|
+
if (!probehostbinary("wg")) {
|
|
918
|
+
absentruntime("network-wireguard", "wg");
|
|
919
|
+
}
|
|
920
|
+
return withtempdirhost(async (dir) => {
|
|
921
|
+
const inputenv = (await writeinputsfile(dir, inputs)) ?? {};
|
|
922
|
+
if (code.trim().length === 0) {
|
|
923
|
+
let report = "";
|
|
924
|
+
let reporterr = "";
|
|
925
|
+
try {
|
|
926
|
+
const show = await runhostprocess("wg", ["show"], { cwd: dir, deadline, env: inputenv });
|
|
927
|
+
report = show.stdout;
|
|
928
|
+
reporterr = show.stderr;
|
|
929
|
+
}
|
|
930
|
+
catch (error) {
|
|
931
|
+
// wg show on a host without interfaces or privileges answers
|
|
932
|
+
// nonzero: the report is informational, the failure rides stderr
|
|
933
|
+
reporterr = `${errormessage(error)}${error.stderr ?? ""}`;
|
|
934
|
+
}
|
|
935
|
+
const pair = await runhostprocess("bash", ["-c", "wg genkey | wg pubkey"], {
|
|
936
|
+
cwd: dir,
|
|
937
|
+
deadline,
|
|
938
|
+
env: inputenv,
|
|
939
|
+
});
|
|
940
|
+
return { stdout: `${report}${pair.stdout}`.trim(), stderr: `${reporterr}${pair.stderr}` };
|
|
941
|
+
}
|
|
942
|
+
if (code.trimStart().startsWith("wg-quick") && !isroot()) {
|
|
943
|
+
throw edgelaneerror("HOST_PRIVILEGE_REQUIRED", "the lane network-wireguard requires root to bring a wg-quick interface up (the lane never fakes a handshake)");
|
|
944
|
+
}
|
|
945
|
+
await writeFile(join(dir, "script.sh"), code, "utf8");
|
|
946
|
+
const run = await runhostprocess("bash", ["script.sh"], { cwd: dir, deadline, env: inputenv });
|
|
947
|
+
return { stdout: run.stdout, stderr: run.stderr };
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
951
|
+
/* Section 11: the storage daemon lanes — the minio and garage clients. */
|
|
952
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
953
|
+
/** storage-minio: the minio client lane. The code field is a shell
|
|
954
|
+
* script riding the mc CLI; the inputs {alias, url, accesskey,
|
|
955
|
+
* secretkey} (all optional) derive the MC_HOST_<alias> env so the
|
|
956
|
+
* script addresses the vault — without them mc answers its own honest
|
|
957
|
+
* error and the lane wraps it, never invents a vault. */
|
|
958
|
+
async function storageminiolane(code, inputs, deadline) {
|
|
959
|
+
if (!probehostbinary("mc")) {
|
|
960
|
+
absentruntime("storage-minio", "mc");
|
|
961
|
+
}
|
|
962
|
+
return withtempdirhost(async (dir) => {
|
|
963
|
+
const record = (inputs ?? {});
|
|
964
|
+
const alias = typeof record.alias === "string" && record.alias.length > 0 ? record.alias : "lane";
|
|
965
|
+
const extraenv = {};
|
|
966
|
+
if (typeof record.url === "string" && record.url.length > 0) {
|
|
967
|
+
const accesskey = typeof record.accesskey === "string" ? record.accesskey : "";
|
|
968
|
+
const secretkey = typeof record.secretkey === "string" ? record.secretkey : "";
|
|
969
|
+
extraenv[`MC_HOST_${alias}`] = `https://${accesskey}:${secretkey}@${record.url}`;
|
|
970
|
+
}
|
|
971
|
+
return await runscript("bash", ["script.sh"], dir, code, inputs, deadline, extraenv);
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
/** storage-garage: the garage client lane. The same script law — the
|
|
975
|
+
* code field rides bash with the garage CLI on PATH, and the inputs
|
|
976
|
+
* {rpchost, rpcsecret} derive the GARAGE_RPC_HOST and
|
|
977
|
+
* GARAGE_RPC_SECRET env when the caller sent them. */
|
|
978
|
+
async function storagegaragelane(code, inputs, deadline) {
|
|
979
|
+
if (!probehostbinary("garage")) {
|
|
980
|
+
absentruntime("storage-garage", "garage");
|
|
981
|
+
}
|
|
982
|
+
return withtempdirhost(async (dir) => {
|
|
983
|
+
const record = (inputs ?? {});
|
|
984
|
+
const extraenv = {};
|
|
985
|
+
if (typeof record.rpchost === "string" && record.rpchost.length > 0) {
|
|
986
|
+
extraenv.GARAGE_RPC_HOST = record.rpchost;
|
|
987
|
+
}
|
|
988
|
+
if (typeof record.rpcsecret === "string" && record.rpcsecret.length > 0) {
|
|
989
|
+
extraenv.GARAGE_RPC_SECRET = record.rpcsecret;
|
|
990
|
+
}
|
|
991
|
+
return await runscript("bash", ["script.sh"], dir, code, inputs, deadline, extraenv);
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
995
|
+
/* Section 12: the lane table — the specs, the executors, the ids. */
|
|
996
|
+
/* ════════════════════════════════════════════════════════════════════ */
|
|
997
|
+
/** the host lane table of the ninety modalities wave, group a: the
|
|
998
|
+
* twenty-five host binary lanes, every one kind live — the executor
|
|
999
|
+
* exists and drives the runtime when the host carries it. The ids are
|
|
1000
|
+
* pinned — the dispatcher merge and the api surface read them exactly. */
|
|
1001
|
+
export const hostlanespecs = Object.freeze([
|
|
1002
|
+
{ modality: "sandbox-bubblewrap", kind: "live", runtime: "bwrap", present: () => probehostbinary("bwrap") },
|
|
1003
|
+
{ modality: "sandbox-nsjail", kind: "live", runtime: "nsjail", present: () => probehostbinary("nsjail") },
|
|
1004
|
+
{
|
|
1005
|
+
modality: "sandbox-chroot",
|
|
1006
|
+
kind: "live",
|
|
1007
|
+
runtime: "chroot",
|
|
1008
|
+
present: () => probehostbinary("chroot") || probehostbinary("fakechroot"),
|
|
1009
|
+
},
|
|
1010
|
+
{ modality: "container-podman", kind: "live", runtime: "podman", present: () => probehostbinary("podman") },
|
|
1011
|
+
{ modality: "container-containerd", kind: "live", runtime: "ctr", present: () => probehostbinary("ctr") },
|
|
1012
|
+
{ modality: "container-runc", kind: "live", runtime: "runc", present: () => probehostbinary("runc") },
|
|
1013
|
+
{ modality: "container-crun", kind: "live", runtime: "crun", present: () => probehostbinary("crun") },
|
|
1014
|
+
{ modality: "container-max", kind: "live", runtime: "docker", present: probedockersurface },
|
|
1015
|
+
{ modality: "container-lite", kind: "live", runtime: "docker", present: probedockersurface },
|
|
1016
|
+
{ modality: "container-ghcr", kind: "live", runtime: "docker", present: probedockersurface },
|
|
1017
|
+
{ modality: "runtime-clr", kind: "live", runtime: "dotnet", present: () => probehostbinary("dotnet") },
|
|
1018
|
+
{ modality: "runtime-workerd", kind: "live", runtime: "workerd", present: () => probehostbinary("workerd") },
|
|
1019
|
+
{ modality: "screen-xvfb", kind: "live", runtime: "xvfb-run", present: () => probehostbinary("xvfb-run") },
|
|
1020
|
+
{
|
|
1021
|
+
modality: "screen-wayland",
|
|
1022
|
+
kind: "live",
|
|
1023
|
+
runtime: "cage",
|
|
1024
|
+
present: () => probehostbinary("cage") || probehostbinary("weston"),
|
|
1025
|
+
},
|
|
1026
|
+
{ modality: "mobile-termux", kind: "live", runtime: "termux", present: () => probehostbinary("termux") },
|
|
1027
|
+
{ modality: "build-bazel", kind: "live", runtime: "bazel", present: () => probehostbinary("bazel") },
|
|
1028
|
+
{ modality: "build-buck", kind: "live", runtime: "buck2", present: () => probehostbinary("buck2") },
|
|
1029
|
+
{ modality: "build-turborepo", kind: "live", runtime: "turbo", present: () => turboseat().present },
|
|
1030
|
+
{
|
|
1031
|
+
modality: "ci-actions",
|
|
1032
|
+
kind: "live",
|
|
1033
|
+
runtime: "actions-runner",
|
|
1034
|
+
present: () => probehostbinary("Runner.Listener") || actionsrunnerroot().length > 0,
|
|
1035
|
+
},
|
|
1036
|
+
{ modality: "ci-gitlab", kind: "live", runtime: "gitlab-runner", present: () => probehostbinary("gitlab-runner") },
|
|
1037
|
+
{ modality: "network-tor", kind: "live", runtime: "tor", present: () => probehostbinary("tor") },
|
|
1038
|
+
{ modality: "network-i2p", kind: "live", runtime: "i2p", present: () => probehostbinary("i2prouter") },
|
|
1039
|
+
{ modality: "network-wireguard", kind: "live", runtime: "wg", present: () => probehostbinary("wg") },
|
|
1040
|
+
{ modality: "storage-minio", kind: "live", runtime: "mc", present: () => probehostbinary("mc") },
|
|
1041
|
+
{ modality: "storage-garage", kind: "live", runtime: "garage", present: () => probehostbinary("garage") },
|
|
1042
|
+
]);
|
|
1043
|
+
/** the host lane executor table: one executor per lane id, keyed by the
|
|
1044
|
+
* modality id in the same order as the spec table; the dispatch plane
|
|
1045
|
+
* refuses to boot over a lane it cannot wire. */
|
|
1046
|
+
export const edgelaneexecutorshost = Object.freeze({
|
|
1047
|
+
"sandbox-bubblewrap": sandboxbubblewraplane,
|
|
1048
|
+
"sandbox-nsjail": sandboxnsjaillane,
|
|
1049
|
+
"sandbox-chroot": sandboxchrootlane,
|
|
1050
|
+
"container-podman": containerpodmanlane,
|
|
1051
|
+
"container-containerd": containercontainerdlane,
|
|
1052
|
+
"container-runc": containerrunclane,
|
|
1053
|
+
"container-crun": containercrunlane,
|
|
1054
|
+
"container-max": containermaxlane,
|
|
1055
|
+
"container-lite": containerlitelane,
|
|
1056
|
+
"container-ghcr": containerghcrlane,
|
|
1057
|
+
"runtime-clr": runtimeclrlane,
|
|
1058
|
+
"runtime-workerd": runtimeworkerdlane,
|
|
1059
|
+
"screen-xvfb": screenxvfblane,
|
|
1060
|
+
"screen-wayland": screenwaylandlane,
|
|
1061
|
+
"mobile-termux": mobiletermuxlane,
|
|
1062
|
+
"build-bazel": buildbazellane,
|
|
1063
|
+
"build-buck": buildbucklane,
|
|
1064
|
+
"build-turborepo": buildturborepolane,
|
|
1065
|
+
"ci-actions": ciactionslane,
|
|
1066
|
+
"ci-gitlab": cigitlablane,
|
|
1067
|
+
"network-tor": networktorlane,
|
|
1068
|
+
"network-i2p": networki2plane,
|
|
1069
|
+
"network-wireguard": networkwireguardlane,
|
|
1070
|
+
"storage-minio": storageminiolane,
|
|
1071
|
+
"storage-garage": storagegaragelane,
|
|
1072
|
+
});
|
|
1073
|
+
/** the host lane ids in the spec order — the pinned contract the
|
|
1074
|
+
* dispatcher merge and the api surface read exactly. */
|
|
1075
|
+
export const hostlaneids = Object.freeze(hostlanespecs.map((lane) => lane.modality));
|
|
1076
|
+
/* the wiring self-check: the host lane table refuses to load over a gap
|
|
1077
|
+
* between the specs and the executors (a spec without an executor is a
|
|
1078
|
+
* miswired lane, and miswiring must fail loud at boot, never silently
|
|
1079
|
+
* at run time). */
|
|
1080
|
+
for (const lane of hostlanespecs) {
|
|
1081
|
+
if (edgelaneexecutorshost[lane.modality] === undefined) {
|
|
1082
|
+
throw new Error(`the host lane plane is miswired: no executor for the lane ${lane.modality}`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
for (const id of Object.keys(edgelaneexecutorshost)) {
|
|
1086
|
+
if (!hostlaneids.includes(id)) {
|
|
1087
|
+
throw new Error(`the host lane plane is miswired: the executor ${id} carries no spec`);
|
|
1088
|
+
}
|
|
1089
|
+
}
|