Skip to main content

pm on LosOS

Administering the machine​

There is no sudo, su or run0. An administrator changes the system with pm system, and pm does the work as its own user, pm:

CommandWhat it does
pm system start|stop|restart|reload <unit>the same to a system unit; a bare name is a service
pm system update [--reboot]installs the newest version for the next boot (systemd-sysupdate.service), and reboots into it if asked and there was one

That is all it does. It runs no command as root and opens no shell. An administrator's session, or whatever takes it over, can only start and stop the units the image already has and install its signed updates. A machine that needs more, because something is broken, gets a root shell from the boot menu, which needs someone physically at the machine. See When something goes wrong.

pm asks pmd --system, a service that the system bus starts on the first call. It is pm-system.service, set up in nixos/modules/pm.nix. The daemon asks polkit whether the caller is allowed. polkit asks an administrator, a member of wheel, to authenticate as themselves, either in derisk's dialog, as polkit and pm shows, or on the terminal through pkttyagent when the session has no agent. The password stays valid for a few minutes. Only then does the daemon act, as pm and through systemd, and the journal records who asked for what.

The pm user owns nothing and holds no capability. Everything it may do comes from one polkit rule, which lets it manage units without asking, except debug-shell.service. That unit is a root shell on tty9 with no password, so starting it would hand out a root shell by another name. The recovery shell starts it from the kernel's command line instead, where polkit has no say.

A second rule, evaluated last, refuses unit management to everyone else. Without it an administrator could still systemctl restart a service, or use the run0 that systemd itself carries, and become root without going through pm. Root is never asked, so the system's own services and the test driver are unaffected. Narrower rules placed before it still answer first, such as the one in nixos/modules/disk.nix that lets an administrator ask for a factory reset from Settings. Everything else polkit governs works as before: accounts through homed, the network through NetworkManager, updates from Settings through sysupdated, and Flatpak.

The image's boot test, nixos/tests/boot.nix, runs pm system restart as root. It checks that the daemon runs as pm, that pm system run is gone, and that pm cannot start the debug shell. It also asks polkit what it says to pm and to an administrator about managing units, both directly and through pm.

pm's plugins​

nixos/pkgs/pm-plugins.nix builds eight components: this repository's three and pm's own libvirt, podman, sysext, sysupdate and systemd. The image carries them in /run/current-system/sw/share/pm/plugins. pm loads plugins only from a user's own ~/.config/pm/plugins, and only when they are signed by a key that user trusts. So the image offers the plugins but trusts them for no one:

