Guide

Developer Setup

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

Quick Start

git clone <repo-url> && cd ze
./le setup install

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

le is the native development-tool personality built from cmd/ze. The root launcher executes the cached bin/le binary and builds it only when absent. Setup behavior lives in internal/le/setup.Answer.

The cache is an existence test, never a freshness test, so a change under internal/le/ does not reach ./le on its own.

The launcher does not rebuild by itself, because a peer session's half-written source would then fail every call in every session. It tells you instead. About one call in sixteen compares the binary with the build inputs and prints one line on stderr when a COMMITTED file is newer:

le: bin/le is older than committed sources; refresh it with './le --update'

A file that git holds as modified is never counted, so a peer's work in progress says nothing. The check runs after your command, so it never delays the answer you asked for.

./le --update builds the working tree into bin/le:

./le --update

It renames the new binary into place rather than writing through the file. A peer executing the old one therefore keeps the inode it started with. A failed build leaves bin/le as it was and says so. With a command after it, the option updates first and then runs that command against the new binary.

--update is the answer to a stale shared binary. --name below is the answer to reading a result back from your own uncommitted edits.

Do not delete bin/le. Several sessions share one checkout and one of them can be executing that file right now. Ask for a build of your own instead:

./le --name mywork verify current mode full

--name comes first, before the command, and the launcher consumes it. Every argument after it reaches the binary unchanged. The build lands in bin/le-mywork/le, which bin/ already keeps out of git, and it runs on every call, so the toolchain decides what to recompile and the binary carries your edits. The shared bin/le is never written and never waits for a peer.

A name accepts letters, digits, dot, underscore and hyphen. A name carrying / or .. is refused with a message and exit 2, never repaired into something safe.

The launcher exports the name to the process it runs and to that process's children, so a script that calls ./le inside a named session reaches the same build without repeating the option. A binary that is not the one you named refuses to answer and reports both names:

le: --name asked for 'mywork' and this process is 'le' (/home/you/ze/bin/le).
    Reach the named build through ./le --name mywork, never through a hardcoded binary path.

Check Mode

Probe the current host without installing anything:

./le setup check

The check action probes only and changes nothing: it installs no package, edits no sysctl, and adds no loopback address. It exits 0 if all required tools are present, nonzero if any are missing. Use it as a CI preflight check. Run ./le setup install to install what the probe found missing.

One row is behaviour rather than a binary: gopls-answers runs the language server and checks that it replies. A server on PATH that does not answer fails this check, because every LSP call against it fails the same way.

Editor plugin

A language server on PATH is not the whole capability. Claude Code reaches it through a plugin, and without that plugin the LSP tool refuses every Go file.

./le setup check reports a missing plugin as a pending step and names the command that installs it:

Plugin Serves Install
gopls-lsp .go /plugin install gopls-lsp@claude-plugins-official

Run the slash command inside a Claude Code session. The plugin and the binary it names fail the same way and have different fixes, so the report says which of the two is absent.

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.2.1 (via go install)
goimports Go import formatter (via go install)
gopls Go language server behind the agent LSP tool (via go install)

Regenerate the checked-in protobuf Go files after you change api/proto/ze.proto or the module path:

./le setup proto-generate

The action builds both protoc plugins from the vendored module versions, runs protoc, and applies explicit json_name options to Go struct tags.

Run the installed checker through the repository gate:

./le staticcheck-feature-matrix check

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

Appliance

Tool Purpose
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 Optional SSH probe fallback
docker / colima Container appliance and kernel builds

Platform Notes

macOS

Linux

./le setup install 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

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, ./le setup install 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. ./le setup check 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 native QEMU action 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 './le 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 native QEMU actions 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 ./le setup install after one; ./le setup check 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:

./le verify current mode full

Check that appliance tools are detected:

./le setup check

Drift Guard

The dev setup action 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.