diff --git a/src/lib/onboard/experimental/portable-cpu-delegation-preflight.test.ts b/src/lib/onboard/experimental/portable-cpu-delegation-preflight.test.ts new file mode 100644 index 00000000000..b2f4d01e881 --- /dev/null +++ b/src/lib/onboard/experimental/portable-cpu-delegation-preflight.test.ts @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; +import { + cpuDelegationControllerPaths, + inspectPortableCpuDelegation, + portableCpuDelegationError, +} from "./portable-cpu-delegation-preflight"; + +function files(contents: Record): (file: string) => string { + return (file: string) => { + const value = contents[file]; + return ( + value ?? + (() => { + throw new Error(`ENOENT: no such file or directory, open '${file}'`); + })() + ); + }; +} + +const UID = 1001; +const PATHS = cpuDelegationControllerPaths(UID); + +const CPU_FULL = "cpuset cpu io memory pids"; +const NO_CPU = "cpuset io memory pids"; + +describe("inspectPortableCpuDelegation", () => { + it("skips the check on non-Linux platforms", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "darwin", + uid: UID, + }); + expect(preflight.ok).toBe(true); + }); + + it("reports cgroups v2 unavailable when the root controllers file is missing", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "linux", + uid: UID, + readFileSync: files({}), + }); + expect(preflight.ok).toBe(false); + expect(preflight.failure).toBe("cgroups-v2-unavailable"); + expect(preflight.detail).toContain("cgroups v2"); + }); + + it("reports cgroups v2 unavailable when the root controllers file is unreadable", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "linux", + uid: UID, + readFileSync: () => { + throw new Error("EACCES: permission denied"); + }, + }); + expect(preflight.ok).toBe(false); + expect(preflight.failure).toBe("cgroups-v2-unavailable"); + }); + + it("reports when the kernel hierarchy does not expose the cpu controller", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "linux", + uid: UID, + readFileSync: files({ + [PATHS.root]: NO_CPU, + [PATHS.userManager]: CPU_FULL, + [PATHS.appSlice]: CPU_FULL, + }), + }); + expect(preflight.ok).toBe(false); + expect(preflight.failure).toBe("cpu-controller-unavailable"); + expect(preflight.detail).toContain('no "cpu"'); + }); + + it("reports when systemd did not delegate cpu to the user manager (missing file)", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "linux", + uid: UID, + readFileSync: files({ + [PATHS.root]: CPU_FULL, + }), + }); + expect(preflight.ok).toBe(false); + expect(preflight.failure).toBe("systemd-user-delegation-missing"); + expect(preflight.detail).toContain("Delegate=cpu memory pids"); + }); + + it("reports when systemd did not delegate cpu to the user manager (no cpu token)", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "linux", + uid: UID, + readFileSync: files({ + [PATHS.root]: CPU_FULL, + [PATHS.userManager]: NO_CPU, + [PATHS.appSlice]: CPU_FULL, + }), + }); + expect(preflight.ok).toBe(false); + expect(preflight.failure).toBe("systemd-user-delegation-missing"); + expect(preflight.detail).toContain("user@.service"); + }); + + it("reports when the cpu controller is not available to app.slice for this boot", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "linux", + uid: UID, + readFileSync: files({ + [PATHS.root]: CPU_FULL, + [PATHS.userManager]: CPU_FULL, + [PATHS.appSlice]: NO_CPU, + }), + }); + expect(preflight.ok).toBe(false); + expect(preflight.failure).toBe("app-slice-cpu-unavailable"); + expect(preflight.detail).toContain("app.slice"); + }); + + it("reports when the app.slice controllers file is missing", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "linux", + uid: UID, + readFileSync: files({ + [PATHS.root]: CPU_FULL, + [PATHS.userManager]: CPU_FULL, + }), + }); + expect(preflight.ok).toBe(false); + expect(preflight.failure).toBe("app-slice-cpu-unavailable"); + }); + + it("passes when cpu is delegated through the whole current-user hierarchy", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "linux", + uid: UID, + readFileSync: files({ + [PATHS.root]: CPU_FULL, + [PATHS.userManager]: CPU_FULL, + [PATHS.appSlice]: CPU_FULL, + }), + }); + expect(preflight.ok).toBe(true); + expect(preflight.failure).toBeUndefined(); + expect(preflight.detail).toContain("cpu controller"); + }); + + it("skips when the user id cannot be resolved", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "linux", + uid: Number.NaN, + readFileSync: files({}), + }); + expect(preflight.ok).toBe(true); + }); + + it("formats a throwable error from a failed inspection", () => { + const preflight = inspectPortableCpuDelegation({ + platform: "linux", + uid: UID, + readFileSync: files({}), + }); + const error = portableCpuDelegationError(preflight); + expect(error.message).toContain("Portable CPU-delegation preflight failed"); + expect(error.message).toContain("cgroups v2"); + }); +}); diff --git a/src/lib/onboard/experimental/portable-cpu-delegation-preflight.ts b/src/lib/onboard/experimental/portable-cpu-delegation-preflight.ts new file mode 100644 index 00000000000..0518319b98e --- /dev/null +++ b/src/lib/onboard/experimental/portable-cpu-delegation-preflight.ts @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Portable admission must know whether the current user's systemd/cgroup +// hierarchy can actually enforce the sandbox CPU limit before any sandbox +// build or creation. OpenShell applies the limit through rootless Podman, +// which needs the `cpu` controller delegated down to the current user's +// `app.slice`. The stock systemd `user@.service` delegates only `pids memory`, +// so a host can pass the generic rootless-Podman checks and still fail at +// sandbox creation (gh #9188). +// +// The check is deliberately credential-free and read-only: it reads +// `cgroup.controllers` files under /sys/fs/cgroup and never edits systemd +// units, never uses sudo, and never weakens resource isolation. When the +// hierarchy cannot enforce the CPU limit, the caller must fail early with a +// diagnostic that distinguishes the four failure modes and states the exact +// administrator remediation, then require the user to rerun the preflight. + +import fs from "node:fs"; + +export type CpuDelegationFailureReason = + | "cgroups-v2-unavailable" + | "cpu-controller-unavailable" + | "systemd-user-delegation-missing" + | "app-slice-cpu-unavailable"; + +export interface CpuDelegationPreflight { + readonly ok: boolean; + readonly failure?: CpuDelegationFailureReason; + readonly detail: string; +} + +export interface CpuDelegationPreflightDeps { + readonly platform?: NodeJS.Platform; + readonly uid?: number; + readonly readFileSync?: (file: string, encoding: "utf8") => string; +} + +const CGROUP_ROOT = "/sys/fs/cgroup"; + +export function cpuDelegationControllerPaths(uid: number): { + readonly root: string; + readonly userManager: string; + readonly appSlice: string; +} { + return { + root: `${CGROUP_ROOT}/cgroup.controllers`, + userManager: `${CGROUP_ROOT}/user.slice/user-${uid}.slice/user@${uid}.service/cgroup.controllers`, + appSlice: `${CGROUP_ROOT}/user.slice/user-${uid}.slice/user@${uid}.service/app.slice/cgroup.controllers`, + }; +} + +function controllerNames(content: string): Set { + return new Set(content.split(/\s+/u).filter((token) => token.length > 0)); +} + +function readControllers( + file: string, + readFileSync: (file: string, encoding: "utf8") => string, +): string | null { + try { + return readFileSync(file, "utf8"); + } catch { + return null; + } +} + +export function inspectPortableCpuDelegation( + deps: CpuDelegationPreflightDeps = {}, +): CpuDelegationPreflight { + if ((deps.platform ?? process.platform) !== "linux") { + return { + ok: true, + detail: "CPU-delegation preflight only applies on Linux; skipping.", + }; + } + const uid = deps.uid ?? process.geteuid?.() ?? process.getuid?.(); + if (!Number.isInteger(uid) || Number(uid) < 0) { + return { + ok: true, + detail: "Could not resolve the current user ID; CPU-delegation preflight skipped.", + }; + } + const readFileSync = deps.readFileSync ?? fs.readFileSync; + const { root, userManager, appSlice } = cpuDelegationControllerPaths(Number(uid)); + + const rootContent = readControllers(root, readFileSync); + if (rootContent === null) { + return { + ok: false, + failure: "cgroups-v2-unavailable", + detail: + `cgroups v2 is not available: ${root} is missing or unreadable. ` + + "Rootless Podman cannot enforce the sandbox CPU limit without a cgroups v2 " + + "kernel and mount. Boot a cgroups v2 host and rerun the portable preflight.", + }; + } + const rootControllers = controllerNames(rootContent); + if (!rootControllers.has("cpu")) { + return { + ok: false, + failure: "cpu-controller-unavailable", + detail: + `The kernel cgroup hierarchy does not expose the cpu controller: ${root} ` + + `is "${rootContent.trim()}" (no "cpu"). Rootless Podman cannot enforce the ` + + "sandbox CPU limit. Enable the cpu controller in the kernel cgroup hierarchy " + + "and rerun the portable preflight.", + }; + } + + const userManagerContent = readControllers(userManager, readFileSync); + if (userManagerContent === null) { + return { + ok: false, + failure: "systemd-user-delegation-missing", + detail: + `The current user's systemd manager has no cgroup controllers file ` + + `(${userManager} missing or unreadable), so systemd has not delegated any ` + + "controllers to it. Have an administrator add `Delegate=cpu memory pids` to " + + "user@.service (for example via `systemctl edit user@.service`), restart the " + + "user manager, and rerun the portable preflight.", + }; + } + const userManagerControllers = controllerNames(userManagerContent); + if (!userManagerControllers.has("cpu")) { + return { + ok: false, + failure: "systemd-user-delegation-missing", + detail: + `systemd did not delegate the cpu controller to the current user's manager: ` + + `${userManager} is "${userManagerContent.trim()}" (no "cpu"). The stock ` + + "user@.service delegates only `pids memory`. Have an administrator add " + + "`Delegate=cpu memory pids` to user@.service (for example via `systemctl edit " + + "user@.service`), restart the user manager, and rerun the portable preflight.", + }; + } + + const appSliceContent = readControllers(appSlice, readFileSync); + if (appSliceContent === null) { + return { + ok: false, + failure: "app-slice-cpu-unavailable", + detail: + `The current user's app.slice has no cgroup controllers file (${appSlice} ` + + "missing or unreadable), so the cpu controller is not available to it for " + + "this boot. Restart the user manager (or the host) after the delegation " + + "change and rerun the portable preflight.", + }; + } + const appSliceControllers = controllerNames(appSliceContent); + if (!appSliceControllers.has("cpu")) { + return { + ok: false, + failure: "app-slice-cpu-unavailable", + detail: + `The cpu controller is not available to the current user's app.slice for ` + + `this boot: ${appSlice} is "${appSliceContent.trim()}" (no "cpu"). Restart ` + + "the user manager (or the host) after the delegation change and rerun the " + + "portable preflight.", + }; + } + + return { + ok: true, + detail: + "The current user's systemd/cgroup hierarchy can enforce the sandbox CPU " + + "limit: the cpu controller is exposed, delegated to the user manager, and " + + "available to app.slice.", + }; +} + +export function portableCpuDelegationError(preflight: CpuDelegationPreflight): Error { + return new Error(`Portable CPU-delegation preflight failed: ${preflight.detail}`); +} diff --git a/src/lib/onboard/experimental/portable-host-preparation.test.ts b/src/lib/onboard/experimental/portable-host-preparation.test.ts index 007b319f0bc..b8a58c2f8b6 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.test.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.test.ts @@ -72,6 +72,11 @@ function preparePortableExperimentalHost( env, { ...deps, + // Tests run on hosts without the /sys/fs/cgroup hierarchy the portable + // CPU-delegation preflight reads; default to a passing stub and inject + // explicit results for the preflight wiring tests below. + cpuDelegationPreflight: + deps.cpuDelegationPreflight ?? (() => ({ ok: true, detail: "stubbed in tests" })), runtimeReadiness: deps.runtimeReadiness ?? successfulReadiness(deps.home ?? expectedAuthority?.homeDir ?? os.userInfo().homedir), @@ -100,6 +105,79 @@ describe("preparePortableExperimentalHost", () => { expect(docker).not.toHaveBeenCalled(); }); + it("fails the portable preflight when the user hierarchy cannot enforce the CPU limit (#9188)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-")); + tempDirs.push(home); + const systemctl = vi.fn< + (args: readonly string[], env: NodeJS.ProcessEnv, timeoutMs?: number) => SpawnResult + >(() => result()); + const docker = vi.fn(); + const env: NodeJS.ProcessEnv = { + HOME: home, + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + }; + const cpuDelegationPreflight = () => ({ + ok: false, + failure: "systemd-user-delegation-missing" as const, + detail: "systemd did not delegate the cpu controller to the current user's manager.", + }); + + expect(() => + preparePortableExperimentalHost(env, { + platform: "linux", + home, + uid: 1001, + systemctl, + docker, + cpuDelegationPreflight, + }), + ).toThrow(/Portable CPU-delegation preflight failed/); + + // The gate must fire before any config write or service activation. + expect(systemctl).not.toHaveBeenCalled(); + expect(docker).not.toHaveBeenCalled(); + }); + + it("passes portable host preparation when the CPU-delegation preflight succeeds (#9188)", () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-")); + tempDirs.push(home); + const systemctl = vi.fn< + (args: readonly string[], env: NodeJS.ProcessEnv, timeoutMs?: number) => SpawnResult + >(() => result()); + const docker = vi + .fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>() + .mockReturnValueOnce(result()) // --version probe + .mockReturnValueOnce(result(1)) // inspect: registry not present + .mockReturnValueOnce(result()); // run + const podman = vi.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>( + () => result(0, "/run/user/1001/custom/podman.sock\n"), + ); + const hardenSocketDirectory = vi.fn(); + const env: NodeJS.ProcessEnv = { + HOME: home, + NEMOCLAW_EXPERIMENTAL_PROFILE: "portable", + }; + const cpuDelegationPreflight = () => ({ ok: true, detail: "cpu delegated" }); + + const prepared = preparePortableExperimentalHost( + env, + { + platform: "linux", + home, + uid: 1001, + systemctl, + podman, + docker, + hardenSocketDirectory, + validateConfigAuthority: vi.fn(), + cpuDelegationPreflight, + }, + ); + + expect(prepared).not.toBeNull(); + expect(prepared?.authority.uid).toBe(1001); + }); + it("prepares the rootless socket and managed loopback registry deterministically", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-portable-")); tempDirs.push(home); diff --git a/src/lib/onboard/experimental/portable-host-preparation.ts b/src/lib/onboard/experimental/portable-host-preparation.ts index c6b8a8950b9..362e25273e0 100644 --- a/src/lib/onboard/experimental/portable-host-preparation.ts +++ b/src/lib/onboard/experimental/portable-host-preparation.ts @@ -23,6 +23,12 @@ import { portablePodmanReadinessError, type PortablePodmanReadinessDeps, } from "./portable-runtime-readiness"; +import { + inspectPortableCpuDelegation, + portableCpuDelegationError, + type CpuDelegationPreflight, + type CpuDelegationPreflightDeps, +} from "./portable-cpu-delegation-preflight"; const REGISTRY_CONTAINER = "nemoclaw-portable-registry"; const REGISTRY_LABEL = "com.nvidia.nemoclaw.portable=1"; @@ -53,16 +59,8 @@ export interface PortableHostPreparationDeps { platform?: NodeJS.Platform; home?: string; uid?: number; - systemctl?: ( - args: readonly string[], - env: NodeJS.ProcessEnv, - timeoutMs?: number, - ) => SpawnResult; - podman?: ( - args: readonly string[], - env: NodeJS.ProcessEnv, - timeoutMs?: number, - ) => SpawnResult; + systemctl?: (args: readonly string[], env: NodeJS.ProcessEnv, timeoutMs?: number) => SpawnResult; + podman?: (args: readonly string[], env: NodeJS.ProcessEnv, timeoutMs?: number) => SpawnResult; docker?: (args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult; hardenSocketDirectory?: (socketPath: string, uid: number) => void; captureSocketAuthority?: (socketPath: string, uid: number) => PodmanSocketAuthority; @@ -76,6 +74,7 @@ export interface PortableHostPreparationDeps { socketPath: string | null; uid: number; }) => void; + cpuDelegationPreflight?: (deps: CpuDelegationPreflightDeps) => CpuDelegationPreflight; } export interface PortableHostPreparationResult { @@ -355,6 +354,13 @@ export function preparePortableExperimentalHost( if (!Number.isInteger(uid) || Number(uid) < 0) { throw new Error("The portable experimental profile could not resolve the current user ID."); } + // Fail early, before any config write or service activation, when the + // current user's systemd/cgroup hierarchy cannot enforce the sandbox CPU + // limit (gh #9188). The diagnostic is credential-free and never edits + // systemd units or weakens isolation. + const cpuDelegation = deps.cpuDelegationPreflight ?? inspectPortableCpuDelegation; + const cpuPreflight = cpuDelegation({ platform: deps.platform, uid: Number(uid) }); + if (!cpuPreflight.ok) throw portableCpuDelegationError(cpuPreflight); const currentHome = canonicalAbsolute(deps.home ?? os.userInfo().homedir, "home directory"); const home = canonicalAbsolute(expectedAuthority?.homeDir ?? currentHome, "home directory"); const configHome = path.join(home, ".config");