install -Dm644 -t ~/.config/pm/plugins /run/current-system/sw/share/pm/plugins/*.wasm
for p in ~/.config/pm/plugins/*.wasm; do pm sign "$p"; done

With Home Manager​

Home Manager runs on a build host, or on any machine with Nix. The image itself has no Nix, and its users are systemd-homed records. Where Home Manager does run, the flake's homeModules.pm installs pm and its plugins instead of the copy above:

{
imports = [ losos-desktop.homeModules.pm ];
programs.pm.enable = true;
# All eight by default; or name the ones you want:
# programs.pm.plugins = [ "losos-nix" "systemd" ];
}

Each plugin is linked into ~/.config/pm/plugins from the Home Manager generation. A plugin taken off the list is uninstalled at the next switch, and a rollback restores the previous set. Activation then runs the same pm sign over each one with your own key, which is made and trusted on first use as it would be by hand. So you still give the trust, not the module. extraPlugins installs other components by name. signatures takes a publisher's .sig for a plugin instead of signing it locally, and trustedKeys adds that publisher's key to pm's trust store, which also governs build files.

nix flake check builds the module into a Home Manager generation and runs its signing step against it. It then checks that pm plugins loads every plugin with no flags, in nixos/tests/home-manager-pm.nix. No home-manager switch on a real account has run it yet.

Driving the flake from pm​

pm stays the system manager here too, so a pm build file can build and check the flake. pm's fingerprint table would otherwise refuse nix build, nix flake check, nix eval and the other commands that read or build, by constraint C2. The losos-nix plugin names them, as plugins/README.md describes. Two things about pm's jail shape such a step. /nix/store is mounted read-only and the daemon socket not at all, so the step builds into a store of its own with nix --store /build/nix .... And a step that says --offline gets no network, so the jail enforces what the step claims. Without --offline, the step is on the host network, and by constraint C8 so is the whole build file, because evaluating fetches the inputs and building substitutes from the project's cache. nixos-rebuild, nix profile, nix run and nix flake update are recognised and left unclassified on purpose. This OS is updated by systemd-sysupdate, never by switching a generation, and a pin moves by hand.

nixpkgs in a pm build​

All of nixpkgs is reachable from a pm build file, at the revision flake.lock pins, built for the same stock glibc platform as this OS. The flake's legacyPackages is the image's own package set, so .#ripgrep is nixpkgs' ripgrep, the same store path the image would carry. .#pm-payloads.ripgrep is what a pm package can actually hold, built by nixos/pkgs/pm-payloads.nix. It is the same package from pkgsStatic, with every output merged into usr/bin, usr/sbin, usr/libexec and usr/share.

It has to be static because pm extracts a package at /pkg and runs it in a jail whose /nix/store is either the host's or nothing, and a dynamic program names its loader and libraries by store path. The payload sets allowedReferences = [ ], so Nix itself refuses a payload where any file still names a store path, and says which file. gnugrep is refused that way, because egrep is a script whose shebang is bash's store path. That is the check doing its job, since such a package could not run from /pkg.

A build file that installs one:

name: ripgrep
version: ['15', '1', '0']
dependencies: []
steps:
- stage: Build
dl_urls: null
name: build
run:
- nix --store /build/nix build --out-link /build/payload github:dasmatus/losos-desktop/<commit>#pm-payloads.ripgrep
- stage: Install
dl_urls: null
name: stage
run:
- find /build/nix/nix/store -maxdepth 1 -type d -name *-pm-payload-ripgrep-* -exec cp -rT {}/usr /dest/usr ;

The second step uses find instead of cp /build/payload/usr, because the out-link points at /nix/store/.... In pm's jail that is the host's store, not the one at /build/nix the step built into. There is no shell, so the pattern reaches find unexpanded.

losos-nix holds the flake reference to the same rule as a pin, as plugins/README.md describes. A commit, a path, or a URL with narHash= is classified. nixpkgs#ripgrep, github:NixOS/nixpkgs/nixos-26.05, <nixpkgs> and anything else that resolves to whatever is newest gets no verdict, and pm refuses the build file before it runs.

note

Untested beyond evaluation and the payload builder itself: both architectures evaluate .#pm-payloads.<name>, and the builder, run against nixpkgs' own cached static packages, produced a static hello and refused gnugrep. No pm build has run the steps above, and CI does not build any payload, so the first one of each package compiles its whole static closure.

Virtual machines​

The image runs libvirt from boot, as nixos/modules/libvirt.nix sets up and Virtual machine desktop describes. It uses QEMU for the machine's own architecture, with KVM where the CPU has it. virt-manager and virsh use it as on any distribution. LIBVIRT_DEFAULT_URI is qemu:///system for every session. Administrators, the wheel members that first-boot setup makes, may manage machines there without being asked, and everyone else is asked for an administrator's password.

pm boots a package that ships its own kernel, marked kernel(...) in its recipe as pm's README explains, in a virtual machine. On its own, pm starts QEMU itself, as you. With pm's libvirt plugin installed and signed as above, and it is one of the eight, pm asks libvirt instead:

pm run hello.cpkg # through libvirt, when the plugin is installed
pm run --qemu hello.cpkg # QEMU directly, as you, asking no plugin

The plugin is a WebAssembly component like the rest, and it can reach nothing by itself. It writes a transient domain definition and names one program, virsh, which pm plugins lists under launchers:. pm then runs virsh create --console --autodestroy with it. virsh connects where it always does, to LIBVIRT_DEFAULT_URI, which here is qemu:///system. There QEMU runs as libvirt's own user instead of as you, in its own cgroup and mount namespace with QEMU's seccomp sandbox on. libvirt hands it the kernel, the initramfs and the two serial sockets for the life of the machine. While the machine runs, virsh list and virt-manager show it as pm-vm-<random>, titled pm run: <program>. It is destroyed when virsh's connection closes, and pm stops virsh when pm dies. So a killed pm takes its machine with it, and Ctrl-C stops the machine and gives the terminal back. The guest is the same as under QEMU: the package and the libraries its programs link, with no network device and no disk. Only x86-64 boots a package's kernel.

A real boot through the plugin passed on an Ubuntu host with libvirt 10.0. It ran a distribution kernel with a probe through qemu:///system, with QEMU as libvirt's own user, under TCG. The same boot with pm's own QEMU passed too. Neither the module nor the plugin has run on this image yet.

Machines that stay​

pm machine keeps virtual machines that outlive any one command. These are the machines of the virtual machine desktop. As with pm run, pm leaves libvirt to its libvirt plugin, which turns each request into the virsh steps that carry it out:

pm machine create work \
--image /run/current-system/sw/share/losos/machines/losos-guest.qcow2 \
--label blue --memory 4096 --cpus 4 --disk 64
pm machine start work
pm machine list
pm machine stop work # asks the guest to shut down
pm machine kill work # pulls the plug
pm machine remove work # the machine and its disk; the template stays

The template becomes a read-only volume in libvirt's default pool, named after the template and its digest, so every machine made from one image shares it. A machine's disk, pm-machine-<name>.qcow2, is a qcow2 overlay on it, as large as --disk says and never smaller than the template. Its definition is a persistent domain that carries its label and template in <metadata>, a vsock device, and a channel for the QEMU guest agent. derisk's Machines app runs these same commands.

--installer CD.iso, given more than once for more CDs, makes an empty machine that installs its system from those CDs instead. --system windows makes it a Windows PC. pm adds the image's CD of guest tools for that system from share/pm/machines on XDG_DATA_DIRS, or the one --tools names, as Machines from an installer CD describes. Removing a machine deletes only its own disk, with virsh undefine --storage vda, and never a template or CD that another machine may share.

nixos/tests/machines.nix makes a machine from the image's own template this way, under TCG. It checks that the machine boots, grows its root over its 8 GiB disk, gets an address on libvirt's network, and lists its apps through the agent. It also checks that opening an app reaches the host's vsock port 7350 from the address libvirt gave the machine. CI runs it with the other VM tests.