Skip to content

Configuration reference

jailbee reads a committed repo config, an optional global config, and an optional host-local per-repo override:

  1. Per-repo: <repo>/.jailbee/config.yaml — required for a repo you work in regularly. A directory with no such file still works, though: unless scratch.enabled: false, jailbee synthesizes a repo layer from global.yaml's scratch block instead. See that section for what changes.
  2. Global: ~/.config/jailbee/global.yaml — optional, host-level only.
  3. Host-local per-repo: ~/.config/jailbee/repos/<container_prefix>.yaml — optional; this host's own settings for one repo, never committed. See Host-local overrides.

Run jailbee config init in a repo to generate a per-repo template.

Services

services: defines named foreground processes in the repo config. Names start with a lowercase letter or digit, contain only lowercase letters, digits, _ or -, and are at most 63 characters.

Field Meaning and default
run Required nonempty foreground command.
prepare Serial steps, default []; run once on first selected autostart and explicitly on refresh.
ready, ready_timeout Optional probe and positive timeout in seconds (120).
status, status_timeout Optional read-only status probe and timeout in seconds (5, minimum 1); a failed or timed-out probe reports an error without changing lifecycle state.
logs.filters, logs.history_limit Named regex filters ({}) and per-service tmux scrollback limit (50000, minimum 1000); the session uses the maximum configured service limit for later windows. Not durable logging.
stop, stop_timeout Optional stop command and positive timeout in seconds (10).
working_dir, env Repo-relative directory ("") and environment ({}).
depends_on Map of service names to {restart: false} by default.

Prepare steps have run, network (strict, loose, or unset), mounts (list), env, working_dir, timeout (default autostart step timeout), and continue_on_error (false). Loose network or nonempty mounts requires elevation; only prepare can be elevated. Elevated refresh requires host or orchestrator authority. Dependencies must exist, be acyclic, and be ready before the dependent starts. restart: true cascades restarts to the dependent; {} does not. status is an optional read-only command used by service ls; JSON object output becomes a status and short detail, while other output uses its first nonempty line. Status errors and timeouts are reported, never treated as service lifecycle changes.

logs.filters maps lowercase names matching [a-z][a-z0-9_-]* to Python regular-expression strings, validated when configuration loads. For example, errors: 'error|failed|exception' defines a reusable filter for jailbee service logs api --filter errors. Each service's logs.history_limit contributes to the session's scrollback setting: JailBee uses the maximum limit across configured services. It applies to windows created later; changing it does not resize existing panes and does not provide durable log retention. See service log queries. On startup, a generated services stage prepares and starts selected services and their dependencies, waiting for readiness. By default it runs before the agent stage (or last in on_start when no agent starts). To place startup after repo prerequisites, use reserved services stages. Successful prepare is recorded per service and container; later starts do not repeat it, and a wider selection prepares newly selected services. This record is never automatically reset. Elevated prepare uses the autostart network/mount safeguards: branch changes require the usual consent or refusal, and child preparation cannot exceed its stored rights. An accepted host or orchestrator refresh may update the approved envelope for successfully prepared services. The accepted plan contains container-edited prepare commands: the external authority accepts that risk, bounded by the child's grant and repository cap for network and mounts.

The stage is enabled by the host-effective services: block (including the branch graft on creation) or an explicit nonempty selection. Adding services only inside a container does not enable autostart; they remain manually startable. Definitions are read from the container. An old image without in-container jailbee warns and skips services. See service commands.

Placing service startup

In stage-form autostart.on_start, a services stage with no steps and no chains is a reserved service-startup slot. A legacy stage named services with its own steps/chains remains ordinary work in either trigger and can coexist with the slots or the generated stage. Two ordinary stages with that name are still duplicates. A services: subset list is invalid on an ordinary stage. A stepless services stage in on_create is invalid: move it to on_start or add steps/chains to make it ordinary work.

Slots reject network and nonempty mounts: put elevation on service prepare steps instead. Position a slot after the setup its processes need:

autostart:
  on_start:
    - stage: setup
      detach: true
      steps: [{name: deps, run: "make dev-env && pnpm install"}]
    - stage: services
      services: [db, frontend]
    - stage: backend-build
      steps: [{name: build, run: "./gradlew bootJar"}]
    - stage: services
      services: [backend]
    - stage: services             # any other selected services

The service names must exist in the repo's services: block. Several slots may name disjoint subsets; a name cannot occur twice, and at most one slot may omit its services list (or set it to null). That bare slot starts selected services not explicitly placed elsewhere. An empty list starts none at that slot. If all slots have lists, selected services not named in any slot start at the old generated position: before agents, or last when no agent starts. Without any reserved slot, placement is unchanged. Legacy flat steps named services are still ordinary steps; the reserved stage is allowed only in on_start.

Slots inherit blocking/detached execution from their position: after a detach: true boundary they run detached, and detach: true on a services slot starts that boundary there. --wait and --no-wait apply as usual. In the example, the default agent launches before setup and service startup continues detached.

Selection still decides which services run: new --services, a profile's services: list, and orchestration spawn/restart --services never get broadened by a slot. Slots decide where the selected dependency-closed plan runs. Dependencies start with their dependent when needed, even if explicitly placed in a later slot; their own later start is idempotent. Place dependencies earlier if they need separate prerequisites. Named autostart profiles always retain all reserved services slots, just as they retain agents; control service selection with the profile's services: field instead of its stages: list. Ordinary stages named services still follow the profile's stage selection.

