From 335ef85ffb6836a434aece841a72a8281627b183 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 22 Mar 2026 12:29:56 -0700 Subject: [PATCH 1/2] fix: add cgroup v2 preflight check with platform-specific guidance Detect cgroup v2 misconfiguration before gateway startup and provide fix instructions for Linux (setup-spark), Docker Desktop (settings UI), and Colima (restart with --cgroupns-mode host). Closes #136 --- bin/lib/onboard.js | 20 ++++++- bin/lib/preflight.js | 130 ++++++++++++++++++++++++++++++++++++++++- test/preflight.test.js | 126 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 273 insertions(+), 3 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 252a303c8d5..85d03b5df08 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -32,7 +32,7 @@ const { prompt, ensureApiKey, getCredential } = require("./credentials"); const registry = require("./registry"); const nim = require("./nim"); const policies = require("./policies"); -const { checkPortAvailable } = require("./preflight"); +const { checkPortAvailable, checkCgroupConfig } = require("./preflight"); const EXPERIMENTAL = process.env.NEMOCLAW_EXPERIMENTAL === "1"; const USE_COLOR = !process.env.NO_COLOR && !!process.stdout.isTTY; const DIM = USE_COLOR ? "\x1b[2m" : ""; @@ -343,6 +343,24 @@ async function preflight() { console.log(` ✓ Container runtime: ${runtime}`); } + // Cgroup v2 — must be configured for cgroupns=host + const cgroup = checkCgroupConfig({ runtime }); + if (!cgroup.ok) { + console.error(""); + console.error(" !! cgroup v2 detected but Docker is not configured for cgroupns=host."); + console.error(" OpenShell's gateway runs k3s inside Docker, which will fail with:"); + console.error(""); + console.error(" openat2 /sys/fs/cgroup/kubepods/pids.max: no such file or directory"); + console.error(""); + console.error(" To fix:"); + console.error(""); + console.error(` ${cgroup.fix}`); + console.error(""); + console.error(` Detail: ${cgroup.reason}`); + process.exit(1); + } + console.log(" ✓ cgroup configuration OK"); + // OpenShell CLI if (!isOpenshellInstalled()) { console.log(" openshell CLI not found. Installing..."); diff --git a/bin/lib/preflight.js b/bin/lib/preflight.js index 7f191413d48..0da3ac12370 100644 --- a/bin/lib/preflight.js +++ b/bin/lib/preflight.js @@ -88,4 +88,132 @@ async function checkPortAvailable(port, opts) { }); } -module.exports = { checkPortAvailable }; +/** + * Detect whether the Docker host uses cgroup v2. + * + * On Linux: runs `stat -fc %T /sys/fs/cgroup` (returns "cgroup2fs" for v2). + * On macOS: Docker runs in a VM, so check `docker info` for "Cgroup Version: 2". + * + * opts.statOutput — inject stat output for testing (Linux path) + * opts.dockerInfoOutput — inject docker info output for testing (macOS path) + * opts.platform — override process.platform for testing + */ +function isCgroupV2(opts) { + const o = opts || {}; + const platform = o.platform || process.platform; + + if (platform === "linux") { + let statOut; + if (typeof o.statOutput === "string") { + statOut = o.statOutput; + } else { + statOut = runCapture("stat -fc %T /sys/fs/cgroup 2>/dev/null", { ignoreError: true }); + } + return typeof statOut === "string" && statOut.trim() === "cgroup2fs"; + } + + // macOS / other — check Docker VM's cgroup version via docker info + let dockerInfo; + if (typeof o.dockerInfoOutput === "string") { + dockerInfo = o.dockerInfoOutput; + } else { + dockerInfo = runCapture("docker info 2>/dev/null", { ignoreError: true }); + } + if (typeof dockerInfo === "string") { + const match = dockerInfo.match(/Cgroup Version:\s*(\d+)/i); + return match && match[1] === "2"; + } + return false; +} + +/** + * Read Docker daemon configuration. + * + * On Linux: /etc/docker/daemon.json + * On macOS Docker Desktop: ~/.docker/daemon.json + * + * opts.daemonJsonContent — inject file content for testing + * opts.platform — override process.platform + */ +function readDaemonJson(opts) { + const o = opts || {}; + const platform = o.platform || process.platform; + + if (typeof o.daemonJsonContent === "string") { + try { return JSON.parse(o.daemonJsonContent); } catch { return null; } + } + + const fs = require("fs"); + const paths = []; + if (platform === "linux") { + paths.push("/etc/docker/daemon.json"); + } else if (platform === "darwin") { + const home = process.env.HOME || "/tmp"; + paths.push(require("path").join(home, ".docker", "daemon.json")); + } + + for (const p of paths) { + try { + const content = fs.readFileSync(p, "utf-8"); + return JSON.parse(content); + } catch { + continue; + } + } + return null; +} + +/** + * Check whether Docker is configured for cgroup v2 compatibility. + * + * Returns { ok: true } when cgroup v1 or properly configured. + * Returns { ok: false, runtime, reason, fix } with platform-specific guidance. + * + * opts.platform — override process.platform + * opts.runtime — container runtime name (from inferContainerRuntime) + * opts.cgroupV2 — override isCgroupV2 result for testing + * opts.daemonConfig — override readDaemonJson result for testing + */ +function checkCgroupConfig(opts) { + const o = opts || {}; + const platform = o.platform || process.platform; + const runtime = o.runtime || "unknown"; + + const isV2 = typeof o.cgroupV2 === "boolean" ? o.cgroupV2 : isCgroupV2({ platform, statOutput: o.statOutput, dockerInfoOutput: o.dockerInfoOutput }); + if (!isV2) { + return { ok: true }; + } + + const config = o.daemonConfig !== undefined ? o.daemonConfig : readDaemonJson({ platform, daemonJsonContent: o.daemonJsonContent }); + const cgroupMode = config && config["default-cgroupns-mode"]; + if (cgroupMode === "host") { + return { ok: true }; + } + + // cgroup v2 detected but not configured — provide platform-specific fix + let fix, reason; + + if (runtime === "colima") { + fix = "colima stop && colima start --cgroupns-mode host"; + reason = "Colima is running with cgroup v2 but cgroupns-mode is not set to host"; + } else if (runtime === "docker-desktop" || platform === "darwin") { + fix = 'Open Docker Desktop → Settings → Docker Engine → add "default-cgroupns-mode": "host" → Apply & restart'; + reason = platform === "darwin" + ? "Docker Desktop VM uses cgroup v2 but daemon.json does not set default-cgroupns-mode to host" + : "Docker Desktop uses cgroup v2 but daemon.json does not set default-cgroupns-mode to host"; + } else { + // Linux with plain Docker + fix = "sudo nemoclaw setup-spark"; + if (config === null) { + reason = "/etc/docker/daemon.json does not exist"; + } else if (!config["default-cgroupns-mode"]) { + reason = '/etc/docker/daemon.json exists but "default-cgroupns-mode" is not set to "host"'; + } else { + reason = `/etc/docker/daemon.json has "default-cgroupns-mode": "${config["default-cgroupns-mode"]}" (expected "host")`; + } + } + + return { ok: false, runtime, reason, fix }; +} + +module.exports = { checkPortAvailable, isCgroupV2, readDaemonJson, checkCgroupConfig }; diff --git a/test/preflight.test.js b/test/preflight.test.js index 6471d798361..70aa46c49e5 100644 --- a/test/preflight.test.js +++ b/test/preflight.test.js @@ -5,7 +5,7 @@ const { describe, it } = require("node:test"); const assert = require("node:assert/strict"); const net = require("net"); -const { checkPortAvailable } = require("../bin/lib/preflight"); +const { checkPortAvailable, isCgroupV2, readDaemonJson, checkCgroupConfig } = require("../bin/lib/preflight"); describe("checkPortAvailable", () => { it("falls through to net probe when lsof output is empty", async () => { @@ -123,3 +123,127 @@ describe("checkPortAvailable", () => { assert.equal(result.ok, true); }); }); + +describe("isCgroupV2", () => { + it("returns true when stat output is cgroup2fs (Linux)", () => { + const result = isCgroupV2({ platform: "linux", statOutput: "cgroup2fs\n" }); + assert.equal(result, true); + }); + + it("returns false when stat output is tmpfs (Linux, cgroup v1)", () => { + const result = isCgroupV2({ platform: "linux", statOutput: "tmpfs\n" }); + assert.equal(result, false); + }); + + it("returns false when stat fails (empty output)", () => { + const result = isCgroupV2({ platform: "linux", statOutput: "" }); + assert.equal(result, false); + }); + + it("returns true when docker info shows Cgroup Version: 2 (macOS)", () => { + const dockerInfo = [ + "Client:", + " Version: 24.0.7", + "Server:", + " Cgroup Driver: systemd", + " Cgroup Version: 2", + ].join("\n"); + const result = isCgroupV2({ platform: "darwin", dockerInfoOutput: dockerInfo }); + assert.equal(result, true); + }); + + it("returns false when docker info shows Cgroup Version: 1 (macOS)", () => { + const dockerInfo = [ + "Client:", + " Version: 24.0.7", + "Server:", + " Cgroup Driver: cgroupfs", + " Cgroup Version: 1", + ].join("\n"); + const result = isCgroupV2({ platform: "darwin", dockerInfoOutput: dockerInfo }); + assert.equal(result, false); + }); +}); + +describe("readDaemonJson", () => { + it("parses valid JSON content", () => { + const content = JSON.stringify({ "default-cgroupns-mode": "host", "storage-driver": "overlay2" }); + const result = readDaemonJson({ daemonJsonContent: content }); + assert.deepEqual(result, { "default-cgroupns-mode": "host", "storage-driver": "overlay2" }); + }); + + it("returns null for invalid JSON", () => { + const result = readDaemonJson({ daemonJsonContent: "not json {{{" }); + assert.equal(result, null); + }); + + it("returns null when no content provided and no file exists", () => { + // Use a platform where the daemon.json path won't exist + const result = readDaemonJson({ platform: "linux" }); + // On CI/dev machines /etc/docker/daemon.json may or may not exist, + // but we can at least verify the return type is object or null. + assert.ok(result === null || typeof result === "object"); + }); +}); + +describe("checkCgroupConfig", () => { + it("returns ok when cgroup v1 (not v2)", () => { + const result = checkCgroupConfig({ cgroupV2: false }); + assert.deepEqual(result, { ok: true }); + }); + + it("returns ok when cgroup v2 + cgroupns=host configured", () => { + const result = checkCgroupConfig({ + cgroupV2: true, + daemonConfig: { "default-cgroupns-mode": "host" }, + }); + assert.deepEqual(result, { ok: true }); + }); + + it("returns not ok on Linux with no daemon.json, fix suggests setup-spark", () => { + const result = checkCgroupConfig({ + platform: "linux", + runtime: "docker", + cgroupV2: true, + daemonConfig: null, + }); + assert.equal(result.ok, false); + assert.ok(result.fix.includes("setup-spark")); + assert.ok(result.reason.includes("does not exist")); + }); + + it("returns not ok on Docker Desktop, fix suggests Docker Desktop settings", () => { + const result = checkCgroupConfig({ + platform: "darwin", + runtime: "docker-desktop", + cgroupV2: true, + daemonConfig: null, + }); + assert.equal(result.ok, false); + assert.ok(result.fix.includes("Docker Desktop")); + assert.ok(result.reason.includes("daemon.json")); + }); + + it("returns not ok on Colima, fix suggests colima restart with flag", () => { + const result = checkCgroupConfig({ + platform: "darwin", + runtime: "colima", + cgroupV2: true, + daemonConfig: null, + }); + assert.equal(result.ok, false); + assert.ok(result.fix.includes("colima stop")); + assert.ok(result.fix.includes("--cgroupns-mode host")); + assert.ok(result.reason.includes("Colima")); + }); + + it("returns ok when cgroup v2 + Colima + daemonConfig has host mode", () => { + const result = checkCgroupConfig({ + platform: "darwin", + runtime: "colima", + cgroupV2: true, + daemonConfig: { "default-cgroupns-mode": "host" }, + }); + assert.deepEqual(result, { ok: true }); + }); +}); From c4305824042fa819fb57828697df71d8e27196e4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 22 Mar 2026 12:33:29 -0700 Subject: [PATCH 2/2] fix: auto-fix cgroup v2 on Linux and Colima, bail with instructions for Docker Desktop --- bin/lib/onboard.js | 61 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 85d03b5df08..ad297c5b8f6 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -346,18 +346,55 @@ async function preflight() { // Cgroup v2 — must be configured for cgroupns=host const cgroup = checkCgroupConfig({ runtime }); if (!cgroup.ok) { - console.error(""); - console.error(" !! cgroup v2 detected but Docker is not configured for cgroupns=host."); - console.error(" OpenShell's gateway runs k3s inside Docker, which will fail with:"); - console.error(""); - console.error(" openat2 /sys/fs/cgroup/kubepods/pids.max: no such file or directory"); - console.error(""); - console.error(" To fix:"); - console.error(""); - console.error(` ${cgroup.fix}`); - console.error(""); - console.error(` Detail: ${cgroup.reason}`); - process.exit(1); + console.log(""); + console.log(" !! cgroup v2 detected but Docker is not configured for cgroupns=host."); + console.log(" OpenShell's gateway runs k3s inside Docker, which will fail with:"); + console.log(""); + console.log(" openat2 /sys/fs/cgroup/kubepods/pids.max: no such file or directory"); + console.log(""); + + if (runtime === "colima") { + // Colima — auto-fix by restarting with the flag + console.log(" Fixing: restarting Colima with --cgroupns-mode host..."); + const colResult = spawnSync("bash", ["-lc", "colima stop && colima start --cgroupns-mode host"], { + stdio: "inherit", + timeout: 120000, + }); + if (colResult.status !== 0) { + console.error(" Failed to restart Colima. Please run manually:"); + console.error(" colima stop && colima start --cgroupns-mode host"); + process.exit(1); + } + console.log(" ✓ Colima restarted with cgroupns=host"); + } else if (runtime === "docker-desktop") { + // Docker Desktop — can't automate GUI settings + console.error(' Open Docker Desktop → Settings → Docker Engine → add:'); + console.error(''); + console.error(' "default-cgroupns-mode": "host"'); + console.error(''); + console.error(' Then click "Apply & restart" and re-run nemoclaw onboard.'); + process.exit(1); + } else { + // Linux — auto-fix via setup-spark + console.log(" Fixing: running setup-spark to configure Docker for cgroupns=host..."); + const sparkResult = spawnSync("sudo", ["-E", "bash", path.join(SCRIPTS, "setup-spark.sh")], { + stdio: "inherit", + timeout: 120000, + }); + if (sparkResult.status !== 0) { + console.error(" Failed to configure Docker. Please run manually:"); + console.error(" sudo nemoclaw setup-spark"); + process.exit(1); + } + console.log(" ✓ Docker configured for cgroupns=host"); + } + + // Re-verify after auto-fix + const recheck = checkCgroupConfig({ runtime }); + if (!recheck.ok) { + console.error(` !! Fix applied but cgroup check still failing: ${recheck.reason}`); + process.exit(1); + } } console.log(" ✓ cgroup configuration OK");