Skip to main content
podbox/docs/architecture.md
On this page

Architecture

How It Works

A definition TOML is the single source of truth. Everything podbox generates — Containerfiles, Quadlet systemd units, lock files, desktop entries — derives from this one file. The user never writes a raw Containerfile or systemd unit manually.

How podbox works Write a TOML file, optionally extending a profile. podbox build generates an OCI image and Quadlet units. systemd --user then runs the container and its host-side services. 1 · write 2 · build 3 · run podbox.toml image · packages · access Profile extends = "profile:fedora" extends podbox build pure function of the config OCI image packages baked in Quadlet units .container .socket systemd --user starts, restarts, keeps logs Container your shell Host side socket, filters install No podbox process stays running: after the build, it is ordinary systemd units and Podman.
How podbox works

Codegen Pipeline

podbox build runs these steps in order. Each codegen step is a pure function: data in, string out, no I/O. Orchestration (file writes, podman invocations) is separate.

podbox build: codegen pipeline A parsed and validated Config feeds pure generator functions, each returning one file as a string. Orchestration then writes the build context, copies the guest binary, runs podman build and installs the units. pure functions · data in, string out, no I/O Config parsed profiles merged validated containerfile::generate() Containerfile quadlet::generate_container() <name>.container quadlet::generate_build() <name>.build quadlet::generate_socket() <name>.socket quadlet::generate_host_service() <name>-host.service quadlet::generate_compositor_service() <name>-compositor.service quadlet::generate_dbus_proxy_service() <name>-proxy.service orchestration · writes files, calls podman and systemctl Build context ~/.local/share/podbox/<name> 1 Copy guest static musl binary 2 podman build skipped when unchanged 3 Install units and write the lock file 4 Dashed rows only run when the config enables the Wayland filter or the D-Bus proxy.
Codegen pipeline

Generated Containerfile

dockerfile
1FROM fedora:44
2 
3# [image.packages]
4RUN dnf install -y git gcc ripgrep && dnf clean all
5 
6# [image.run] custom steps
7RUN dnf clean all
8 
9# podbox integration layer — always last
10COPY podbox-guest /usr/local/bin/podbox-guest
11RUN chmod +x /usr/local/bin/podbox-guest
12 
13ENV PODBOX_CONTAINER=myenv
14ENV PODBOX_HOST_VERSION=0.5.0
15ENV LANG=en_US.UTF-8
16ENTRYPOINT ["/usr/local/bin/podbox-guest", "--entry"]
17CMD ["/usr/bin/fish"]

Build Context Layout

~/.local/share/podbox/<name>/
├── Containerfile
├── podbox-guest          # static musl binary from host

Generated Quadlet Files

Files written to ~/.config/containers/systemd/:

myenv.build

ini
[Build]
ImageTag=localhost/podbox-myenv:latest
File=/home/user/.local/share/podbox/myenv/Containerfile

The .build unit makes myenv.service depend on the build. Images are only rebuilt when the Containerfile changes.

myenv.socket

ini
1[Unit]
2Description=podbox host-guest socket — myenv
3 
4[Socket]
5ListenStream=%t/podbox/myenv.sock
6Service=myenv-host.service
7SocketMode=0600
8DirectoryMode=0700
9 
10[Install]
11WantedBy=sockets.target

%t is systemd's specifier for $XDG_RUNTIME_DIR. The socket is created before the container starts and persists across restarts.

myenv.container

Key Quadlet settings (see quadlet.md for full list):

SettingValuePurpose
UserNSkeep-idMaps host UID/GID into container
UserrootRun as root (UID mapped via UserNS)
SecurityLabelDisabletrueRequired for Wayland socket access
NoNewPrivilegestrueBlock setuid escalation (sudo, su)
PodmanArgs--initcatatonit as PID 1 (zombie reaping)
PodmanArgs--workdir=/home/%uDefault working directory
Volume<context>/.flatpak-info:/.flatpak-info:roSandbox detection marker (portals)
Volume%h/containers/<name>:/home/%u:ZIsolated home (never the host home)
Volume%t/podbox/<name>.sock:%t/podbox/<name>.sockHost-guest socket
EnvironmentHOST_USER, HOST_UID, HOST_GIDHost identity injected
EnvironmentPATH=/run/podbox/bin:…Interceptor directory prepended
Restarton-failureAuto-restart on crash

Volumes for Wayland, audio, D-Bus, XDG dirs, GPU devices, and theme/icon/font sync are added conditionally based on the config.

Host-Guest Socket Protocol

The guest daemon connects to a Unix socket on the host to bridge container capabilities. Messages are length-prefixed JSON (see protocol.md for the wire format).

Host-guest socket protocol The guest daemon and every interceptor open a connection to the host socket, send hello with the capabilities they want, receive hello_ack, then send requests such as notify, which podbox serve passes to the desktop. notify-send interceptor podbox-guest daemon podbox serve host socket Your desktop notifications container host hello capabilities it wants hello_ack accepted · rejected installs interceptors hello hello_ack notify summary, body, actions show Every frame: 4-byte big-endian length, then that many bytes of JSON. See protocol.md.
Host-guest socket protocol