Branch-config differences identify slots by occurrence (services#1, services#2, ...), so changing an earlier subset is visible. Multiple generated service stages also use those distinct labels in autostart status. Old container versions whose service plan lacks dependency metadata run the full selected plan at each slot, including all required preparation, rather than partition unsafely.

Service example

This illustrative excerpt references pricing without defining it: define a pricing service or remove that dependency before validating the configuration.

services:
  db:
    prepare:                       # optional serial list of steps
      - run: docker compose pull
        network: loose
    run: docker compose up         # long-running, foreground
    ready: docker compose exec db pg_isready   # optional
    ready_timeout: 120             # seconds, optional
    stop: docker compose stop      # optional
    stop_timeout: 10               # seconds, optional
    working_dir: ""                # relative to repo dir
    env: {}
  backend:
    depends_on:
      db: { restart: true }        # db restart cascades to backend
      pricing: {}                  # must be running/ready; no cascade
    prepare:
      - run: uv sync
        network: loose
      - run: uv run manage.py migrate
    run: uv run manage.py runserver

Configuration layers

jailbee deep-merges three explicit YAML layers, then supplies schema defaults for omitted fields:

Layer Path Purpose
Global (user-level) ~/.config/jailbee/global.yaml (or $XDG_CONFIG_HOME/jailbee/global.yaml) Personal defaults applied to every repo (mounts, IDE preference, common egress endpoints).
Repo <repo>/.jailbee/config.yaml Per-repo configuration (stack versions, autostart, repo-specific egress, resources).
Host-local repo ~/.config/jailbee/repos/<container_prefix>.yaml (or $XDG_CONFIG_HOME/jailbee/repos/<container_prefix>.yaml) Per-repo, per-host overrides and credentials; never committed.

Precedence is global < repo < host-local repo, with schema defaults as the fallback. List append/reset rules apply between YAML layers, not between a schema-default list and its first explicit value. Agent presets are a separate base for agent entries (see agents). The effective Config object contains the validated result.

Validating inside a container

Inside a Jailbee container, jailbee config validate checks the repository's .jailbee/config.yaml against the schema and defaults. Host global and host-local layers and host path existence are not checked; mount references that may be defined by a host layer are reported as unchecked. Use --config PATH to choose another file. On the host, jailbee config validate --repo-layer performs the same repo-only check; without that flag the host validates the merged configuration.

Host-local overrides

Use the host-local file for a repo-specific choice that should not be shared with teammates. It lives under the JailBee config directory (normally ~/.config/jailbee/repos/), named for the repo's container_prefix; it is outside the checkout and is never committed. JailBee creates the directory with mode 0700 and local files with mode 0600.

For example, disable an agent only for this repo on this host:

agents:
  codex:
    enabled: false

Keep the GitHub integration's enabled switch in global.yaml if it should apply host-wide, but put this repo's token in its local file:

github:
  token: github_pat_...

Keep the local file containing github.token private, normally mode 0600. JailBee warns if group or other permissions are present; loading does not reject it. jailbee doctor reports insecure token-file permissions. github.token remains visible as a masked, disabled row in the interactive config editor; edit the YAML file directly to change it. github.api_tokens is deprecated and will be removed in 2.0.0; move entries with jailbee config migrate.

Credential-group membership is also host-specific. A local group overrides the global default; explicit null opts this repo out of sharing:

credentials:
  group: team-a
credentials:
  group: null

credentials.repos in global.yaml is deprecated and will be removed in 2.0.0; migrate its entries into the local credentials.group field.

Lists follow the ordinary layering rule: the local list appends to what the global and committed repo layers provide. To clear their accumulated explicit entries, set the local list to []:

egress_allow:
  - pypi.org:443
egress_allow: []

A local list cannot replace the lower-layer list with only its own entries; it can append entries or clear the accumulated list, but not express a replacement subset. Clearing egress_allow does not remove destinations added by enabled agents, JetBrains or GitHub; those integrations have their own gates.

The local file is a per-repo overlay, not another host-global config. It rejects container_prefix, computed fields such as credential_group and claude_credentials_dir, the host-only keys scratch, config_edit, update_check, default_command, install_host_skills, agent_instructions, remote, and claude_credentials, and github.api_tokens. Other Config fields—including ls, dashboard, and docker_registry_mirror—are allowed in the local overlay. Refused keys either describe the file/repo identity, apply to the whole host, or are legacy per-repo maps; put host-wide settings in global.yaml and per-repo secrets in this local file.

A litellm: block is accepted in the local file with only the keys routes, profiles, default_profile and autostart; it overrides the host's block for this repo's claude-jb (merged per route field and profile tier, not appended). enabled, version, accounts, egress and extra are refused. See Per-repo overrides.

Migrating older per-repo settings

jailbee config migrate previews migrations without writing anything. Review the diff, then pass --apply to write changes. It moves legacy chrome: blocks found in global.yaml or host-local files to browsers.chrome; a chrome: block in a committed repo config must be updated there manually. It renames global claude_credentials: to credentials:, moves github.api_tokens and credentials.repos entries into the matching local files, and repo-scoped egress overrides from state.sqlite into local egress_allow lists. It also moves legacy PR settings into pr: renames remote.ssh.shell to console and rewrites the deprecated remote.ssh.gui to rdp and waypipe. Existing edited files are backed up as .bak files. Conflicts while moving tokens, credential groups and egress entries, malformed local lists, and invalid repo prefixes are reported for manual resolution. Legacy PR aliases differ: an explicit pr: value wins and the old spelling is removed even when its value differs. Review the preview before applying. After migration, run jailbee apply in affected repos to apply egress changes. Legacy settings remain readable until migrated (the deprecated spellings are scheduled for removal in 2.0.0).

Merge rules

Source type Rule How to reset
Scalar (str, int, bool, Path, enum) Higher layer replaces lower layer null is valid only for nullable fields; it does not automatically restore a default
List Each higher-layer list appends to lower-layer lists (exception: apps.<name>.command replaces instead — see apps layering) [] in a higher layer replaces the accumulated list with an empty list
Map / dict Recursive deep-merge per key An empty {} is a no-op. null replaces a value; it does not delete a key and must pass that field's schema

Example: a global host_mounts entry plus a repo-level one yields two mounts after merge. A layer cannot both clear and re-list in one step — [] resets and any non-empty list appends — so to exclude lower-layer entries, put host_mounts: [] in the layer that should start over and re-list the entries to keep in the layers above it. The local layer follows this rule too.

Several keys are exempt from this pipeline — see Keys that bypass the deep-merge pipeline.

Field Layer Why
container_user.uid / container_user.gid global Constant per developer across repos
git.user.name / git.user.email global or local Personal — refused in the repo file. Usually unset: the identity comes from the host repo's own git config
gpg.enabled global Personal — depends on whether host has gpg-agent. Default false.
ssh.* global Personal — depends on host SSH config. ssh.enabled default false.
jetbrains.enabled global Personal — turn on if you use a JetBrains IDE. Default false.
jetbrains.userprefs_from_host global Personal license state (off by default — opt in only to share host JBA login)
jetbrains.ai_enabled global Personal — turn on if you use JetBrains AI Assistant
jetbrains.toolbox_host_path global Personal Toolbox install path
browsers.chrome.enabled / browsers.firefox.enabled global Personal — turn on if you want jailbee chrome / jailbee firefox / auto-launch. Both default false.
browsers.chrome.dark_mode / browsers.firefox.dark_mode global Personal preference
browsers.chrome.host_path global Personal Chrome install path (default /opt/google/chrome). Firefox has no host default — it defaults to source: image instead.
browsers.default global Personal — which browser jailbee browser opens when more than one is enabled
apps.<name> repo A GUI app is part of the repo's tooling, like agents:
ls (column preference) global Which columns jailbee ls shows is personal; see ls:. Dashboard column selection is remembered by each front-end.
egress_allow (Claude API, JetBrains license hosts) global Cross-cutting, repo appends
optional_mounts (personal ~/.m2, ~/.aws) global Personal opt-in caches
defaults.{memory,cpu,...} repo Repo size determines limits
golden.{java,node,ubuntu_version} repo Stack-specific (golden.python is deprecated — see below)
golden.stacks repo Repo's runtime/tool set — see Stacks
golden.extra_apt_packages repo Repo's system-level deps
jetbrains.ide repo Repo's stack determines the IDE
jetbrains.autostart repo Repo decides whether autostart launches IDE
jetbrains.share_idea repo Repo decides whether to shadow VCS-tracked .idea/* with a per-repo shared mount
browsers.url (or per-browser browsers.chrome.url) repo Repo's app URL
browsers.chrome.autostart / browsers.firefox.autostart repo Repo's autostart workflow
autostart.on_create, autostart.on_start repo Repo-specific runtime workflow
container.env repo Repo-specific runtime env (NODE_OPTIONS, app feature flags, …)
container.path repo The repo's own scripts/, bin/; global for a personal ~/bin, repo appends
container.tmpfs global or repo Whether /tmp is RAM-backed and how large; a memory trade-off, not a repo fact
shared_dir repo only (auto-derived) Setting globally forces all repos to share the same dir

The table is a placement convention for ordinary mergeable fields, not permission to put every key in every layer. Host-only keys, credentials, GitHub settings and LiteLLM have the restrictions described separately.

Host-level docker_registry_mirror

The key docker_registry_mirror is ambiguous: GlobalConfig (host-level) uses it with the shape {port, enabled, image, data_dir}, while per-repo Config.docker_registry_mirror uses it with {extra_registries}. To disambiguate:

  • In ~/.config/jailbee/global.yaml, docker_registry_mirror is always interpreted as host-level (GlobalConfig). It is not merged into the Config layer.
  • In <repo>/.jailbee/config.yaml, docker_registry_mirror is interpreted as the per-repo Config.docker_registry_mirror override (with extra_registries).

If you need per-user defaults for extra_registries, set them per-repo. There is no global default for that field.

Keys that bypass the deep-merge pipeline

Thirteen top-level keys are read from ~/.config/jailbee/global.yaml into GlobalConfig and are not merged into the Config layer: docker_registry_mirror (see above), ls, dashboard, credentials, scratch, config_edit, update_check, default_command, install_host_skills, agent_instructions, remote, litellm and orchestration. ls's column block is merged field-by-field instead (repo block over global block) — the generic pipeline would append its fields/hide lists and concatenate the two layers' column lists rather than let one replace the other. dashboard is deprecated and is never merged this way — see ls:/dashboard:. credentials is resolved to the single computed field Config.credential_group instead of being merged at all — see credentials below. scratch and config_edit describe this host rather than any one repo — what a directory with no config file gets, and how jailbee writes your files — so there is no repo-layer counterpart to merge them with; see scratch and config_edit. update_check, default_command, remote and litellm are likewise properties of this host, not a repo: they are validated directly against GlobalConfig; explicitly configured values override their schema defaults, omitted fields retain their defaults, and no repo layer can augment or override them (litellm alone has a narrow per-repo override in the host-local file, resolved separately from Config; see Per-repo overrides). See update_check, remote.ssh and litellm.

One consequence: jailbee config show prints the Config layer, so the ls: / dashboard: values it shows come from the repo and host-local overlay, not from the global display preferences. Use jailbee config show --layer global to see what the global file contributes.

Inspecting the layers

  • jailbee config show --layer global — print the raw user-level YAML.
  • jailbee config show --layer repo — print the raw repo YAML.
  • jailbee config show --layer local — print reserialized host-local YAML with github.token masked; comments are not preserved.
  • jailbee config show (or --layer effective) — print the merged result.
  • jailbee config edit --local — edit the host-local file interactively.

Initialising both layers

  • jailbee config init — write <cwd>/.jailbee/config.yaml (the repo config).
  • jailbee config init --global — write ~/.config/jailbee/global.yaml (the user config).
  • --force overwrites an existing file in either case.

Provisioning snippets (install.d/)

The golden image is provisioned by src/jailbee/provision/install.sh, which performs LXC/Incus plumbing (user creation, sudoers, SSH_AUTH_SOCK passthrough, bind-mount parents, linger) and then runs every executable in /provision/install.d/*.sh in lexical order.

It also masks Ubuntu's automatic apt machinery in the image — apt-daily{,-upgrade}.timer, their services, and unattended-upgrades. A background upgrade in a branch container takes the dpkg lock out from under your own apt-get, and one still running at shutdown can block systemd long enough for a stop to time out. Containers get their updates through new containers created from a rebuilt golden image (jailbee base build); rebuilding does not update existing container filesystems. If you need automatic updates, unmask and enable the appropriate units in your provisioning and verify their state in the resulting container.

Resolution order

The bundled snippet set is split into two libraries, both shipped in the wheel:

  • install.d/ — always on. Stack-neutral plumbing only: locale, prompt, GUI libs, GitHub CLI, extra apt packages. No language runtime or cloud helper lives here.
  • install.d.available/ — opt-in. Language runtimes and cloud helpers; staged via golden.stacks (recommended — see Stacks below) or, at the low level, by naming the snippet directly in golden.enable_snippets (see Opt-in snippets below).
Source Path Precedence
Bundled (always-on) src/jailbee/provision/install.d/*.sh (shipped in the wheel) lowest
Bundled (opt-in, if enabled) src/jailbee/provision/install.d.available/*.sh (shipped in the wheel; staged via golden.stacks or golden.enable_snippets) above always-on, below user/repo
User ~/.config/jailbee/install.d/*.sh overrides bundled by filename
Repo <repo>/.jailbee/install.d/*.sh overrides user (and bundled) by filename; ignored for synthesized scratch configs

Repo snippets and empty repo shadows apply only to file-backed configs. Scratch directories use bundled and host-user snippets; create a repo config if the directory needs its own provisioning.

The effective set is computed at jailbee base build. New filenames from user or repo are simply added; same-filename files at a higher tier replace the lower-tier file. After resolution, names listed in golden.disable_snippets are dropped (suffix .sh is optional in the list — both "75-github-cli" and "75-github-cli.sh" work); disable_snippets wins over enable_snippets if the same name appears in both.

At runtime inside the container, install.sh skips any empty (zero-byte) snippet file. Combined with same-name shadowing, this gives a low-effort disable: drop an empty <repo>/.jailbee/install.d/<name>.sh and that snippet won't run in this repo's golden image.

Bundled snippets (install.d/ — always on)

Name Installs Reads env
05-extra-apt.sh Packages from golden.extra_apt_packages EXTRA_APT_PACKAGES
10-locale.sh en_US.UTF-8 locale —
15-prompt.sh Bash prompt branch indicator ($JAILBEE_BRANCH) CONTAINER_USER
60-gui-libs.sh JetBrains/Chrome/Firefox runtime libs + fonts —
75-github-cli.sh GitHub CLI (gh) from cli.github.com —

Stacks (golden.stacks)

The recommended way to turn on a language runtime or cloud helper. Each key expands to the matching install.d.available/ snippet(s), the shared caches it needs, and the JAVA_PACKAGE/NODE_MAJOR build-env values — one field instead of an enable_snippets entry plus a manual shared_caches list.

Key Type Values Effect
java bool | string false (default) | true | "openjdk-N" | "corretto-N" true or "openjdk-N" stage 20-openjdk (apt default-jdk or openjdk-N-jdk); "corretto-N" stages 20-corretto (apt java-N-amazon-corretto-jdk). Either form adds the gradle/m2 shared caches.
node bool | int false (default) | true | N Stages 30-nodejs; NODE_MAJOR is N, or 24 when true. Adds the npm/pnpm-store shared caches.
python bool false (default) | true Stages 40-python.
docker bool false (default) | true Stages 50-docker.
ecr bool false (default) | true Stages 80-ecr-helper.

java and docker together also auto-stage 90-registry-mirror-ca (it imports the Docker registry mirror's CA into the JDK truststore). Opt out with golden.disable_snippets: ["90-registry-mirror-ca"] if this repo doesn't use the registry mirror.

Stack-derived snippet names are unioned with golden.enable_snippets (duplicates deduped); stack-derived shared caches are unioned with shared_caches the same way the claude/jetbrains auto-adds are — a manual entry with a matching name suppresses the auto-add.

Full stack, in one line:

golden:
  stacks: { java: corretto-17, node: 24, python: true, docker: true, ecr: true }

golden.enable_snippets/disable_snippets/shared_caches (and the version-pin fields golden.java/golden.node) remain available directly — they're the low-level escape hatch for anything stacks doesn't cover. See Opt-in snippets below.

Opt-in snippets (install.d.available/)

Low-level escape hatch. golden.stacks (above) is the recommended way to enable these; reach for enable_snippets directly only when stacks doesn't cover what you need. Bundled but off by default; stage a snippet by adding its logical name (or full filename) to golden.enable_snippets:

golden:
  enable_snippets: [nodejs, docker]
Name Logical name (for enable_snippets) Installs Reads env
20-openjdk.sh openjdk OpenJDK from the Ubuntu archive JAVA_PACKAGE
20-corretto.sh corretto Amazon Corretto JDK JAVA_PACKAGE
30-nodejs.sh nodejs Node.js + per-user ~/.npmrc NODE_MAJOR, JAILBEE_USER_HOME, CONTAINER_USER
40-python.sh python System python3 + venv + pip —
50-docker.sh docker Docker Engine + AppArmor systemd override; adds dev user to docker group CONTAINER_USER
70-chrome.sh chrome Google Chrome from Google's own apt repository —
70-firefox.sh firefox Mozilla Firefox from Mozilla's own apt repository, pinned above Ubuntu's transitional/snap package —
80-ecr-helper.sh ecr-helper amazon-ecr-credential-helper —
90-registry-mirror-ca.sh registry-mirror-ca Imports /opt/jailbee-mirror-ca.crt into the Java truststore (no-op if absent) —

registry-mirror-ca needs a JDK enabled too (it uses keytool — either openjdk or corretto); the Docker registry mirror needs docker enabled. golden.stacks auto-stages registry-mirror-ca whenever both java and docker are on (see Stacks above). Unknown names in enable_snippets are ignored with a warning at jailbee base build time.

jailbee.golden.browser_snippet_names automatically stages 70-chrome.sh and 70-firefox.sh for enabled browsers with source: image. Explicit enable_snippets entries can also stage them, but automatic derivation keeps installation and browser configuration in sync. Both run after the always-on 60-gui-libs.sh, which installs the shared X11/Wayland/EGL libraries and fonts a browser needs to render at all. 70-chrome.sh fails the build outright on an arm64 golden-image host — Google publishes no linux/arm64 package — and points at browsers.chrome.source: host or Firefox instead.

Snippet contract

Every snippet runs as root inside the golden-build container. The header convention is:

#!/bin/bash
# <name> — <short description>
# Env: <env vars consumed>
# Installs: <what gets added to the image>
set -euo pipefail
# ...

Environment available to snippets run by the bundled install.sh: jailbee base build supplies the reserved variables below, and install.sh adds CONTAINER_USER=dev. A replacement script must define the latter itself if it needs it.

Variable Source Notes
CONTAINER_USER bundled install.sh exports dev The fixed Unix username inside the container; not supplied by the Python build command.
CONTAINER_UID, CONTAINER_GID container_user.{uid,gid} Match host uid/gid for bind-mount readability.
JAVA_PACKAGE golden.stacks.java (preferred) or golden.java (mapped) apt package name (e.g. java-17-amazon-corretto-jdk).
NODE_MAJOR golden.stacks.node (preferred) or golden.node Major version for NodeSource (e.g. 24).
EXTRA_APT_PACKAGES golden.extra_apt_packages Whitespace-separated; may be empty.
JAILBEE_USER_HOME /home/dev (constant) Lets snippets avoid hardcoding the path.
JAILBEE_PROVISION_DIR /provision (constant) Where snippets are staged inside the container.

Custom env vars can be passed via golden.provision_env. All of the above except CONTAINER_USER are reserved — passing one via provision_env raises ConfigError. The bundled script overwrites CONTAINER_USER with dev; a replacement script receives any value explicitly supplied for it.

Disabling bundled snippets

Two ways:

  1. Config: add to golden.disable_snippets (recommended for documentation reasons).
golden:
  disable_snippets:
    - "60-gui-libs"
    - "80-ecr-helper"
  1. Empty shadow: drop an empty file at the same path under <repo>/.jailbee/install.d/ (or ~/.config/jailbee/install.d/).
mkdir -p .jailbee/install.d
: > .jailbee/install.d/60-gui-libs.sh

The config approach makes the choice visible in jailbee config show; the shadow approach is useful when you want to inspect the disable state via the filesystem.

Disable 60-gui-libs only for a headless setup or when custom provisioning supplies equivalent runtime libraries and fonts; enabled GUI apps may otherwise fail to launch.

90-registry-mirror-ca.sh needs the JDK's keytool when a mirror CA file is present. If you disable the JDK snippet, disable this snippet too unless your own provisioning supplies keytool. With no readable CA file it is a no-op.

Full provisioning override

golden.provision_script (path, relative to repo root) replaces the bundled install.sh entirely. When set, install.d/ snippets are not staged — the custom script owns the whole provisioning surface. Use it for a substantially different provisioning workflow. It does not change base-image selection: the build still starts images:ubuntu/<golden.ubuntu_version>.

A replacement script inherits nothing from install.sh, including the apparmor package. On a host that sets kernel.apparmor_restrict_unprivileged_userns=1 (Ubuntu 24.04 and later), the dev user can only create a user namespace if AppArmor policy is loaded inside the container. Without that policy, Chrome aborts at launch with credentials.cc … Permission denied, and bwrap and rootless podman fail too. A custom script should install apparmor, as install.sh does. Its comment explains why.

Per-repo config (.jailbee/config.yaml)

All keys are optional. An empty file ({}) is valid and yields full defaults. The schema is fail-closed — unknown keys are rejected.

container_user

UID/GID of the user account inside the container. The unix username is hardcoded to dev. It used to be configurable, but nothing enforced consistency between that value and the user baked into the golden image, and a mismatch surfaced as a confusing Permission-denied error.

Key Type Default Description
uid int current host uid UID inside container
gid int current host gid GID inside container

container

Container-wide settings applied via the Incus base profile.

Key Type Default Description
env map {} Non-secret env vars injected into every process Incus starts in the container — jailbee shell, jailbee tmux, autostart steps, and any nested tmux/shell. Values are passed through verbatim (no shell expansion). These are visible in incus profile show; do not store secrets here. Keys must match [A-Za-z_][A-Za-z0-9_]*.
path list[string] [] Container-side directories prepended to PATH — repo-internal scripts, a vendored toolchain. See container.path below.
tmpfs.enabled bool false Mount /tmp as an in-memory tmpfs. See container.tmpfs below.
tmpfs.size string 1G Size cap of that tmpfs. Only used when tmpfs.enabled is true.

container.env is ambient: it applies to interactive shells (jailbee shell), the autostart tmux session (jailbee tmux), and every autostart step. Per-step overrides live under autostart.env (every step) or autostart.on_{create,start}[*].env (one step), and win on conflict because they are passed as tmux new-window -e.

User entries also win over JailBee's own GUI/SSH defaults (DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR, SSH_AUTH_SOCK) — set them only if you know why you want to.

WAYLAND_DISPLAY is the exception to "profile changes need jailbee apply": unless you pin it here, JailBee re-points it at the compositor socket each time it attaches that socket (on the first GUI launch, or every boot with gui.wayland: always), so a host whose socket is renumbered (wayland-1) needs no re-apply. That is why incus config show <container> lists the key on the instance and not only on the profile.

Profile changes take effect after jailbee apply (which prompts to restart running containers).

Example:

container:
  env:
    NODE_OPTIONS: "--max-old-space-size=4096"
    MYAPP_FEATURE_FLAG: "1"

container.path

Directories prepended to PATH inside the container. The common case is a repo's own scripts/ or bin/ directory: the checkout is already there, and this is what makes deploy-staging work as a bare command instead of ./scripts/deploy-staging.

container:
  path:
    - scripts           # -> /home/dev/<container_prefix>/scripts
    - tools/bin
    - ~/bin             # -> /home/dev/bin
    - /opt/vendor/bin   # used as given

Resolution is container-side — the host never looks these paths up, so nothing here is checked for existence and a typo is silent:

  • a relative entry resolves against the container's repo checkout (/home/dev/<container_prefix>),
  • a leading ~ against the container user's home (/home/dev),
  • an absolute entry is used as given.

Entries are prepended in the order written, so the first one wins a name collision. An entry may not be blank or contain a : (the PATH separator itself) or a newline — those are rejected at load time.

container.path lands in the Incus base profile and supplies PATH to newly started Incus exec processes, including direct GUI launches. Shell startup files and applications can change it further. Existing shells and tmux servers retain their environment; a new window in an old tmux server need not inherit the updated profile. No image rebuild is needed, but jailbee apply currently offers a restart for a changed base profile, including a PATH-only edit. --no-restart skips that restart; reopen sessions or restart their tmux server as needed.

Two consequences of that mechanism, both deliberate:

  • The profile replaces PATH rather than extending it. Incus offers no way to reference the previous value, so once a repo sets container.path, JailBee spells out the system half itself: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin. A container image whose own default carries something extra (/snap/bin) loses it. Leave container.path empty and no PATH is written at all, which is why a repo that has not opted in is unaffected.
  • ~/.local/bin is not in that system half. It reaches PATH through /etc/profile.d/local-bin.sh, which every login shell sources on top of this value. Non-login launches do not source that file; list the directory explicitly if those launches need tools installed there.

container.env.PATH overrides container.path outright. Setting both is not an error, but the container.path entries are then ignored and jailbee config validate says so.

In the layered config, container.path follows the list-append convention: global.yaml's entries come first, the repo's are appended after them — so a global entry wins a collision against a repo one.

container.tmpfs

By default /tmp is ordinary disk inside the container: the golden image masks systemd's tmp.mount. Ubuntu's own default is a tmpfs sized at half the host's RAM, and a tmpfs is charged to the container's memory cgroup — so a large temp file exhausts the container's memory (an OOM kill) long before /tmp looks full.

container:
  tmpfs:
    enabled: true
    size: 2G       # 512M, 2G, 1024 (bytes), or 5% (of the *host's* RAM)

With enabled: true the base profile mounts a tmpfs on /tmp (raw.lxc), capped at size (1G unless set). The content is lost on every container restart, and what it holds counts against the container's memory limit.

  • A new size takes effect after jailbee apply and a container restart.
  • Containers built from a golden image older than 1.7.5 still carry the unmasked tmp.mount; run jailbee base build to move them to disk-backed /tmp.
  • size is validated at load time; a value the kernel would reject is refused by jailbee config validate instead of failing the container's boot.

shared_dir

Host directory that holds JailBee's shared state: shared caches (pnpm, gradle, npm, m2), JetBrains config/data, Chrome and Firefox pool slots, and Claude state. It is the host-side root for these — each entry is bind-mounted individually at its own in-container path (e.g. ~/.claude, and the cache paths), not under a single /mnt/shared mount point.

| Default | ~/.local/share/jailbee/shared/<container_prefix> |

A .owner stamp file is written here on first jailbee init. Two repos with conflicting shared_dir paths will fail loudly on the second jailbee init.

share_local

Key Type Default Description
share_local bool true When true and a directory <repo_root>/.local exists, RW-bind-mount it into each new container at ~/<container_prefix>/.local as a host<->container file-transfer channel. Presence-triggered: an absent .local dir is a silent skip and is never auto-created. Skipped in --mount mode (the full-repo RW bind already exposes it). Set false to disable entirely.

host_mounts

List of bind mounts added to every container. jailbee apply brings an added, changed or removed entry to existing containers as well, without a restart — including an entry whose container path is inside the repository checkout, which is attached per container rather than through the shared profile.

Field Type Default Description
host path — Host path (~ expanded).
container string — In-container mount target.
readonly bool false Read-write unless set to true. Read-only prevents filesystem writes, not reading/copying secrets or communicating through mounted sockets.

Containers use the fonts the golden image installs (60-gui-libs.sh); with that snippet disabled or golden.provision_script overriding it, they have none unless the custom provisioning installs some or a mount below supplies them. To make the host's fonts available to GUI apps as well, mount them where fontconfig looks in addition to the image's own directory, typically in the global config:

host_mounts:
  - { host: /usr/share/fonts, container: /usr/local/share/fonts, readonly: true }
  - { host: ~/.local/share/fonts, container: /home/dev/.local/share/fonts, readonly: true }

Mounting onto /usr/share/fonts itself would hide the image's fonts. A missing host path fails the container's start, so list only directories the host has.

optional_mounts

Named mounts, attached with jailbee mount <name> <container> or an autostart stage's mounts list. jailbee new --mount is different: it shares the entire host repository read-write and takes no mount name.

Field Type Default Description
host path — Host path (~ expanded).
container string — In-container mount target.
readonly bool true Read-only by default.
description string "" Human-readable description of the optional mount.
auto_unmount_after duration policy default Overrides mount_auto_revert.after for this mount; never disables its deadline. A disabled policy suppresses the default deadline, not existing or explicitly requested deadlines.
optional_mounts:
  aws:
    host: ~/.aws
    container: /home/dev/.aws
    auto_unmount_after: 1h
  docs:
    host: ~/reference
    container: /home/dev/reference
    auto_unmount_after: never

auto_unmount_after accepts 30s, 15m, 2h, an integer number of minutes, or never.

An optional mount is also the way to lend a container a credential for a while — an API key, ~/.aws — without deliberately copying it into the container. Processes can still copy or use it while mounted; detaching does not revoke credentials or erase copies. See Lending a container a credential.

host_devices

Pass arbitrary host character/block devices into every container as Incus unix-char / unix-block devices. Opt-in, default empty.

host_devices:
  - { path: /dev/kvm }                        # Android emulator / KVM VMs
  # - { path: /dev/net/tun }
  # - { source: /dev/bus/usb/001/004, path: /dev/bus/usb/001/004 }
Field Meaning Default
path device path inside the container; absolute required
source host device path; absolute defaults to path
type unix-char or unix-block unix-char
mode node mode on the Incus profile device (octal string) "0666"
gid / uid node owner on the Incus profile device unset
group container group the dev user is added to for access auto

Layered like host_mounts: per-repo entries append to global ones; [] resets. A device whose host source is absent is skipped (JailBee does not fail), and jailbee config validate reports it as an advisory — so a team-shared config still works on hosts that lack the device.

How the dev user gets access (group, not mode). The reliable access mechanism is group membership, not the profile mode. Many host devices (/dev/kvm, /dev/net/tun, /dev/fuse, …) carry a udev static_node rule, so the container's own systemd-udevd resets the node to its distro default (e.g. /dev/kvm → root:kvm 0660) on every boot — overriding whatever mode the Incus profile set. (Verified: neither the profile mode nor an in-container udev override survives this; only group membership does.) So JailBee adds the dev user to the device's owning group:

  • When group is unset (default), JailBee looks up the host source node's owning group. Automatic access requires a resolvable non-root group with the appropriate name already present inside the container. Missing groups are skipped, not created; verify the resulting node ownership and id output.
  • Set group explicitly to override (e.g. when the host and container group names differ): { path: /dev/kvm, group: kvm }.
  • mode/gid/uid still apply to the Incus profile device and remain useful for devices without a static_node rule (e.g. the GPU render nodes, which keep 0666).

Group membership takes effect on the next session. JailBee runs the group-add on jailbee new and on jailbee apply (for running containers). A new jailbee shell / jailbee tmux / autostart session picks the group up via incus exec --user's supplementary-group setup; an already-open shell must be reopened. Check with id inside the container — the device's group (e.g. kvm) should appear.

Security. JailBee containers are unprivileged (userns + raw.idmap), so /dev/kvm does not grant container escape, host root, or new filesystem access on its own. It does widen the host-kernel attack surface: KVM ioctls run in host-kernel context, so a process that can open /dev/kvm could in principle exploit a KVM kernel bug to escalate past the userns isolation. For a single-user dev box where the host user already runs VMs this is the same trust boundary they already extend to their own account. Treat every host_devices entry as attack-surface-widening and list only what the repo's workflow needs.

host_ports

Make a host service reachable inside every container of the repo — the classic case is an adb server: with the forward in place, plain adb devices works inside the container, and no ADB_SERVER_SOCKET juggling is needed, because the host's adb server already listens on 127.0.0.1:5037 by default.

host_ports:
  - { name: adb, port: 5037 }

Each entry becomes one Incus proxy device (named port-cfg-<name>): the container listens on container_address:port, and Incus's forkproxy connects to host_address:host_port on the host whenever something inside the container connects to that listener. So port/container_address name the container-side listener, and host_port/host_address name the host service it reaches.

Field Type Default Description
name string required Handle for this forward. Must match [a-z0-9][a-z0-9-]*, max 40 chars, unique within host_ports. Becomes the Incus device name port-cfg-<name> and the jailbee port rm key.
port int required Container-side port (1–65535) — what listens inside the container.
host_port int port Host-side port the container connects to. Set this when the container-side port and the host-side port differ.
proto tcp | udp tcp Protocol.
host_address string 127.0.0.1 Host address the container connects to. Must be an IP literal — a hostname is rejected, because resolving one at device-add time would silently pin a single IP into the device.
container_address string 127.0.0.1 Container address the proxy listens on. Must also be an IP literal.

A worked example with the two ports differing — forwarding a host service on port 9000 to port 3000 inside the container:

host_ports:
  - { name: api, port: 3000, host_port: 9000 }

Here the container listens on 127.0.0.1:3000; anything the container connects to at 3000 actually lands on the host's 127.0.0.1:9000.

Only this direction is configurable. A host-side listener is a machine-wide resource: if a repo's config declared one, every container of that repo would fight over the same host port, breaking the property that many branch containers of the same repo coexist. The reverse direction — a container service reachable on the host — is not something host_ports exposes at all; use jailbee port to-host per container instead (see Commands). A direction:/to_host:/bind: key in a host_ports entry is rejected with this same explanation, not a generic "unknown field" error.

This is a hole through the net strict ACL's egress half by construction. The forwarded traffic never traverses the bridge the ACL is attached to — Incus's forkproxy connects directly out of the container's network namespace to the host — so a strict container's default-deny ACL never sees it, on the egress side. (jailbee port to-host's forwards are the ingress-side mirror of this same hole; host_ports only ever opens the egress one.) See Security and limitations for the full picture.

Entries are attached when jailbee new creates a container, and reconciled by jailbee apply: an entry that's new is added, one whose properties changed is replaced, and one that's been deleted from the config is removed. There's no rebuild and no restart — proxy devices hotplug on a running container. Reconciliation only ever touches port-cfg-* devices; a forward you added by hand with jailbee port is never modified or removed by it.

Layered like host_mounts/host_devices: per-repo entries append to global ones; [] resets.

shared_caches

The state layer every container of this repo has in common. Each entry is bind-mounted read-write into all of them, which is what makes a tool worth configuring once: a package-manager cache stays warm across branches instead of being refilled per container, and settings written in one container are visible in the next. The same mounts outlive jailbee destroy / jailbee new, and they live in <shared_dir> rather than in your host's dotfiles, so a container can write to them freely without touching your own setup.

That "one cache, all containers" description is the plain shared-mount case. An entry whose pool resolves to non-None (see pooled_caches below) is not a shared mount at all: it is a per-container pool slot, seeded from the warmest existing slot rather than shared live. Gradle and Maven default to pooled, precisely because their tools take an inter-process lock on the cache directory — sharing one mount across containers meant one build's lock made every other container's build wait or fail.

List of bind-mounted shared caches, each {name, host_subpath, container_path}. The host source is <shared_dir>/<host_subpath>; container_path may start with ~ (expands to /home/dev).

Default is stack-neutral — ssh only:

name host_subpath container_path
ssh ssh ~/.ssh

The language caches (pnpm-store, gradle, npm, m2) that used to ship as defaults are now opt-in. Enabling the matching golden.stacks key (recommended) adds them automatically via Config.effective_shared_caches(); the low-level alternative is listing them by hand under shared_caches: alongside the matching golden.enable_snippets entry.

The JetBrains entries (jetbrains-config → ~/.config/JetBrains, jetbrains-data → ~/.local/share/JetBrains) are not defaults; they are appended by Config.effective_shared_caches() when jetbrains.enabled: true. See ### jetbrains below.

The claude entries follow the same pattern. Two claude rows are appended by Config.effective_shared_caches() when claude.enabled: true: claude → ~/.claude and claude-install → ~/.local/share/claude. Claude Code's global config (.claude.json) lives inside the shared ~/.claude mount: the golden image exports CLAUDE_CONFIG_DIR=$HOME/.claude, and Claude Code reads (CLAUDE_CONFIG_DIR \|\| $HOME)/.claude.json. See ### claude below.

chrome-profile (chrome-pool → ~/.config/google-chrome) and firefox-profile (firefox-pool → ~/.mozilla/firefox) are appended the same way, when browsers.chrome.enabled: true / browsers.firefox.enabled: true respectively. See ### browsers below.

ssh is seeded on first jailbee init from host ~/.ssh/ (config, known_hosts, config.d/) when ssh.seed_from_host is on (default). The container then has RW access to its own ~/.ssh/ — ControlMaster sockets work, known_hosts updates persist, and users can drop their own keys into <shared_dir>/ssh/ if they don't want to use the host gpg-agent. Other top-level files, including private keys and authorized_keys, are excluded, but the entire config.d/ tree and its symlink targets are copied: keep secrets and sockets out of that tree. With ssh.enabled: true, jailbee init enforces mode 0700. ssh.seed_from_host: false skips seeding; ssh.enabled: false skips SSH setup but does not remove a configured/default ssh shared-cache mount.

shared_caches: [] clears the explicit/default list, including its SSH entry. Enabled stacks and integrations still add their own caches. A non-empty list replaces schema defaults but appends to explicit lists from lower YAML layers. name must match [a-z0-9][a-z0-9-]* and be unique. container_path must be absolute or start with ~.

An entry can carry its own pool: block instead of relying on pooled_caches below — see SharedCache.pool in the next section.

pooled_caches

Key Type Default Description
pooled_caches dict of name → bool {} Per-cache override of pooling. true pools a shared_caches entry using its builtin preset (POOL_PRESETS[name]); false keeps it a plain shared mount. A key naming a cache with no builtin preset is rejected at load time unless that cache's shared_caches entry carries its own pool: block. chrome-profile: false and firefox-profile: false are also rejected: each one's host directory is its pool root, so an un-pooled mount would point every container at the pool's own slots/ and by-container/. Use browsers.chrome.enabled: false / browsers.firefox.enabled: false to turn that browser off instead.

A pooled cache is not mounted by the binds profile like the rest of shared_caches. Instead each container gets its own slot directory under <shared_dir>/<host_subpath>/slots/, attached as a per-container disk device named <cache name>-slot — allocated on jailbee new and on every boot (or on first use, for chrome-profile and firefox-profile), released on jailbee destroy. This is what stops two containers from contending on one tool's lock files: Gradle and Maven both take an inter-process lock on their cache directory, so a build in one container used to block or fail while another container's build held it.

A key absent from pooled_caches follows the preset's own default_on:

Preset default_on What's hardlinked (link_paths)
gradle true caches/modules-2/files-2.1, wrapper/dists
m2 true repository
chrome-profile true none — SQLite + Preferences are rewritten in place
firefox-profile true none — places.sqlite and prefs.js are rewritten in place
npm false _cacache
pnpm-store false v3/files

pooled_caches is a dict rather than a list specifically so ~/.config/jailbee/global.yaml and a repo's .jailbee/config.yaml merge per key (the generic dict rule from Merge rules) instead of one layer's list appending to the other's.

A fresh slot is seeded from the warmest existing slot: the one with the newest configured warmth_file mtime. A missing configured file scores zero; directory mtime is used only when no warmth_file is configured. Free slots (released by a destroyed container) are preferred over allocated ones, however much warmer: an allocated slot may be in use, and rsync copies a live profile file by file rather than as one snapshot. An allocated slot is the source only when no slot is free — which is the case after jailbee pool prune. link_paths names subtrees hardlinked from the seed source instead of copied, so a multi-gigabyte artifact store (Gradle's module cache, Maven's repository/) costs close to nothing per extra container. link_paths may only name subtrees whose files are written once and later deleted whole, never modified in place — hardlinking a lock file, or a .bin that a tool rewrites in place, would restore exactly the cross-container sharing pooling exists to remove. wipe_paths and stale_globs are the other side of that same rule: content excluded from seeding and removed when a slot is released — regenerable bulk (Gradle's daemon/ dir) and stale lock files an unclean exit left behind, respectively. Both accept glob patterns; wipe_paths needs them for Firefox, whose profile directory carries a random <id>.default-release/ component that no literal path can name (*/cache2).

Pooling separates lock files and mutable cache state, not trust boundaries. Hardlinked artifacts remain shared writable inodes: an in-place modification in one slot can affect another. Do not treat slots as isolation between mutually untrusted containers.

To pool a cache with no builtin preset — including one of your own shared_caches entries — give that entry an explicit pool: block instead of a pooled_caches key:

shared_caches:
  - name: my-tool-cache
    host_subpath: my-tool
    container_path: ~/.cache/my-tool
    pool:
      link_paths: [blobs]
      stale_globs: ["*.lock"]

An explicit pool: block on a shared_caches entry always overrides pooled_caches — even a pooled_caches: {my-tool-cache: false} key does not un-pool it. This is also true of the presets themselves: setting pool: on the gradle/m2/chrome-profile/firefox-profile/npm/ pnpm-store entries replaces their builtin PoolSpec outright rather than merging into it.

jailbee pool ls [NAME] / jailbee pool prune [NAME] inspect and clean pool slots — see commands.md. A pre-existing non-pooled cache is migrated automatically: jailbee init and jailbee apply move a cache sitting directly under the pool root into slots/slot-0, so the warm cache becomes the first seed source rather than being discarded. A pooled cache attaches to a container when that container next boots, so restart any container that was running during jailbee apply (jailbee restart <name>) before trusting it to be using its own slot; jailbee doctor flags a pool root that still needs migrating.

Networks

jailbee ships two hardcoded network modes — strict, loose — selectable per container via defaults.network or per autostart step via network. The modes are not user-configurable: their names, semantics, and the descriptions stamped onto the generated Incus profiles all live in code.

Mode Egress behaviour
strict Default-deny ACL; only egress_allow destinations reachable.
loose All egress permitted (dedicated jailbee-loose bridge).

The bridges above are the legacy network generation, still the default. The optional jailbee-work network gives each container a fixed address on one bridge and switches mode by swapping ACLs on its NIC; it is chosen per host with jailbee net migrate, not in this file. See work-network activation.

