Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop. | 1 | #!/usr/bin/env node |
| 2 | // The sandbox's images: what `g1t-runner sandbox up` runs on a person's own | |
| 3 | // computer (crates/runner/src/selfhosted/launcher/), built from | |
| 4 | // deploy/sandbox/Dockerfile for each runner release and listed, with their | |
| 5 | // SHA-256, in the release's signed manifest (scripts/runner-release.mjs). | |
| 6 | // | |
| 7 | // node scripts/sandbox-image.mjs build --arch x64 --runner target/runner-release/0.3.0/g1t-runner-linux-x64 \ | |
| 8 | // [--kind wsl|vm|all] [--out DIR] [--version 0.3.0] | |
| 9 | // node scripts/sandbox-image.mjs section --dir DIR [--runner-image g1t.sh/flagon-io/g1t-runner@sha256:…] | |
| 10 | // | |
| 11 | // What it makes, in --out (default target/runner-release/<version>/): | |
| 12 | // | |
| 13 | // g1t-sandbox-wsl-<arch>.tar.gz a root filesystem for `wsl --import` | |
| 14 | // g1t-sandbox-vm-<arch>-kernel a VM's kernel (uncompressed on arm64, | |
| 15 | // g1t-sandbox-vm-<arch>-initrd as Apple's Virtualization framework | |
| 16 | // g1t-sandbox-vm-<arch>-disk.raw.gz wants), initrd and ext4 system disk | |
| 17 | // | |
| 18 | // `section` prints the manifest's `sandbox` section for the files in a | |
| 19 | // folder; runner-release.mjs `build` and `sign` take it from sandbox.json | |
| 20 | // there. Building needs Docker with buildx (and QEMU's emulators for the | |
| 21 | // other architecture); the VM's disk is made with mke2fs -d inside a | |
| 22 | // container, so nothing needs root on the machine. | |
| 23 | ||
| 24 | import { spawnSync } from "node:child_process"; | |
| 25 | import { createHash } from "node:crypto"; | |
| 26 | import { copyFileSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; | |
| 27 | import { tmpdir } from "node:os"; | |
| 28 | import { dirname, join, resolve } from "node:path"; | |
| 29 | import { pipeline } from "node:stream/promises"; | |
| 30 | import { fileURLToPath } from "node:url"; | |
| 31 | import { createGzip } from "node:zlib"; | |
| 32 | ||
| 33 | const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); | |
| 34 | ||
| 35 | /** The files of a sandbox for an architecture, by the key the manifest and the launcher use. */ | |
| 36 | export function sandboxFiles(arch) { | |
| 37 | return { | |
| 38 | [`wsl-${arch}`]: `g1t-sandbox-wsl-${arch}.tar.gz`, | |
| 39 | [`vm-${arch}-kernel`]: `g1t-sandbox-vm-${arch}-kernel`, | |
| 40 | [`vm-${arch}-initrd`]: `g1t-sandbox-vm-${arch}-initrd`, | |
| 41 | [`vm-${arch}-disk`]: `g1t-sandbox-vm-${arch}-disk.raw.gz`, | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | export const ARCHES = ["x64", "arm64"]; | |
| 46 | const PLATFORM = { x64: "linux/amd64", arm64: "linux/arm64" }; | |
| 47 | ||
| 48 | const sha256File = async (path) => { | |
| 49 | const hash = createHash("sha256"); | |
| 50 | await pipeline(createReadStream(path), hash); | |
| 51 | return hash.digest("hex"); | |
| 52 | }; | |
| 53 | ||
| 54 | /** The manifest's `sandbox` section for the sandbox files in `dir`. */ | |
| 55 | export async function section(dir, runnerImage = process.env.RUNNER_SANDBOX_IMAGE || null) { | |
| 56 | const files = {}; | |
| 57 | for (const arch of ARCHES) { | |
| 58 | for (const [key, name] of Object.entries(sandboxFiles(arch))) { | |
| 59 | const path = join(dir, name); | |
| 60 | if (existsSync(path)) files[key] = { name, sha256: await sha256File(path), size: statSync(path).size }; | |
| 61 | } | |
| 62 | } | |
| 63 | return { runner_image: runnerImage, files }; | |
| 64 | } | |
| 65 | ||
| 66 | function run(command, args, options = {}) { | |
| 67 | const done = spawnSync(command, args, { stdio: "inherit", cwd: ROOT, ...options }); | |
| 68 | if (done.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`); | |
| 69 | } | |
| 70 | ||
| 71 | function output(command, args) { | |
| 72 | const done = spawnSync(command, args, { cwd: ROOT, encoding: "utf8" }); | |
| 73 | if (done.status !== 0) throw new Error(`${command} ${args.join(" ")} failed: ${done.stderr}`); | |
| 74 | return done.stdout.trim(); | |
| 75 | } | |
| 76 | ||
| 77 | function version() { | |
| 78 | const toml = readFileSync(join(ROOT, "crates/runner/Cargo.toml"), "utf8"); | |
| 79 | return /^version\s*=\s*"([^"]+)"/m.exec(toml)[1]; | |
| 80 | } | |
| 81 | ||
| 82 | /** A build context: deploy/sandbox and the runner's Linux binary. */ | |
| 83 | function context(runner) { | |
| 84 | const dir = mkdtempSync(join(tmpdir(), "g1t-sandbox-")); | |
| 85 | for (const name of ["Dockerfile", "wsl.conf", "g1t-sandbox.service", "vm-network.network"]) { | |
| 86 | copyFileSync(join(ROOT, "deploy/sandbox", name), join(dir, name)); | |
| 87 | } | |
| 88 | copyFileSync(runner, join(dir, "g1t-runner")); | |
| 89 | return dir; | |
| 90 | } | |
| 91 | ||
| 92 | /** Builds a target and exports its file system as a tar. */ | |
| 93 | function exportTarget(ctx, target, arch, v, tar) { | |
| 94 | const tag = `g1t-sandbox-build:${target}-${arch}`; | |
| 95 | run("docker", ["buildx", "build", "--platform", PLATFORM[arch], "--target", target, "--build-arg", `IMAGE_VERSION=${v}`, "-t", tag, "--load", ctx]); | |
| 96 | const id = output("docker", ["create", "--platform", PLATFORM[arch], tag]); | |
| 97 | try { | |
| 98 | run("docker", ["export", "-o", tar, id]); | |
| 99 | } finally { | |
| 100 | run("docker", ["rm", id], { stdio: "ignore" }); | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | async function gzip(from, to) { | |
| 105 | await pipeline(createReadStream(from), createGzip({ level: 9 }), createWriteStream(to)); | |
| 106 | rmSync(from); | |
| 107 | } | |
| 108 | ||
| 109 | async function buildWsl(ctx, arch, v, out) { | |
| 110 | const tar = join(out, `g1t-sandbox-wsl-${arch}.tar`); | |
| 111 | exportTarget(ctx, "wsl", arch, v, tar); | |
| 112 | await gzip(tar, join(out, sandboxFiles(arch)[`wsl-${arch}`])); | |
| 113 | } | |
| 114 | ||
| 115 | async function buildVm(ctx, arch, v, out) { | |
| 116 | const tar = join(out, `g1t-sandbox-vm-${arch}.tar`); | |
| 117 | exportTarget(ctx, "vm", arch, v, tar); | |
| 118 | const names = sandboxFiles(arch); | |
| 119 | // Kernel and initrd out of /boot, and the rest made into an ext4 disk, | |
| 120 | // in a container with e2fsprogs, as root there and nowhere else. | |
| 121 | const script = [ | |
| 122 | "set -e", | |
| 123 | "apt-get update -qq >/dev/null && apt-get install -y -qq e2fsprogs >/dev/null", | |
| 124 | "mkdir /work/root && tar -C /work/root -xpf /out/vm.tar", | |
| 125 | `cp /work/root/boot/vmlinuz-* /out/${names[`vm-${arch}-kernel`]}`, | |
| 126 | `cp /work/root/boot/initrd.img-* /out/${names[`vm-${arch}-initrd`]}`, | |
| 127 | // Apple's Virtualization framework boots an uncompressed arm64 Image. | |
| 128 | arch === "arm64" ? `if gzip -t /out/${names[`vm-${arch}-kernel`]} 2>/dev/null; then mv /out/${names[`vm-${arch}-kernel`]} /out/k.gz && gzip -dc /out/k.gz > /out/${names[`vm-${arch}-kernel`]} && rm /out/k.gz; fi` : "true", | |
| 129 | "mke2fs -q -t ext4 -L g1t-system -d /work/root /out/disk.raw 3G", | |
| 130 | "chmod 0644 /out/*", | |
| 131 | ].join(" && "); | |
| 132 | copyFileSync(tar, join(out, "vm.tar")); | |
| 133 | rmSync(tar); | |
| 134 | run("docker", ["run", "--rm", "--tmpfs", "/work:size=6g", "-v", `${resolve(out)}:/out`, "debian:bookworm-slim", "sh", "-c", script]); | |
| 135 | rmSync(join(out, "vm.tar")); | |
| 136 | await gzip(join(out, "disk.raw"), join(out, names[`vm-${arch}-disk`])); | |
| 137 | } | |
| 138 | ||
| 139 | export async function build({ arch, runner, kind = "all", out, v = version() }) { | |
| 140 | if (!PLATFORM[arch]) throw new Error(`--arch is one of ${ARCHES.join(", ")}`); | |
| 141 | if (!runner || !existsSync(runner)) throw new Error("--runner names the runner's static Linux build for that architecture"); | |
| 142 | mkdirSync(out, { recursive: true }); | |
| 143 | const ctx = context(runner); | |
| 144 | try { | |
| 145 | if (kind === "wsl" || kind === "all") await buildWsl(ctx, arch, v, out); | |
| 146 | if (kind === "vm" || kind === "all") await buildVm(ctx, arch, v, out); | |
| 147 | } finally { | |
| 148 | rmSync(ctx, { recursive: true, force: true }); | |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/sandbox-image.mjs")) { | |
| 153 | const [command, ...rest] = process.argv.slice(2); | |
| 154 | const option = (name) => { | |
| 155 | const at = rest.indexOf(`--${name}`); | |
| 156 | return at >= 0 ? rest[at + 1] : undefined; | |
| 157 | }; | |
| 158 | try { | |
| 159 | switch (command) { | |
| 160 | case "build": { | |
| 161 | const v = option("version") ?? version(); | |
| 162 | await build({ arch: option("arch") ?? "x64", runner: option("runner"), kind: option("kind") ?? "all", out: option("out") ?? join(ROOT, "target", "runner-release", v), v }); | |
| 163 | break; | |
| 164 | } | |
| 165 | case "section": { | |
| 166 | const dir = option("dir") ?? join(ROOT, "target", "runner-release", version()); | |
| 167 | const s = await section(dir, option("runner-image") ?? process.env.RUNNER_SANDBOX_IMAGE ?? null); | |
| 168 | writeFileSync(join(dir, "sandbox.json"), `${JSON.stringify(s, null, 2)}\n`); | |
| 169 | console.log(JSON.stringify(s, null, 2)); | |
| 170 | break; | |
| 171 | } | |
| 172 | default: | |
| 173 | console.error("usage: node scripts/sandbox-image.mjs build|section"); | |
| 174 | process.exit(2); | |
| 175 | } | |
| 176 | } catch (error) { | |
| 177 | console.error(String(error.message ?? error)); | |
| 178 | process.exit(1); | |
| 179 | } | |
| 180 | } |