Configuration reference¶
jailbee reads a committed repo config, an optional global config, and an
optional host-local per-repo override:
- Per-repo:
<repo>/.jailbee/config.yaml— required for a repo you work in regularly. A directory with no such file still works, though: unlessscratch.enabled: false,jailbeesynthesizes a repo layer fromglobal.yaml'sscratchblock instead. See that section for what changes. - Global:
~/.config/jailbee/global.yaml— optional, host-level only. - 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.
Recommended placement¶
| 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_mirroris always interpreted as host-level (GlobalConfig). It is not merged into the Config layer. - In
<repo>/.jailbee/config.yaml,docker_registry_mirroris interpreted as the per-repoConfig.docker_registry_mirroroverride (withextra_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 withgithub.tokenmasked; 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).--forceoverwrites 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 viagolden.stacks(recommended — see Stacks below) or, at the low level, by naming the snippet directly ingolden.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:
- Config: add to
golden.disable_snippets(recommended for documentation reasons).
golden:
disable_snippets:
- "60-gui-libs"
- "80-ecr-helper"
- 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
PATHrather than extending it. Incus offers no way to reference the previous value, so once a repo setscontainer.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. Leavecontainer.pathempty and noPATHis written at all, which is why a repo that has not opted in is unaffected. ~/.local/binis not in that system half. It reachesPATHthrough/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
sizetakes effect afterjailbee applyand a container restart. - Containers built from a golden image older than 1.7.5 still carry the
unmasked
tmp.mount; runjailbee base buildto move them to disk-backed/tmp. sizeis validated at load time; a value the kernel would reject is refused byjailbee config validateinstead 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
groupis 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 andidoutput. - Set
groupexplicitly to override (e.g. when the host and container group names differ):{ path: /dev/kvm, group: kvm }. mode/gid/uidstill apply to the Incus profile device and remain useful for devices without astatic_noderule (e.g. the GPU render nodes, which keep0666).
Group membership takes effect on the next session. JailBee runs the group-add on
jailbee newand onjailbee apply(for running containers). A newjailbee shell/jailbee tmux/ autostart session picks the group up viaincus exec --user's supplementary-group setup; an already-open shell must be reopened. Check withidinside 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 byConfig.effective_shared_caches()whenjetbrains.enabled: true. See### jetbrainsbelow.The claude entries follow the same pattern. Two claude rows are appended by
Config.effective_shared_caches()whenclaude.enabled: true:claude→~/.claudeandclaude-install→~/.local/share/claude. Claude Code's global config (.claude.json) lives inside the shared~/.claudemount: the golden image exportsCLAUDE_CONFIG_DIR=$HOME/.claude, and Claude Code reads(CLAUDE_CONFIG_DIR \|\| $HOME)/.claude.json. See### claudebelow.
chrome-profile(chrome-pool→~/.config/google-chrome) andfirefox-profile(firefox-pool→~/.mozilla/firefox) are appended the same way, whenbrowsers.chrome.enabled: true/browsers.firefox.enabled: truerespectively. See### browsersbelow.
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.commatchesgithub.comitself as well asapi.github.comand 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:8443allows only 8443. - Only proxy-honouring tools. A strict container gets
HTTP_PROXY,HTTPS_PROXY(and lower-case twins) pointing at the proxy.curl,gitover HTTPS,pip,npm,aptand 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 theCONNECTrequest, 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_PROXYso they go direct only on legacy containers or withegress_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. Elsewherejailbee 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--repothere (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 runjailbee 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/figmaand runsudo 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.serviceloads 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 destroycarries no guard against a detached run.stoprefuses while a supervisor is still working unless--forceis given, andrestartrefuses too with no--forceat all;destroydoes 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 youron_startlist — it may not carrychains/stepsof its own (jailbee config validaterejects that as an error):jailbeefills it with the generated per-agent launch steps, one chain namedmain. Its ownnetworkandmountsare yours to set and are kept as written. When it is the first stage in the trigger to carrydetach: true(and no--wait/--no-waitoverride 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 carriesdetach: true, the agents stage simply falls inside that deferred region like any other stage after the boundary, and its owndetach(if set) changes nothing. - Left unwritten,
jailbeeinserts it itself at the effective attach boundary withinon_start— immediately before whateveron_startstage would otherwise be deferred, or at the very end ofon_start's stages when nothing inon_startitself detaches. Withinon_startalone, 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_mountsentry 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 wholejailbee newwith 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
stricttoloose. This asks only for an untrusted head:jailbee new --pr Nwhere the PR's head lives in a fork (isCrossRepository) — code nobody with push access to your repo has vouched for — and ajailbee forkof anyjailbee new --prcontainer (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,strictis an egress allowlist of package registries and forges that all accept uploads, so it is no boundary against that code — whilelooseis 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=trueand the fetch fails (no network, ACL denial, bad credentials, …),jailbee newaborts before touching Incus state. Resolve the underlying issue or setautofetch: false. - If upstream mode selects a tracking ref that is missing while the remote
exists,
jailbee newaborts. 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, useclone_from: localor 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 usesmerge:fails to load with a clear error naming both the old and new key and the file path. Rename the block topull: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,Spacetoggles,Tabswitches between the Fields, Repos and Visibility tabs,Esccloses. 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 acrossname, 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,Ireverses 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 (
namestays 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. Fallbackorigin.default_branch— auto-detected. See Which branch is the default? below. Fallbackmain.credential_group— the host-levelcredentialsgroup this repo resolves to, orNonewhen 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 torepo_root.name, overridable via the optionalcontainer_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:
refs/remotes/<upstream_remote>/HEAD— the project's own answer, written bygit clonefrom what the server reports;refs/remotes/<upstream_remote>/main, then.../master— a repo whose branches were fetched by hand has no symref to read;- the local
refs/heads/main, thenrefs/heads/master; - the currently checked-out branch — the only evidence left in a repo that follows neither convention;
- the literal
main, for a detached HEAD, a repo with no commit yet, or nogitat 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:
- the sole remote, when the repo has exactly one;
origin, when it exists;remote.pushDefault;- the current branch's
branch.<branch>.remote; - the one remote carrying a
refs/remotes/<remote>/HEADsymref — 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:443in the strict-mode egress allowlist. - Injects
GH_TOKENinto the container at autostart via/etc/profile.d/jailbee-github.shso login shells (and AI agents launched through them) authenticate withoutgh auth login. - Runs
jailbee doctorchecks 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 applyonce 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 shellandjailbee tmux. A session reads it when it starts, so an edit reaches the next agent session, not one already running. Nojailbee applyneeded 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.fileonly 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 settingagent_instructions: falseinglobal.yaml, or remap the mount orglobal_instructions.dirto a disjoint destination. - Opting out:
agent_instructions: falseinglobal.yamlskips updates and omits the mount. Runjailbee applyin each repo and restart its running containers to remove existing mounts. Host staging is retained, not deleted. - Agents: Claude Code today (the
global_instructionspreset field, see agents.md). Codex is not wired: its instruction path lies inside its writable shared mount. - Diagnosis:
jailbee doctorreports source size and staged-copy status; a stale copy is refreshed byjailbee 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, andexec: falsetogether;default_entrypoint: dashboardorconsolewhile that entry point is disabled;commands.mode: allowlistwith an emptyallowlist;- duplicate, malformed, or unknown command paths; unknown fields; a non-IP
listenvalue; and a port outside1..65535. - malformed or duplicate
excluded_reposprefixes, or a nonemptyexcluded_reposlist withrestrict_host: false. - a
mount_rootsentry whosepathis 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 invalidreposprefix, or is non-empty whilerestrict_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 toglobal.yaml— saving fromjailbee config edit --global, orjailbee account group set/unsetwhen they remove a legacyreposentry for this repo — renames the key tocredentials:in place, in the same write, even when the change itself was to an unrelated key.jailbee claude ...remains as a hidden alias forjailbee 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
applyaborts. To keep this repo on its own login instead, setcredentials.group: nullin its host-local file (or runjailbee account group set none) and re-runapply.
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),noneopts 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>|noneapplies 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/unsetclear 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 ofsha256over the directory's resolved path, so~/a/test-repoand~/b/test-repoget 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 atjailbee config init, wherecontainer_prefix:can be set explicitly.golden.alias— defaults tojailbee-scratch-base, so scratch directories normally reuse one golden image. Repo-localinstall.d/snippets are excluded from synthetic builds; bundled and host-user snippets still apply.scratch.configand 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 customprovision_scriptis 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.