egress_allow

List of allowed egress destinations for strict mode. loose ignores this list. Each entry takes one of eight forms:

Form Meaning Example
<hostname> Resolve via DNS, allow any protocol and port to each IPv4 github.com
<hostname>:<port> Resolve, allow only TCP/<port> github.com:22
<ipv4> Allow any protocol and port 192.168.1.5
<ipv4>:<port> Allow only TCP/<port> 192.168.1.5:5432
<cidr> Allow any protocol and port 10.0.0.0/8
<cidr>:<port> Allow only TCP/<port> 10.0.0.0/8:5432
*.<domain> The domain and every subdomain, on ports 80 and 443, through the egress proxy *.github.com
*.<domain>:<port> Same, on TCP/<port> only *.example.org:8443

The port-less forms emit an ACL rule with no protocol field at all, which Incus reads as "any protocol" — UDP and ICMP to that destination included, not only TCP. Adding :<port> narrows the rule to TCP. Prefer the host:port form when you know the port: github.com:443 is a materially tighter rule than github.com.

Hostname entries are resolved to IPv4 addresses at ACL-apply time (during jailbee init and jailbee apply), and all A records returned by the resolver are inserted — useful for CDN-fronted services that round-robin a small pool. Resolution does not stop there: the jailbee net refresh timer re-resolves every registered repo's hostnames each minute into a cumulative IP pool (SQLite-backed, 24 h TTL per IP, capped per host), so a service that rotates through a set of addresses accumulates all of them and stale ones expire on their own. The same pass rewrites the ACL and mirrors the allowed IPs into each strict container's /etc/hosts, so the container's own resolver answers with exactly the addresses the ACL permits instead of drifting to an IP the ACL will drop. jailbee apply --no-restart forces the same refresh immediately, live, without restarting a container.

Error handling: initial jailbee init fails if any hostname cannot resolve. jailbee apply and periodic refresh tolerate partial failures with warnings and update the repo ACL/hosts using the cumulative pool. If all hostname lookups fail, they leave that repo's ACL/hosts unchanged and apply fails. This is not an atomic rollback of other work already done by apply.

Limitations: IPv6 is not supported (a single : is the host/port separator, which would clash with IPv6 syntax). The host:port form is TCP-only — there is no way to allow a specific UDP port, so UDP to a destination is all-or-nothing via the port-less form (DNS and DHCP are allowed unconditionally, independent of this list).

Wildcards go through the egress proxy

A *.<domain> entry cannot be an ACL rule, because an ACL names addresses and a wildcard names none. It is enforced by a small Squid proxy instead, in a jailbee-egress-proxy container that jailbee apply and jailbee net egress add bring up on demand, once some entry is a wildcard or the repo has a work-network container with egress_proxy_always on. Every start, restart, network-mode switch and egress change of such an always-on container (one whose network mode is known) also starts the proxy if it is stopped, or adds the missing NIC if it was created on another network; jailbee new and jailbee restart therefore bring it back on their own. If it cannot be started, they warn rather than abort. Synchronization may still update proxy settings if an endpoint is discoverable; otherwise an always-on container retains its existing settings. Neither outcome guarantees a usable proxy. Things to know:

  • The apex is included. *.github.com matches github.com itself as well as api.github.com and every deeper subdomain. It needs at least two labels after *.; a * anywhere but the leading label is rejected.
  • Ports 80 and 443 by default. A wildcard without :<port> allows exactly those two, not "any port" as a port-less hostname does. *.example.org:8443 allows only 8443.
  • Only proxy-honouring tools. A strict container gets HTTP_PROXY, HTTPS_PROXY (and lower-case twins) pointing at the proxy. curl, git over HTTPS, pip, npm, apt and most language runtimes honour them. A tool that ignores them connects directly and the NIC ACL rejects it, so it fails closed rather than escaping the allowlist. TLS is not intercepted: the proxy sees the host in the CONNECT request, never the traffic.
  • Open a new shell. On the work network with egress_proxy_always (the default) the variables are already set and no new shell is needed. Otherwise they are container environment, so only processes started after the change see them. The change is also copied into a tmux server already running in the container, so a new tmux window picks it up; a shell, window or agent that is already open needs to be restarted.
  • Plain entries still work. Hostname, IP and CIDR entries keep their ACL rules; the proxy also lets a container reach them through the proxy. IP literals are added to NO_PROXY so they go direct only on legacy containers or with egress_proxy_always: false; always-on containers send them through the proxy, which allows them by address.
  • Loose mode. On the work network with egress_proxy_always, a loose container keeps the variables and the proxy passes it through unfiltered. Elsewhere jailbee net loose <container> clears them.
  • Container scope needs the work network. jailbee net egress add '*.example.org' <container> is refused (exit 2) on a legacy-network container, because the proxy tells containers apart by source address and only the work network gives each one a fixed address. Use --repo there (it covers every container of the repo, whose addresses are read at sync time). See Egress proxy for the security trade-offs. On the legacy network a root process in one strict container can impersonate another container's address and borrow its allowlist, so repos that use wildcards should run jailbee net migrate.

jailbee net egress ls adds a VIA column (proxy for a wildcard, acl+proxy for the rest) once any entry is a wildcard; jailbee net status and jailbee doctor report the proxy's state when a wildcard is in use, or when the proxy container exists. jailbee net egress add does not DNS-resolve a wildcard (there is nothing to resolve). The LiteLLM gateway's own per-route egress list (under litellm: routes) does not accept wildcards: config validation rejects them.

github.com and strict-mode push: github.com is intentionally not in the base egress_allow. Only HTTPS is added automatically — api.github.com:443 with github.enabled, and github.com:443 plus GitHub's content hosts with agents.claude.plugins_enabled (on by default) — and the container's token should be scoped read-only by you. JailBee cannot verify or enforce its effective permissions. Strict mode filters destinations, not Git operations: when github.com:443 is reachable, a write-capable credential can permit HTTPS pushes. The intended host-side publication workflow is documented in Git remote & push.

Widening the list without editing it. This key is the committed, shared-by-everyone allowlist. jailbee net egress add <entry> [<name>] widens one container's copy of it, and --repo this host's copy of the repo's, without touching config.yaml — useful for a host that only one developer needs, or for trying an entry before proposing it to the team. Container-scoped entries live in the container's own user.jailbee.egress_extra label, die with the container and are materialised as the command runs; repo-scoped ones are written to egress_allow in the repo's host-local file (~/.config/jailbee/repos/<container_prefix>.yaml), not in git, and go live on the next jailbee apply. --repo refuses while that file sets egress_allow: [], since an appended entry would silently undo the reset. Overrides are additive only: they can never narrow what this key grants, so reading egress_allow still tells you the minimum every container of the repo can reach — but not the maximum on a given machine, which is what jailbee net egress ls and jailbee net status report. jailbee net egress export prints the whole key back with the promotable overrides folded in, for when a temporary entry has earned its place here. See Egress overrides for the security posture and Commands for the flags.

egress_proxy_always

Key Type Default Description
egress_proxy_always bool true On the work network (jailbee net migrate), every container gets HTTP_PROXY/HTTPS_PROXY (and lower-case twins) from its first boot, in strict and loose mode alike, and the values never change with egress_allow. Adding or removing an entry therefore takes effect in shells and agents that are already running. A strict container's proxy rules are its egress entries (hostnames, IPs, CIDRs and wildcards); a loose container is not filtered by the proxy. The NIC ACL still filters every direct connection, as before. jailbee new starts the proxy if it is not running yet (the first time downloads an image and installs Squid). Legacy-network containers ignore this key. Set false to give the variables only to strict containers with a wildcard entry.

If an always-on container's network mode is unknown, or its proxy cannot be found, its proxy environment is left as it is rather than cleared; clearing it would bring back the new-shell problem this key removes.

loose_auto_revert

Auto-reverts jailbee net loose <c> back to the previous network mode after a TTL. Lives in both ~/.config/jailbee/global.yaml and per-repo .jailbee/config.yaml; per-repo overrides global field by field, so a repo can change just after and inherit enabled from the global file.

loose_auto_revert:
  enabled: true   # default true
  after: 15m      # default 15m — accepts `30s`, `5m`, `2h`, or raw int (minutes)

When jailbee net loose <c> schedules a TTL, two container labels are written: user.jailbee.loose_until (ISO8601 expiry) and user.jailbee.loose_revert_to (the mode in effect before the switch). The existing jailbee-net-refresh.timer (already runs every 60 s) reverts the container when the deadline passes — unless user.jailbee.autostart_in_progress is held by autostart, in which case the check is deferred. Deadlines require the user refresh timer to be installed and running; they are checked on ticks, not enforced by the network itself. PID-based flags allow stale-owner detection, but legacy 1 and malformed flags can continue deferring expiry. See Security for detached stage ownership.

jailbee net strict <c> always clears the labels.

Choosing the TTL per switch

The after value above is the default. Each jailbee net loose decides its own TTL:

jailbee net loose mybug --for 2h      # this switch reverts after 2h
jailbee net loose mybug --for 45m     # any `<int>s|m|h`, up to 24h
jailbee net loose mybug --for never   # no auto-revert (same as --no-revert)
jailbee net loose mybug --no-revert   # stay in loose until manually switched

--for and --no-revert are mutually exclusive. A value over 24h, or one JailBee cannot parse, exits 2 without switching.

With neither flag, JailBee asks — but only when all of these hold: stdin is a TTY, JAILBEE_NONINTERACTIVE is unset, and the effective policy is enabled. The prompt offers a preset list with after pre-selected and labelled (config default), plus no auto-revert, custom… (type any accepted duration) and cancel (aborts without switching). Anywhere else — a script, a CI job, JAILBEE_NONINTERACTIVE=1, or the Qt dashboard, which launches actions detached with no stdin — no question is asked and after applies. To stay non-interactive and explicit, pass --for or --no-revert.

The Qt dashboard asks in a dialog instead, pre-selecting the same configured default and validating a typed value with the same parser as the CLI.

With enabled: false

A disabled policy means JailBee schedules no TTL of its own: jailbee net loose writes no labels, and neither the CLI prompt nor the GUI dialog appears. It does not veto an explicit request — --for 2h still writes the labels and the timer still reverts the container when they expire. A stated intent wins over the config switch, so what jailbee ls and jailbee net status display always matches what will happen.

jailbee ls shows the remaining TTL in a dedicated column (visible only when at least one container is in loose mode), and jailbee net status lists each loose container with its expiry time.

mount_auto_revert

Controls automatic detaching of optional mounts. Lives in both ~/.config/jailbee/global.yaml and per-repo .jailbee/config.yaml; the repo overrides the global policy field by field.

mount_auto_revert:
  enabled: true   # default true — `jailbee mount` schedules a detach
  after: 15m      # default 15m — accepts `30s`, `15m`, `2h`, or raw int (minutes); max 24h

The jailbee-net-refresh.timer (the same timer used for loose-mode reverts) detaches expired mounts on refresh ticks, provided it is installed and running. Autostart ownership can defer detachment. Mounts without a recorded deadline do not expire. Running jailbee mount again for an already attached kind only changes its TTL, not its source or read-only properties; jailbee unmount clears the deadline. Disabling the policy stops default TTL scheduling, but existing and explicit --for deadlines remain effective. Use --no-revert when attaching or retiming a mount to clear its deadline.

defaults

Per-container defaults.

Key Type Default Description
memory string 16GiB Default memory limit, written to the repo's base Incus profile and inherited by new containers unless --memory sets a per-container override. jailbee apply updates the inherited profile limit live on existing containers; instance limits equal to the new default are cleared so they inherit it. If a running container would then use more memory than the new default, apply reports it; the kernel may reclaim memory or OOM-kill processes. Use jailbee mem <container> <size> to override one container, or jailbee mem <container> default to clear its override.
cpu int 8 CPU limit for new containers.
cpu_share int 1–100 unset CPU scheduling weight of the repo's containers against the rest of the host, as a percentage of a normal process group's weight, written to the repo's base profile as Incus limits.cpu.allowance: N%. A soft limit: with 20, a busy container gets roughly a fifth of what the desktop gets while the CPUs are contended, and an idle host still gives it every core cpu allows. Unset means Incus's default (100). Each container gets its own weight, so many busy containers together still weigh more than one. jailbee apply updates existing containers live. A host preference: set it in global.yaml or a repo's local layer (jailbee config edit --local).
network enum strict Initial network mode: strict | loose.
storage_pool string unset Incus storage pool new containers are created on. Unset leaves it to the root disk of Incus's default profile. Pool names are per-host, so set it in global.yaml (or a repo's local layer), not in a committed .jailbee/config.yaml; jailbee new --storage <pool> overrides it for one container. Existing containers stay on the pool they were created on. A pool that does not exist is an error (exit 2) listing the pools that do. Set in global.yaml, it also decides where the host-wide helper containers (egress proxy, registry mirror, LiteLLM, shared display) are created; a repo's own value does not. See Storage.

golden

Golden image build params.

The golden image is stack-neutral by default: essential development and container plumbing (including Git, build tools, tmux, OpenSSH and AppArmor) plus the default snippets for locale, prompt, GUI libraries and GitHub CLI. Additional runtimes and cloud helpers ship as opt-in provisioning snippets, not preinstalled disabled software. Enable them with golden.stacks (recommended; see Stacks) or the lower-level golden.enable_snippets. The Python stack adds system Python venv/pip tooling. Stack-derived Java/Node values override golden.java/golden.node, including stacks.java: true selecting default-jdk and stacks.node: true selecting 24.

Key Type Default Description
alias string <container_prefix>-base Image alias used by jailbee base build.
ubuntu_version string 26.04 Ubuntu image tag pulled from images:.
java string amazon-corretto-17 Java identifier. amazon-corretto-N maps to apt package java-N-amazon-corretto-jdk; everything else is passed through as an apt package name. Only takes effect when the matching snippet is staged (golden.stacks.java or golden.enable_snippets).
node int 24 Node.js major version (used by NodeSource). Only takes effect when the nodejs snippet is staged (golden.stacks.node or golden.enable_snippets).
python string "" Deprecated and ignored. The container's Python is always the base image's system python3 (its version is a function of ubuntu_version — the Ubuntu archive ships one python3.X per release). Setting this raises a soft warning in jailbee config validate and jailbee base build; the value has no effect. Need a different Python? Add it via extra_apt_packages (e.g. python3.12, if the base archive has it).
provision_script path null (= bundled install.sh) Path to an alternative provisioning script. Relative paths resolve against the repo root.
provision_env map {} Extra env vars passed to the provisioning script. Reserved keys — CONTAINER_UID, CONTAINER_GID, JAVA_PACKAGE, NODE_MAJOR, EXTRA_APT_PACKAGES, JAILBEE_USER_HOME, JAILBEE_PROVISION_DIR — raise ConfigError (the bundled install.sh relies on them).
extra_apt_packages list[string] [] Extra apt package names installed by the bundled 05-extra-apt.sh snippet (via EXTRA_APT_PACKAGES). Each entry must match [a-z0-9][a-z0-9+\-.]*.
disable_snippets list[string] [] Names of bundled snippets to drop from the effective set at jailbee base build. Matches the logical name ("registry-mirror-ca"), the numbered name ("90-registry-mirror-ca"), or the full filename ("90-registry-mirror-ca.sh") — same name forms enable_snippets accepts. Also drops snippets auto-added by golden.stacks. See Disabling bundled snippets.
enable_snippets list[string] [] Names of opt-in install.d.available/ snippets to stage into the effective set (by logical name, e.g. "nodejs", or full filename, e.g. "30-nodejs"/"30-nodejs.sh"). Unioned with the snippets golden.stacks implies. See Opt-in snippets. Unknown names are ignored with a warning.
stacks object all fields off High-level java/node/python/docker/ecr toggles — the recommended way to enable a runtime. See Stacks.

Master switches — opt-in by default

Host-tooling blocks (gpg, ssh, jetbrains, and each browser under browsers) ship with enabled: false. Their automatic integration work is opt-in, typically at the global layer (~/.config/jailbee/global.yaml). Per-repo overrides can also turn a block on for repos that need it. The point is to keep the container minimal until the user opts in. Explicit mounts and caches are independent: notably, the default ssh cache remains mounted even when ssh.enabled is false.

jailbee config init --global writes a file that flips gpg, ssh, jetbrains and browsers.chrome to enabled: true — a working starting point for a typical developer setup, not a literal echo of the built-in defaults. browsers.firefox is left off in that seed; enable it by hand if you want it too. The file itself is generated, not hand-written: its comments come from schema descriptions (config_writer.render_documented). That reduces template duplication, but descriptions and this reference can still lag the implementation.

gpg

Key Type Default Description
enabled bool false RO bind-mount ~/.gnupg, attach the host /run/user/<uid>/gnupg socket dir as the gpg-socket device (read-only), and set SSH_AUTH_SOCK in the base profile to the host gpg-agent's SSH socket. Disables the doctor gpg-agent socket check when false.

When enabled: true, the host gpg-agent provides SSH authentication inside the container (YubiKey / GPG-SSH keys work transparently). The auto-added bind-mount can be overridden by adding a manual entry to host_mounts with container: /home/dev/.gnupg — the manual entry wins.

The gpg-socket device is mounted read-only, and so is pulse-socket. Both are directories inside the host's own /run/user/<uid>, and the container runs its own systemd --user: its gpg-agent.socket, dirmngr.socket and pulseaudio.socket listen on paths inside those mounts and unlink whatever file is already there before binding — which would take the host's agent down (socket file has been removed - shutting down) on every container boot. Read-only makes that unlink EROFS while leaving the socket fully usable, since a unix-socket client needs no writable filesystem, and the host stays free to re-create its own sockets. The golden image also masks those user units, so the container does not even try.

Read-only does not hide the host keyring: the full ~/.gnupg directory can be read and disk-stored secret keys copied. Mounted agent sockets remain usable.

With enabled: false, JailBee omits its automatic ~/.gnupg mount, gpg-socket device and SSH_AUTH_SOCK default. Explicit host_mounts and container.env overrides still apply. The golden image's /etc/profile.d/jailbee-env.sh fallback only sets SSH_AUTH_SOCK when the variable is still unset and the gpg-agent socket is actually present, so login shells stay clean on hosts that run no gpg-agent.

git

Key Type Default Description
identity host | off host host writes the git identity into the container's ~/.config/git/config on jailbee new and on every start. off leaves that file alone.
user.name string unset Overrides user.name. Unset takes the host repo's value.
user.email string unset Overrides user.email. Unset takes the host repo's value.

JailBee resolves each key by running git config --get <key> in the host repo. Git then applies its own precedence, so the container gets what a commit in the host checkout would get: repo-local .git/config, includeIf rules, then ~/.gitconfig. Keeping a different identity per repository needs no JailBee configuration. A git.user field wins over the host value, one field at a time.

When gpg.enabled is true, user.signingkey, commit.gpgsign, tag.gpgsign and gpg.format are copied as well. When it is false they are removed from the file, so commits do not wait on a missing agent. A key that resolves to nothing is removed, never written empty. jailbee doctor warns when the name or email is missing.

git.user is personal and is refused in a repo's .jailbee/config.yaml. Set it in global.yaml, or in the host-local file for one repo. git.identity is allowed in every layer.

Changes reach a container at its next start. A host_mounts entry that mounts your ~/.gitconfig at /home/dev/.gitconfig still works and wins over the written file, because git reads ~/.gitconfig after ~/.config/git/config. Remove that entry to use the per-repo identity; jailbee doctor points it out.

gui

Key Type Default Description
dbus bool false Attach the host's session D-Bus socket (/run/user/<uid>/bus) as the dbus-socket device.
audio bool false Attach the host's PulseAudio socket dir (/run/user/<uid>/pulse) as the read-only pulse-socket device.
wayland on-demand | always on-demand When the host's Wayland display socket is attached as the wayland-socket device: on the first GUI launch or jailbee display attach (on-demand), or on every boot (always).

The Wayland display socket is what jailbee chrome, jailbee ide and apps run draw through. By default (wayland: on-demand) it is attached when the first of them — or jailbee exec -d --gui, or jailbee display attach — runs in the container, so a container that never opens a window cannot reach the host compositor at all. Each such launch also replaces a socket that a restarted compositor (a re-login) left dead. A GUI app started directly from a shell or agent needs an attached display first. A direct GUI command in an autostart shell step needs wayland: always or an explicit attachment. Registry autostart (jetbrains.autostart, browser and apps entries) attaches the display on launch and works with on-demand. The socket exposes the compositor's client protocol, not a filesystem-only window handle; treat attaching it as access to the host desktop interface.

dbus and audio came in alongside it as a desktop bundle but do more than draw. The session bus is the host desktop's control channel — with it, anything in the container can talk to the host user's session services — and the pulse socket is the host's speakers and microphone. So both are opt-in, per repo or in global.yaml. GUI apps run without them; what they lose is desktop notifications, portals and sound. A change takes effect on the container's next start (every boot detaches and re-attaches the socket devices; with wayland: on-demand the Wayland socket follows at the next GUI launch), no jailbee apply needed.

ssh

Key Type Default Description
enabled bool false Enable SSH seeding and 0700 setup on jailbee init. false skips that setup, but does not remove the default/configured ssh shared-cache mount.
seed_from_host bool true Seed host ~/.ssh/{config, known_hosts, config.d/} into the shared dir. Other top-level files are excluded, but config.d/ is copied recursively, following symlinks; keep secrets and sockets out of it. Ignored if enabled: false.

jetbrains

Key Type Default Description
enabled bool false Master switch. When false, jailbee ide exits 2 with a clear message, autostart skips the IDE launch, the userprefs / toolbox auto-mounts are omitted from effective_host_mounts, and the JetBrains egress hosts are NOT auto-added to egress_allow. The individual sub-toggles below have no effect.
ide enum idea Which JetBrains binary jailbee ide (no --app) and autostart launch. Supported: idea \| webstorm \| pycharm \| goland \| clion \| phpstorm \| rider \| rubymine \| datagrip \| rustrover \| aqua \| dataspell \| studio.
userprefs_from_host bool false Opt-in RW bind-mount of ~/.java/.userPrefs/jetbrains/ (host JetBrains Account / license tokens). Enable only if you want to reuse the host's JBA login state across containers. Does not affect egress (license-host egress is gated only on enabled). Ignored when enabled: false.
share_idea bool true Bind-mount <shared_dir>/jetbrains-idea over ~/<container_prefix>/.idea inside each container so project-side JetBrains state (run configs, code styles, inspection profiles, project view) survives jailbee destroy / jailbee new cycles. The mount is attached as a per-container device after git clone (it cannot live in the binds profile — Incus pre-creating the target would break the clone). Skipped automatically in --mount mode so the host's own .idea/ wins. Set to false if the source repo tracks .idea/* files in VCS that should remain visible to the IDE — the shared dir starts empty and would otherwise shadow them on first launch. Ignored when enabled: false.
ai_enabled bool false Opt-in: also auto-extend strict-mode egress_allow with the JetBrains AI Assistant backend hosts (api.app.prod.grazie.aws.intellij.net, api.jetbrains.ai). Leave off unless you actually use AI Assistant. Ignored when enabled: false.
autostart bool false Launch the IDE after autostart steps. No-op if no graphical session is detected. Ignored when enabled: false.
toolbox_host_path path | null ~/.local/share/JetBrains/Toolbox Host path RO-mounted to /opt/jetbrains-toolbox. The container-side path is hardcoded because jailbee's IDE launcher looks for binaries there. null disables the auto-mount. Ignored when enabled: false.