Guest Daemon (podbox-guest)

The guest binary is a static musl binary baked into every built image. Its behavior is determined by argv[0]:

Invoked asMode
podbox-guest --entryFork daemon, exec user shell/command
podbox-guest --daemonEvent loop, interceptor setup
notify-send (symlink)Parse args, forward to daemon
xdg-open (symlink)Parse args, forward to daemon
podbox-clipboard (symlink)Read stdin / write stdout for clipboard
host-exec (symlink)Execute command on host, relay output

Daemon startup sequence

  1. Read PODBOX_CONTAINER env → derive socket paths
  2. Create /run/podbox/bin/ directory
  3. Check version drift — compare PODBOX_HOST_VERSION against podbox-guest version
  4. Connect to host socket (3 retries × 500ms)
  5. Handshake: send capabilities, receive accepted list and idle timeout
  6. Install interceptor symlinks in /run/podbox/bin/ for accepted capabilities
  7. Prepend /run/podbox/bin to $PATH via /etc/profile.d/podbox.sh and /etc/fish/conf.d/podbox.fish
  8. Enter event loop (poll + pidfd-based, 0% CPU when idle, configurable idle timeout)

If the socket is absent at startup, the daemon logs a warning and exits cleanly. The container continues running without integration — this is intentional.

UID Mapping

UserNS=keep-id + User=root creates an idmapped mount that shifts UIDs by 1 inside the container (host UID 1000 → container UID 999). The entrypoint reads the actual home owner and makes the directory world-writable. No chown is performed on bind-mounted directories — that would corrupt host ownership through the idmapped mount.

Runtime Flow (Full Sequence)

podbox runtime flow Sequence from login to a ready shell: systemd starts the socket and the container, catatonit runs podbox-guest --entry, which spawns the daemon, drops privileges and execs your shell; the daemon connects to the host socket and completes the handshake. host · systemd --user container Login, or podbox start systemd --user is running 1 <name>.socket listens $XDG_RUNTIME_DIR/podbox/<name>.sock 2 <name>.service starts podman run --init, from the .container unit 3 catatonit is PID 1 reaps zombies, forwards signals 4 podbox-guest --entry first boot: creates your user and home 5 Spawns podbox-guest --daemon as root, so it can install interceptors 6 Drops to your user, execs the shell or the command you passed 7 Meanwhile, the daemon connects socket activates <name>-host.service 8 Hello / HelloAck interceptors installed for accepted capabilities 9
Runtime flow, from login to a shell

Project Structure

podbox/
├── Cargo.toml                    # workspace root
├── crates/
│   ├── podbox/                   # host CLI binary
│   │   └── src/
│   │       ├── main.rs / cli.rs   # entry point, argument parsing
│   │       ├── codegen/          # pure string generators (Containerfile, Quadlet)
│   │       ├── commands/         # one module per subcommand
│   │       ├── config/           # TOML parsing, types, validation, defaults
│   │       ├── compositor/       # Wayland firewall proxy
│   │       ├── export/           # .desktop + bin shim export
│   │       ├── quadlet_install/  # Quadlet file installation
│   │       ├── socket_host/      # host-side socket server
│   │       ├── systemd/          # systemctl wrappers
│   │       ├── wizard/           # interactive setup wizard
│   │       └── …                # podman, profiles, env, history, xdg, …
│   ├── podbox-guest/             # static musl sidecar (entry, daemon, interceptors)
│   ├── podbox-protocol/          # shared wire-format types
│   └── podbox-wasm/              # pure core compiled for the Studio
├── tests/                        # integration + unit tests
├── scripts/                      # install / uninstall
└── docs/                         # documentation

Per-file listings rot on every refactor — this one already did — so the tree stops at directories. find crates/<crate>/src -name '*.rs' fills in the rest.

Contributor notes
  • Pure codegen: codegen::* functions are pure — data in, string out.
  • X11 companion: when [integration].x11 = true, codegen emits an optional host-side xwayland-satellite unit that connects to the same Wayland socket as native GUI applications and exposes an isolated X socket directory. No I/O, no env reads.
  • Boundary separation: I/O lives in the thin modules (commands/, build/, quadlet_install/, socket_host/, export/, systemd/).
  • Visibility: submodule internals are pub(crate); the parent module re-exports the public surface.
  • musl static: podbox-guest must stay statically linkable — no tokio, no openssl, nothing glibc-linked. poll() + pidfds.
  • TTY: shell and exec use CommandExt::exec() to replace the process, never spawn_interactive — preserves readline, Ctrl+L, etc.
  • Single source of truth: Containerfile, Quadlet units, lock files, and desktop entries all derive from one TOML definition.

Exit Codes

CodeMeaning
0Success
1General error
2Configuration error
3Container missing
4Build or inspect failure
5Missing dependency (podman not found)
6Pull or tag failure