Guide

Developer Setup

Set up a Ze development environment with all build, lint, and test dependencies.

Quick Start

git clone <repo-url> && cd ze
make ze-dev-setup

This detects your OS (macOS with Homebrew or Debian/Ubuntu with apt), installs missing tools, vendors Go dependencies, and reports what it did.

Check Mode

Probe the current host without installing anything:

make ze-dev-setup CHECK=1

Exits 0 if all required tools are present, nonzero if any are missing. Useful as a CI preflight check.

Two of the rows are behaviour, not binaries: gopls-answers and pyright-answers run each language server and check that it replies. A server on PATH that does not answer fails this check, because every LSP call against it fails the same silent way.

What It Installs

Build and Lint

Tool Purpose
go Go toolchain
git Version control
protobuf (protoc) Protocol buffer compiler
jq JSON processing
golangci-lint Go linter (via go install)
staticcheck Feature-tag structural type checker, pinned to 2026.1 (via go install)
goimports Go import formatter (via go install)
gopls Go language server behind the agent LSP tool (via go install)
python3 Runs evidence and dev scripts
pipx Python tool installer
ruff Python linter (via pipx)
pyright Python language server behind the agent LSP tool (via pipx)

Run the installed checker through the repository gate:

make ze-staticcheck-feature-matrix-check

The target and its checked feature population are documented in docs/contributing/testing.md.

Appliance and Evidence

Tool Purpose
uv Python package runner for SSH probe (uv run --with paramiko, via pipx)
qemu QEMU functional and install gate tests
e2fsprogs mkfs.ext4 and debugfs for appliance builds
xorriso ISO image creation
grub GRUB EFI tooling for ISO builds (Linux only)

Optional

Tool Purpose
sshpass SSH probe fallback (uv+paramiko is primary)
docker / colima Container appliance and kernel builds

Platform Notes

macOS

Linux

make ze-dev-setup installs the apt packages itself, the same way it installs the Homebrew ones on macOS. Each command is echoed before it runs. It takes apt-get update once per run, because a container image ships no package lists, and it sets DEBIAN_FRONTEND=noninteractive so a package with a debconf prompt cannot stop the run.

How it reaches root. The answer is decided before any command runs, and sudo is always given -n, so no path can stop at a password prompt:

State What setup does
You are root (a container build) Runs the command directly. sudo need not be installed
sudo acts with no password Runs sudo -n <command>
sudo wants a password, a terminal is attached Asks once with sudo -v, then runs sudo -n <command>
sudo wants a password, no terminal (CI, an agent session) Prints the command, installs nothing, exits nonzero

uv is not in the Debian or Ubuntu repositories, so it installs through pipx on both platforms. One route is one thing to fix, and it keeps curl | sh off every dev machine.

GRUB follows your host architecture. Debian packages one module set per architecture: an amd64 host takes grub-efi-amd64-bin, an arm64 host takes grub-efi-arm64-bin. Asking an arm64 host for the amd64 package installs nothing at all, grub-mkstandalone included. ze appliance iso picks its GRUB target from the architecture of the image it packs, so building an ISO for the OTHER architecture needs that architecture's set too, through dpkg --add-architecture.

Unprivileged user namespaces. Ubuntu 23.10+ ships kernel.apparmor_restrict_unprivileged_userns=1, which blocks the sandbox Chrome relies on and makes the agent-browser web functional tests fail to launch Chrome (No usable sandbox!). Setup checks this tunable as userns-unrestricted. When it is restricted, make ze-dev-setup (install mode) echoes and then runs these commands via sudo to lift it globally:

echo "kernel.apparmor_restrict_unprivileged_userns = 0" | sudo tee /etc/sysctl.d/60-ze-userns.conf
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0

The /etc/sysctl.d drop-in makes the change survive reboots. It goes through the same root route as the package installs, so on a root run the echoed lines carry no sudo, and when root is out of reach it prints the commands to run by hand instead. make ze-dev-setup CHECK=1 only reports the state, never changes it.

KVM device access. /dev/kvm is root:kvm mode 0660, so QEMU-backed evidence (the appliance boot proofs and every ze-qemu-* target) needs your user in the kvm group. Without it QEMU does not quietly fall back to emulation: it refuses to start with Could not access KVM kernel module: Permission denied, and the calling script reports a timeout instead. Setup checks this as kvm-access and, in install mode, runs:

sudo usermod -aG kvm $USER

Group membership is fixed at login, so an existing shell keeps the old groups even after the command succeeds. Log out and back in, or run one command with the new group:

sg kvm -c 'make ze-qemu-vpp-hugepages-test'

Setup distinguishes the two states: kvm-access reports pending when the group database lists you but the running session predates it, and missing when the group is not granted at all. A host with no /dev/kvm (no hardware virtualisation, or a VM without nested virt) reports n/a: QEMU runs under tcg there, only slower. macOS has no /dev/kvm and needs no group; the evidence scripts select the Apple hypervisor (hvf) by platform.

Loopback addresses. The functional fixtures give each end of a BGP session its own address: RFC 4271 Section 5.1.3 forbids a peer its own address as NEXT_HOP, so a session whose two ends share one address has every originated route withheld. IPv4 spends 127.0.0.0/8, which Linux already routes to lo and macOS does not, so setup adds 127.0.0.2 through 127.0.0.5 there. IPv6 gives a host exactly ::1 on every platform, so setup adds fd00::2 on both. That address is unique-local (RFC 4193) and never globally routable, so a fixture cannot leak a packet toward a real destination. Setup checks this as loopback-addresses and, in install mode, runs:

sudo ifconfig lo0 inet6 fd00::2/128 alias      # macOS
sudo ip -6 addr add fd00::2/128 dev lo         # Linux

Presence is decided by binding a socket to the address, which is the same question a fixture asks and a stronger one than reading the interface list: an IPv6 address is listed while duplicate-address detection still refuses it. The test runner cannot add either family itself (the ioctl returns EPERM unprivileged, and the Linux route needs CAP_NET_ADMIN), so a test that binds a missing address fails at once naming the command above.

Neither addition survives a reboot. Re-run make ze-dev-setup after one; make ze-dev-setup CHECK=1 says when it is needed. The merge gate adds the IPv6 address the same way, as its own workflow step (.github/workflows/verify.yml).

These three, and the apt installs above, are every place setup reaches for root. All of them go through one helper, so the table of states earlier in this section governs each of them.

After Setup

Verify everything works:

make ze-smoke-verify    # lint + unit tests + build (~2 min)

Check that appliance tools are detected:

bin/ze-setup appliance iso --check

Drift Guard

The dev setup script and ze doctor appliance checks share the same tool list. A Go test (TestDevSetupMatchesDoctor in internal/appliance/dev_setup_drift_test.go) fails if they disagree, preventing the lists from drifting apart.