When jetbrains.enabled is true, egress_allow is automatically extended (in strict-mode ACL generation only — the YAML field is not modified) with the JetBrains hosts the IDE needs for account/license activation, plugin marketplace and installer CDNs: account.jetbrains.com, oauth.account.jetbrains.com, cloudconfig.jetbrains.com, plugins.jetbrains.com, downloads.marketplace.jetbrains.com, www.jetbrains.com, resources.jetbrains.com, download.jetbrains.com, download-cf.jetbrains.com, download-cdn.jetbrains.com, frameworks.jetbrains.com, data.services.jetbrains.com, api.jetbrains.cloud (all port 443). The list is sourced from JetBrains' published allowlist guidance plus empirical observation of the JBA sign-in flow. Without these, the IDE falls back to "Start free trial" after the locally cached license expires (~30 days for paid plans), and plugin updates / framework dependency lookups fail in strict mode. resources.jetbrains.com serves the OAuth provider icons rendered in the JBA sign-in dialog — blocking it prevents the login UI from finishing, so the IDE silently stays in trial state. oauth.account.jetbrains.com is the OAuth sign-in endpoint (AWS ELB in eu-west-1, distinct IP space from account.jetbrains.com); without it the sign-in handshake cannot complete. downloads.marketplace.jetbrains.com is the CloudFront-backed plugin payload CDN, separate from plugins.jetbrains.com. api.jetbrains.cloud (note the .cloud TLD) hosts the license trace-status endpoint.

When jetbrains.ai_enabled is also true, the AI Assistant backend hosts are appended too: api.app.prod.grazie.aws.intellij.net, api.jetbrains.ai (both port 443).

When jetbrains.userprefs_from_host is true, concurrent host+container IDE writes to the same files are possible but rare in practice (login tokens are written once per session). If both write at the same time, last-flush wins; the loser's in-memory state diverges until the next IDE restart.

browsers

Chrome and Firefox as registry entries — see apps below for the umbrella both feed into, and Provisioning snippets above for how source: image reaches the golden image.

Turning on an image-sourced browser needs jailbee base build and a new container, not just enabling it in config. Under the default source: image (Firefox), setting enabled: true alone does nothing to an already-built image — the browser is installed into the new image only by jailbee base build. jailbee <browser> on a container from an older image starts, prints its usual "Launching …" line, and exits 0: the launch is detached, so a missing binary fails silently inside the container, visible only in /tmp/jailbee-app-<name>.log or as missing in jailbee apps ls <container>. Run jailbee base build after enabling, the same as after changing source to image, then create a new container from that image. Rebuilding does not install the browser into existing containers.

Key Type Default Description
default chrome | firefox | null null Which browser jailbee browser opens. null resolves at command time: the single enabled browser if exactly one is, otherwise the command asks you to set this or name one directly (jailbee chrome / jailbee firefox). Naming a disabled browser here is a config error.
url string | null null URL every enabled browser opens on launch, unless that browser sets its own url. Most repos have one app URL and no reason to write it twice. A browser cannot opt back out: url: null on a browser reads the same as not setting it, so it inherits — set the URL per browser instead when one should launch bare.
chrome BrowserConfig see below Google Chrome.
firefox BrowserConfig see below Mozilla Firefox.

Each of browsers.chrome and browsers.firefox is the same BrowserConfig shape:

Key Type Default Description
enabled bool false Master switch. When false, jailbee chrome / jailbee firefox exits 2 with a clear message, the browser is hidden from jailbee apps ls, autostart skips its launch, and the host_path auto-mount is omitted from effective_host_mounts.
source host | image host for Chrome, image for Firefox host RO-mounts an existing host install (see host_path); image installs the browser into the golden image during jailbee base build — no host install needed. For image, run jailbee base build and create a new container; for host, run jailbee apply to update mounts. Firefox defaults to image because on Ubuntu the host's Firefox is a snap and /snap/firefox is not usefully mountable into a container; Chrome defaults to host, matching how it has always worked.
host_path path | null /opt/google/chrome for Chrome, null for Firefox Host path RO-mounted into the container when source: host (must be null under source: image). The container-side mount target is hardcoded per browser (/opt/google/chrome, /opt/firefox) — browsers.py's BROWSER_BINARIES map is what actually invokes the binary there. Override for a non-standard install (e.g. a chromium dir); null disables the auto-mount. Ignored when enabled: false. A manual host_mounts entry with a matching container: wins. Firefox has no default here because its host install is normally a snap — but setting host_path on Firefox without also setting source implies source: host, so naming a real install is enough.
url string | null null URL this browser opens on launch, overriding the shared browsers.url. null (default) inherits browsers.url, and means no URL when that is unset too. jailbee chrome <name> <URL> / jailbee firefox <name> <URL> override both per-call.
dark_mode bool false Force a dark theme — asymmetric between the two browsers. Chrome gets --force-dark-mode --enable-features=WebContentsForceDark, which darkens page content as well as the browser chrome. Firefox has no equivalent flag, so it gets GTK_THEME=Adwaita:dark instead, which darkens the browser UI only — pages render exactly as the site sends them; forcing dark page content in Firefox is an extension's job, not jailbee's.
autostart bool false Launch this browser after autostart steps. No-op if no graphical session is detected, or when enabled: false.

Each enabled browser gets its own per-container profile pool (chrome-profile / firefox-profile — see pooled_caches), so two containers running the same browser never fight over one profile directory.

The top-level chrome: block from before 1.3.0 still works, folded into browsers.chrome at load time with a deprecation hint on stderr naming docs/config.md and the source file — once per source file per process, however many times the config is loaded. Unlike the agents/claude legacy alias, defining both is not an error: an explicit browsers.chrome overlays the folded legacy block field-by-field, so a half-migrated config behaves the way the newer spelling says. It is removed entirely in 2.0.0, alongside .gie/config.yaml — migrate to browsers.chrome.

The fold runs on the merged global+repo dict, so browsers: wins over chrome: regardless of which layer each one sits in — a browsers.chrome block in ~/.config/jailbee/global.yaml overlays a repo's legacy chrome: block, not the other way round. Migrate the global layer last, or a repo still on the old spelling silently loses its overrides.

apps

User-defined GUI applications beyond the built-in browsers and JetBrains IDE — an AppImage, a vendor binary, a wrapper script. A mapping keyed by app name; each entry resolves to the same AppSpec shape the builtins use, so jailbee apps ls, autostart and the dashboards handle a user-defined app identically to Chrome or the IDE. No pool and no mount is generated for an apps: entry — that YAGNI call is deliberate: if an app needs a per-container profile pool or an auto-mount, model it as a builtin instead.

Key Type Default Description
command list[string] required Container-side command to run. A string is split with shell quoting rules; a list is taken as-is. The first element is an absolute container path or a name on the container's PATH — never resolved on the host. A repo layer's value replaces the global layer's (see Layering below).
args list[string] [] Extra arguments appended after command. Arguments passed on the jailbee apps run command line are appended after these. A repo layer's entries append to the global layer's.
cwd string "repo" Working directory inside the container: repo (the checkout), home (the dev user's home), or an absolute container path.
env map[string, string] {} Extra environment variables, merged over the GUI environment jailbee already supplies (HOME, DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR).
description string "" One-line summary shown in jailbee apps ls.
top_level bool false Promote this app to a top-level command, so jailbee <name> launches it directly instead of jailbee apps run <name>. A built-in command of the same name always wins; a name that collides with one is a config error (jailbee config validate reports it, naming the fix: rename the app, or set top_level: false and use jailbee apps run <name>).
autostart bool false Launch this app after autostart steps complete.
apps:
  figma:
    command: /opt/figma-linux/figma-linux
    top_level: true
    description: "Figma desktop app"

With the above, jailbee figma [args…] launches it in the container this branch would attach to, and jailbee figma --container <container> [args…] names a different one. Both are literally jailbee apps run figma … — the promotion only drops the apps run, and everything after the app name is passed through untouched. Arguments that start with a dash need a -- separator, exactly as jailbee apps run does: jailbee figma -- --ozone-platform=wayland.

A Chromium-based app sandboxes itself only if AppArmor has a profile for its path, both on the host and in the container. On a host that sets kernel.apparmor_restrict_unprivileged_userns=1 (Ubuntu 24.04 and later), Incus stacks two AppArmor labels on every container process: the host's own and the container's. Each label attaches to a profile by the binary's path, and both must allow the app to create a user namespace. Ubuntu's apparmor package, which the golden image installs and the host already has, ships profiles for Chrome, Chromium, Edge, Brave, VS Code, Slack and Firefox at their standard install paths, so those apps are covered on both sides.

An app at any other path, such as the Figma binary above, falls back to the unprivileged_userns profile. That profile lets the app create a user namespace but gives it no capabilities inside it, so Chromium's sandbox aborts with The SUID sandbox helper binary was found, but is not configured correctly. Don't reach for --no-sandbox. Instead, write a profile modelled on /etc/apparmor.d/chrome for the app's container-side path, and load it in both places:

abi <abi/5.0>,
include <tunables/global>

profile figma /opt/figma-linux/figma-linux flags=(unconfined) {
  userns,
  @{exec_path} mr,
}
  • On the host: save it as /etc/apparmor.d/figma and run sudo apparmor_parser -r /etc/apparmor.d/figma. The host resolves the path as the container sees it, so the host doesn't need a binary at that path.
  • In the container: write the same file from an install.d/ snippet so the golden image carries it. apparmor.service loads it on every boot.

A profile on one side only leaves the sandbox broken.

jailbee figma <container> does not name a container. apps run takes the container as an option, not as a positional, so a bare name there is appended to the app's own arguments: the app starts in the default container and receives <container> on its command line, with no error. Use --container.

Note the asymmetry: jailbee apps run takes the app name as its first positional and the container behind --container, while jailbee chrome [container] [url] takes the container as its first positional. The two shapes can't be unified — jailbee apps run allows an optional app name, an optional container and a variadic list of extra arguments, and three positionals with an optional one in the middle cannot be told apart reliably.

An app name must match [a-z0-9][a-z0-9._-]* — it becomes a command word, a log-file path segment (/tmp/jailbee-app-<name>.log), and a dashboard action verb, so it is restricted to characters none of the three need quoted.

jailbee apps ls [<container>] lists every app this config can launch — builtins first, then apps: entries, in that order regardless of YAML key order. Without a container it is configuration only; name one to add a STATUS column that actually probes each app inside it (present / missing).

Layering. apps: is an ordinary merge-layer key, so an app defined in ~/.config/jailbee/global.yaml merges with a repo's apps: per app name, and within one app per key — a repo can set autostart: true on a host-wide app without restating its command. One key breaks the general "lists append" rule on purpose: a repo layer's command replaces the global layer's rather than appending to it, because a second binary path would otherwise become an argument to the first. args still appends, which is what lets a repo add one flag to a host-wide app.

agents

Generic hook for terminal coding agents — Claude Code plus five best-effort templates (codex, gemini, aider, opencode, grok), or one you define yourself. Claude is the recommended integration; how far each template has been verified is listed in agents.md. A mapping keyed by agent name, valid at both this file and ~/.config/jailbee/global.yaml, and it merges over a shipped preset (deep-merge — see Merge rules above) rather than needing every field spelled out. Full mechanism, the preset table, the "which paths to share" rule, and a worked example live in Generic agent support — this entry is the schema reference.

Field Type Default Description
enabled bool false Master switch: gates the shared mount, the strict-mode egress add, install/update at jailbee new time, and the jailbee doctor shared-dir check.
autostart bool false Launch command in a background autostart tmux window. Requires enabled: true.
command string "" The command line the autostart window execs; also the default source for install_check. Required when enabled: true.
install string | null null Shell command run at jailbee new time when install_check fails.
install_check string | null null Probe deciding install-vs-update. Defaults to command -v <first token of command>.
update string | null null Shell command run at jailbee new time when install_check succeeds and auto_update is true.
auto_update bool true false leaves an existing install untouched; a missing one is still installed.
install_network strict | loose strict Network mode for the install/update step only.
shared list of {subpath, path, type, seed, private} [] Bind mounts from <shared_dir>/<subpath> to <path>. type: dir (default) or file; seed (file only) is written once if the target is absent; private (dir only) names subpaths inside the mount that stay per container — an IPC socket, a pid file, a lock.
egress_allow list[string] [] Strict-mode allowlist entries added while this agent is enabled. Same grammar as top-level egress_allow.
env map[string, string] {} Env vars passed to the install/update step and the autostart launch step.
prompt_args string | null preset Arguments appended to command (joined with one space) when the agent is the primary agent and a jailbee new --prompt is pending. Run in a shell; the prompt text is in the file $JAILBEE_INITIAL_PROMPT_FILE — read it from the environment, never interpolate it. Presets set it for claude, codex and opencode; unset means the agent cannot take an initial prompt and --prompt is refused (exit 2). The presets pass the prompt as a bare argument, so a prompt starting with - may be read as an option by the agent CLI and lost; start prompts with text. See agents.md.
headless string | null preset One-shot command line jailbee pr push runs to write PR text (see pr): run in a bash -lc login shell in the repo directory, with the prompt in $JAILBEE_PR_PROMPT and the model (empty when none applies) in $JAILBEE_PR_MODEL. Read both from the environment — never interpolate them. $JAILBEE_PR_SESSION carries a fresh UUID an agent may use as its session id, so a timed-out run's transcript can be named. Presets set it for claude, codex, gemini and opencode; aider and grok have none.
skills_dir string | null preset Container-side directory the agent reads user-level skills from (~/.codex/skills, …). When set and covered by a shared mount, jailbee new/apply copy the bundled jailbee skills into the shared copy of it — see the bundled skills. The four skill-capable presets set it; leave unset for an agent with no skills mechanism. Rejected at load if empty or carrying a . / .. segment — the value is joined onto a host-side path.
install_jailbee_skills bool true false keeps this agent's shared skills directory untouched by jailbee's bundled skills. Does nothing when skills_dir is unset or no shared mount covers it. A disabled agent gets nothing either way. The pre-1.0 claude.install_gie_skills name was retired in 1.1.0: a config still using it fails to load with an error naming this key.
global_instructions {dir, file} | null preset Where the agent reads host-wide instructions: ~/.config/jailbee/AGENTS.md is mounted read-only at dir, renamed to file — see Agent-wide instructions. Only Claude's preset sets it (/etc/claude-code, CLAUDE.md); constraints are in agents.md.

An agent name that matches one of the six shipped presets is deep-merged over that preset (preset → global.yaml → repo, same append/reset rules as every other list field); any other name is used as-is with no preset base. jailbee config validate additionally rejects a name outside [a-z0-9-]+, enabled: true with an empty command, autostart: true without enabled: true, and a shared subpath that collides with a built-in shared subdir or with a different mount target another agent already claimed.

agents:
  codex:
    enabled: true
    autostart: true

claude

agents.claude is the preferred spelling of this block. The top-level claude: key documented below is a supported legacy alias — moved to agents.claude at config-load time, before validation. Defining both claude: and agents.claude in the merged config is a ConfigError. Everything below applies identically under either spelling, and claude also carries the generic agents fields from the table above (install, update, install_check, install_network, shared, egress_allow, env, headless, prompt_args, skills_dir, install_jailbee_skills, global_instructions) — not repeated here since they mean the same thing for every agent. See Generic agent support for the short version of this same note.

Claude Code CLI integration. The schema default is disabled, so a repo with no claude:/agents.claude block anywhere gets no Claude Code. Opt-in belongs in ~/.config/jailbee/global.yaml — and the template written by jailbee config init --global already carries claude.enabled: true (or agents.claude.enabled: true), so the usual first-run path turns it on. Delete or flip that block to keep Claude Code out.

Key Type Default Description
claude.enabled bool false Master switch. When true, JailBee mounts <shared_dir>/claude → ~/.claude and <shared_dir>/claude-install → ~/.local/share/claude as shared caches, auto-extends strict-mode egress_allow with api.anthropic.com:443 + platform.claude.com:443 (the /login token exchange) + mcp-proxy.anthropic.com:443 (claude.ai connectors) + code.claude.com:443 + claude.ai:443 + downloads.claude.ai:443 (the last two cover the install.sh bootstrap and the native CLI's self-update), creates an empty <shared_dir>/claude on jailbee init, and includes it in jailbee doctor checks. Claude Code's global config (.claude.json) lives inside the shared ~/.claude mount: the golden image exports CLAUDE_CONFIG_DIR=$HOME/.claude, and Claude Code reads (CLAUDE_CONFIG_DIR \|\| $HOME)/.claude.json. Host ~/.claude is not read — Claude Code runs its onboarding flow inside the first container from a clean state (unless claude.seed_onboarding seeds onboarding from an existing credential file), and subsequent containers in the same repo inherit that state via the shared cache.
claude.plugins_enabled bool true When true (and claude.enabled is true), also auto-extends egress_allow with the GitHub hosts Claude Code's plugin marketplace, skills and SessionStart hooks reach (github.com, api.github.com, raw.githubusercontent.com, objects.githubusercontent.com, release-assets.githubusercontent.com, codeload.github.com). The npm registry is deliberately not included: a GitHub-sourced plugin of skills, hooks and commands never reaches it, and an open registry.npmjs.org is an upload channel (npm publish with any account's token). Add registry.npmjs.org:443 to egress_allow if a plugin needs it — an npm-sourced plugin, one shipping a package.json and lockfile, or an MCP server started with npx. GitHub is the same kind of channel (a push or gist with another account's token); set false to keep the API reachable while closing marketplace traffic. Has no effect when claude.enabled: false.
claude.autostart bool false When true (requires claude.enabled: true), jailbee appends a synthetic claude window to the autostart tmux session on every container start; the first jailbee tmux <c> lands in that window (later attaches return to the window you were last on). validate_runtime rejects autostart: true with enabled: false.
claude.command string "claude" Command line executed in the claude autostart window — override to pass flags (e.g. claude --dangerously-skip-permissions) or an env-prefix wrapper. Ignored when claude.autostart is false.
claude.auto_update bool true When true, jailbee new runs claude update inside the container so the shared install advances to the latest release. When false, an existing install is left untouched, but a missing one is still installed. Has no effect when claude.enabled: false.
claude.agent_view bool false When false, containers get CLAUDE_CODE_DISABLE_AGENT_VIEW=1, which turns off Claude Code's agent view (claude agents, --bg, /background) and its on-demand background daemon. The daemon's daemon.lock lives in the ~/.claude every container of the repo shares, so daemons in two containers take it from each other and background jobs die. Set to true if you run one container of the repo at a time. Takes effect on jailbee apply. Has no effect when claude.enabled: false. See agents.md.
claude.seed_onboarding bool true When enabled with Claude, jailbee init / apply seed hasCompletedOnboarding and repository trust in <shared_dir>/claude/.claude.json if the resolved credential holder contains a credential file (grouped or repo-local). The credential is not validated. Existing JSON containing hasCompletedOnboarding or oauthAccount is left alone; otherwise the fields are added while preserving other keys. Unreadable or malformed JSON is not overwritten. Set false to leave onboarding and trust acceptance to the agent session.

Example global config:

claude:
  enabled: true
  plugins_enabled: true

The claude shared caches are not present in the shared_caches: default list — they are auto-added by Config.effective_shared_caches() when claude.enabled is true. Manual entries in shared_caches: with names claude or claude-install suppress the auto-add (same precedent as effective_host_mounts).

pr

How jailbee pr push writes a pull request's title, body and head branch name. These settings used to hang off the Claude agent (claude.ai_pr_*, claude.pr_prompt); they describe jailbee pr push, not Claude, so they moved. The old spelling still loads — each key is folded into pr: before the layers merge (so a repo's old key still beats the global pr:), with a deprecation notice naming the file — and is removed in 2.0.0. jailbee config migrate --apply rewrites it in global.yaml and the host-local files for you.

Key Type Default Description
pr.agent string auto Which in-container agent writes the PR text. auto picks the repo's own agent (see below): an explicit primary_agent first, if it has a headless command. Name an agent (claude, codex, …) to pin it, or claude-jb to run Claude through the LiteLLM wrapper. A pinned agent must be enabled and have a headless command; if it cannot be used jailbee pr push says what to change and falls back to a placeholder — it never quietly uses another agent. Settable in global.yaml and per repo.
pr.ai_description bool true When true, jailbee pr push generates the PR title and body from the branch's commits and diff, showing a spinner while it runs. Falls back to a commit-subject title + placeholder body on any failure, with a warning. Pass --no-ai to opt out per invocation.
pr.ai_branch bool true When true, jailbee pr push asks the agent to propose a convention-following head branch name when opening a new PR. Has no effect when pr.ai_description: false.
pr.model string | null agent's own Model passed as --model. Left unset, Claude (and claude-jb) use sonnet — writing a description is a bounded job, and pinning it keeps generation from competing for the budget of the coding work — and every other agent uses its own default. Accepts an alias or a full model ID. An explicit null omits the flag on every agent, so the container's default model applies. haiku works but has a smaller context window. Rejected at load if it is not a single whitespace-free token.
pr.prompt string | null null Project-specific PR-writing instructions, usually a YAML block scalar in a repo's .jailbee/config.yaml. Embedded in JailBee's own prompt as a delimited section that outranks the generic title/body guidance, so a project can dictate the shape of its descriptions — but it is placed before the JSON response contract, which it cannot override. Whitespace-only is treated as unset; capped at 20 000 characters.
pr.timeout int 600 Seconds jailbee pr push gives the agent to produce the PR text before falling back to a placeholder. Generation is an agentic run, not one model call — it reads the log, the cumulative diff, the PR template, the branch's spec and the CI config across a dozen-plus turns, so the cost scales with the repository, not just with the diff. Measured in JailBee's own repo on a 21-file/+940 diff: 109 s. Raise it for a large tree, or when pr.prompt asks for slower work. Must be positive — to switch generation off use pr.ai_description: false.

What auto picks. An explicit primary_agent name (not auto) wins when that agent has a headless command. Otherwise, among the enabled agents, the ones with autostart on first, then every enabled one; inside each group Claude first and then by name; the first with a headless command wins. So a repo that autostarts codex gets codex to write its PRs, a repo with only Claude enabled gets Claude, and a repo whose autostart agent has no one-shot mode (aider) still falls back to Claude if it is enabled. When litellm.autostart is on, Claude runs as claude-jb, as it does in the autostart window. With no usable agent at all, AI generation is simply off — not an error. A pinned agent that is unusable is reported.

Only Claude's headless command is exercised in production. The codex, gemini and opencode headless commands are taken from each tool's docs and have never been run against a live agent; if one is wrong, override it for that agent (agents.<name>.headless) — see agents.md.

# ~/.config/jailbee/global.yaml — every repo, unless it says otherwise
pr:
  agent: codex
  timeout: 900

Encoding a project's PR standard

jailbee pr push instructs the selected agent to read .github/pull_request_template.md, the spec or issue a branch implements, and CONTRIBUTING.md / CLAUDE.md / AGENTS.md before drafting. This is a prompt instruction, not proof that every agent read every file. pr.prompt is for the rules that live in none of those files — commit them to the repo's .jailbee/config.yaml so every container generates descriptions the same way:

pr:
  prompt: |
    Body sections, in this order and with these exact headings:
      ## Why      — the user-visible problem, one paragraph, no implementation
      ## What     — bullets, each naming the file or symbol it changed
      ## Testing  — the commands you actually ran, verbatim
    Never use the word "comprehensive". Link the Jira ticket from the branch
    name as `[ABC-123](https://example.atlassian.net/browse/ABC-123)`.

The prompt gives these instructions priority over JailBee's generic guidance, then states JailBee's JSON response contract. That ordering is not a guarantee of model compliance; contradictory instructions or malformed output can still make generation fail and fall back to a placeholder.

terminal / terminal.kitty

When a developer runs jailbee shell / jailbee tmux from a kitty terminal on the host, TERM=xterm-kitty propagates into the container via incus exec. The base image's terminfo database doesn't ship the xterm-kitty entry, so curses-aware tools warn terminal is not fully functional and degrade. The golden image ships that entry (kitty-terminfo) itself. When active, this block also RO bind-mounts the host's xterm-kitty terminfo file into every container, which keeps containers built from older images working; the mount is planned for removal in 2.0.0.

The golden image also ships Ghostty's xterm-ghostty entry, so jailbee shell from Ghostty works without any host file; there is no mount for it.

Any other terminal works too. When jailbee shell or jailbee tmux finds that the container does not know the host's $TERM, it compiles the host's entry (infocmp -x) into the container user's ~/.terminfo; if the host has none either, the session runs as xterm-256color. COLORTERM, TERM_PROGRAM and TERM_PROGRAM_VERSION are forwarded as well. On a jailbee tmux attach, tmux is also told to pass escape sequences through (allow-passthrough, so image protocols such as kitty graphics reach the terminal) and that Ghostty and kitty do truecolor and hyperlinks. Inside tmux, an image tool still has to wrap its output for passthrough — kitten icat --passthrough=tmux, for example.

Key Type Default Description
terminal.kitty.enabled "auto" | bool "auto" "auto" and true mount the host terminfo file when it can be located; without one the image's own entry applies. false disables the mount unconditionally.
terminal.kitty.host_terminfo_path path | null null Explicit host path to the xterm-kitty terminfo file. When null (default), autodetect probes /usr/share/terminfo/x/xterm-kitty, ~/.local/kitty.app/lib/kitty/terminfo/x/xterm-kitty, and ~/.terminfo/x/xterm-kitty in that order.

autostart

IDE and browser launch decisions live in jetbrains.autostart, browsers.<name>.autostart and apps.<name>.autostart (not here). The autostart block describes the shell work that runs inside the container during startup, organized into stages of chains of steps.

Top-level keys:

Key Type Default Description
on_create list[Step] | list[Stage] [] Run once after jailbee new provisions the container. Either a flat list of steps (legacy) or a list of stages — see below. Mixing both shapes in the same trigger is a config error.
on_start list[Step] | list[Stage] [] Run on every stopped→running transition: both jailbee new (after on_create) and jailbee start/restart. Put one-shot setup in on_create and recurring launches (dev servers, etc.) in on_start — don't duplicate. Same two shapes as on_create.
step_timeout int 600 Default per-step timeout in seconds (overridable per step).
env map {} Global env merged into every step (per-step env wins on key collisions).
resume_agent_sessions bool true Resume eligible interactive Claude sessions captured on a normal JailBee stop/restart. false consumes the saved snapshot without resuming. Does not cover shell environment, non-Claude processes or background sessions; host reboot and raw incus stop are unverified.

Profiles

Named profiles select stages without changing their order or settings:

autostart:
  default_profile: full
  profiles:
    full: all
    frontend:
      stages: [deps, frontend-dev]
      services: [web]
  on_create:
    - stage: deps
      steps: [{name: install, run: "pnpm install"}]
    - stage: docker-images
      steps: [{name: images, run: "docker compose pull"}]
  on_start:
    - stage: backend
      steps: [{name: backend, run: "docker compose up -d"}]
    - stage: frontend-dev
      steps: [{name: frontend, run: "pnpm dev", background: true}]

A profile is the literal all or a nonempty stages list. Stage names select from both triggers, in config order; the reserved agents and services stages always stay selected, even when not listed. Profiles require stage-form triggers, a defined default_profile, and known stage names. The names none and all are reserved for built-in profiles and cannot be defined in profiles: none selects no configured stages or profile services (an explicit service selection can still override the profile), while the generated agents stage remains selected; all selects every configured stage and service. Without profiles, default_profile may still select the built-in none or all; when no profile is selected or configured as the default, autostart retains the all-stages-and-services behavior. The reserved services slots determine placement, not selection; see placing service startup.

A profile may specify services: [web] to start those known services and their dependencies, or services: [] for none; omitted or null means all. An explicit jailbee new --services web,db overrides the profile, and --services '' starts none. The explicit selection is remembered for later starts and takes precedence over profile services; without it, the profile wins over all services. Define each example service in the top-level services: block.

jailbee new --autostart-profile frontend remembers the selection for later starts and restarts. The built-in none profile selects no configured repository stages or profile services (an explicit service selection can still override it), while the generated agents stage remains selected; --no-autostart also suppresses agents. An absent selection uses the default; a stored name removed from config warns and falls back to the default. Creation uses the target branch's autostart block; later commands use the host config.

jailbee autostart profile <container> shows the current/default profiles and attempted one-shot stages. Add a profile name to switch. On a running container this starts missing on_create stages, then newly selected on_start stages, in the foreground; lighter profiles stop nothing. Live switching only adds work and does not stop existing processes. none selects no configured repository stages or profile services, while retaining the generated agents stage; an explicit service selection still overrides the profile. all selects all configured stages and services. Stopped containers and --no-run only store the selection. The next start runs any missing one-shot stages before on_start. A live detached run must be cancelled before switching.

One-shot stages are recorded as attempted, even after failure, so they are never retried automatically. Containers predating this tracking count all one-shot stages as attempted. new --no-autostart marks the selected profile's one-shot stages attempted without running them. Profiles do not control agent, IDE, browser or app autostart settings.

The terminal dashboard offers Lifecycle → Autostart profile…; the optional autostart_profile / PROFILE column is off by default. Orchestration spawn/restart manifests accept autostart_profile, or jailbee inbox send spawn|restart --autostart-profile NAME.

Stages and chains

A stage is one milestone of a run. Stages execute in list order, waiting for foreground steps and background-step startup probes, not for background processes to exit. Stage-scoped mounts and temporary network settings are restored while those processes may still be running. A stage owns the network profile and the mounts for all of its chains: it switches the network once on entry and restores it once on exit, attaches its optional_mounts once and detaches them once, and nothing inside its chains may touch either — that's what makes running several chains at once safe, since no chain can swap the profile or drop a mount out from under a sibling.

A stage's chains run in parallel with each other; a chain's own steps run one at a time, in order. Put two steps in the same chain when one depends on the other; put them in different chains (or different stages) when they don't.

When one chain's step fails (and that step doesn't set continue_on_error), every chain stops launching further steps — not just the one that failed — but steps already in flight in other chains are left to run to completion rather than interrupted; a half-finished install is worse than a finished one. The stage then fails once those in-flight steps have exited.

