Skip to content

Kiosk Mode

How the KN-86 boots straight into the nOSh runtime with no login prompt, no display manager, and a read-only root filesystem — and how a developer drops out of that mode for debugging. Read this if you are configuring auto-login, changing the read-only-root layout, disabling a service, or debugging “why won’t my edit to /etc/... survive a reboot.”


There is no display manager (no LightDM, no GDM, no SDDM). The system boots to multi-user.target, then a getty override on tty1 auto-logs the kiosk user.

# /etc/systemd/system/getty@tty1.service.d/kn86-autologin.conf
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin nosh --noclear %I $TERM
Type=idle

The nosh user is created by stage-kn86-base of system-image-build.md:

Terminal window
useradd -m -s /bin/bash -G video,audio,input,dialout nosh

video for KMSDRM framebuffer access, audio (legacy — actual audio is via the Pico, not Linux), input for /dev/input/event*, dialout for /dev/serial0 to talk to the Pico. No sudo, no shell access in production mode (see “Recovery / dev-mode toggle” below).

The user’s ~/.profile chains directly into the nOSh runtime systemd unit:

Terminal window
# /home/nosh/.profile (snippet)
if [[ "$(tty)" == "/dev/tty1" ]] && [[ -z "$KN86_BOOTED" ]]; then
export KN86_BOOTED=1
exec systemctl --user start kn86-nosh
fi

The combination of auto-login + the ~/.profile chain delivers the user to nOSh in <8 s from kernel handoff. The multi-user.target is the right systemd target — graphical.target would pull in display-manager assumptions we explicitly don’t want.

The rootfs (/dev/mmcblk0p4 or p5, depending on active slot per ../../adr/ADR-0011-device-firmware-update-system.md) is mounted read-only by /etc/fstab in the slot:

/dev/disk/by-label/rootfs-A / ext4 ro,noatime,errors=remount-ro 0 1
/dev/disk/by-label/bootfs-A /boot vfat ro,noatime 0 2
/dev/disk/by-label/share /home/shared ext4 rw,noatime 0 2
tmpfs /tmp tmpfs rw,nosuid,nodev,size=64m 0 0
tmpfs /var/log tmpfs rw,nosuid,nodev,size=32m 0 0

Ephemeral writes (anything that systemd, journald, or nOSh wants to scribble during a session) land on tmpfs at /tmp and /var/log. They evaporate on reboot. The only writable persistent location is /home/shared (p6) — the Universal Deck State partition.

We use pi-gen’s built-in overlayroot support (an overlay-root overlay that mounts an overlayfs over the root, backed by tmpfs) for any case where a runtime tool insists on writing to a non-tmpfs path under /. This is enabled in stage-kn86-base of system-image-build.md and handled by adding init=/init-overlay to cmdline.txt for that slot. We prefer the explicit fstab ro mount above when possible (smaller surface, no overlayfs surprise behaviour); overlayroot is the fallback for code that won’t honour ro.

/home/shared (p6 from ADR-0011) is the only persistent writable location the operator’s data ever touches. It carries:

