diff --git a/README.md b/README.md index dbd7943..8ea1bd9 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,10 @@ ROOTCELL_LIMACTL=/path/to/limactl # Lima provider ROOTCELL_TERRAFORM=/path/to/tofu # AWS EC2 provider ``` +The Lima provider requires Lima 2.0.2 or newer for `ssh.overVsock`; use +`nix shell .#hostTools --command ./rootcell` if your PATH has an older +`limactl`. + The AWS EC2 provider uses OpenTofu's `tofu` command by default. Set `ROOTCELL_TERRAFORM=/path/to/terraform` if you want to use a Terraform binary you installed yourself. diff --git a/common.nix b/common.nix index fbdc444..8110907 100644 --- a/common.nix +++ b/common.nix @@ -68,6 +68,9 @@ in environment.enableAllTerminfo = true; services.lima.enable = !isAwsEc2; + # nixos-lima v0.0.5 boots with dbus-daemon; keep first switch from + # attempting an in-place migration to dbus-broker. + services.dbus.implementation = "dbus"; networking.nat.enable = lib.mkForce false; # Lima's hostagent probes `/bin/bash` even when the configured user shell is diff --git a/flake.lock b/flake.lock index f86176c..e1c3098 100644 --- a/flake.lock +++ b/flake.lock @@ -102,18 +102,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1778003029, - "narHash": "sha256-q/nkKLDtHIyLjZpKhWk3cSK5IYsFqtMd6UtXF3ddjgA=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "0c88e1f2bdb93d5999019e99cb0e61e1fe2af4c5", - "type": "github" + "lastModified": 1778869304, + "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=", + "rev": "d233902339c02a9c334e7e593de68855ad26c4cb", + "revCount": 998534, + "type": "tarball", + "url": "https://api.flakehub.com/f/pinned/DeterminateSystems/nixpkgs-weekly/0.1.998534%2Brev-d233902339c02a9c334e7e593de68855ad26c4cb/019e3efc-e09a-7ff1-b05f-0c8f85ba7441/source.tar.gz" }, "original": { - "owner": "NixOS", - "ref": "nixos-25.11", - "repo": "nixpkgs", - "type": "github" + "type": "tarball", + "url": "https://flakehub.com/f/DeterminateSystems/nixpkgs-weekly/0.1" } }, "nixpkgs-unstable": { diff --git a/flake.nix b/flake.nix index 465f986..19b9284 100644 --- a/flake.nix +++ b/flake.nix @@ -2,7 +2,7 @@ description = "rootcell: root-capable coding-agent workspaces with allowlisted egress"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixpkgs.url = "https://flakehub.com/f/DeterminateSystems/nixpkgs-weekly/0.1"; home-manager = { url = "github:nix-community/home-manager/release-25.11"; @@ -42,12 +42,7 @@ forEachDarwin = nixpkgs.lib.genAttrs [ "aarch64-darwin" "x86_64-darwin" ]; darwinPkgs = forEachDarwin (sys: let - p = import nixpkgs { - system = sys; - config.permittedInsecurePackages = [ - "lima-1.2.2" - ]; - }; + p = nixpkgs.legacyPackages.${sys}; in { lima = p.lima; hostTools = p.buildEnv { diff --git a/src/rootcell/integration/providers/macos-lima-user-v2/preflight.ts b/src/rootcell/integration/providers/macos-lima-user-v2/preflight.ts index 94cec9c..161a59f 100644 --- a/src/rootcell/integration/providers/macos-lima-user-v2/preflight.ts +++ b/src/rootcell/integration/providers/macos-lima-user-v2/preflight.ts @@ -1,4 +1,5 @@ import { commandExists, runCapture } from "../../../process.ts"; +import { assertLimactlSupportsSshOverVsockYaml } from "../../../providers/lima-version.ts"; export function preflightMacOsLimaUserV2Integration(): Promise { if (process.platform !== "darwin") { @@ -19,6 +20,7 @@ export function preflightMacOsLimaUserV2Integration(): Promise { throw new Error(`macos-lima-user-v2 integration tests require '${tool.command}' on PATH or ${tool.envVars?.join(" or ") ?? "a configured override"}`); } } + assertLimactlSupportsSshOverVsockYaml(resolveTool("limactl", ["ROOTCELL_LIMACTL", "LIMACTL"])); return Promise.resolve(); } @@ -31,3 +33,13 @@ function toolAvailable(command: string, envVars: readonly string[] = []): boolea return envVars.some((envVar) => process.env[envVar] !== undefined && process.env[envVar].length > 0) || commandExists(command); } + +function resolveTool(command: string, envVars: readonly string[]): string { + for (const envVar of envVars) { + const configured = process.env[envVar]; + if (configured !== undefined && configured.length > 0) { + return configured; + } + } + return command; +} diff --git a/src/rootcell/providers/lima-version.ts b/src/rootcell/providers/lima-version.ts new file mode 100644 index 0000000..528d4eb --- /dev/null +++ b/src/rootcell/providers/lima-version.ts @@ -0,0 +1,75 @@ +import { runCapture } from "../process.ts"; + +export interface LimaVersion { + readonly major: number; + readonly minor: number; + readonly patch: number; + readonly raw: string; +} + +export const MIN_LIMA_SSH_OVER_VSOCK_YAML_VERSION: LimaVersion = { + major: 2, + minor: 0, + patch: 2, + raw: "2.0.2", +}; + +export function assertLimactlSupportsSshOverVsockYaml(limactl: string): void { + const result = runCapture(limactl, ["--version"], { allowFailure: true }); + const output = `${result.stdout}${result.stderr}`.trim(); + if (result.status !== 0) { + throw new Error(output.length > 0 ? output : `failed to run ${limactl} --version`); + } + + const version = parseLimactlVersionOutput(output); + if (version === null) { + throw new Error(`could not parse ${limactl} version output: ${output}`); + } + if (limaVersionSupportsSshOverVsockYaml(version)) { + return; + } + + throw new Error([ + `${limactl} ${formatLimaVersion(version)} does not support .ssh.overVsock; rootcell requires Lima >= ${formatLimaVersion(MIN_LIMA_SSH_OVER_VSOCK_YAML_VERSION)} for the macos-lima provider.`, + "Without SSH over VSOCK, Lima falls back to localhost TCP forwarding and firewall provisioning can interrupt the bootstrap transport.", + "Update Lima or run with the repo-pinned host tools:", + " nix shell .#hostTools --command ./rootcell", + ].join("\n")); +} + +export function parseLimactlVersionOutput(output: string): LimaVersion | null { + const match = /\bv?([0-9]+)\.([0-9]+)\.([0-9]+)(?:[-+][0-9A-Za-z.-]+)?\b/.exec(output); + if (match === null) { + return null; + } + const major = Number(match[1]); + const minor = Number(match[2]); + const patch = Number(match[3]); + if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor) || !Number.isSafeInteger(patch)) { + return null; + } + return { + major, + minor, + patch, + raw: `${String(major)}.${String(minor)}.${String(patch)}`, + }; +} + +export function limaVersionSupportsSshOverVsockYaml(version: LimaVersion): boolean { + return compareLimaVersions(version, MIN_LIMA_SSH_OVER_VSOCK_YAML_VERSION) >= 0; +} + +export function compareLimaVersions(left: LimaVersion, right: LimaVersion): number { + for (const key of ["major", "minor", "patch"] as const) { + const diff = left[key] - right[key]; + if (diff !== 0) { + return diff; + } + } + return 0; +} + +export function formatLimaVersion(version: LimaVersion): string { + return version.raw; +} diff --git a/src/rootcell/providers/macos-lima-user-v2-network.ts b/src/rootcell/providers/macos-lima-user-v2-network.ts index 932447c..e96d54d 100644 --- a/src/rootcell/providers/macos-lima-user-v2-network.ts +++ b/src/rootcell/providers/macos-lima-user-v2-network.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { resolveHostTool } from "../host-tools.ts"; import { runCapture } from "../process.ts"; import type { RootcellConfig } from "../types.ts"; +import { assertLimactlSupportsSshOverVsockYaml } from "./lima-version.ts"; import type { NetworkPlan, NetworkProvider, VmNetworkAttachment, VmRole } from "./types.ts"; export interface LimaUserV2NetworkAttachment extends VmNetworkAttachment { @@ -74,7 +75,7 @@ export class MacOsLimaUserV2NetworkProvider implements NetworkProvider { - this.ensureLimactl(); + assertLimactlSupportsSshOverVsockYaml(this.ensureLimactl()); return Promise.resolve(); } diff --git a/src/rootcell/providers/macos-lima-user-v2/README.md b/src/rootcell/providers/macos-lima-user-v2/README.md index e798be6..bee132c 100644 --- a/src/rootcell/providers/macos-lima-user-v2/README.md +++ b/src/rootcell/providers/macos-lima-user-v2/README.md @@ -75,6 +75,10 @@ ROOTCELL_LIMACTL=/path/to/limactl # LIMACTL=/path/to/limactl also works ``` +The Lima provider requires Lima 2.0.2 or newer because its generated YAML uses +`ssh.overVsock: true` for the VZ bootstrap SSH path. The repo's `.#hostTools` +package pins a compatible Lima release. + rootcell does not override `LIMA_HOME`; Lima instances, the Lima user key, and user-v2 networks are managed through the normal Lima home. Set `LIMA_HOME` yourself if you want Lima state somewhere else. diff --git a/src/rootcell/rootcell.test.ts b/src/rootcell/rootcell.test.ts index 66996ee..149ae51 100644 --- a/src/rootcell/rootcell.test.ts +++ b/src/rootcell/rootcell.test.ts @@ -24,6 +24,11 @@ import { limaUserV2ReservedIps, MacOsLimaUserV2NetworkProvider, } from "./providers/macos-lima-user-v2-network.ts"; +import { + assertLimactlSupportsSshOverVsockYaml, + limaVersionSupportsSshOverVsockYaml, + parseLimactlVersionOutput, +} from "./providers/lima-version.ts"; import { directSshConfig, LimaVmProvider, limaYaml, NIXOS_LIMA_AARCH64_IMAGE, parseLimaVmState, userV2ProofScript } from "./providers/lima.ts"; import { ImageStore, @@ -465,6 +470,65 @@ describe("host tool resolution", () => { }); }); +describe("Lima version compatibility", () => { + test("parses limactl version output", () => { + expect(parseLimactlVersionOutput("limactl version 2.1.1")).toEqual({ + major: 2, + minor: 1, + patch: 1, + raw: "2.1.1", + }); + expect(parseLimactlVersionOutput("limactl version v2.0.2")).toEqual({ + major: 2, + minor: 0, + patch: 2, + raw: "2.0.2", + }); + expect(parseLimactlVersionOutput("limactl version 2.1.1-dev")).toEqual({ + major: 2, + minor: 1, + patch: 1, + raw: "2.1.1", + }); + expect(parseLimactlVersionOutput("limactl unknown")).toBeNull(); + }); + + test("requires the Lima release that introduced ssh.overVsock YAML", () => { + const oldLima = parseLimactlVersionOutput("limactl version 1.2.2"); + const firstYamlFieldRelease = parseLimactlVersionOutput("limactl version 2.0.2"); + const newerLima = parseLimactlVersionOutput("limactl version 2.1.1"); + if (oldLima === null || firstYamlFieldRelease === null || newerLima === null) { + throw new Error("test failed to parse Lima versions"); + } + + expect(limaVersionSupportsSshOverVsockYaml(oldLima)).toBe(false); + expect(limaVersionSupportsSshOverVsockYaml(firstYamlFieldRelease)).toBe(true); + expect(limaVersionSupportsSshOverVsockYaml(newerLima)).toBe(true); + }); + + test("fails preflight before provisioning with a limactl that cannot read ssh.overVsock", () => { + const dir = mkdtempSync(join(tmpdir(), "rootcell-lima-version-test-")); + try { + const limactl = join(dir, "limactl"); + writeFileSync(limactl, [ + "#!/bin/sh", + "printf 'limactl version 1.2.2\\n'", + "", + ].join("\n"), "utf8"); + chmodSync(limactl, 0o755); + + expect(() => { + assertLimactlSupportsSshOverVsockYaml(limactl); + }).toThrow(".ssh.overVsock"); + expect(() => { + assertLimactlSupportsSshOverVsockYaml(limactl); + }).toThrow("nix shell .#hostTools --command ./rootcell"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("secret providers", () => { test("registry routes provider-qualified references", async () => { const calls: string[] = []; @@ -893,6 +957,7 @@ describe("VM and network providers", () => { expect(commonModule).toContain('home = lib.mkDefault "/home/${username}";'); expect(commonModule).toContain("ln -sfn /run/current-system/sw/bin/bash /bin/bash"); expect(commonModule).toContain("services.lima.enable = !isAwsEc2;"); + expect(commonModule).toContain('services.dbus.implementation = "dbus";'); expect(commonModule).toContain("/virtualisation/amazon-image.nix"); expect(commonModule).toContain("networking.nat.enable = lib.mkForce false;");