- stage: name              # required, unique within the trigger
  network: null            # strict|loose|null — switched once for the whole stage
  mounts: []                # optional_mounts keys, attached for the whole stage
  detach: false             # see "Detaching a run" below
  chains:                   # OR `steps:` — not both
    - name: backend
      steps:
        - { name: migrate, run: "pnpm db:migrate" }
        - { name: server, run: "pnpm start", background: true }
    - name: frontend
      steps:
        - { name: assets, run: "pnpm build:assets" }

steps: on a stage is shorthand for a single chain named main — the common case of one stage doing one thing in order:

- stage: install
  network: loose
  steps:
    - { name: deps, run: "pnpm install" }

is exactly:

- stage: install
  network: loose
  chains:
    - name: main
      steps:
        - { name: deps, run: "pnpm install" }

A stage that sets both chains and steps is a config error. A chain is {name, steps} — name unique within its stage, steps the same Step schema as the flat form (below), run in list order. jailbee autostart status reports progress by stage and step, not by chain — a chain's name exists to keep chains apart in the config and doesn't appear in that output.

Each step is {name, run, ...}:

Key Type Default Description
name string required Identifier; unique across the whole trigger (not just its chain) — step names are tmux window names, and with parallel chains a colliding name can kill a running sibling's window.
run string required Shell command run as the dev user. Always cd'd into working_dir first.
network strict/loose/null null Legacy, flat form only — see Deprecated: step-level network/mounts below. Set on the enclosing stage instead.
mounts list[string] [] Legacy, flat form only — see the deprecation note below. Set on the enclosing stage instead.
env map {} Per-step env (merged on top of autostart.env).
working_dir string "" Path relative to repo_dir. Empty = repo_dir itself.
background bool false Run in a detached tmux window; do not wait for completion. Attach via jailbee tmux <container> to see output.
timeout int null (= step_timeout) Per-step timeout override in seconds.
continue_on_error bool false If true, a non-zero exit warns instead of aborting subsequent steps in its chain.

jailbee config validate additionally rejects: a duplicate stage name within one trigger, a duplicate chain name within one stage, a duplicate step name anywhere in one trigger (see name above), and a step name that collides with an autostarting agent's own tmux window name (see Generic agent support).

Re-running autostart

jailbee autostart run <container> --list lists eligible stages; add --json for stage and step outcomes. Select with repeatable --stage NAME, --step STAGE/STEP, or --failed (selectors combine), or choose interactively when none is supplied. If a stage name occurs in both triggers, specify --trigger on_create or --trigger on_start. Execution requires a running container; --list also works while it is stopped.

Re-runs use the same host-effective configuration and stored profile as restart, not branch-only additions. Agents and generated or reserved services slots are never re-run; use jailbee service for services. Ordinary stages named services with steps remain eligible. A legacy flat group normalized to the name agents is also excluded: rename its first step or use restart instead. Steps whose sanitized tmux window identity matches a configured agent or service are protected: their stages are omitted from listing, the picker and --failed, and explicit selections containing such steps are refused. Creation-only configurations with those names remain valid. A safe step in the same stage can still be selected explicitly with --step. A background: true step restarts its tmux window. Stage network and mount wrappers still apply and are cleaned up, including on cancellation. In a restricted remote SSH session, re-runs that request any stage or legacy-step network or mount change are refused, even when the network or mount unlocks are enabled. --list remains read-only and includes those stages; execute them on the host.

Last step outcomes are recorded best-effort at ${XDG_STATE_HOME:-~/.local/state}/jailbee/autostart-last/<full-container-name>.json. --failed selects failed and started-but-unfinished steps still present in the effective configuration; missing or unreadable outcomes are treated as absent. The dashboard and own-repository listings count only currently eligible steps in the stored profile: removed/renamed steps and the none profile contribute no failures. Foreign/orphan listings retain raw counts when no repository configuration is available. Creating a genuinely new instance clears outcomes left behind for that name (for example after an external Incus deletion); refused or failed creation attempts before an instance exists retain them. A successful re-run replaces that step's outcome without clearing untouched steps. Re-runs run detached by default: use autostart status and autostart cancel to inspect or stop them, or --wait to run in the foreground. A live autostart run is refused whenever its Incus in-progress claim is nonempty, including a foreground lifecycle run's 1 or an unknown/dead marker. Re-run handoffs use a distinct rerun:<pid> claim and release only their own claim on failure. start and restart also refuse while a foreground --wait re-run holds its lock, before storing choices or booting. Apply operations are not coordinated by that lock; avoid running apply concurrently with re-runs.

Detaching a run

detach: true on a stage means: this stage, and every stage after it in the same trigger, run in a background supervisor process instead of blocking jailbee new/jailbee start/jailbee restart. The first stage marked detach: true wins — repeating it on a later stage is legal but redundant. Everything before it still runs in the foreground and blocks as before, so the CLI hands you the session (shell, tmux, or just the prompt back, per after_new/--attach) right after the last blocking stage finishes, while the rest continues in the background.

--wait runs every stage in the foreground, ignoring detach: true. --no-wait splits each trigger after its first stage; an implicitly generated agents stage also stays on the blocking side. If on_create leaves deferred stages, the entire on_start trigger runs in the supervisor. These flags on jailbee new/jailbee start/jailbee restart override the config per invocation and are mutually exclusive. See Commands for jailbee autostart status/cancel, which inspect and stop a detached run, and Security for what a stage's network switch means while it's running unattended.

A config with no detach: true anywhere behaves exactly as before: every stage blocks, in order, and there is nothing to inspect afterwards.

Two known gaps, both narrow:

  • jailbee destroy carries no guard against a detached run. stop refuses while a supervisor is still working unless --force is given, and restart refuses too with no --force at all; destroy does not check either way, so destroying a container mid-run tears it down out from under its own supervisor. Cancel the run first (jailbee autostart cancel) if you want a clean stop.
  • On jailbee new --background, GUI autostart apps launch after every deferred stage finishes, not at the attach boundary. The worker that keeps running the deferred stages in-process is the same one that would launch them, so they wait behind the whole detached run rather than coming up as soon as the session itself would be usable.

The reserved agents stage

jailbee generates one autostart step per agent with autostart: true (see Generic agent support) and needs somewhere to put them. The stage named agents is reserved for exactly this, but only in the stage form — a flat step literally named agents is just an ordinary step; the reserved name means nothing there, because a flat config's step names are user data, not a namespace jailbee claims.

  • Written explicitly — {stage: agents, ...} in your on_start list — it may not carry chains/steps of its own (jailbee config validate rejects that as an error): jailbee fills it with the generated per-agent launch steps, one chain named main. Its own network and mounts are yours to set and are kept as written. When it is the first stage in the trigger to carry detach: true (and no --wait/ --no-wait override is in play), that flag behaves differently from an ordinary stage: instead of deferring the agent launch itself, it keeps the agents stage blocking — so the CLI still hands you a session with the agent already up — and defers everything after it instead. If an earlier stage already carries detach: true, the agents stage simply falls inside that deferred region like any other stage after the boundary, and its own detach (if set) changes nothing.
  • Left unwritten, jailbee inserts it itself at the effective attach boundary within on_start — immediately before whatever on_start stage would otherwise be deferred, or at the very end of on_start's stages when nothing in on_start itself detaches. Within on_start alone, that is the latest point that still runs before the session is handed over.

If on_create already deferred something, none of the above applies. A detach: true stage in on_create (or --no-wait splitting it) hands the session over before on_start is even reached in the foreground — on_start then runs entirely in the background supervisor that resumes it, agents stage included, regardless of where you put it or what its own detach says. In that case the agent's tmux window is created shortly after you attach, not before — the same as every other on_start stage.

An explicit stage: agents is dropped entirely whenever there is nothing to fill it with — not only under on_create (which never launches an agent at all), but also under on_start when no agent currently has autostart: true. If the dropped stage carried detach: true, that flag moves to whichever stage takes its place, so the split point you drew doesn't silently vanish.

A pending initial prompt moves the agents stage to the end. With a jailbee new --prompt (or an orchestration child's task) waiting, the prompted agent starts working the moment it launches, so the agents stage runs after every other stage instead of at the attach boundary: last of the blocking stages when nothing detaches, otherwise last of the deferred run, after the stages that detach: true (in on_create or on_start) or --no-wait deferred. An explicit stage: agents moves there too, keeping its network and mounts; a detach: true on it passes to the stage that followed it, as above. When the agents stage ends up deferred, its tmux window appears after you attach.

Which window jailbee tmux (or --attach tmux) lands on is chosen by name on the first attach, not by tmux's own "most recently created" default — so the agent stage's position only decides when its window is created relative to the hand-off, not which window ends up focused. Later attaches return to the window you were last on; see Where the generated launch steps go.

Deprecated: step-level network/mounts

A step's own network/mounts are legal only in the flat form — the pre-stage shape jailbee still reads and runs exactly as before. Inside a stage they are a config error: move them to the enclosing stage, which is the level that now owns both.

 - stage: install
+  network: loose
   steps:
     - name: deps
       run: "pnpm install"
-      network: loose

A flat step that still sets network or mounts loads and runs exactly as before — jailbee new, jailbee apply and every other command are unaffected. jailbee config validate alone reports it, naming the (implicit) enclosing stage's key to move it to; that reported issue still makes jailbee config validate itself exit non-zero, so a CI check gated on it fails until you move the key, even though nothing else does. Removal is planned for 2.0.0.

Legacy compatibility: the flat form

A flat list of steps under on_create/on_start keeps working exactly as it always has — nothing needs converting. Internally, jailbee normalizes it into stages before running it: consecutive steps that share the same network collapse into one implicit single-chain stage, named after the first step in that run. This is the one observable behaviour change: a run of several steps sharing a network mode switches the profile once rather than once per step. Ordering, per-step mounts (still honoured, if deprecated), and everything else about a flat config are unaffected.

Where do my autostart steps run?

All steps run inside a container-local tmux session named autostart. One window per step. To attach interactively:

jailbee tmux <container>

Every step window is created in the background, so attaching while a run is still going does not drag your view from step to step. A step that fails keeps its window open with the output still on screen (remain-on-exit failed, set on that window alone); a step that succeeds closes its window, as do the windows you open yourself. Background steps (e.g. pnpm dev) keep running in their own window until the container stops.

Where does the autostart config come from?

In clone mode, jailbee new <branch> reads autostart from the target branch's committed .jailbee/config.yaml, at the exact commit it is about to clone — not from your checkout. The container runs the branch's files, so it runs the branch's startup steps. Every other key (mounts, network defaults, cpu/memory, container_prefix, and host-level keys like docker_registry_mirror, ls, dashboard) still comes from the checkout you run jailbee new from; a branch cannot change how the operator runs containers.

If the branch's autostart deviates from your checkout's, jailbee new prints a compact diff naming what it read from — the branch ref (refs/heads/feat/x) when cloning a local branch as-is, or a short sha with the branch name in parens (a1b2c3d4e5f6 (feat/x)) when the clone resolved to a pinned commit (origin-mode, a PR review, or an explicit --pr/commit checkout):

autostart config comes from a1b2c3d4e5f6 (feat/x), not your checkout:
  + on_create[migrate]
  - on_start[old-watcher]
  ~ on_create[build]: run changed
  ! step_timeout: 600 → 900
  ! env: NODE_ENV

Step names are trigger-qualified (on_create[build] vs on_start[build]) because the same name can exist under both triggers as distinct steps. A stage is named the same way but in angle brackets (on_create<setup>), because a stage and a step may carry the same name.

The privilege check is a separate comparison

That diff explains a surprise: why the container runs steps you don't have. Whether the branch gains anything by them is a different question, and it is answered against a different reference — the repo's reviewed baseline, refs/remotes/<upstream_remote>/<default_branch>, rather than your checkout:

branch autostart widens privileges beyond refs/remotes/origin/main:
  ⚠ network access 'loose' in: on_start[warmup]
  ⚠ attaches host mount(s): aws

Your checkout is one snapshot of one branch: it may lag the upstream, run ahead of it, or be an unrelated feature branch with local edits. Measuring privileges against it made the same jailbee new ask one developer and not another, and turned "my checkout is a few commits behind" into an escalation. The default branch on the upstream is what review and CI gate, so that is the baseline. If it carries no usable config, the check falls back to comparing against your checkout and says so in that line. When the baseline ref cannot be read at all — no such remote, or a default branch never fetched — that fallback is a genuinely weaker gate, so it is warned about rather than only noted in the line.

Two kinds of widening are reported, and they are weighed differently:

  • a step or stage attaching an optional_mounts entry the baseline does not attach — these are typically personal credential directories (~/.aws, ~/.m2), and the command lines that run while it is attached come from the same branch. This always asks for confirmation, defaulting to no; declining aborts the whole jailbee new with nothing created. Attaching a mount is what creates the asset — a credential directory the container would not otherwise hold — and no network mode protects against it.
  • a step or stage widening network access from strict to loose. This asks only for an untrusted head: jailbee new --pr N where the PR's head lives in a fork (isCrossRepository) — code nobody with push access to your repo has vouched for — and a jailbee fork of any jailbee new --pr container (whether its PR's head was in a fork is not recorded, so every one counts). Everything else warns and proceeds. Once the container runs the branch's code, strict is an egress allowlist of package registries and forges that all accept uploads, so it is no boundary against that code — while loose is the ordinary way a step installs dependencies.

A PR number is not the signal, deliberately: an internal PR's head is a branch in your own origin, byte-identical to what jailbee new <branch> would clone and pushed by someone who can already run code in your containers. Gating one spelling and not the other would ask about how the command was typed rather than about risk — and a question that fires routinely trains you to click through the mount one, which is the question that matters.

In both cases a brand-new step counts as widening, since the baseline has no counterpart to compare against. Everything else a step controls (run, env, working_dir, background, timeout, continue_on_error), and a stage's detach and chain layout, only warns: it is container-internal, and adds nothing beyond the code execution a cloned branch inherently has. A chain layout warns as ~ on_create<setup>: chains changed, which covers a chain renamed, re-split, or moving a step the two configs share — including a step moved from one stage to another, which does change the mode and mounts it runs with. A step that exists on one side only is left to its own +/- line instead, so renaming a step inside a stage does not also report the stage.

In the stage form, network and mounts live on the stage — a step inside one may not carry either — so that is where both the diff and the ⚠ line name them (on_start<warmup>), rather than repeating them on each of the stage's steps. Underneath, the two forms are matched step by step and compared on the mode and mounts each step effectively runs with. So moving an unchanged loose step into a loose stage is neither a widening nor a reported change: the only line it produces is the new stage itself.

Locally, --yes/-y accepts without asking (in addition to skipping the "branch already exists" prompt). Over remote SSH, changes requiring this privilege confirmation are refused even with --yes; approve and provision them locally. --no-autostart skips the branch config entirely: none of its steps run, so there is nothing to diff or confirm.

With jailbee new --background the question is asked before the run detaches, in the terminal you are still sitting at — a detached worker has no stdin, so a question left for it could only ever be answered "no". Declining exits without creating a container or recording a job. The answer is pinned to the commit you were shown: if the branch moves between the confirmation and provisioning, the worker aborts naming the move instead of provisioning a config nobody saw. When there is no terminal to ask on at all (a script, CI), jailbee new says so and tells you to pass --yes.

