Skip to content

Architecture

jailbee wraps Incus system containers to give each git branch its own isolated, full-stack development environment. This is a short overview of how the pieces fit together; see the code under src/jailbee/ for the authoritative behaviour.

Where a container's config comes from

jailbee.config builds a validated Config from built-in defaults, ~/.config/jailbee/global.yaml, <repo>/.jailbee/config.yaml, and a host-local repo overlay at ~/.config/jailbee/repos/<container_prefix>.yaml, in that order. Some settings are host-only, and lists have field-specific merge rules; see Configuration reference. Commands may load configuration more than once.

For an eligible directory without a repo config, the loader synthesizes the repo layer from the global scratch: block unless scratch.enabled: false. The synthetic layer goes through the same validation and host-local overlay as a file-backed repo. Most downstream functions consume the resulting Config without distinguishing its source; setup and provisioning paths do check whether it was synthesized (see scratch). After loading, callers treat the object as read-only and derive modified copies rather than mutating the loaded configuration.

In-container jailbee

The entry point dispatches to separate host and container CLI apps. The golden image supplies a dependency venv, launcher and context marker; the host stages versioned package code and mounts it read-only at /opt/jailbee/app. The launcher resolves the current version once. The hash includes the installed package version as well as its files. Init, apply, container creation and boot, broker startup and handshake retry stage the code; config-bearing triggers sync their repository's skills independently of whether staging changed. The current and previous active versions are retained; a process lasting through two further distinct upgrades can lose its code. A host-to-container version handshake retries once after restaging on mismatch. Its internal exit codes distinguish version skew (86), missing or outdated dependencies (87), and code not mounted (88).

Service runtime

Service definitions come from the container worktree's .jailbee/config.yaml. The host queries the container's jailbee service plan --json (version 1) as the machine interface rather than reading its own checkout. Mutations serialize on a container lock; state is stored under ~/.jailbee/services/, with tmux windows as ground truth. Each foreground service runs in a svc-* window of the shared autostart session. Readiness gates dependent starts. services/local_proc.py is the container-only subprocess boundary for tmux and bash; runtime operations receive it as a dependency. Elevated prepare is refused until the orchestrated elevation path is implemented.

Health and waiting

For live ready panes, status derives unhealthy on read from health, falling back to ready when no health command is configured. Probe results are cached per service and launch for five seconds; a fresh wait tick bypasses the cache. The derived state is not persisted as lifecycle state. There is no supervisor or automatic restart: an operator or agent must diagnose and restart the service.

Golden image

