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 legacy Pi ALSA driver is blacklisted at the modprobe level (/etc/modprobe.d/kn86-blacklist.conf):

blacklist snd_bcm2835

Per ADR-0017 / ADR-0054 and the Canonical Hardware Specification in canonical-spec.md, the YM2149 PSG is synthesized on the Pi Pico 2 coprocessor and emitted via I2S to a WM8960 codec; loading snd_bcm2835 would only expose an unused ALSA device. Bench phase (GWP-698): snd_soc_bcm2835_i2s is deliberately not blacklisted — the WM8960 sits on the Pi’s header as a HAT during bring-up and its overlay needs the Pi I2S DAI to autoload (see device-tree-overlays.md “Pi-side audio”). The blacklist line returns when the audio chain moves to the Pico. The kiosk user (nosh) remains NOT in the audio Linux group, yet nOSh opens the default PCM anyway: the tty1 autologin session grants per-device logind seat ACLs on /dev/snd/* (verified on deckline 2026-07-08 — nOSh held pcmC1D0p at boot). Group membership stays off so non-seated users (SSH) get no standing audio grant.

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.