A branch with no committed .jailbee/config.yaml falls back silently to your checkout's autostart — a branch need not define one. A branch config that exists but can't be used (invalid YAML, a validation failure, or an autostart step naming an optional_mounts key your host config doesn't define) warns and falls back the same way.

--mount and --no-clone are unaffected — they share the host working tree, so there is no distinct target branch to read from. jailbee start, jailbee restart, and jailbee apply always use your checkout's config; only creation reads the branch.

container_prefix

Key Type Default Description
container_prefix string derived from repo_root.name Prefix for all jailbee-owned Incus resources (containers, profiles, ACL). Must match [a-z0-9][a-z0-9-]*. Override only if repo_root.name doesn't match the regex (e.g. underscore, dot, or capital letter).

primary_agent

Key Type Default Description
primary_agent auto | agent name auto The repo's primary agent: the one that receives the initial prompt of jailbee new --prompt and the agent pr.agent: auto prefers when it has a headless command (an explicit name steers it; with auto the result equals the plain search). auto picks among the enabled agents: those with autostart on first, then the rest; inside each group claude first, then by name. A name must be an enabled agent, otherwise the problem is reported (--prompt exits 2) and no other agent is used. See Primary agent and initial prompt.

docker_registry_mirror.extra_registries

docker_registry_mirror:
  extra_registries:
    - 803520778560.dkr.ecr.eu-north-1.amazonaws.com
Key Type Default Description
extra_registries list[string] [] Every registry hostname this repo pulls images from that is not one of rpardini's defaults. The mirror refuses any other registry. Entries must be exact hostnames — no wildcard (*.example.com, .example.com), no :port (the mirror serves registries on 443 only), no scheme, no path. Each entry becomes a key in the mirror's nginx host maps, where a wildcard would admit every host under it.

rpardini's image defaults cover only Docker Hub, registry.k8s.io, gcr.io, quay.io, and ghcr.io. The mirror serves those and the hostnames listed here, and refuses everything else (see Registry mirror) — so a repo that pulls from another registry (notably AWS ECR — *.dkr.ecr.<region>.amazonaws.com) must list it. Listing it pushes it into the mirror's REGISTRIES env on the next jailbee new / jailbee apply; pulls then go through the cache.

A pull from an unlisted registry fails in dockerd with Forbidden:

Error response from daemon: failed to resolve reference "public.ecr.aws/docker/library/alpine:3.20": failed to do request: Head "https://public.ecr.aws/v2/docker/library/alpine/manifests/3.20": Forbidden

Add the registry's hostname (public.ecr.aws here) to extra_registries and run jailbee apply.

Some registries issue their pull tokens from a different host. GitLab's registry.gitlab.com, for example, authenticates against gitlab.com. The mirror refuses that host too unless it is listed, and the pull then fails with the same Forbidden. List both:

docker_registry_mirror:
  extra_registries:
    - registry.gitlab.com
    - gitlab.com

To find a registry's token host, read the realm in its challenge: curl -sI https://<registry>/v2/ | grep -i www-authenticate.

Mechanics: jailbee new and jailbee apply write the union of these entries into /etc/jailbee-registry-proxy.env inside the jailbee-registry-mirror container and restart jailbee-registry-proxy.service — only when the file actually changes, since a restart drops every pull in flight through the mirror. The mirror is host-global, so the set accumulates across repos — once added, a hostname stays until the mirror container is recreated.

The same file carries jailbee's host-global proxy tuning, written by jailbee registry up, jailbee new and jailbee apply whether or not the repo lists any registries: DISABLE_IPV6=true (nginx stops trying IPv6 upstreams the host may not be able to route) and PROXY_CONNECT_TIMEOUT / PROXY_CONNECT_CONNECT_TIMEOUT of 5s instead of nginx's 60 s, so one unresponsive upstream address cannot hold a pull past dockerd's patience. These three keys are jailbee's and a hand-edited value is put back; any other key in the file (rpardini's AUTH_REGISTRIES, say) is preserved. The same sync also keeps the mirror's locked-down nginx.conf and its Quadlet unit at the versions this jailbee ships.

fork

Warm paths seed HEAD forks (jailbee fork and orchestrator fork spawns) with Git-ignored build outputs from the source container, using btrfs reflinks only. The default empty list disables seeding. Other storage drivers, dirty sources, and forks of a --ref other than the source's HEAD are skipped with an advisory.

fork:
  warm_paths:
    - .gradle
    - "*/build"
    - app/bibleview-js/node_modules

Patterns are repository-relative: no absolute paths, .., or .git root. * and ? match one path segment; ** matches zero or more segments. Only existing ignored entries that are also ignored in the child are copied; unmatched patterns get an advisory. Select the outermost ignored directory: Git collapses its listing (if cache/ is ignored, select cache, not cache/gradle). Submodule outputs are not listed. Symlinks and special files are skipped to keep the copy independent; existing destinations are not replaced. --warm PATH (repeatable) replaces the configured list; --no-warm disables it for one fork. The flags are mutually exclusive. No outputs remain shared.

new

Policy for selecting the source commit when jailbee new clones a repository. The origin policy name means the resolved upstream_remote, not necessarily a remote literally named origin.

new:
  clone_from: origin   # 'origin' (default) | 'local'
  autofetch: true      # default true
  background: false    # default false
  submodules: true     # default true
Key Type Default Description
clone_from enum origin With no explicit base and the default branch selected as source, prefer refs/remotes/<upstream_remote>/<default_branch> (including an existing requested default branch). local prefers the local source branch. A source available only as a remote-tracking branch uses upstream mode regardless.
autofetch bool true In upstream mode, fetch the selected source branch from upstream_remote before resolving its tracking ref. Applies to default-branch preference and upstream-only sources, not to an explicit local base or an existing local feature branch. false uses the refs already present.
background bool false Run jailbee new detached in the background by default. Overridable per-invocation with --background / --no-background. An explicit --attach shell/--attach tmux, --tmux, or --shell also forces foreground, since a detached run has no terminal to attach; --attach none / --no-attach don't, and combine fine with --background.
submodules bool true Initialize the superproject's git submodules (recursively, offline from the host bind mount) in the new container. Set false to skip.

When creating a missing branch, the BASE positional (jailbee new NAME BASE) uses the local branch if present, otherwise its upstream-tracking copy. If the requested container branch already exists, that branch remains the source and BASE records the comparison base. --pr N performs its own fetch and is unaffected by this source preference. background is independent of source selection; submodule initialization applies to clone mode, not a full-repo mount.

Errors:

  • If autofetch=true and the fetch fails (no network, ACL denial, bad credentials, …), jailbee new aborts before touching Incus state. Resolve the underlying issue or set autofetch: false.
  • If upstream mode selects a tracking ref that is missing while the remote exists, jailbee new aborts. Fetch first or select a local source.
  • With autofetch: false, a missing upstream remote falls back to the local source branch. With autofetch enabled, the fetch runs first and can fail before that fallback. For a local-only repo, use clone_from: local or disable autofetch; a source commit must still exist.

destroy

Key Type Default Description
background bool false Run jailbee destroy detached in the background by default. Overridable per-invocation with --background / --no-background.

boot

Key Type Default Description
background bool false Run jailbee start and jailbee restart detached in the background by default. Overridable per-invocation with --background / --no-background.

One key covers both commands: what makes either slow is the autostart run that follows the boot, and it is the same run. A detached boot is refused while another background job for that container is still live — two of them would interleave their autostart steps.

after_new

Key Type Default Description
after_new "shell" | "tmux" | "none" "none" After a successful jailbee new, automatically attach to the new container. "tmux" attaches to the autostart tmux session (creating it on demand), "shell" opens an interactive bash login shell, "none" (default) returns to the host prompt. Override per-invocation with jailbee new --attach <mode>, the --tmux / --shell shorthands, or --no-attach. Unlike --attach shell/--attach tmux, --tmux, and --shell — which force foreground — this config default yields silently to a background run, same as --attach none / --no-attach.

confirm

confirm:
  auto_target: true    # confirm push/pull/checkout when jailbee picks the container

jailbee git push / pull / checkout settle on the single existing container without showing a picker. With confirm.auto_target on (the default) they first print a plan block — both branch names, both tips, the action — and ask [Y/n]. Declining aborts before anything reaches the container or a host branch (though on the push path, the host's origin/<source> fetch already ran). Per-invocation overrides: --confirm / --no-confirm. Off a TTY, pull/checkout print the block and only skip the prompt; push requires an explicit name off a TTY in the first place, so it never reaches this confirmation there. See Confirming an auto-picked container.

pull

Controls jailbee git pull's post-merge cleanup prompts after a successful merge from a container's branch into the container's recorded base branch (user.jailbee.base_branch, set at jailbee new time); override the merge target for a single invocation with --into <branch>.

Key Type Default Meaning
destroy_container "prompt" | "always" | "never" "prompt" Whether to destroy the container after a successful merge.
delete_branch "prompt" | "always" | "never" "prompt" Whether to delete the merged local host branch.
tags "none" | "reachable" | "all" "reachable" Which tags travel from a container to the host — governs jailbee git fetch, checkout and pull alike, not just pull. "reachable" is what git's automatic tag-following has always done here (lightweight and annotated tags both); "all" mirrors every tag the container has; "none" leaves the host's refs/tags/* untouched. No setting ever re-points a tag that already exists on the host. See Tags.
ff "never" | "auto" | "always" "auto" How jailbee git pull merges the container's branch. "auto" fast-forwards when the host branch is strictly behind and writes a merge commit otherwise (the behaviour before 1.4.0 was always a merge commit — set "never" to keep it); "always" demands a fast-forward and fails on divergence. A target branch that isn't checked out is fast-forwarded at ref level regardless of this key. See Fast-forward policy.

--cleanup on the CLI forces both cleanup keys to always; --no-cleanup forces both to never. destroy_container: always and --cleanup force destruction without the interactive work-loss guard: uncommitted container changes can be lost even after the committed history was merged. prompt adds that safety check. All cleanup is skipped when the known target HEAD remains unchanged. Cleanup failures are warnings, not errors. --ff/ --no-ff override ff for one run; --tags/--follow-tags/--no-tags override tags for one run (mutually exclusive within each pair).

Example (~/.config/jailbee/global.yaml):

pull:
  destroy_container: prompt
  delete_branch: prompt
  tags: reachable
  ff: auto

Migration note: this block was previously called merge:. A config file that still uses merge: fails to load with a clear error naming both the old and new key and the file path. Rename the block to pull: to fix.

push

Controls jailbee git push's default behavior when called with partial arguments. Each key may be "ask" (open an interactive prompt) or a concrete value; default_action defaults to "ask", default_source defaults to "base".

Key Type Default Meaning
default_action "merge" | "rebase" | "plain" | "ask" "ask" What to do after pushing the ref. "plain" is transport-only (no merge/rebase).
default_source "default-branch" | "current" | "base" | "ask" "base" Which branch to push. "base" resolves to each container's recorded base branch (user.jailbee.base_branch), so it selects that branch's current ref, not necessarily the original branch-off commit. "default-branch" always uses the repo's default branch, regardless of the container's base. "current" uses git symbolic-ref --short HEAD. "ask" opens an interactive picker every time.
push_from "origin" | "local" "origin" Which copy of that branch to push. "origin" sends refs/remotes/<upstream_remote>/<source> and falls back to refs/heads/<source> when the branch has no upstream copy; "local" reverses the order.
autofetch bool true Run git fetch <upstream_remote> <source> on the host before resolving the ref, so the remote-tracking copy is current. Only applies in push_from: origin mode. Best-effort — the push proceeds with the refs already present; a failure is reported only when the origin ref is what ended up travelling (and may therefore be stale), not when resolution fell back to the local branch because the source simply isn't on origin.
tags "none" | "reachable" | "all" "none" Which tags travel from the host into a container. "none" sends none; "reachable" sends the tags reachable from the pushed branch (computed with git tag --merged, not --follow-tags, so lightweight tags survive); "all" mirrors every tag the host has. Never re-points a tag that already exists in the container, and never reaches the GitHub origin — jailbee pr push is unaffected. See Tags.
ff "never" | "auto" | "always" "auto" How jailbee git push --merge merges inside the container. "auto" tries fast-forward-only when already on the source branch, asking (TTY) or failing (no TTY) on divergence; on another branch it permits a normal merge. "always" requires fast-forward-only. "never" permits a normal merge, but currently does not force a merge commit: the container merge command omits --no-ff. See Fast-forward policy.

CLI flags (--merge, --rebase, --plain, --from, --current, --from-origin, --from-local, --fetch/--no-fetch, --ff/--no-ff, --tags/--follow-tags/--no-tags) always win over the configured defaults. With "ask", the command opens a questionary prompt; in a non-TTY environment, the command errors and points at the relevant config key.

The dashboards follow the same rule from the other side. jailbee dashboard hands over the real terminal, so the questionary prompt appears exactly as it would on the command line. The Qt dashboard cannot — its child process has no stdin — so it asks in a dialog instead and passes the answer as a flag, and only for a key that is "ask": pin default_action or default_source and the GUI stops asking about it. Its source dialog offers the container's recorded base branch and the host's checked-out branch, the two choices it can express without reading the host repo; for "default-branch", set default_source in the config rather than answering per push.

Why push_from defaults to origin

git fetch updates refs/remotes/origin/<branch>; the local refs/heads/<branch> only moves on git pull. For any branch you do not check out on the host — typically the base branch a container is pushed with — the local ref is stale exactly when you just fetched. With push_from: origin (plus autofetch), jailbee git push sends the upstream tip, matching what jailbee new already does via new.clone_from: origin.

When the source equals the container's base branch, the push force-updates refs/jailbee/base/<base> inside that container. Pushing a local base that trails origin can move that transport anchor backwards, but status no longer uses it: jailbee ls compares to the host's live local target branch.

--current (or default_source: current) always resolves locally regardless of these keys: the host's checked-out branch is the work in progress, so the local ref is the fresher one by construction.

--pr ignores them entirely — jailbee fetches the PR head into refs/jailbee/pr/<N>/head and pushes exactly that ref, so there is no local-vs-origin choice to make (passing --from-local/--from-origin with --pr is rejected).

Use --from-local when the host has commits not yet pushed to origin. jailbee warns when it pushes an origin ref while the local branch holds commits that ref lacks, so nothing is dropped silently.

Lives in either ~/.config/jailbee/global.yaml (user-wide) or <repo>/.jailbee/config.yaml (per-repo). The repo file overrides the global file via the standard deep-merge pipeline.

Example (~/.config/jailbee/global.yaml):

push:
  default_action: merge
  default_source: current

With the above, jailbee git push feat-foo runs git merge in the container using the host's currently checked-out branch as the source, skipping the action/source pickers and the fetch (current implies the local ref). The automatic fast-forward policy may still ask for confirmation when the container is on that same branch and the histories have diverged.

ls: / dashboard: — remembered columns

Which columns jailbee ls and the dashboards show, by default.

ls:
  fields: null      # explicit ordered list, or null for the built-in default
  hide: []          # subtractive; applies only when `fields` is null

fields, when set, wins outright: naming a column is a request for exactly that column, in that order, even one that is off by default (local_diff, local_count, …) or would otherwise be hidden by a dynamic rule (e.g. pr with no container carrying one). hide is subtractive and only prunes the built-in default set — a dynamic rule such as pr's "show only when something has one" still applies to a hidden-by-config column, unlike fields. This one rule is implemented once, in table_format.apply_column_config, and used by jailbee ls; the deprecated dashboard: block followed the same rule for its one-time import into view_prefs (see below).

On terminals, the default jailbee ls table drops low-priority columns until it fits the width, keeping NAME. Setting ls.fields or passing --fields disables fitting; --wide / -w also keeps every selected column. Piped output and JSON are never fitted. A notice below the table lists hidden columns.

The two views have different built-in defaults. jailbee ls is a one-shot listing and stays narrow: NAME, BASE, STATE, CREATED, NETWORK, WT, DIFF ±, ↑, ↓, MERGE, plus the dynamic columns below when they apply. The dashboards add USED and MEM% (mem_used, mem_pct), CPU, AI (agent_compact) and OUTBOX (outbox), and leave out ISSUES — OUTBOX carries that count together with the PR outbox's. The compact agent status omits agent names to save width. MEM (mem, used / limit), LIMIT (memory_limit), DOING and ISSUES stay selectable in the dashboard settings; the details panel always lists every busy process. The full AGENT column includes those names and is available by selecting it in dashboard settings. CPU and DOING are rates and have no value at all in a single reading, so ls takes a second one when you name either in --fields. Either agent field needs a single reading. IP is off in both — enable it in the dashboard settings UI, or ask for it from ls with --fields ip.

Dashboard presentation only. Field names and ls/JSON output stay unchanged. The dashboards label state ST (▶ Running, ■ Stopped, Ⅱ Frozen), creation AGE (elapsed seconds/minutes/hours/days), network LOOSE (empty for strict; a red ● and the remaining auto-revert time for loose, ● 45m, or ● ∞ with no deadline; Qt cards show the same text without the colour), target diff DIFF, local diff L DIFF, combined status GIT, full name FULL, memory limit LIMIT, memory in use USED, its share of the limit MEM%, loose deadline UNTIL, optional mounts MOUNT (empty when none are attached; a red ● — the same flag as LOOSE — and the time remaining until the last attached mount detaches, e.g. ● 12m, or ● ∞ if one has no deadline), pending issues ISS (when selected), and staged PR plus issue manifests OUTBOX (✉N), orchestration role ROLE (orch, child, or a red child! when the orchestrator is not running), CHILDREN and INBOX. AI uses ◆ waiting, ● busy, ◐ shell, ○ idle — bright when the agent went idle less than 30 minutes ago, dim after that — and ? for an unknown agent state. Full AGENT remains unchanged. BASE's ↗ marks a remote-tracking base; MODE uses cln/mnt. WT, DIFF and L DIFF show ✓ for clean, not for an unknown result. DOING uses ×N for process counts; JOB shortens working verbs (start, create, clone, stop, delete, destroy) and autostart:<stage> to auto:<stage>, preserving failed and (dead). Qt table tooltips expand the labels, exact timestamps and agent details; the TUI's h help contains the legend. MERGE keeps its meaning; PR shows only #123 / #123↓ (the ✉N that jailbee ls appends moves to OUTBOX). A column set stored by an older release is migrated once — mem → mem_used + mem_pct, issues → outbox, doing removed, and ROLE, CHILDREN and INBOX added after OUTBOX in stored views that still show OUTBOX — with one notice; turning any of them back on afterwards sticks.

Eleven columns are dynamic and appear only when they have something to say: job (a background job is running), ttl (a container is in loose mode), mounts (optional mounts are attached), pr (a container tracks a PR), mode (a mount-mode container exists — on a clone-only host the column would be a constant), issues (a container has pending issue-outbox actions), outbox (a container has staged PR or issue manifests) and group (a container resolves to a credential group). Three more belong to container orchestration and appear only when it is in use: role (orch for an orchestrator, child for a spawned child, child! when its orchestrator is stopped), children (running/limit and queued children of an orchestrator) and inbox (✉N unread orchestration messages).

Table output only. jailbee ls --format json always emits its own built-in field set (FieldSpec.default_json), regardless of ls.fields/ls.hide — a personal display preference in global.yaml must never silently narrow machine-readable output a script depends on. An explicit --fields flag on the command line still wins in every format, table or JSON.

Allowed names (also the jailbee ls --fields vocabulary): name, full_name, alias, repo, mode, base, state, created, job, network, ttl, loose_until, mounts, ip, memory_limit, mem, mem_used, mem_pct, wt, target_diff, ahead_count, behind_count, conflict, local_diff, local_count, git_status, pr, issues, outbox, group, autostart_profile, role, children, inbox, cpu, doing, agent, agent_compact. claude and claude_group are accepted aliases for group.

ahead_diff was retired: configured uses produce a migration warning and explicit --fields ahead_diff is an error. Use target_diff for the direct committed-tree comparison with the host target branch, or jailbee git diff --incoming for the former branch-contribution patch. Explicit JSON git_status uses target_diff, behind_count, base_source, base_sha, tracking_relation and upstream_ref instead of ahead_diff; its submodule line fields are target_ins/target_del instead of ahead_ins/ahead_del. local_diff and local_count remain opt-in comparisons with the host's currently checked-out HEAD, which can differ from the target branch. If the host's local target branch is absent, status uses the configured upstream's last-fetched tracking ref and marks BASE as tracking; it never fetches. If neither ref or the selected commit object is available, comparison-dependent values are ?, with no stale-anchor or unrelated-branch fallback. Where both local and tracking refs exist, local wins; a tracking-ahead/diverged relation is reported as a notice and does not claim the remote server's current state.

Three things are problems: an unknown name (reported with the allowed set listed), fields: [] (a table with no columns at all — write fields: null if you want the built-in default set back), and the same name twice in fields (it would render that column twice). None of these three is fatal at load time any more, in either file — a column choice is a personal display preference, and a typo in it must never break an unrelated command. Both ~/.config/jailbee/global.yaml and a repo's .jailbee/config.yaml recover the same way: an unknown name is dropped, a duplicate collapsed to its first occurrence, and an empty (or emptied-by-dropping) fields reset to the built-in default set (hide is never reset this way — an explicitly empty hide is a real value, not the same footgun). The command proceeds with whatever remains valid, and the fix is printed as a warning naming the file it came from, so a global-layer fix isn't confused with a repo-layer one. Either way, jailbee config validate is where all three are still reported as errors, with the allowed set listed for the unknown-name case — the one command whose job is telling you what's wrong.

ls: exists in ~/.config/jailbee/global.yaml and in a repo's .jailbee/config.yaml, merged the same field-by-field way as loose_auto_revert and mount_auto_revert: the repo's block overrides the global one per field (setting only hide in the repo still inherits the global fields, and vice versa). Note the key is not part of the general deep-merge pipeline used by the rest of the file — that pipeline appends list values, which would concatenate the two fields lists instead of replacing one with the other. A repo block that names fields replaces the global list outright; fields: null in the repo discards the global list and restores the built-in default set. Column choice is a personal preference, so the normal home is global.yaml; a repo that sets the block does so for everyone working in that repo — deliberate, and rare.

Host-local ls settings first merge into the repo block using ordinary deep-merge rules: non-empty lists append, [] clears, and null replaces. The resulting repo/local block then overrides global preferences field by field. This differs from the global-to-repo list replacement described above.

--fields on the CLI beats the configured ls: blocks outright — this is a remembered preference, not a lock.

The dashboards remember their own columns

jailbee dashboard and jailbee gui do not read dashboard.fields or dashboard.hide as ongoing column preferences. Each remembers its own columns and its own folded repo groups, because a live view can own the state you are looking at:

  • In the TUI, press F2 (or S) for the settings overlay: ↑/↓ moves, Space toggles, Tab switches between the Fields, Repos and Visibility tabs, Esc closes. The Visibility tab's first row, Show system status, shows or hides the one-row host memory, storage pool and root filesystem bar above the table (on by default, persisted); Show empty repos follows it. Changes apply immediately — the table stays on screen behind the panel. The Fields tab lists the enabled fields in your order, then the disabled ones alphabetically; Shift+↑/Shift+↓ moves an enabled field (never across name, which is always first and cannot be turned off), and a newly enabled field is appended. Outside the overlay, </> sort the rows by the previous/next column, I reverses the sort and a header click sorts by that column; the sorted column's header shows ▲/▼. A sort column that is not enabled (or, in the terminal dashboard, not shown because every value in it is empty) sorts by the default order; the sort is kept and applies again once it is shown.
  • In the GUI, use View ▸ Columns (enabled columns in order, then the rest alphabetically); drag a header to reorder the columns (name stays first) and click one to sort by it.

The two are independent on purpose: a wide Qt table and a narrow TUI is a supported setup. State lives in state.sqlite's view_prefs table, one row per front-end — the enabled columns, their order and the row sort are each remembered separately for the terminal and the Qt dashboard. It is machine-written, so it stays out of your hand-edited config.

In the Qt dashboard, enabling a conditional column means "show it when it has something to say"; empty dynamic columns are omitted. The terminal dashboard does the same on every refresh: a selected column appears as soon as it has something to show (a mount attached, a container turned loose) and goes again once every cell in it is empty. Empty columns remain selected in your preferences. ls --fields also forces named columns on.

The terminal dashboard never hides a column because the terminal is narrow. Every column keeps its width; when they do not all fit, the first column (normally NAME) stays put and ←/→ scroll the others one column at a time. A dim ‹ after the first column and › at the right edge mark columns out of view, and a column cut by the edge ends in …. On opening, the widths are fitted to the visible data, and fitted once more when the first refresh after that arrives. These session-only budgets survive later refreshes and resizes; press O to measure new values. Changing settings recomputes column membership, resets widths to their fixed default budgets and scrolls back to the first column. Only unfolded, visible repositories contribute to the snapshot.

dashboard.auto_hide.hide_first, which chose the columns to hide first, is deprecated and has no effect; jailbee config validate reports it, and it can be deleted.

dashboard.fields and dashboard.hide are deprecated too. These keys remain accepted so an existing config keeps loading: they are imported into each front-end's own settings the first time you open that dashboard after upgrading, and can be deleted once both have been opened at least once. jailbee config validate says so. Only ~/.config/jailbee/global.yaml is imported this way — the setting is personal and applies in every repo, so a repo-level dashboard: block is reported and dropped rather than seeded. ls: is unaffected and still lives in config.

The Qt dashboard's Compact card style is the one exception: it renders a hardcoded selection — name, state, mode/base/network, a job badge and a folded wt/target_diff/ahead_count/behind_count/conflict summary — so a configured column outside that set (local_diff, say) reaches the tree and the Grid card style but never Compact. Switch card style to see it.

dashboard.refresh — how often the dashboards update

Global config only. Every open dashboard — jailbee dashboard, jailbee tui, jailbee gui — shows what one shared background service gathers, so this sets the pace for all of them at once:

dashboard:
  refresh:
    interval: 3        # seconds between base-state gathers (floor 0.5)
    git_interval: 10   # seconds between git-status probes (never below interval)
    git: true          # false: skip the git probes entirely
    agent_activity: true   # false: never read an agent's transcript (no activity lines)

For the live agent session with the most recently written readable transcript, the dashboard's details panel shows the state with its duration and ~N subagents / N shells, the last tool call while the session is active, and the last assistant message, wrapped in full up to 1000 characters. When the terminal has room it then lists the session's recent tool calls and messages, newest first (the last 20 events, minus the newest tool call and message while those are shown above; each cut at 200 characters). The Qt dashboard puts the state, the tool and the message (cut at 200 characters) in the agent tooltip. All of it comes from the session transcript, which contains prompts and code, so turn agent_activity off on a shared screen. The subagent count is an estimate (a subagent file written in the last 30 seconds).

The service reads it when it starts, and exits once no dashboard has been open for 30 seconds — close every dashboard to apply a change. The dashboards' --interval, --git-interval and --no-git flags are deprecated and ignored.

dashboard.mouse — mouse in the terminal dashboard

Global config only. On by default: click a row to select it, double- or right-click to open its menu, click ▾/▸ to fold a repo and ‹/› to scroll the columns; menu, picker and suggestion entries and the settings tabs are clickable too. The wheel scrolls the table or list under the pointer and never moves the selection; Shift+wheel or a horizontal wheel steps the columns. The row under the pointer is shaded.

dashboard:
  mouse: false   # leave the mouse to the terminal (text selection)

With the mouse on, most terminals still select text with Shift-drag. M in the dashboard turns the mouse off or on for that session only.

orchestration

Rights a container may be granted to start and manage child containers (see jailbee orchestrate, commands). Nothing is granted unless you run jailbee orchestrate grant or set a default_profile.

Host level (~/.config/jailbee/global.yaml or the repo's host-local file; a local profile replaces a global one of the same name):

orchestration:
  default_profile: worker        # applied by `jailbee new`; must name a profile
  default_runner:                # optional preference for spawned children
    agent: claude-jb             # claude or claude-jb
    profile: grok                # optional; only with claude-jb
    model: haiku                 # optional: fable, opus, sonnet or haiku
  profiles:
    worker:
      max_running_children: 4    # 1-64, default 4
      default_runner: {agent: claude, model: opus}  # optional container preference
      operations: [spawn, message, import, restart, stop, destroy]   # default
      grantable:                 # what the holder may hand on to its children
        mounts: []               # optional_mounts names
        loose: false
        egress: []               # egress allow entries
      publish:                   # optional; absent means no publishing
        issues: [comment, state] # own repo and declared submodules
        foreign_issues:
          repos: [someorg/upstream-lib] # owner/name or ["*"]
          actions: [comment]
        pull_requests: false     # own source PR manifests
        pr_reviews: false        # bound own-repository PRs
        max_actions_per_hour: 30 # 1..500; reserved; refunded only when the in-lock policy refuses before any mutation

Profile names are lowercase letters, digits and dashes, starting with a letter (max 32 characters). operations may be spawn, message, import, restart, stop and destroy, and must include spawn and message. default_profile is also where a plain jailbee orchestrate grant starts when the container holds no grant yet and no --profile is named. It is applied by jailbee new to the new container, except for mount-mode containers, children, and when the repo's caps (allowed or an excess) would refuse the grant.

default_runner has the shape {agent, profile?, model?}. Runner selection prefers an explicit runner, then the container's runner, then a non-null host-local default, the repo default, and finally the global default. It is a preference, not a right, and does not grant orchestration permissions.

Per repo (.jailbee/config.yaml): rights may only be narrowed; the repo may set default_runner and the idle-report threshold freely.

Key Default Effect
idle_after_seconds 900 Positive seconds a live prompted child must await input before a broker idle message; also the repeat interval. Requires an adapter session signal (currently Claude); see idle monitoring.
allowed true false: containers of this repo cannot be granted and existing grants have no effect
max_running_children none Lowers the grant's limit
mounts none Mounts children may be given; others are dropped
egress none Egress entries children may be given
loose none false forbids handing loose network to children
publish none Same block as a profile: intersect actions/repos/booleans and take the smaller rate; false forbids all publishing. Never grants new rights.
default_runner none Preferred runner, with the same {agent, profile?, model?} shape as the host setting

profiles and default_profile are refused in a repo's config. A profile grants rights, and a repo file arrives with the repo (a clone, a pull request): if it could grant, a cloned repo could widen what its own containers may do. A grant above the repo's caps is refused by jailbee orchestrate grant with each excess named; jailbee orchestrate show prints the effective grant.

Computed attributes

The Config object exposes four attributes set at load time, not from YAML, plus container_prefix, a real YAML key whose fallback is computed:

  • repo_root — directory containing .jailbee/.
  • upstream_remote — which of the repo's git remotes jailbee treats as the upstream. See Which remote is the upstream? below. Fallback origin.
  • default_branch — auto-detected. See Which branch is the default? below. Fallback main.
  • credential_group — the host-level credentials group this repo resolves to, or None when it keeps its own login. A shared name only: each enabled agent turns it into its own holder directory inside the account adapters.
  • container_prefix — defaults to repo_root.name, overridable via the optional container_prefix: YAML key. Used as the prefix for every jailbee-owned Incus resource (containers, profiles, ACL).

Which branch is the default?

default_branch is jailbee new's starting point when the requested branch does not exist yet, and supplies the base branch when a command does not name one. Status and container diffs resolve each container's recorded base branch against the host's live local branch (tracking-ref fallback), not this computed default and not the host's checked-out branch. A jailbee pr push that names no base still uses the default. The default therefore has to be stable — a value that followed whatever the host has checked out would silently change those defaults — so jailbee reads something that exists rather than guessing a name, taking the first of:

  1. refs/remotes/<upstream_remote>/HEAD — the project's own answer, written by git clone from what the server reports;
  2. refs/remotes/<upstream_remote>/main, then .../master — a repo whose branches were fetched by hand has no symref to read;
  3. the local refs/heads/main, then refs/heads/master;
  4. the currently checked-out branch — the only evidence left in a repo that follows neither convention;
  5. the literal main, for a detached HEAD, a repo with no commit yet, or no git at all.

Steps 2-4 are what make a local-only repo (git init, no remote) work: upstream_remote falls back to the name origin whether or not such a remote exists, so a guessed main used to name a branch that existed nowhere — and jailbee new then refused to create any container in that repo. The conventional names outrank the current branch deliberately: a checkout sitting on feature/x must not make feature/x the diff base for every container of the repo.

Which remote is the upstream?

origin is only the name git clone picks by default, and git remote rename is an ordinary thing to do. jailbee therefore resolves the name instead of assuming it, once per invocation, taking the first of:

  1. the sole remote, when the repo has exactly one;
  2. origin, when it exists;
  3. remote.pushDefault;
  4. the current branch's branch.<branch>.remote;
  5. the one remote carrying a refs/remotes/<remote>/HEAD symref — the signal that survives both a rename and a branch that was never pushed.

A candidate naming a remote that no longer exists is skipped, so a stale remote.pushDefault cannot win.

origin sits ahead of every other signal on purpose: a repo that has one behaves exactly as it always did. In particular, a fork checkout where origin is your fork and branches track the canonical repo keeps pushing to the fork.

There is no config key for this — git already holds the answer, and a submodule may answer differently from its superproject (each is resolved against its own directory). If jailbee cannot tell, it falls back to the literal origin and jailbee doctor reports the ambiguity; disambiguate with git config remote.pushDefault <name> or by giving the branch an upstream.

github

GitHub CLI (gh) integration. When enabled, jailbee:

  • Opens api.github.com:443 in the strict-mode egress allowlist.
  • Injects GH_TOKEN into the container at autostart via /etc/profile.d/jailbee-github.sh so login shells (and AI agents launched through them) authenticate without gh auth login.
  • Runs jailbee doctor checks for token presence, perms, and PAT shape.
github:
  enabled: true

enabled is a host-wide integration switch and normally lives in global.yaml. Put each repo's token in its host-local file instead; see Host-local overrides. A fine-grained PAT is scoped to its owner and selected repositories.

Placement constraint: github.enabled belongs in global.yaml, while github.token belongs in the host-local per-repo file. A github block in committed .jailbee/config.yaml is rejected to avoid committing a secret.

Permissions: use mode 0600 for a local file containing github.token. Loading warns if group or other permissions are present; it does not reject the config. jailbee doctor reports insecure permissions. Run chmod 600 ~/.config/jailbee/repos/<container_prefix>.yaml after editing.

Token shape: prefer fine-grained PATs (github_pat_*) scoped to "Only select repositories" with Contents: Read, Issues: Read, Pull requests: Read, Metadata: Read — read-only. Classic PATs (ghp_*) get a doctor warning because they cannot be scoped per-repo. JailBee's outbox workflows publish issue and PR changes through the host's own gh, applying a manifest an in-container agent staged into ~/.jailbee/issue-outbox or ~/.jailbee/pr-outbox — see jailbee issue and the jailbee-issue-management / jailbee-pr-review skills. This token authenticates the container's own gh, which has whatever permissions the PAT grants. JailBee cannot verify a fine-grained PAT's effective permissions; a write-capable token can bypass the intended host review gate. Network allowlists do not distinguish GitHub reads from writes. Disabling the integration or removing its token does not erase an already-written profile script or a credential copied by a process; revoke exposed tokens separately.

Field defaults:

Field Type Default Notes
enabled bool false Master switch. Opt-in via global.yaml.
token SecretStr unset This repo's GitHub PAT; set only in the host-local file. Masked in output and read-only in the interactive config editor.
api_tokens dict[str, SecretStr] {} Deprecated; removed in 2.0.0. Legacy map from container_prefix to PAT; migrate to local github.token with jailbee config migrate.

enabled: true requires this repo's local token or a non-empty legacy github.api_tokens map. The legacy map need not contain this repo's prefix; without an applicable token its containers receive no GitHub authentication. With neither source, loading fails and asks for a token. Doctor checks local presence, file permissions and token shape, not effective GitHub authorization.

Agent-wide instructions (~/.config/jailbee/AGENTS.md)

Instructions that apply to every wired agent in every container of every repo — model preferences, house rules — go in one file on the host: ~/.config/jailbee/AGENTS.md ($XDG_CONFIG_HOME/jailbee/AGENTS.md if set).

Jailbee prepends a built-in container-environment preamble to these optional instructions and stages them in ~/.local/share/jailbee/agent-instructions/<agent>/by-file/<file>/ ($XDG_DATA_HOME/jailbee/agent-instructions/... if set), under each agent's own file name, and mounts that directory read-only into containers where the agent is enabled and has global_instructions configured. Currently only the Claude Code preset configures it. For Claude Code the file is /etc/claude-code/CLAUDE.md, its managed-policy memory file: loaded in every session before ~/.claude/CLAUDE.md and the project's CLAUDE.md, and not excludable. A container cannot change it. These are instructions, not hard model-policy enforcement; they do not configure the agent running on the host.

  • First use: run jailbee apply once in each existing repo to add the mount, then restart its running containers so the mounts land. New containers get it automatically.
  • Editing: the copy is refreshed by jailbee apply, jailbee new, jailbee start/restart, jailbee ls, jailbee shell and jailbee tmux. A session reads it when it starts, so an edit reaches the next agent session, not one already running. No jailbee apply needed after the first.
  • No file: the staged copy contains the built-in preamble alone. Removing the source restores that copy on the next refresh. Each (agent, file name) pair has its own staging directory, so a repo that sets an unusual global_instructions.file only adds that file to its own containers.
  • Mount conflicts: instruction destinations must be disjoint from effective host/shared mounts and other instruction destinations (including parent/child paths). An existing policy mount at /etc/claude-code, for example, is not silently replaced: profile generation reports the conflicting mount. Keep that policy mount by setting agent_instructions: false in global.yaml, or remap the mount or global_instructions.dir to a disjoint destination.
  • Opting out: agent_instructions: false in global.yaml skips updates and omits the mount. Run jailbee apply in each repo and restart its running containers to remove existing mounts. Host staging is retained, not deleted.
  • Agents: Claude Code today (the global_instructions preset field, see agents.md). Codex is not wired: its instruction path lies inside its writable shared mount.
  • Diagnosis: jailbee doctor reports source size and staged-copy status; a stale copy is refreshed by jailbee ls.

Example:

# ~/.config/jailbee/AGENTS.md
- Never use the Fable model unless explicitly asked to.
- Run subagents that write code on Sonnet; reviews on Opus.

Global config (~/.config/jailbee/global.yaml)

Optional. Host-global settings shared across all repos. It is the usual home for the github.enabled switch (above) and the usual home for the opt-in integration blocks (gpg, ssh, jetbrains, browsers, agents). agents: is valid at both layers, though — see agents above — and a repo entry merges over a global one, so a team default set globally can still be adjusted per repo. The Docker registry mirror overrides and remote (below) are unique to this file. credentials.group is the host-wide default and may be overridden for one repo in its host-local file (above).

docker_registry_mirror:
  enabled: auto                                  # auto | true | false
  port: 3128                                     # rpardini default
  image: rpardini/docker-registry-proxy:0.7.0    # OCI image pin
  data_dir: ~/.local/share/jailbee/registry          # cache + CA storage
Key Default Description
enabled auto auto wires the mirror only into repos that ask for it: a golden image that would contain Docker (golden.stacks.docker, an enable_snippets/install.d 50-docker, a golden.extra_apt_packages entry starting with docker, minus disable_snippets), a non-empty per-repo docker_registry_mirror.extra_registries, or golden.stacks.ecr (which stages a Docker credential helper). true forces it on, false skips all mirror-related work. Both are host-global — this file is host-level, so true set for one undetectable repo also re-imposes the strict-mode jailbee new abort on every other repo on the machine; extra_registries is the per-repo way to opt in. Mirror container lifecycle is unaffected either way (use jailbee registry up/down).
port 3128 Port the rpardini proxy listens on inside the mirror container.
image rpardini/docker-registry-proxy:0.7.0 OCI image podman runs inside the mirror Incus container. Pin to a specific tag — upgrades are deliberate.
data_dir ~/.local/share/jailbee/registry Host directory bind-mounted into the mirror for cache + CA storage.

auto cannot see every route to Docker. A differently-named install.d snippet (55-docker-ce.sh resolves to the logical name docker-ce, not docker), a custom golden.provision_script that installs Docker without golden.stacks.docker being set, and Docker installed by hand inside a running container are all invisible to it. Those repos need enabled: true — or, for the first two, a declared stack and a golden-image rebuild.

When a repo wants the mirror but the mirror container is stopped or missing, jailbee init, jailbee apply and the background egress refresh warn and continue — the ACL simply omits the mirror rule until a later run finds it running. jailbee start / jailbee restart never aborted on this and stay silent: they skip the /etc/hosts mirror pin without comment. jailbee net strict warns, since switching to strict is what removes the container's direct route to Docker Hub. Only jailbee new refuses, and only in strict mode: the default egress allowlist contains no registry hosts, so there the mirror is the container's only route to Docker Hub. In loose mode it is a pull cache, so jailbee new warns and proceeds.

The remedy in every case is jailbee registry up && jailbee apply. Note that apply only re-pins /etc/hosts and re-installs the dockerd proxy on running containers, so a container that was stopped at the time is not fixed by it — start it and run jailbee apply again.

Lifecycle commands: jailbee registry up, jailbee registry down, jailbee registry status (running / stopped / degraded / missing).

litellm

Host-only proxy settings for claude-jb; see Claude Code through LiteLLM for setup, route examples, security and limitations. The full block is accepted in ~/.config/jailbee/global.yaml only, not in a committed repo config. A host-local per-repo file may carry a narrower override (routes, profiles, default_profile, autostart); see Per-repo overrides. It is disabled by default.

Key Default Meaning
enabled false Permit jailbee litellm up and dev-container proxy settings.
version pinned 1.106.0 LiteLLM version. An explicit version bypasses the bundled hash lock and warns.
default_profile codex Profile used by claude-jb unless overridden by its --profile or JAILBEE_LITELLM_PROFILE.
autostart false Start the Claude autostart window with claude-jb instead of claude. A repo's host-local file can override it. See Autostart.
accounts [default] Logins (ChatGPT or xAI), one proxy instance each. The built-in codex profile uses default.
egress [] Extra host[:port] the proxy may reach, for extra deployments. Changing it needs jailbee litellm up; jailbee apply does not apply an egress-only edit.
extra none Path to a raw LiteLLM config fragment merged into every instance last.
routes five built-in routes Named models; same-name entries overlay the built-in route field by field.
profiles built-in codex Named maps of fable, opus, sonnet, haiku tiers to routes; same-name entries overlay individual tiers.

Routes accept model (required for new routes), description (optional model strengths and intended use in the generated map; non-blank, at most 2 KiB UTF-8; a higher layer replaces it, null removes it), effort (fixed), min_effort (floor; mutually exclusive with effort), context_window (required for unknown models, default 1050000 (included in a ChatGPT subscription) for gpt-6-astra, gpt-6.1-sol, gpt-6.1-terra and gpt-6-luna; a profile uses the smallest of its routes' windows), max_context_window (the most claude-jb --context may select; default 1050000 for those three models, otherwise context_window; never below it), oauth (true: use the account's xAI subscription login, only on xai/ routes), api_key (name of a variable in ~/.config/jailbee/litellm/secrets.env, never the key; not allowed on chatgpt/ routes), api_base (endpoint URL; its host joins the egress allowlist; not allowed on chatgpt/ routes), egress (extra host[:port] for this route; needed for a provider jailbee has no host table for, unless api_base is set) and params (raw LiteLLM deployment parameters; keys that change the provider, endpoint or credential, such as model, api_base, api_key or extra_headers, are rejected there: use the route's own fields). A route's egress, like litellm.egress, reaches the proxy's allowlist whenever jailbee apply reloads or restarts an instance for another reason; an edit that changes nothing else needs jailbee litellm up. Subscription routes (chatgpt/ or oauth: true) also accept account, defaulting to the first entry in accounts. All subscription routes in one profile must use the same account. Profiles derive their account from those routes and reject a profile-level account key. They accept effort as the default session effort, model_map (default true; false disables the generated tier-to-model map) and instructions (model-policy text appended after that map to Claude Code's system prompt; see Model map and Profile instructions). jailbee litellm up renders changes and restarts the instance if needed; jailbee apply reconciles an enabled, already-running proxy, but does not start a missing or stopped one. Use litellm up for version/account-list changes too. Apply re-renders and reloads route changes into running instances (restarting only for extra outside its model_list, settings or an unconfirmed reload; --no-restart defers only the restarts), and synchronizes the gateway file/key into each running dev container.

remote.ssh

Host-global policy for the optional authenticated SSH service. It can only be set in ~/.config/jailbee/global.yaml; a repo's committed config cannot enable or broaden remote access. Installing JailBee does not start a listener. See Installation for the explicit install and enable steps and Security and limitations for the trust boundary.

The built-in defaults enable all three entry points on IPv4 loopback. The command policy governs JailBee command execution in the console and dashboard; exec independently enables one-shot SSH execution. A disabled command policy still permits dashboard and console navigation:

remote:
  ssh:
    listen: 127.0.0.1
    port: 8022
    dashboard: true
    console: true
    exec: true
    default_entrypoint: help
    commands:
      mode: full
      allow: []
    restrict_host: true
    rdp: false
    waypipe: true
    # files: unset follows commands.mode (on with full)
    mounts: false
    # mount_roots:
    #   - path: /mnt/scratch
    #     readonly: true
    #     repos: []
    excluded_repos: [private-app]

excluded_repos is an optional host-only list of exact registered container_prefix values. It defaults to [] (no exclusions); for example, [private-app] hides and blocks that repository through SSH. These are not checkout paths, globs, or client-key identifiers. A well-formed prefix may be listed before it is registered. The key is accepted only in the ~/.config/jailbee/global.yaml remote.ssh block, never in committed repo config or repos/<prefix>.yaml host-local overlays. Exclusions require restrict_host: true; validation rejects a nonempty list with host restrictions disabled, including jb remote ssh serve --no-restrict-host.

The policy is loaded when each SSH channel opens. That channel's console, dashboard, and child commands retain the same exclusion snapshot for their lifetime; editing global.yaml affects new channels, not sessions already running. This setting changes SSH visibility only: local CLI, TUI, and Qt behavior is unchanged.

To restrict command execution to selected public command leaves, replace full with an exact-leaf allowlist (entry points remain independently enabled):

remote:
  ssh:
    listen: 127.0.0.1
    port: 8022
    dashboard: true
    commands:
      mode: allowlist
      allow:
        - ls
        - new
        - shell
        - tmux
        - git pull
Key Type Default Description
listen IP literal 127.0.0.1 Address to bind. DNS names and values with surrounding whitespace are invalid; IPv4 and IPv6 literals are accepted. Changing it requires jb remote ssh restart. Non-loopback deployment is outside JailBee's supported security boundary.
port int 8022 Listener port, from 1 through 65535. Changing it requires a restart.
dashboard bool true Permit the reserved dashboard entry point. It always starts the terminal dashboard in its remote form — registered repos only, no config editor, no pager, no GUI app launches — and requires a PTY.
console bool true Permit the reserved console [--repo PREFIX] entry point: a restricted interactive JailBee console, not a host shell. Console-local navigation remains available when command execution is disabled.
exec bool true Permit one-shot COMMAND [ARGS...] [--repo PREFIX] execution and repos. This switch affects only the one-shot entry point.
default_entrypoint help | dashboard | console help Route a commandless SSH login to this entry point. help prints the enabled remote forms and allowed command paths; dashboard and console require their corresponding entry point to be enabled and a PTY. An explicit ssh jailbee@host help always prints the list, even with a different default.
commands.mode disabled | allowlist | full full when the whole commands block is omitted; disabled when a commands block is written without mode Policy for JailBee command execution in the console and dashboard. disabled blocks command-running dashboard actions while dashboard/console navigation remains available; allowlist accepts exact leaves from commands.allow; full accepts classified public leaves. In restricted sessions, unknown/unclassified command paths fail closed. While host restrictions apply, a remote command may not set a path-typed option or argument (such as --config) nor new --mount, regardless of command mode — see Security.
restrict_host bool true Keep remote sessions off the host itself: no path-typed arguments (--config, ...) or new --mount on any command; no config editor, diff pager or GUI app launches in the dashboard (with rdp on, or in a waypipe ssh session with waypipe on, app commands use the shared RDP display or the session's waypipe display); a git bridge that moves refs but never the host's checked-out tree; no shell/tmux/exec into a mount-mode container (it shares the host's working tree); no approving a branch's privilege-widening autostart config; and no host-management command (config edit, remote ..., setup, apply, port to-container, gui, ...) in any commands.mode, full included. GUI launches, network widening and optional mounts have the narrow opt-ins described below; these do not unlock general host management. false lifts all of these at once, so an allowed command behaves exactly as it does locally; the startup log then says host restrictions: OFF. A server started from inside a restricted session stays restricted whatever this says. See Security.
rdp bool false Permit the shared RDP display: GUI launches over plain SSH draw on it, SSH may forward to its TCP port, and dev containers mount its socket directory (one screen and clipboard shared by every participating container). See Remote GUI and Security.
waypipe bool true Permit waypipe ssh sessions: GUI launches in such a session open as native windows on the client. Each container reaches only its own waypipe server. Allows one constrained reverse Unix-socket forward per connection. See Remote GUI.
gpg bool false Accept one client gpg-agent forward per authenticated SSH connection at the fixed marker /jailbee/gpg-agent. While connected, all gpg.enabled containers use the client's keys; the newest live forward wins, then the host agent resumes. Restart containers once to pick up the switch mounts. SSH authentication still uses the host agent.
network bool false Let remote sessions widen a container's network: net loose, new --net loose and container-scope net egress add. Either can open the host's LAN to the container, which is why it is opt-in. Narrowing (net strict, net egress rm) does not need this opt-in, but still requires command-policy permission; --repo on net egress add/rm never is, since it writes the host-local repo layer and affects every container of the repo. See Security.
orchestration enum ssh Let remote sessions run orchestrate grant: off | ssh | orchestrator; true means ssh, false means off. ssh bounds what the grant may let children have by what the session could do itself: loose network and egress hosts need network, optional-mount kinds need mounts, and mount_roots never counts. orchestrator bounds it by the orchestrator's own permanent rights as defined (mounts attached without a deadline, permanent loose, its effective egress allowlist). orchestrator is a ceiling on what a grant may carry, not on what a session can first give the orchestrator itself: with network or mounts on, a session could first make such rights permanent on the orchestrator (for example mount --no-revert, loose without a TTL, net egress add) and grant them on, which is within what it could do itself. --children and --allow/--deny are never bounded. Narrowing is always allowed, and elements a stored grant already holds are kept. The bound is checked when the grant is made, so turning network or mounts off later does not narrow stored grants (revoke or edit them on the host). orchestrate revoke never needs this. See Security and Container orchestration.
files bool (unset allowed) follows commands.mode: on with full, off otherwise Let remote sessions use sftp and scp against a container's repo directory. The tree is /<container>/…, rooted at that container's repository; the host and the rest of the container are never reachable, and symlinks that leave the repo are refused. One switch covers both reading and writing. An explicit value, or serve --files/--no-files, always wins. Changing it needs jb remote ssh restart. See Security.
mounts bool false Let remote sessions attach every optional_mounts entry as defined, with no path check. The repo config then decides which host path a remote session reaches; prefer mount_roots.
mount_roots list [] "Host directories a restricted session may attach with jailbee mount: an optional_mounts entry qualifies only when its host path resolves to exactly one of them (no subdirectories). Each entry: path (absolute, fully resolved), readonly (default true: attached read-only whatever the mount says), repos (container prefixes; empty = all). Requires restrict_host: true. See Security."
excluded_repos list[string] [] Host-controlled exact registered container_prefix values unavailable through SSH. Filters repo routes, dashboard/console listings and supported aggregate views. Requires restrict_host: true; see Security.
commands.allow list[str] [] Public command leaves retained for allowlist mode, for example ls or git pull. Entries must be unique lowercase command paths made of letters, digits and hyphens, separated by single spaces. Every entry is validated against the current public CLI even when another mode is active.

Deprecated: gui. remote.ssh.gui was split into rdp and waypipe. gui: true means rdp: true plus waypipe: true, and gui: false turns both off. Setting gui next to either new key is an error. jailbee config migrate rewrites it.

Publish rights in an orchestration grant require the same SSH policy permission as the corresponding manual command (issue apply, pr push --source-manifest, or review apply), checked by the normal command router. A permitted remote session may add or widen those rights; orchestrate grant permission alone is not enough. See publish grants.

An allowlist matches the exact public command leaf, not a prefix and not its arguments. git pull permits git pull feat-x --cleanup; git is not a leaf and does not grant every git ... command. Options cannot precede the command path, except the global --repo PREFIX selector (see Commands). Hidden internal commands, including _remote-console, are never valid allowlist entries and are never included by full.

A handful of hidden top-level spellings (merge, fetch, checkout, pull, push, retarget, diff, and the egress short alias for net egress) are aliases of a public command: running one is policy-checked against its canonical public leaf, not against its own hidden name. allow: [git merge] therefore also permits merge; full permits every alias whose target is public. You still allowlist the canonical leaf (git merge), never the alias itself — an alias is not a valid commands.allow entry, and neither is a hidden command with no public twin (_remote-console, submodule checkout, the deprecated jailbee claude .../chrome-pool ... groups, and the internal _*-worker commands all fall in that second category, even though the deprecated groups otherwise read like aliases).

A public group's own help is also permitted on its own — git or git --help — even though git alone is not a leaf: full allows it outright, and allowlist allows it whenever at least one allowed leaf lies under that group (allow: [git pull] permits git --help, not net --help). The bare top-level --help/-h is allowed the same way whenever any command at all is reachable. A command name that matches nothing anywhere in the CLI is rejected before a JailBee child starts.

In restricted sessions, full includes the public commands classified for remote use. A newly added or otherwise unclassified command is refused until explicitly classified. With restrict_host: false and no inherited restricted session marker, full is high trust and admits public leaves, including future ones, except reserved routes and hidden internal commands. Host-reaching commands remain refused while restrictions apply.

Validation rejects all of these combinations:

  • dashboard: false, console: false, and exec: false together;
  • default_entrypoint: dashboard or console while that entry point is disabled;
  • commands.mode: allowlist with an empty allow list;
  • duplicate, malformed, or unknown command paths; unknown fields; a non-IP listen value; and a port outside 1..65535.
  • malformed or duplicate excluded_repos prefixes, or a nonempty excluded_repos list with restrict_host: false.
  • a mount_roots entry whose path is not absolute or not fully resolved, is / or the user's home or a parent of either, is a JailBee config/data/state directory or a parent of it, duplicates another root's path, names an invalid repos prefix, or is non-empty while restrict_host: false.

The allow list may remain populated in disabled or full mode so a later switch back to allowlist does not discard policy. Listener address and port are fixed until jb remote ssh restart. Entry-point and command policy are loaded for each new SSH session; an already-running dashboard or console keeps the policy snapshot it started with. Upgrading to a release with these defaults enables console and one-shot command entry points on an already-enabled SSH service unless its host-global configuration explicitly disables them; review global.yaml and authorized client keys before upgrading.

The old shell spelling of the remote entry point (remote.ssh.shell, default_entrypoint: shell, ssh … shell, serve --shell) still works until 2.0.0; jailbee config migrate --apply renames it.

jb remote ssh serve accepts command-line flags that override any of the above for that one foreground run, for trying out a different policy without editing this file — see serve. Persistent settings still belong here; the systemd service never passes those flags.

credentials

Lets several repos on this host share one login per agent. The default credentials.group belongs in this host-level block; a repo-specific credentials.group override belongs in that repo's host-local file, not its committed .jailbee/config.yaml. The computed credential_group is never a YAML key.

# ~/.config/jailbee/global.yaml
credentials:
  group: work                    # default for every repo on this host
# ~/.config/jailbee/repos/my-side-project.yaml — one repo's exception
credentials:
  group: personal                # or null: keep this repo on its own credential
Key Type Default Description
group (global) str \| None None (unset); default in a freshly generated global.yaml Default credential group for every repo on the host. Absent means no sharing.
group (host-local file) str \| None unset This repo's group. Wins over everything below it, including when the value is null, which keeps the repo on its own credential while the rest of the host shares one. jailbee account group set/unset write it.
repos (global) dict[str, str \| None] {} Deprecated; removed in 2.0.0. The pre-1.6 per-repo map keyed by container_prefix; still honoured below a local group.

credentials.repos is deprecated. Migrate entries to each repo's local credentials.group using jailbee config migrate; new per-repo choices should not be added to this map.

A group name must match [a-z0-9][a-z0-9-]*: it becomes one directory name per agent, under <xdg_data_home>/jailbee/<agent>-credentials/<group>/ (Claude's is claude-credentials/). The name itself is agent-agnostic: each enabled agent keeps its own credential in the group, and one login per agent can be live there at a time.

New hosts share by default. jailbee config init --global writes credentials: {group: default} into the generated global.yaml, so every repo on a fresh host shares one login without any configuration: the first /login in any container lands in the group directory, and the next repo is already logged in. The schema default is still None — an existing global.yaml that predates the key keeps every repo on its own credential, and write_global_template refuses to overwrite an existing file without --force. That asymmetry is deliberate: turning sharing on for a host that already has several logged-in repos means answering the two-credential prompt below on every repo but the first, which is a migration, not a default. To opt a whole host out, set group: null.

The group mechanism shares the credential, not the whole config home. With separate shared_dir paths, each repo retains its own agent config home (for Claude, ~/.claude). Explicitly sharing those paths or adding mounts can also share history, MCP config, sessions and onboarding state. See Shared credential groups in agents.md for the mechanism.

Compatibility. The old claude_credentials: spelling of this key is still read, with a deprecation warning; setting both keys is an error. Any write jailbee makes to global.yaml — saving from jailbee config edit --global, or jailbee account group set/unset when they remove a legacy repos entry for this repo — renames the key to credentials: in place, in the same write, even when the change itself was to an unrelated key. jailbee claude ... remains as a hidden alias for jailbee account ..., warning once per invocation. Both are removed in 2.0.0.

Joining a group requires jailbee apply: it creates the group directory (mode 0700) and moves this repo's .credentials.json into it.

If both the group directory and this repo already hold a credential, only one of the two logins can be shared and the other becomes unused, so apply asks which to keep:

  • the group's login — this repo's copy is deleted; the repo adopts the account every other member already uses. This is the usual answer.
  • this repo's login — the group's copy is deleted and this repo's is moved in, which re-points every member repo at this account.
  • cancel — nothing changes and apply aborts. To keep this repo on its own login instead, set credentials.group: null in its host-local file (or run jailbee account group set none) and re-run apply.

The losing credential is deleted, not archived. The two are independent grants — two /logins to one account each mint their own refresh-token lineage — so removing one never disturbs the survivor, and a stale credential left in the shared tree is read by nothing. Restoring it means one /login. Without a TTY to ask on (a piped or CI apply), the prompt is skipped and apply refuses instead, changing nothing.

Every successful join leaves this repo's own config home with no .credentials.json of its own — either it had none to begin with, or the move took it. There is no restore-on-leave: opting out with local credentials.group: null (or jailbee account group set none) and applying the change removes the group mount. The repo then needs its own login if none exists. Removing the override or using group unset instead restores the host default, which may still be a shared group. No credential is copied back automatically: other repos may still be using it. jailbee doctor names the group, its directory, and the other member repos.

If the file is absent, defaults apply silently. Invalid YAML → error.

Per-container override: jailbee account group

Everything above is the repo's permanent group, set in global.yaml or the repo's host-local file and shared by every container of that repo. A single container can also carry a temporary override, stored in its own user.jailbee.credential_group instance label rather than in any file:

  • jailbee account group use [<name>|none] [<container>] sets it — <name> moves that one container into another group (creating the group directory if needed), none opts it out of grouping entirely, for as long as the container lives.
  • jailbee account group reset [<container>] drops the override, so the container falls back to inheriting the repo's group again.
  • jailbee new --credential-group <name>|none applies the override at creation time, in one step.

Precedence: container override beats the repo's credentials entry, which beats the host's group default. A container with no override reads the repo's setting: the host-local credentials.group if the key is present (even as null), else a deprecated repos.<container_prefix> entry, else the global group. A container with an override ignores all of them.

An override that names the repo's own group is dropped rather than kept. Because the label outranks the repo, one that merely repeats it would look like "follows the repo" right up to the next jailbee account group set, and then silently keep that one container on the old group. So:

  • jailbee account group use <the repo's own group> clears the override instead of writing one, and says so;
  • jailbee account group set/unset clear every override of this repo's containers that the change has made redundant, after the profile has been re-rendered;
  • jailbee new --credential-group <the repo's own group> creates no override at all.

The one exception is an agent that is disabled in this repo (claude.enabled: false), where the repo's profile carries no credential device: the label is then the only thing mounting one, so it is not redundant and is left alone. Overrides that already existed are not touched until one of those commands runs — jailbee account group names them.

jailbee account group is a command group with no status view of its own: the host-wide picture is jailbee account ls, per-container labels are jailbee ls's GROUP column, and jailbee doctor reports an override that only repeats this repo's group. jailbee account group ls lists the groups themselves and what each holds, jailbee account group create [<name>] creates an empty group, and jailbee account group rm [<name>] removes one nothing uses (parking any login it holds rather than deleting it). jailbee account group set <name>|none and jailbee account group unset are the permanent, repo-wide equivalents of use/reset — they write credentials.group in the repo's host-local file (and drop any legacy credentials.repos entry for the repo from global.yaml) instead of a container label, so unlike use/reset they affect every container of the repo that has no override of its own.

The override lives on the Incus instance, not in any file under version control: it dies with the container, a recreated same-named container starts without it, and it is not committable and cannot reach a teammate — there is nothing to git add.

Group use/reset refuse when a running pooled agent is detected in the target container; set/unset check the repo's containers. Close those sessions first. The probe is best-effort, not proof that no process is using the credential. --force bypasses the precaution; changing a credential mount beneath a live process can conflict with a later token refresh. Commands do not terminate sessions. Restart the agent after a successful group change.

jailbee account use and jailbee account park take a -g/--group <name> flag that points the command at that group's holder instead of the repo's own. This matters when a group is otherwise unreachable through this repo — e.g. one only a container override uses, with no repo permanently assigned to it via credentials — since without the flag those commands only ever act on the repo's own group.

jailbee account ls takes -g too, but there it only filters: the listing is host-wide, so every group and the account it holds is already visible without the flag, including a group reachable only as a container override.

-a/--agent picks the pool. account ls lists enabled pooled agents by default. use, park and rm consider candidates across them, then operate on one selected login in one agent's pool. -a <agent> limits selection to that adapter and can explicitly select one disabled in this repo. A typed account reference that matches in more than one agent's store is ambiguous, and off a TTY the error names the -a values to pass; account park with several agents live opens a picker on a TTY and refuses with the candidates off one. account group commands act on every enabled agent already — a group name is shared — so they take no -a.

A -g command targeting a different group does not rewrite this repo's recorded account. If it names the repo's own permanent group, account records are updated or invalidated normally. A repo is a member of the holder its credentials entry resolves to, and of no other: its ~/.claude records the account of that group's login, so a command aimed elsewhere neither reads it (it would name the wrong account) nor rewrites it (it would destroy the name of the login this repo does use). The account of a login in such a holder is known from the note JailBee keeps beside a login it activated there itself; one that arrived by /login in a group no repo resolves to has no record to read, and jailbee account park stores it as unknown-<timestamp> — a working login whose account name is missing, recoverable by activating it, running claude once in a container of that holder, and parking it again.

scratch

Lets jailbee new (and every other command) work in a directory that has no <repo>/.jailbee/config.yaml — a repo you're only poking at for an afternoon, or a folder that isn't a repo config's business at all. Host-level only, like credentials: scratch cannot be set in a repo's own .jailbee/config.yaml — it would be an unknown top-level key there.

scratch:
  enabled: true                      # false -> today's ConfigNotFoundError everywhere
  config:                            # a .jailbee/config.yaml document, same schema
    defaults:
      memory: 8GiB
      network: loose
    golden:
      stacks: {python: true}
Key Type Default Description
enabled bool true Whether a directory with no config file gets one synthesized. false restores the pre-feature behaviour everywhere: ConfigNotFoundError, and the jailbee config init advice.
config map {} A repo-config document, same schema as .jailbee/config.yaml. Validated by the real Config schema at synthesis time — there's no separate, parallel schema for it to drift from.

A synthesized config is layered exactly like a real one, outermost last:

Layer Source Applies to
Fallback schema defaults for omitted fields, not an appended YAML layer every repo
2 global.yaml's config layer (gpg, ssh, agents.claude, host_mounts, …) every repo, already today
3 global.yaml → scratch.config synthesized configs only
4 repos/<derived-container_prefix>.yaml host-local overrides for this scratch directory, if present

Layer 2 is not new here — it is the same set of keys Recommended placement already lists as living in global.yaml, and it already applies to every configured repo on the host. A scratch directory that ignored it would be strictly less useful than the same directory with an empty .jailbee/config.yaml. Layer 3 merges with the same deep_merge() rules as a real repo config — see Merge rules.

Two fields are set by the synthesized layer itself, before scratch.config merges on top:

  • container_prefix — <slug>-<digest>. The slug is the directory name lowercased, with runs of non-[a-z0-9] characters collapsed to one - and leading/trailing - trimmed, since a directory name routinely breaks the [a-z0-9][a-z0-9-]* regex (Tutkimus_A, my project) and there is no file to tell the user to edit. The digest is the first six hex characters of sha256 over the directory's resolved path, so ~/a/test-repo and ~/b/test-repo get distinct identities instead of sharing one, and reaching a directory through a symlink does not mint a second one. If the slug comes out empty (nothing but punctuation survives), synthesis fails naming the directory and pointing at jailbee config init, where container_prefix: can be set explicitly.
  • golden.alias — defaults to jailbee-scratch-base, so scratch directories normally reuse one golden image. Repo-local install.d/ snippets are excluded from synthetic builds; bundled and host-user snippets still apply. scratch.config and the host-local overlay can override the alias or build settings. If a local overlay changes image contents, give it a distinct alias too: reusing the shared alias does not verify that an existing image matches those settings. A custom provision_script is still resolved relative to this directory when its path is relative.

scratch.config.container_prefix still wins if set, since layer 3 merges normally over the derived default — but setting it makes every scratch directory on the host share that one prefix, hence the same profiles, the same container names, and one registry row between them. Usually you don't want that; it's here for the case you do.

Refused directories. Synthesis refuses the filesystem root (/), $HOME, and any ancestor of $HOME — /home, /Users, and so on. All are always a mistaken cd, never a research directory, and the cost of being wrong is a container bind-mounting the user's whole home (or, one level up, everyone's).

Moving a scratch directory changes its identity. The same mechanism seen from the other side: the digest is taken of the path, so mv ~/a/tutkimus ~/b/ derives a new container_prefix, and the containers and profiles created under the old path are left behind. The refresh timer removes a registration whose directory no longer exists. A prefix change at the same path is different: the timer warns and skips it until jailbee apply or jailbee new re-registers that path and drops its old row. Neither case deletes Incus resources or shared data. Remove those separately; see Removing a scratch directory's containers. A repo with a .jailbee/config.yaml is unaffected — its prefix comes from the file (or, left unset, from the directory name), never from the path.

A scratch directory cannot take over a registered configured repo. The six-hex-digit digest reduces accidental collisions; it does not make them impossible, and an explicit scratch.config.container_prefix can collide too. If the prefix is already registered to another, non-synthetic repo, jailbee init, jailbee new and jailbee apply refuse in the scratch directory, naming both paths. Taking the registration over would repoint the refresh timer at the scratch directory and silently re-render the configured repo's egress allowlist from scratch defaults. Run jailbee config init in the scratch directory and set an explicit container_prefix:, or drop the host-wide one.

Removing a scratch directory's containers

A scratch directory owns no config file to delete, and nothing prunes its Incus objects when its container_prefix changes — after a mv, or on the upgrade to 1.5.0, which added the path digest to every synthesized prefix. jailbee dashboard shows the leftovers as their own group, labelled by the old prefix and with no repo behind it. Before deleting anything, preserve uncommitted files, unpublished commits, outboxes and any shared credentials or cache state you need. Prefer guarded jailbee destroy --no-background <name> from a config that still resolves the old prefix. If that config is no longer available, raw Incus deletion bypasses JailBee's work-loss checks:

OLD=tutkimus                        # the exact old prefix
incus list -f csv -c n | grep "^$OLD-"   # candidates only; prefixes can overlap
incus config show "$OLD-<branch>"       # inspect profiles, devices and labels
# After backing up work and checking the exact container:
incus stop "$OLD-<branch>"
incus delete "$OLD-<branch>"

Delete only the old repo's unused profiles and ACLs after inspecting their used_by references (incus profile show, incus network acl show). Network generation and per-container egress overrides determine which objects exist; there is no universal four-profile/two-ACL list. Inspect device source paths before removing shared data: shared_dir may be customized or shared with another repo. Do not remove host-wide bridges or service containers as part of one scratch directory's cleanup.

Delete the containers before the profiles, and not only because Incus refuses to remove a profile still in use: jailbee recognises a container by the <prefix>-base profile it carries, so a container whose profiles are gone disappears from jailbee ls and jailbee dashboard entirely and has to be found with incus list.

Do not delete the default jailbee-scratch-base image alongside those resources: other scratch directories reuse it. Check any custom alias for other users too.

One command genuinely needs a file. jailbee net egress export prints a replacement for the repo config's egress_allow: key, and there is no such key here to replace; it says so and points at jailbee config init. Promoting a single directory's overrides into global.yaml's scratch.config would widen every other scratch container's allowlist on the host, so it is not offered.

For anything that outlasts an afternoon, jailbee config init is still the answer: a committed, editable, per-repo config beats host-wide scratch defaults shared with every other directory on the machine.

config_edit

Settings for jailbee config edit itself. Host-level only, like credentials and scratch: how your own files get written is a personal editing habit, so a repo's .jailbee/config.yaml cannot set it.

config_edit:
  write_policy: auto      # auto (default) | patch | regenerate
Key Default Description
write_policy auto How a save writes the file. patch touches only the keys you changed and leaves comments, key order and formatting alone. regenerate rewrites the whole file with jailbee's own generated comments — which drops anything you wrote in it by hand. auto picks per layer: regenerate for this file, which jailbee owns and nobody reviews, and patch for a repo config, which is committed and read as a PR diff.

jailbee config edit --write patch|regenerate overrides the key for one run, except for the host-local layer: local files always use patch, even with --write regenerate or a global regenerate policy. A regenerate that would drop hand-written comment lines always shows the diff and asks first — that confirmation cannot be turned off.

default_command

What jailbee (or jb) run with no arguments opens. Host-level only: it describes how you like to work, not a repo.

default_command: dashboard   # dashboard (default) | gui | console | help
Key Default Description
default_command dashboard dashboard opens the TUI, gui the Qt dashboard, console the interactive console, help prints the help text. Without a terminal (a pipe, a script, JAILBEE_NONINTERACTIVE) bare jailbee always prints help, whatever this says. A broken global.yaml falls back to dashboard with a warning.

update_check

Whether JailBee tells you when a newer release is on PyPI. Host-level only, like credentials, scratch and config_edit: whether your machine talks to PyPI is not a repo's decision.

update_check: true      # true (default) | false
Key Default Description
update_check true false stops both the hint and the fetch behind it. JAILBEE_NO_UPDATE_CHECK=1 does the same for a single command, for scripts and CI that cannot edit this file.

The network fetch runs outside the foreground command. jailbee ls / new / shell read the state database and, when the cached result is missing or at least a day old, spawn a detached process to fetch https://pypi.org/pypi/jailbee/json for a later run. The foreground still does local config/database work and may start that process. The dashboard's state service does the same once a minute, so a dashboard left open keeps the cache fresh without any command being run. Concurrent commands can spawn more than one probe before a result is recorded. JailBee sends no repo contents, paths or explicit user identifier; PyPI still sees the request's source IP and the jailbee/<version> User-Agent. A failed fetch records the attempt time and retains the last successful answer.

When a newer release is known, one line appears on stderr:

⚠ jailbee 1.5.0 is available (you are running 1.4.0).
    Upgrade with: uv tool upgrade jailbee
    Or `jb dismiss update` to stop repeating this.

The upgrade command is derived from how JailBee was installed (uv tool, pipx, or plain pip). An editable install is never advised: it runs from a checkout, so no upgrade command would give you what you want. The line is printed on every one of those commands until you upgrade or dismiss it.

The dashboard shows the same advice on a row below the table, with a clickable [dismiss] label. Remote SSH dashboards do not show it: whoever is connected cannot upgrade the host's JailBee.

jailbee dismiss update, or the [dismiss] label, silences the release until a release newer still appears; both write the same record, so either one silences the other. jailbee doctor reports it either way — including the dismissal — under update check.

agent_instructions

Host-level only: a repo cannot turn host-wide instructions on or off. See Agent-wide instructions for delivery and refresh behavior.

agent_instructions: true       # true (default) | false
Key Default Description
agent_instructions true Stage the built-in preamble and optional host AGENTS.md, and mount the staged directory read-only for every enabled agent with global_instructions. false skips updates and omits the devices on jailbee apply; host staging is retained. Apply in each repo and restart running containers to remove existing mounts.

install_host_skills

Whether jailbee setup installs the bundled jailbee skills for the agents running on this host (not the containers). Host-level only, like update_check: which agents run on your machine is your call, not a repo's.

install_host_skills: false      # false (default) | true
Key Default Description
install_host_skills false true makes the skills step of jailbee setup detect every skill-capable agent on the host (claude, codex, gemini, opencode — found via shutil.which) and copy the bundled skills into each one's own skills directory (~/.claude/skills, ~/.codex/skills, …). jailbee doctor then verifies them. A host with none of these agents owes nothing, and false reports the step as opted out rather than missing.

The containers' skills are independent of this key: they are copied for an enabled agent only when install_jailbee_skills is true and its skills_dir is covered by a shared mount. Copy failures warn rather than abort. See the bundled skills. The step used to install Claude's host-side skills unconditionally; it is now opt-in, so an upgraded host that wants them must set this key.

--config / -c override

jailbee -c /path/to/config.yaml <subcommand> bypasses discovery and uses the given path. repo_root is derived as the path's grandparent (i.e. the path is assumed to end in .jailbee/config.yaml). The flag is intended for tests and edge cases — odd paths produce odd repo_root values without complaint.