jailbee base build provisions a golden using the configured golden.alias (normally <container_prefix>-base; scratch directories share jailbee-scratch-base). The default flow runs src/jailbee/provision/install.sh plus the selected install.d/*.sh snippets. The image is stack-neutral by default (locale, prompt, GUI libs, GitHub CLI, tmux, build-essential); additional runtimes and cloud helpers are opt-in via golden.stacks or golden.enable_snippets. Image-installed browsers and user/repo snippets also contribute (scratch builds exclude repo snippets). golden.disable_snippets excludes named snippets, and a custom golden.provision_script replaces the default flow. See Provisioning snippets.

The build runs on defaults.storage_pool (else the default profile's root pool). What it leaves behind depends on that pool's driver. On btrfs the golden is a stopped instance named <alias>--golden, and jailbee new makes a container with incus copy --instance-only, an instant snapshot-based clone. On other drivers the golden is a published image under <alias> and jailbee new launches from it. Only one golden version is kept: every build replaces the previous one and removes dated archives (<alias>-YYYY-MM-DD) left by jailbee before 1.7.5. Creation speed and disk sharing depend on the Incus storage backend: copy-on-write is not a guarantee on every pool (see Storage). Rebuilding the image does not replace existing development containers' root filesystems. They keep their original base and subsequent changes; use a new container to adopt the rebuilt image.

flowchart TB
    UB["upstream Ubuntu image<br>golden.ubuntu_version"]
    BUILD["#lt;alias#gt;--build<br>temporary, on the loose bridge<br>runs install.sh and the enabled install.d snippets"]
    IMG["prefix-base--golden (btrfs instance)<br>or prefix-base (image)"]
    C1["prefix-feat-a"]
    C2["prefix-feat-b"]
    C3["prefix-pr-482"]

    UB -->|"jailbee base build"| BUILD
    BUILD -->|"rename to the golden instance,<br>or publish as an image"| IMG
    IMG -->|"jailbee new feat/a"| C1
    IMG -->|"jailbee new feat/b"| C2
    IMG -->|"jailbee new --pr 482"| C3

Here and below, prefix stands for the repo's container_prefix; the diagram uses the normal alias. There is no archive of earlier builds.

Layered profiles

Incus profiles are composable and independently swappable. For the legacy network generation, a development container uses this stack:

flowchart TB
    P0["default<br>Incus storage + network"]
    P1["prefix-base<br>security flags, idmap, env vars,<br>configured GPU access"]
    P2["prefix-binds<br>host RO mounts + shared RW mounts"]
    MODE{"network mode<br>exactly one"}
    P3A["prefix-net-strict<br>incusbr0 + prefix-allowlist ACL"]
    P3B["prefix-net-loose<br>jailbee-loose bridge, allow-all baseline ACL"]
    CT["the container"]
    DEV["per-container devices, attached outside the stack<br>host-source RO clone source, GUI sockets,<br>port-forward proxies, under-repo mounts"]

    P0 --> P1 --> P2 --> MODE
    MODE -->|"jailbee net strict"| P3A
    MODE -->|"jailbee net loose"| P3B
    P3A --> CT
    P3B --> CT
    DEV -.-> CT

Work-generation containers instead use <prefix>-net-work plus <prefix>-net-work-strict or <prefix>-net-work-loose on the same work bridge (see Optional work-network generation). The diagram's incusbr0 is the default legacy bridge, not a required name.

jailbee net strict|loose preserves unrelated base/bind profiles, but also updates the associated ACLs, hosts entries and proxy configuration. Config changes are reconciled by jailbee apply without rebuilding the root filesystem; some changes require a container restart, and changes to image contents still require a new image and container.

Shared state

<shared_dir> (default ~/.local/share/jailbee/shared/<container_prefix>) holds persistent caches and tool state separately from the host's usual application directories. Which directories are attached depends on enabled stacks, agents, browsers and integrations. Examples include package-manager caches, JetBrains config/data and agent install/config directories. Most are shared read-write mounts: one container's writes are visible to the others using the same directory. Custom paths and credential groups can extend sharing beyond one repo; this is not a confidentiality boundary.

A subset uses pool slots instead: the Gradle, Maven, Chrome and Firefox presets allocate separate per-container directories under <shared_dir>/<host_subpath>/slots/, reusing idle slots or seeding from an existing slot. These reduce cross-container lock contention, but are not all independent copies: configured artifact subtrees in the Gradle/Maven presets are hardlinked during seeding, while mutable state is copied separately. In-place writes to hardlinked files can affect other slots. See pooled_caches and src/jailbee/pool.py.

When enabled, the host-global jailbee-registry-mirror service caches Docker image pulls across repos. The optional jailbee-litellm service similarly provides a host-wide model gateway, but keeps its rendered configuration and OAuth state in an Incus volume rather than these shared cache directories; see LiteLLM gateway.

Agent credential pool

Each supported coding agent gets one account pool: a host-wide store of parked logins, one live login per agent per holder (usually one holder per repo, or one shared credential group — see credentials). Switching moves credentials rather than intentionally keeping two active copies. Same-filesystem moves use atomic rename; cross-filesystem moves use copy, fsync, then unlink, leaving a duplication window that a crash can preserve. This is not a guarantee against manual copies or running processes retaining credentials. A group is a single shared name; each agent resolves it to its own holder directory, so several agents can be pooled in the same group without sharing a credential. src/jailbee/accounts/ holds this as a generic engine plus one adapter per agent. accounts/engine.py is the store — park/switch/remove, slot naming, member resolution — and knows nothing about which agent it serves; accounts/models.py carries the agent-agnostic types (Identity, Slot, Member, LiveAccount); accounts/groups.py resolves a container's credential group; accounts/overview.py renders every login on the host, across holders; accounts/selection.py decides which login, and whose, a command acts on when a reference or a menu spans several agents' pools. Everything agent-specific — the credential filename, how an account is named, session detection, what has to be recorded beside a credential the engine just moved — lives behind the AccountAdapter protocol in accounts/adapters/base.py; accounts/adapters/claude.py is the only adapter today. The rule the split establishes: the engine knows no agent; an adapter knows one — a second agent's pool is a new adapter module, not a change to engine.py.

Read-only host binds

Enabled integrations can expose host resources through read-only mounts: the GnuPG key directory, an SSH agent socket, host-installed JetBrains Toolbox/IDE binaries, host-sourced browser binaries, and the host's xterm-kitty terminfo entry. Browser binaries may instead be installed in the image, depending on configuration.

Read-only prevents filesystem writes through that mount; it does not prevent reading or copying secret keys, or using the capabilities of a mounted Unix socket. GPG integration exposes the host key directory, not just agent sockets. The shared writable SSH configuration directory is a separate resource from the forwarded agent socket. See Security and limitations.

Egress allowlist and /etc/hosts pinning

strict network mode (the default) rejects unmatched egress using Incus ACLs. The effective policy includes egress_allow, host-local repo and container extras, and feature-driven additions for enabled agents, JetBrains and GitHub. It also permits DHCP, TCP/UDP DNS on port 53 to any destination, and configured service endpoints such as the registry mirror and proxies. It is not a read-only GitHub policy: allowed HTTPS destinations can carry writes when a client has suitable credentials.

jailbee init, jailbee apply and the installed refresh service resolve hostnames to IPv4 and maintain a rolling address pool. By default, successful refreshes retain addresses for 24 hours, capped at 20 per hostname; failed lookups preserve that hostname's existing pool. Partial lookup failures warn, while failure to resolve any configured hostname aborts an apply. Strict-mode /etc/hosts pinning uses the same pool as ACL generation, reducing DNS drift for clients that consult that file; it does not force every client to use it.

jailbee apply --no-restart refreshes ACLs and hosts entries in running containers without restarting them. Periodic refresh requires the host refresh timer to be installed and running. Legacy loose containers use a separate bridge with an allow-all baseline ACL, not an ACL-free bridge; the optional work-network generation below keeps both modes on one bridge.

Optional work-network generation

jb net migrate is an explicit host-wide opt-in for future working containers. It provisions jailbee-work with IPv4 DHCP/NAT, no routed IPv6, and a DHCP/DNS-only baseline ACL before storing the new default. Both strict and loose work instances keep the same filtered NIC, reserved IPv4 and bridge; mode changes swap policy rather than moving the interface. Strict policy uses the repo allowlist and per-container extras. Loose adds a source-IP-scoped bridge exception for that container, so it does not widen another repo's policy. Existing containers keep their legacy generation until destroyed; jb apply reconciles both generations. jb net migrate --undo restores only the future-container default and leaves in-use resources intact.

The shared bridge is host-wide across repositories. Before broadening its policy JailBee checks all occupants and their IPv4 filtering. Doctor reports foreign/unmarked occupants, duplicate reservations, missing filtering and policy-marker inconsistencies rather than treating the host default as the instance's mode. The generation requires one-time host firewall setup on firewalled systems; reachability is only verified by probing a running work container (see Installation). Loose networking on this bridge is IPv4-only. Removing a loose exception blocks new forbidden connections, but an already established loose-created flow may persist after returning to strict; strict is not immediate session revocation.

Port forwards sit outside this mechanism by construction. A forward (host_ports in config, or an ad hoc jailbee port) is one Incus proxy device, and Incus's forkproxy connects directly into or out of the container's network namespace instead of sending packets over the NIC — so the traffic never traverses the bridge the ACL is attached to, and neither direction is filtered by it. jailbee net status lists the active forwards next to the strict-mode summary for that reason; see Security and limitations.

The main network paths for a legacy strict-mode container (default bridge name shown). The proxy connection also traverses the NIC and its ACLs; the Incus port-forward device does not:

flowchart TB
    subgraph CT["container, strict mode"]
        APP["build, agent, dev server"]
        HOSTS["/etc/hosts<br>hostname entries from the ACL address pool"]
        APP -.->|"resolves via"| HOSTS
    end

    NIC{"NIC + incusbr0 bridge ACLs<br>repo allowlist, extras, service rules<br>reject unmatched egress"}
    OK(["direct allowed destinations<br>plus DHCP, DNS, registry mirror"])
    NO(["unmatched traffic<br>rejected"])
    SVC["host service<br>e.g. adb on 5037"]

    APP -->|"eth0"| NIC
    NIC -->|"allow rule matches"| OK
    NIC -->|"implicit default"| NO
    APP <-->|"proxy device, never eth0"| SVC
    PROXY["jailbee-egress-proxy<br>Squid, domain rules per source IP"]
    NIC -->|"proxy-aware HTTP(S), service ACL allows TCP 3128"| PROXY

Squid handles wildcard egress_allow entries, which an IP ACL cannot express. Clients reach it on port 3128 through the jailbee-services ACL; Squid fetches upstream from jailbee-loose. With the default egress_proxy_always: true, work containers receive proxy variables in both modes from first boot. Applications must honor those variables: this is not transparent routing, and direct connections still face the NIC/bridge ACLs. Successful rule updates can change access for running proxy-aware clients without a new shell; loose clients are not restricted by the repo domain allowlist.

Always-on proxy variables remain stable across ordinary mode/allowlist changes, not every configuration change. An endpoint change may require a new process environment. Proxy synchronization failures warn rather than abort container startup, so initial setup can leave variables missing, and a failed mode/rule update can leave stale access until a successful sync.

Per-bridge client NICs

The proxy container has eth0 on jailbee-loose (DHCP, the only default route) and one further NIC per client bridge: eth1 on incusbr0, eth2 on jailbee-work when that network exists. Each client NIC has a static address and no default route. Clients talk to the proxy on its address on their own bridge, so the traffic never crosses a router and keeps the client's real source address, which is what the per-source rules match; a packet routed through a masquerading gateway would arrive as the gateway. security.ipv4_filtering is on for these NICs. Each repo's rules are one file in /etc/squid/jailbee.d/, checked with squid -k parse before a squid -k reconfigure, and restored if the check fails.

Two filters, not one

The combined NIC/bridge box above represents two filtering layers. With bridge traffic also passing through the inet-family forward hook, packets must pass both. For legacy strict networking with the default bridge:

Chain Built from Scope
in.<ct>.eth0 / fwd.<ct>.eth0 in table bridge incus the container device's security.acls one container
acl.incusbr0 in table inet incus incus network set incusbr0 security.acls every container on the bridge

With net.bridge.bridge-nf-call-iptables=1, bridge-forwarded IPv4 traffic also traverses the inet-family forward hook. JailBee therefore reconciles the shared bridge's ACLs as well as each NIC's. A NIC grant alone is insufficient if the bridge rejects that destination; do not diagnose the cause solely from a client's error text or timing.

The repo-specific ACLs feeding these layers are:

  • <repo>-allowlist — config.yaml's egress_allow plus host-local repo overrides. On both the NIC and the bridge.
  • <container>-extra — one container's own overrides. On that container's NIC only.
  • <repo>-container-extras — the union of every <container>-extra in the repo. On the bridge only, never on a NIC, so it widens the shared chain without handing one container another's grants. Rebuilt from the per-container ACLs whenever they change.

The shared jailbee-services ACL adds service access alongside those rules. The bridge contains a union of grants across repos; strict-container separation relies on the NIC layer, not on the shared bridge alone.

Work networking applies the same separation on jailbee-work, with its DHCP/DNS baseline, <repo>-work-container-extras unions and source-IP-scoped loose exceptions. Its filtered NIC and reserved IPv4 are essential to keeping one container's loose exception from becoming another container's grant.

Host <-> container git bridge

In clone mode, containers use git clone --shared from a read-only host-source mount. Their .git/objects/info/alternates points at the host's object store, so they need not copy all existing Git objects. New objects still take space, and the checkout contains its own working files; neither disk usage nor transfer size is a fixed amount independent of the changes. In --mount mode there is no independent checkout: the container writes directly to the host working tree, and the clone-mode Git bridge commands refuse it.

The host-side jailbee git fetch/checkout/pull commands fetch container commits over an ext::incus exec ... git upload-pack transport into refs/jailbee/<container>/<branch>. That transport does not contact GitHub. fetch also attempts to create or fast-forward the corresponding host branch and submodule branches. It does not switch branches, but can fast-forward the currently checked-out host branch and change its working tree; diverged or unsafe placements are reported rather than unconditionally overwritten. checkout additionally switches onto the target branch; pull brings the commits into its selected host target. Restricted remote SSH sessions refuse operations that would write the host working tree.

jailbee git push is the inverse local transport. By default it prefers the host's upstream-tracking ref and attempts a best-effort host-side fetch first; --from local selects the local branch instead. It writes refs/jailbee/host/<branch> in the container and creates or fast-forwards the container's local branch where safe, skipping the checked-out branch for that ref-placement step. Optional merge or rebase then acts on the container's working tree. jailbee git merge between containers relays the source through the host without checking it out there, and runs the merge in the target. See Git bridge for flags, conflict handling and cleanup.

jailbee pr push is also run on the host: it fetches the container branch locally, pushes it upstream using host credentials, then opens or updates the GitHub PR via gh. The bridge and publication do not require GitHub egress from the container. Optional AI description generation runs an agent inside the container and does need access to its configured backend.

PR binding snapshots live in the host user's XDG data directory under jailbee/container-metadata/<full-name>/. Foreground and background destroy share the lifecycle deletion path: it holds the same per-container lock as snapshot publication and removes only that container's metadata after Incus successfully deletes it. A waiting refresh rechecks existence under the lock; a recreated namesake reads its own current labels rather than an old snapshot.

jailbee/container-locks/<full-name>.lock files deliberately persist after destroy as empty name-scoped lock anchors. Unlinking even an unlocked file can split mutual exclusion: a waiter may already have opened the old inode while a new caller opens its replacement. Reusing the stable inode avoids that race across deletion and name reuse. The tradeoff is one empty file and inode per distinct container name; automatic lock-file reclamation would need a separate retirement protocol and is not performed.

Dashboard state service

The terminal dashboard (dashboard/tui/, built with Textual) and optional Qt frontend (qtui/) consume snapshots through state_service/client.py's StateClient; neither runs its own status-gathering loop. One on-demand service per host user gathers container state and broadcasts snapshots over a Unix socket. Its Gatherer uses separate cadences for base state and more expensive Git status, avoiding one polling loop per open dashboard. The service exits after an idle period with no connected dashboards.

Frontend-independent models, columns, menus and dispatch live in the shared dashboard/ package; the state service and Qt frontend do not depend on the terminal UI. This split keeps status collection separate from rendering.

Orchestration broker

Container orchestration (a container starting and managing child containers) is carried out on the host by a separate long-lived user service, jailbee-broker.service, which polls the containers' outboxes and answers in their inboxes. It is neither the dashboard state service nor the net-refresh timer, for three reasons. The state service exits after its last dashboard disconnects and is scoped to gather-and-render, while orchestration must run unattended. The net-refresh timer is a one-minute Type=oneshot, so every relay hop would wait up to a minute and systemd would kill the container-creation workers left in its cgroup. And one long-lived process serialises all orchestration actions without cross-process locking.

Security model

Containers share the host kernel (the Linux VM's kernel on macOS). Bind mounts, shared caches, credentials, sockets and forwarded ports are deliberate paths across the boundary, not resources isolated by the container root filesystem. Disabling an agent's own sandbox or confirmations does not remove those trust requirements. See Security and limitations before choosing what to expose to an unattended agent.