/home/shared/
├── deckstate.bin # Universal Deck State (operator handle, credits,
│ # reputation, cartridge_history bitfield, phase chain)
├── boot-success-token # 60-second sentinel for ADR-0011 tryboot commit
├── kn86-mode.txt -> /boot/kn86-mode.txt # symlink for runtime read
└── (cart-side per-cart save data lives on each cart's own SD; not here)

The image-build pipeline never writes p6, the flasher never writes p6, the updater image never writes p6. Only nOSh (running as user nosh) writes p6. This isolation is the load-bearing reason ADR-0011 §Risks #5 holds — operator state survives every conceivable update / re-flash / slot swap.

Per-cartridge save data lives on the cart’s own SD per ../../adr/ADR-0019-cartridge-storage-and-form-factor.md — never on /home/shared.

stage-kn86-base of system-image-build.md masks the following systemd units (the -base flavor of pi-gen pulls some in by default that we don’t want):

UnitReason
getty@tty2.servicegetty@tty6.serviceProduction mode has only tty1 (autologin into nOSh).
bluetooth.serviceBluetooth is unused; the BT module is also disabled at the device-tree level (device-tree-overlays.md disable-bt) to free UART0.
avahi-daemon.servicemDNS exposes kn86.local on the network; in production mode we don’t want network discoverability. Re-enabled in dev mode.
triggerhappy.servicePi OS Lite ships a generic input-event-to-shell-command bridge; conflicts with nOSh’s exclusive ownership of evdev.
keyboard-setup.servicePi OS Lite tries to apply Linux console keymap; nOSh owns the keyboard, the Linux console is never user-visible.
systemd-resolved.serviceProduction mode has no DNS use case post-boot; static /etc/resolv.conf (empty) is sufficient.
wpa_supplicant.serviceWi-Fi is dev-mode-only; production has no network use case post-boot. Re-enabled in dev mode.
NetworkManager.servicePi OS Lite trixie default network stack (“Network stack” below). Production has no network use case post-boot; re-enabled in dev mode.
NetworkManager-wait-online.serviceWait-for-network would block boot in production — there is no network. Re-enabled in dev mode.
systemd-timesyncd.serviceNTP is dev-mode-only; production has no Wi-Fi to sync against. See boot-and-systemd.md “Time and clock”. Re-enabled in dev mode.
ssh.serviceProduction has no SSH; the service is masked. Dev mode enables it.
dphys-swapfile.servicePi OS Lite enables a swapfile by default (/var/swap). Swap on the SD card amplifies write-wear and the kiosk has no large-RSS workload — masked unconditionally; companion vm.swappiness=0 lives in /etc/sysctl.d/99-kn86-no-swap.conf.

Mask command for reference (run in stage-kn86-base/run-chroot.sh):

Terminal window
systemctl mask bluetooth.service avahi-daemon.service triggerhappy.service \
keyboard-setup.service systemd-resolved.service \
wpa_supplicant.service ssh.service dphys-swapfile.service \
NetworkManager.service NetworkManager-wait-online.service \
systemd-timesyncd.service
for tty in 2 3 4 5 6; do
systemctl mask "getty@tty${tty}.service"
done

The Pi-side ALSA audio path is blacklisted at the modprobe level (/etc/modprobe.d/kn86-blacklist.conf):

blacklist snd_bcm2835
blacklist snd_soc_bcm2835_i2s
blacklist snd_bcm2835_i2s

Per ADR-0017 and the Canonical Hardware Specification in CLAUDE.md, the YM2149 PSG is synthesized on the Pi Pico 2 coprocessor and emitted via I2S to a MAX98357A DAC/amp. The Pi never touches I2S audio; loading snd_bcm2835 would only expose an unused ALSA device. The kiosk user (nosh) is correspondingly NOT in the audio Linux group — no capability is granted that doesn’t have a corresponding device.

Pi OS Lite trixie ships NetworkManager as the default network stack (replacing dhcpcd). We keep that default — systemd-networkd would be a gratuitous fork.

  • Production mode: NetworkManager.service and NetworkManager-wait-online.service are masked (see “Disabling unused services” above). The kiosk has no post-boot network use case; mass storage is via cartridge, not the network.
  • Dev mode: both services unmask. Wi-Fi credentials are NEVER pre-baked into the image — the operator sets them via nmcli device wifi connect <ssid> password <pwd> on the first dev-mode boot. The connection profile lands at /etc/NetworkManager/system-connections/ and persists across reboots.
  • NM defaults drop-in: /etc/NetworkManager/conf.d/99-kn86.conf installs a small set of opinionated defaults — no auto-default connection, Wi-Fi power-save off (multi-second SSH latency stalls are intolerable on the dev loop), info-level logging.

Field updates (update-system.md) do NOT use the network — the path is cartridge-MSC sneakernet (per ADR-0011 + ADR-0019 + ADR-0020 surface 1). The fact that production mode has no network is a feature, not a regression.

There is no in-device path to drop to a shell from production mode. By design — a kiosk user cannot click their way out of the kiosk, and an attacker with physical access can’t trick the device into a shell either.

Mode is set by a single file read at boot: /boot/kn86-mode.txt on p1 (the common boot region). Contents:

mode=production

or

mode=development

stage-kn86-runtime of system-image-build.md reads this file in kn86-display-init.service (early in boot) and exports KN86_MODE for the rest of the unit graph. nOSh re-reads it once at start.

ConcernProductionDevelopment
/etc/getty@tty1 autologinnosh user, no shell exitnosh user, but Ctrl+C drops to bash
getty@tty2tty6maskedenabled (alt + arrow on a USB keyboard switches; the KN-86’s mech keeb does not have alt + arrow, so this is effectively bench-keyboard-only)
ssh.servicemaskedenabled (key-only — PasswordAuthentication no, PermitRootLogin no, ChallengeResponseAuthentication no via /etc/ssh/sshd_config.d/99-kn86.conf). Authorized keys baked into /home/kn86/.ssh/authorized_keys at image-build time from the wrapper’s KN86_AUTHORIZED_KEYS_FILE env var.
NetworkManager.servicemaskedenabled — NM is the dev-mode network manager. Wi-Fi creds set via nmcli device wifi connect, never pre-baked. See “Network stack” above.
wpa_supplicant.servicemaskedmasked (NetworkManager owns the Wi-Fi interface in dev mode; wpa_supplicant is only used as NM’s backend, not as a standalone service).
avahi-daemon.servicemaskedenabled (kn86.local resolvable)
systemd-timesyncd.servicemaskedenabled — NTP is reachable via the dev Wi-Fi. See boot-and-systemd.md “Time and clock”.
dphys-swapfile.servicemaskedmasked — swap is bad on SD regardless of mode (vm.swappiness=0 everywhere).
journaldvolatile (/run/log/journal, capped 32 MB / 8 MB free)persistent (/var/log/journal, capped 100 MB total / 30 MB per service)
Rootfsread-onlyread-only by default; mount -o remount,rw / works since the operator is root in dev
nEmacs REPL filesystem accessread-onlyread-write to /home/shared and mounted carts
  • Dev → Prod: edit /boot/kn86-mode.txt (mount p1 from any host with an SD reader, or via the updater MSC mount from ADR-0011), set mode=production, reboot.
  • Prod → Dev: physical SD access required. No in-device path. This is deliberate — production mode should not be re-enableable into dev mode without somebody opening the case and pulling the SD. The Pelican shell makes this a deliberate ritual, not a slip.

ADR-0021 defines a single sanctioned shape for nOSh-spawned child processes: the Legacy Terminal mode. This subsection clarifies how it interacts with the kiosk contract above.

The kiosk authority is preserved. Legacy Terminal does not weaken any kiosk-mode guarantee:

  • nOSh remains the only entry point. The child process is launched by nOSh via posix_spawn inside the same nosh user session — it is not an independently reachable login path.
  • Auto-login is unchanged. The auto-login override on tty1 still chains into nOSh; Legacy Terminal is reachable only after nOSh has started and the operator has entered the SYS+INFO×4 gesture from Bare Deck.
  • The read-only rootfs is unchanged. Legacy Terminal binaries and license materials live under /opt/legacy-terminal/ baked at image-build time — no runtime install path, no apt-get, no writes to the rootfs during a session.
  • The /home/shared isolation contract from ADR-0011 is unchanged. Per-title save state lands in /home/shared/legacy-terminal/saves/ (p6), created at first-boot by systemd-tmpfiles; it is never written by the flasher.
  • Dev mode is not required. Legacy Terminal is a production-mode feature.

The architectural pattern — “nOSh releases framebuffer/input/audio, spawns child, reclaims on SIGCHLD” — is the mode-swap primitive. Future features that follow the same shape (a child process that owns the device for a bounded session) inherit the kiosk-authority preservation argument from ADR-0021 without requiring new kiosk-mode analysis, provided they follow the same constraints: single child, no shell escape path, saves to /home/shared only.

If the SD itself is corrupt and the system won’t boot to the point of mounting p1, the only recovery path is to re-image the SD using a fresh .img from the system-image-build.md pipeline. There is no bench-side rescue partition baked into the SD layout — ADR-0011’s six-partition table does not reserve one. /home/shared (p6) survives any re-image of slots A and B (the flasher and provisioning script never touch p6 — see update-system.md), so operator state survives bootloader-level recovery.

For total-loss situations (corrupt p1 or a totally bad card), reseed the SD from .img and on first boot of the new image nOSh will see an empty /home/shared and bootstrap a fresh deck state.