Using Nekko Agent
Build the CI runner image
CI runners run each job in a throwaway clone of a macOS image you build once. This page builds that image.
What the image is
A macOS VM bundle in Tart's format: config.json, disk.img and nvram.bin. Nekko never runs
it. For every job it makes a copy-on-write clone, boots the clone with a small read-only seed
disk that holds the job's one-time runner config, and deletes the clone when the job ends. So
the image itself never runs a job, and nothing a job does survives it.
What you need
- An Apple silicon Mac.
- About 150 GB free: the Xcode base image is large, and building it needs room for a copy.
- Tart, to build the image only. Nekko does not use Tart to run jobs.
- FileVault off in the base image (Cirrus images have it off).
provision.shdisables every admin, so with FileVault on nobody could unlock the disk and the VM would wait at the unlock screen forever.
Build it
From the root of this repo:
tart clone ghcr.io/cirruslabs/macos-sequoia-xcode:<tag> nekko-golden
tart run --no-graphics --dir runner-image:scripts/runner-image:ro nekko-golden &
tart exec nekko-golden sudo bash "/Volumes/My Shared Files/runner-image/provision.sh"
tart stop nekko-golden
mkdir -p ~/.nekko-agent/runners/images/default
cp -c ~/.tart/vms/nekko-golden/{config.json,disk.img,nvram.bin} ~/.nekko-agent/runners/images/default/Pick <tag> from the Cirrus image list
for the Xcode your jobs need. --dir shares scripts/runner-image into the guest read-only, so
provision.sh finds the boot script and LaunchDaemon beside it. tart exec works only once:
provision.sh removes the Tart guest agent it runs through.
~/.nekko-agent is Nekko's data folder (or $NEKKO_DATA_DIR when you set it). cp -c clones
the files on APFS, so the copy is instant and takes no extra space until one side changes.
After copying you can tart delete nekko-golden.
The runner card shows No VM image yet. until runners/images/default/ holds all three files.
From Apple's restore image
To use no third-party content at all, start from Apple's own restore image instead:
tart create --from-ipsw latest nekko-golden
tart run nekko-goldenGo through Setup Assistant by hand with one admin account, install Xcode and anything else your
jobs need, then share scripts/runner-image in and run sudo bash provision.sh in the guest's
Terminal. Stop the VM and copy the three files as above.
What provision.sh does
- Checks it runs as root on Apple silicon with macOS 14 or later, that no Xcode is named
Xcode_*.app(rename it toXcode.app: the job user cannotsudo xcode-select), that/Users/runneris free, and that FileVault is off. Cirrus images link/Users/runnerto the admin's home for GitHub-hosted compatibility; that link is replaced, a real folder there stops the script. When/Applications/Xcode.appexists it selects it and accepts its license. - Creates the job user
runner, a standard account (not an admin) with a random password that is never shown or saved. It copies the existing admin's Setup Assistant answers sorunner's first login shows no assistant, and turns its screen saver off. - Logs
runnerin automatically at boot, so jobs get a real GUI session for simulators and the keychain. It writes/etc/kcpassword(root only, mode 0600) andautoLoginUseritself:sysadminctl -autologin setwrites nothing on macOS 15 in a VM and still reports success. That file is fine becauserunneris not an admin and cannot become root. - Downloads the pinned GitHub Actions runner, checks its SHA-256, and unpacks it into
/Users/runner/actions-runner. It never registers it: each job's one-time config does that. - Downloads the pinned
gitlab-runnerto/usr/local/bin, checks its SHA-256. Not registered, no service. - Installs the boot script at
/usr/local/libexec/nekko-runner-boot.shand its LaunchDaemondev.nekkolabs.runner-boot. At each boot of a clone it reads the seed, starts one job asrunner, and powers the VM off when the job ends, fails, or the seed is wrong. - Gives
runnerownership of/opt/homebrew, so jobs canbrew installwithout sudo. - Turns off sleep, Spotlight indexing and automatic update downloads and installs (macOS, critical and configuration data updates). macOS 15 restores the automatic update check at boot, so a job VM may still check for updates; the download and install settings stay off.
- Locks the guest down: every account in the
admingroup, however it got there (listed members, primary group 80, nested groups) and root included, is disabled (a base image's admin password is public, Cirrus's isadmin, and root cannot change a secure-token admin's password without the old one, so disabling is what stops a job fromsu admin), every file in/etc/sudoers.dgoes (and it fails if/etc/sudoersstill allows passwordless sudo), SSH, Screen Sharing and Apple Remote Desktop are turned off, and Cirrus Labs' LaunchDaemons, LaunchAgents and the Tart guest agent are removed. Then it checks the result: it fails unlesssudo -l -U runnersaysrunnermay not use sudo,runneris not in theadmingroup, every admin and root are refused authentication, and every local user got a plain answer fromdsmemberutil(anything else stops it). Root keeps working for the system and forsudo; it just cannot be logged in as. - Writes
/usr/local/share/nekko-runner-image.jsonwith the build date and the macOS, Xcode, Actions runner and gitlab-runner versions.
The pinned versions sit at the top of provision.sh, with hashes taken from the official
release pages: the osx-arm64 SHA in the
actions/runner release notes and
binaries/gitlab-runner-darwin-arm64 in
gitlab-runner's release.sha256.
What it does not install
Xcode, iOS simulator runtimes, Homebrew and the tools your jobs use. They come from the base
image: the Cirrus -xcode images carry them. Add anything else before running provision.sh,
while the admin account still works.
GitLab
gitlab-runner is installed and unused until GitLab support lands (T215).
Keeping it current
Rebuild the image when GitHub publishes a new runner. An outdated runner updates itself inside
every throwaway VM, which costs a download on every job, and once GitHub stops supporting that
version it gets no jobs at all. Rebuild for macOS and Xcode updates too. The image is rebuilt,
never patched: provision.sh refuses to run twice on the same image.
Why there is no SSH or agent
Nothing on the host can reach into a job's VM: no SSH, no Screen Sharing, no Tart guest agent. A job that broke out of its user would find no way back to your Mac beyond the private network every job already has. See decision 0022.
Check it
Boot a clone of the image Nekko uses with a fake seed and no network, and watch it shut itself
down. From the repo root,
after node apps/desktop/scripts/stage-daemon.mjs --dev:
mkdir -p target/runner-image-check
target/dev-daemon/nekko-vm clone --from ~/.nekko-agent/runners/images/default --to target/runner-image-check/vm
seed="$(mktemp -d)" && chmod 700 "$seed" && printf not-a-config > "$seed/jit-config"
hdiutil makehybrid -iso -joliet -default-volume-name NEKKOSEED -o target/runner-image-check/seed.iso "$seed"
perl -MSocket -MPOSIX -e 'socketpair(my $a, my $b, AF_UNIX, SOCK_DGRAM, 0) or die $!;
if (my $pid = fork) { waitpid($pid, 0); exit($? >> 8) }
POSIX::dup2(fileno($b), 3); exec @ARGV' \
target/dev-daemon/nekko-vm run --bundle target/runner-image-check/vm --cpus 4 --memory-mb 8192 --net-fd 3 --seed target/runner-image-check/seed.iso
rm -rf target/runner-image-check "$seed"It prints {"event":"started"}, then within 10 minutes {"event":"stopped"...} and exits, with
nobody touching the VM: the guest logged in, read the seed, the runner rejected the config, and
the guest powered off. Past 10 minutes it failed: stop it, boot the clone in Tart with graphics,
and read /var/log/nekko-runner-boot.log.
Use the literal not-a-config. A well-formed JIT config that GitHub does not know (a made-up
one, or a runner that was deleted) makes the runner retry every 30 s forever ("Runner connect
error: The resource cannot be found.. Retrying until reconnected."), so that VM only stops at
the max job time.
Verified on
2026-10-25, on a Mac with Apple silicon running macOS 27.0.1, guest Cirrus
macos-sequoia-base (macOS 15.7.7, no Xcode). Tart was not installed, so the image was built
with nekko-vm through the spike example
(cargo run -p nekkod --features spike --example runner_vm_spike -- --bundle <dir> --seed <iso> --keep),
which forwards 127.0.0.1:2222 to the guest's SSH for the build and copies
scripts/runner-image in with scp in place of --dir.
- Build: SSH up about 30 s after boot;
provision.shran in 2 min (most of it the runner download and handing/opt/homebrewover), thenshutdown -h now, and the helper reportedstopped, reasonguest. - Checked in the guest as root before shutdown:
runneris uid 502, not inadmin, may not run sudo;/etc/sudoers.dis empty and/etc/sudoershas noNOPASSWD; both admins (admin,_mbsetupuser) are refused authentication andsu adminasrunnerwith the public password fails;/etc/kcpasswordis root 0600 andautoLoginUserisrunner; sshd and Screen Sharing are disabled in launchd and Remote Login is off; noorg.cirruslabs.*plists and notart-guest-agentonPATH; the boot script (root:wheel 0755), its LaunchDaemon (root:wheel 0644), gitlab-runner (root:wheel 0755) andnekko-runner-image.jsonare in place. - Boot with the
not-a-configseed:startedtostopped, reasonguest, in 49 s with nobody touching the VM. The runner was started about 8 s afterstarted(the boot script waits for the auto-login session, sorunnerwas logged in by then), rejected the config, and the guest powered off. Read from the clone's disk afterwards: the boot log shows start, the runner's error,runner exited with 0,shutting down;~runner/.nekkois empty. - Boot with a well-formed fake JIT config: the runner started 4 s after
started, reached GitHub, got a 404 and retried every 30 s; stopped by hand after 16 min (see above). - Every clone gets its own MAC address (
nekko-vm clonewrites a new one intoconfig.json). - Found and fixed on the way: the
/Users/runnerlink,sysadminctl -autologin, the admin password change (dscl -passwdasks for the old password of a secure-token admin), andsoftwareupdate --schedule offdoing nothing. - Rechecked after review: a fresh build with the all-users lockdown found three admins by
dsmemberutil(admin,_mbsetupuser, root) and disabled all three. Root reported "Policy allows" before (it has no password,Password: *) and is refused after;sudostill works.fdesetup statussaysFileVault is Off.. A clone of that image with thenot-a-configseed logged in, ran the runner and powered off in 51 s, and its disk still hadAutomaticDownload,CriticalUpdateInstall,ConfigDataInstallandAutomaticallyInstallMacOSUpdatesfalse after the boot (AutomaticCheckEnabledwas gone again). - Left alone: the Tart guest agent's Homebrew Cellar folder stays on disk, with nothing that starts it.