Generic agent support¶
jailbee wires terminal coding agents (Claude Code, and a handful of others)
into the container lifecycle through one declarative key: agents:. It is a
mapping keyed by agent name — {codex: {...}, gemini: {...}} — rather than a
list, because the deep-merge pipeline that combines ~/.config/jailbee/global.yaml
and <repo>/.jailbee/config.yaml appends lists (see
Merge rules). As a list, a global entry and a repo
entry for the same agent would produce two duplicate entries instead of one
adjusted one; as a mapping, the repo layer can tweak a single field of an
agent the global layer already defined.
Six presets ship built in (claude, codex, gemini, aider, opencode,
grok), and you can define an agent that isn't one of them from scratch.
Only claude is exercised in production. The other five are untested
templates — see The five templates below.
1. What this does¶
For every agent with enabled: true, jailbee:
- Mounts its declared
sharedpaths from<shared_dir>into the container, so credentials and settings survive a container rebuild and are shared across the repo's containers (init_command.py,lifecycle.py). - Extends egress. Its
egress_allowhosts are folded into the strict-mode allowlist (Config.effective_egress_allow). - Installs or updates it at
jailbee newtime:install_checkdecides whether the binary is already present; if not,installruns; if it is,updateruns only whenauto_updateis true (agents.ensure_agents, called once fromlifecycle.new_container— not fromjailbee applyorjailbee start). The step is namedinstall-<agent>and is bounded byautostart.step_timeout(default 600s), so a stuck installer costs that much rather than hangingjailbee new; its output stays in the tmux window afterwards, sojailbee tmux <container>is where you read what happened. - Launches it in a background tmux window when
autostart: true(autostart.agent_autostart_steps). - Checks its shared dirs as part of
jailbee doctor'sshared_dir treecheck.
Install, update, and the autostart launch itself all run through the
autostart step pipeline, which starts each in a fresh bash -lc login
shell. The autostart launch runs directly in that shell; install and update
run in a bash -c child of it (agents._ensure_one). Either way,
~/.local/bin and ~/.npm-global/bin end up on PATH: the login shell
sources /etc/profile.d with export, and the bash -c child inherits
that exported PATH — which is also why agents.<name>.env reaches all
three.
Installs are infrastructure rather than user autostart steps, so they run
under jailbee new --no-autostart as well — ensure_agents starts the
container's autostart tmux session itself when nothing else has yet. That is
why jailbee tmux on a --no-autostart container finds a session with an
install-<agent> window in it rather than nothing. The agent's own launch
window is a different matter: that one is an autostart step, and
--no-autostart skips it.
Install happens only at
jailbee new. Enabling an agent for a container that already exists and then runningjailbee applyattaches the mount and widens egress, but never installs the binary — so the autostart window fails with exit 127 ("command not found") until the container is recreated. Destroy and re-create the container after enabling an agent, or install it by hand inside the container.
<agent>andinstall-<agent>are effectively reserved tmux window names. Both windows are killed and re-created on each run, so anautostart.on_startstep you namecodexorinstall-codexwill have its window killed out from under it when thecodexagent runs.jailbee config validatecatches the<agent>collision — a step named the same as an autostarting agent is a config error, since with parallel chains (see Stages and chains) it could kill a running sibling step, not just an idle window.install-<agent>is not checked (installs run outside the trigger's own steps) — still pick a different name.
Where the generated launch steps go¶
Every autostarting agent's launch step (the exec <command> window above)
is placed into on_start for you — you never write it by hand. In the
stage form of autostart (see Stages and chains),
jailbee uses a reserved stage named agents to hold them: write one
yourself to control its position, network, mounts or detach, or leave
it out and jailbee inserts it at the latest point within on_start
that still runs before the session is handed to you. That positioning is
moot, though, once on_create has already deferred anything: the whole of
on_start, agents stage included, then runs after the hand-off regardless
of where it sits or what its own detach says. Full rules — what an
explicit stage: agents may and may not carry, why it's a no-op under
on_create, and the on_create-already-detached case — live in
The reserved agents stage. In the
legacy flat form there is no reserved slot: a step literally named agents
is just an ordinary step, and the generated steps are appended after all of
your own on_start steps regardless.
Which window an attach (jailbee tmux, or --attach tmux) actually lands
on depends on whether you have been in the session before:
- The first attach picks a window by name — the last autostarting
agent (one with
autostart: true),claudesorted last among them — not by tmux's own "most recently created" default. So theagentsstage's position only controls when the agent's window comes up relative to the hand-off, not which window ends up focused once you're in. - Every attach after that lands on the window you detached from. tmux
tracks that itself; jailbee simply stops overriding it once
#{session_last_attached}says a client has been there. Switch tocodex, detach, and the nextjailbee tmuxputs you back incodex.
Restarting the container resets this — the tmux server dies with it, so the next attach is a first attach again and lands on the agent window.
2. Enabling a preset¶
Most presets need only two lines — the config below is enough to get
codex installed and started in the autostart tmux session:
agents:
codex:
enabled: true
autostart: true
Everything else — the install command, the ~/.codex shared mount, the
OpenAI egress entries — comes from the preset. claude ships
enabled with autostart: false by default in the jailbee config init --global
template; see Claude below for its own switches.
3. Presets are starting points¶
A preset is a base layer, not a fixed answer. Resolution order per agent is:
preset → (global + repo, already merged)
Two merges, not three: global.yaml and the repo config combine with each
other first, and the preset is merged under that single combined result —
which is what the ordering note below is about. Both steps use the same
deep-merge rules used
everywhere else in jailbee's config: scalars from the later layer win;
lists append; an explicit empty list (egress_allow: []) resets to
empty instead of appending. Resetting is the only operation deep_merge
offers besides append — there's no "replace with a different non-empty list"
primitive, so to drop a preset's hosts entirely you set egress_allow: []
(at repo layer, ordinarily — see the note below) rather than trying to list a
smaller replacement set.
Ordering note. The presets are merged in after the global and repo layers have already been combined with each other (
resolve_agents_rawinconfig.pyruns once, on the merged global+repo dict). So anegress_allow: []written only inglobal.yaml, with the repo layer later appending its own hosts, does not stick — the repo's non-empty list makes the combined global+repo value non-empty again, and that non-empty value then appends onto the preset instead of replacing it. Put the reset in whichever layer has the last word for that agent — usually the repo layer, since repo is applied after global in every other case too.
This resolution happens at the raw-dict level, before Pydantic validation, so a partial override (just one field) validates against the preset's completed shape rather than failing on missing required fields.
Worked override — fixing a renamed package. Say the gemini npm package
were renamed upstream. Nothing about the mount, the egress host, or the
command name needs to change — override just the two scalar fields:
agents:
gemini:
install: "npm i -g @google/gemini-cli-next"
update: "npm i -g @google/gemini-cli-next@latest"
command, shared, and egress_allow still come from the preset unchanged.
Seeing what actually resolved. jailbee config show prints the merged
agents: section — preset fields included, whether or not your own config
mentions them:
jailbee config show | less # look for the `agents:` block
That's the supported way to answer "what did my preset resolve to" instead
of re-deriving it by hand from agent_presets.py.
4. Writing your own agent¶
An agent name that isn't one of the six shipped presets skips the preset merge entirely — your config is used as-is, no base layer, no forced append/reset semantics.
| Field | Type | Default | Meaning |
|---|---|---|---|
enabled |
bool | false |
Master switch. Gates the mount, egress, install/update, and doctor check. |
autostart |
bool | false |
Launch command in a background tmux window. Requires enabled: true — jailbee config validate rejects autostart: true with enabled: false. |
command |
string | "" |
The binary/command line the autostart window execs, and the default source for install_check. Required (non-empty) when enabled: true. |
install |
string | null | null |
Shell command line run once at jailbee new time when install_check fails. |
install_check |
string | null | null |
Command that decides install-vs-update. Defaults to command -v <first token of command> — the binary's bare name, so flags in command don't leak into the probe. |
update |
string | null | null |
Shell command line run at jailbee new time when install_check succeeds and auto_update is true. |
auto_update |
bool | true |
When false, an existing install is left untouched; a missing one is still installed. |
install_network |
"strict" | "loose" |
"strict" |
Network mode for the install/update step only — widen it when the installer's own hosts aren't known (see grok below). |
shared |
list of {subpath, path, type, seed, private} |
[] |
Bind mounts from <shared_dir>/<subpath> to <path> inside the container. type: dir (default) or type: file; seed (file only) is written once if the target doesn't already exist; private (dir only) names subpaths inside the mount that stay per container — see §5. |
egress_allow |
list[string] | [] |
Hosts added to the strict-mode allowlist when this agent is enabled. Same host[:port]/CIDR grammar as top-level egress_allow. |
env |
map[string, string] | {} |
Env vars passed to the install/update step and the autostart launch step. |
A full custom entry:
agents:
my-agent:
enabled: true
autostart: true
command: my-agent
install: "npm i -g my-agent-cli"
update: "npm i -g my-agent-cli@latest"
auto_update: true
shared:
- { subpath: my-agent, path: "~/.config/my-agent" }
- { subpath: my-agent.json, path: "~/.my-agent.json", type: file, seed: "{}\n" }
egress_allow:
- api.my-agent.example:443
env:
MY_AGENT_HOME: "~/.config/my-agent"
jailbee config validate enforces a few cross-field rules beyond the schema
itself:
- The agent name must match
[a-z0-9-]+— it becomes a tmux window name and a doctor label. It does not become part of any Incus device name: those are derived from eachshared[].subpath. The two coincide for every shipped preset only because each preset names its subpath after the agent. enabled: truerequires a non-emptycommand.- A
sharedsubpath may not collide with a built-in shared subdir — theconstants.SHARED_SUBDIRSnamesjailbeeitself uses:caches/pnpm-store,caches/gradle,caches/npm,caches/m2,docker-registry,ssh. (chrome-poolis not in that list: it is a cache pool root, managed bypool.pyrather than created as a shared subdir, so this guard does not cover it.) - Two agents may share the exact same subpath only if they mount it to the
same
path/type— a conflicting reuse is rejected.
5. Which paths to share¶
Share the agent's auth and settings surface only — never its caches, histories, or logs, and never a generically-named file.
The aider preset is the worked example. Aider writes four things into
HOME:
~/.aider.conf.yml— settings. This is the only onejailbeemounts.~/.aider.input.history,~/.aider.chat.history.md— per-branch working state. These default to the working directory anyway, and even if they didn't, they belong to one branch's session, not to every container of the repo.~/.env— must never be mounted, in this preset or any other you write..envis a generic filename: dozens of unrelated tools read a file by that exact name inHOMEor a project root. A shared mount at~/.envwould silently hand every container's copy of some other tool's secrets to whichever agent happens to read it, and vice versa — a cross-container leak with no relation to the agent you meant to configure.
When you write your own agents.<name>.shared list, ask "does this file hold
something I'd lose by re-authenticating, or is it a cache/history/log the
agent would happily regenerate?" Only the former belongs in shared.
A shared directory must not carry a socket¶
The rule above is about secrets. There is a second one, about control.
agents.<name>.shared bind-mounts one host directory into every
container of the repo. That is what makes a single login, a single settings
file and a single session history serve every branch. It also means the
directory must not contain an IPC socket, a pid file or a lock:
- A pathname AF_UNIX socket is not confined by a network namespace.
connect()resolves the path to an inode, the inode lives in the shared mount, and container A therefore reaches a listener running inside container B. - A PID means nothing across a PID namespace, so the usual "is the daemon still alive?" check reads as true against an unrelated process — or against nothing at all.
- Agent daemons take the working directory as a string. Every container clones the repo to the same path, so the daemon resolves it against its own rootfs and edits the wrong checkout.
Codex is the case this was found on. It keeps its app-server control socket
under $CODEX_HOME and offers no way to relocate it, so a Codex session
driven from one container edited files and committed in another container's
clone.
Name such subpaths in private and each is mounted over with a
per-container directory:
agents:
codex:
shared:
- subpath: codex
path: ~/.codex
private: [app-server-control, app-server-daemon]
private is valid on type: dir mounts only, and each entry is a relative
path inside the mount. The directories start empty, are never seeded, and are
removed when the container is destroyed. Existing containers pick the
carve-out up on their next jailbee start — there is no jailbee apply to
run, because the devices are per-container rather than part of the binds
profile.
jailbee doctor reports any socket it finds in a shared agent mount that is
not already carved out. The gemini, opencode and grok presets share a
whole home directory too and ship unverified (see §3); that doctor row is
what tells you if one of them grows a daemon.
6. Finding an agent's hosts¶
jailbee keeps no egress-denial log — a strict-mode ACL drop is silent
at the kernel level, so there is no file to grep for "what got blocked." A
preset's egress_allow list is a best-effort starting point, not a promise
that it's complete or minimal. To find the real list for an agent:
jailbee net loose <container> # open egress fully
# ... exercise the agent inside the container: log in, run a real task ...
Narrow egress_allow in your config based on what you observed the agent
actually reach (vendor docs, if it publishes a host list, plus your own
observation — e.g. tcpdump/ss inside the container while loose), then
confirm it still works:
jailbee net strict <container>
jailbee apply --no-restart # push the narrowed egress_allow live
# ... exercise the agent again ...
If it breaks under strict, you missed a host — go back to loose and look
harder rather than guessing at what to add.
7. Authentication in a container¶
A browser-based OAuth or device-code sign-in is awkward inside a container:
the login state usually lives in a path that's per-container by default, so
it's lost on every rebuild unless that path is explicitly shared. Claude
solves this by sharing the ~/.claude directory (see Claude
below) across every container of the repo. Where a vendor also offers a plain
API key, that's the simpler and more portable path — no shared login-state
file needed, and it works the same whether the container was just created or
has been running for weeks.
| Preset | Env var | Notes |
|---|---|---|
claude |
— | Browser/device flow; solved via the shared ~/.claude directory, not an API key. |
codex |
OPENAI_API_KEY |
The ChatGPT login (codex login, a device-code flow) works too: the preset allows the hosts it needs — auth.openai.com for the code and the token refreshes, chatgpt.com for the backend it then talks to. The credentials land in the shared ~/.codex, so one login covers every container of the repo. |
gemini |
GEMINI_API_KEY |
API-key path only — the OAuth/Code Assist path uses a different set of hosts (see the table below) and has no key. |
aider |
provider-dependent (e.g. OPENAI_API_KEY, ANTHROPIC_API_KEY) |
Aider proxies whichever model backend you configure; the key follows that backend, not aider itself. |
opencode |
provider-dependent, via opencode auth login → ~/.local/share/opencode/auth.json |
|
grok |
XAI_API_KEY |
Verified against vendor docs. A third-party guide claims GROK_CODE_XAI_API_KEY instead — the vendor's own spelling wins; this exact discrepancy is why presets are templates, not guarantees. |
Only the grok row above was checked directly against vendor documentation
as part of building this feature; the rest follow standard per-vendor
convention and should be treated with the same "untested template" caution
as the rest of that preset.
A key can be passed to the container via agents.<name>.env, but don't put
a real secret in a repo's committed .jailbee/config.yaml — put it in
~/.config/jailbee/global.yaml instead, which is never committed:
# ~/.config/jailbee/global.yaml
agents:
codex:
env:
OPENAI_API_KEY: sk-...
8. The five templates¶
Package names, config paths, and — especially — host lists in the five
non-claude presets are best-effort. The maintainer holds no accounts
with any of these vendors; each preset is a documented starting point for
whoever adopts it to correct, not a maintained integration. The override
path in sections 2–4 above is what makes shipping them acceptable.
| Preset | Install | Config paths | Egress | Verification status |
|---|---|---|---|---|
codex |
curl -fsSL https://chatgpt.com/codex/install.sh \| CODEX_NON_INTERACTIVE=1 sh — not npm |
~/.codex (dir — config, auth, sessions, logs, and the binary), with app-server-control/ and app-server-daemon/ private per container |
api.openai.com:443 (API-key path); auth.openai.com:443 (device-code sign-in + token refresh); chatgpt.com:443 (the ChatGPT-plan backend a signed-in CLI talks to, /backend-api/codex/...). install_network: loose for the installer's own hosts (chatgpt.com, releases.openai.com, with an api.github.com / github.com release fallback) |
Install verified end-to-end in a container with no Node.js: the binary lands in ~/.local/bin/codex as a symlink into ~/.codex/packages/standalone/current. Sign-in hosts are undocumented upstream and were read off a live strict-mode container instead: with api.openai.com alone, codex login hangs on "Requesting a one-time code..." and ends in failed to request device code against auth.openai.com/api/accounts/deviceauth/usercode. Telemetry (ab.chatgpt.com) is left out on purpose. |
gemini |
npm i -g @google/gemini-cli |
~/.gemini (dir) |
generativelanguage.googleapis.com:443 (API-key path), cloudcode-pa.googleapis.com:443 (OAuth / Code Assist path), oauth2.googleapis.com:443, accounts.google.com:443 |
Install + config dir verified; no authoritative complete host list exists — upstream issue #4552 is open with no list, and Google's own Code Assist network doc names only cloudcode-pa.googleapis.com. |
aider |
uv tool install --with pip aider-chat@latest |
~/.aider.conf.yml (file type) and nothing else |
provider-dependent | Install + config filename + HOME surface verified. |
opencode |
curl -fsSL https://opencode.ai/v2/install \| bash -s -- --no-modify-path — not npm — followed by a ~/.local/bin/opencode symlink |
~/.opencode (dir — the binary), ~/.config/opencode (dir), ~/.local/share/opencode (dir, holds auth.json) |
opencode.ai:443 (the built-in "zen" gateway at /zen/v1/..., and the version pointer a self-update reads); models.dev:443 (the model catalogue fetched at startup). Provider hosts are yours to add — opencode is a multi-provider client, so which inference host it needs follows the provider you configure, not opencode itself. install_network: loose for the installer's own hosts (opencode.ai, registry.npmjs.org) |
Install verified end-to-end against the live installer (v2.0.9): it runs non-interactively, drops a 198MB static binary in ~/.opencode/bin, and the preset's ~/.local/bin/opencode link resolves to it; the ~/.local/bin link, the already-installed short-circuit and the failed-download check are covered by unit tests. Not exercised against a real account — the runtime host list is best-effort like the rest of this table. |
grok |
curl -fsSL https://x.ai/cli/install.sh \| bash — not npm |
~/.grok (dir — config.toml, auth.json) |
api.x.ai:443 (API-key path); x.ai:443 (installer); auth.x.ai:443 (OIDC device-code + refresh); cli-chat-proxy.grok.com:443 (SuperGrok inference and hosted web_search). install_network: loose because the installer's redirect target is undocumented. This list is runtime hosts only — it does not open arbitrary HTTPS for web_fetch. |
Install + config dir verified against vendor docs. SuperGrok hosts checked against a live device-auth session in a strict-mode container: without the chat proxy, inference retries https://cli-chat-proxy.grok.com/v1/responses until it fails. API key env var is XAI_API_KEY per vendor docs; a third-party guide claims GROK_CODE_XAI_API_KEY — the vendor spelling wins, and that discrepancy is exactly why presets are templates. |
Source of truth for the exact values: src/jailbee/agent_presets.py.
A preset's install command needs a toolchain the image may not have¶
An install: line is just a shell command run inside the container. Nothing
checks that what it invokes exists, and a failed install step is only a
warning jailbee new prints once and walks past — so enabling a preset whose
installer is missing its toolchain is a silent no-op. The install step
dies with <tool>: command not found, and the agent's autostart window then
dies with <agent>: not found. The evidence lives in the container, not on
the host:
jailbee exec <container> -- tmux capture-pane -p -t autostart:install-gemini
| Preset | Needs | Which is present when |
|---|---|---|
gemini |
npm |
golden.stacks.node is on |
aider |
uv |
your own install.d/ snippet installs it — jailbee's golden image does not ship uv |
claude, codex, opencode, grok |
nothing | always — each installs a static binary through the vendor's own installer |
For gemini, add the stack and rebuild the base image:
golden:
stacks:
node: true # or a major version, e.g. 22
jailbee base build
codex and opencode used to be in the npm row and no longer are. If you
added the node stack solely to get one of them working, you can drop it again;
and if a container already has the npm install, remove it (npm uninstall -g
@openai/codex, npm uninstall -g opencode-ai), because /etc/profile.d puts
~/.npm-global/bin ahead of ~/.local/bin and the old copy would shadow the
new one.
Pinning an install step back to strict¶
codex, opencode and grok ask for install_network: loose because their
installers' hosts are CDN-fronted and rotate their IPs, which is the case the
strict ACL's resolve-at-apply-time pooling handles worst on a first run. To
keep a step strict instead, name the hosts yourself and accept that the first
attempt may need a retry while the pool fills:
agents:
codex:
install_network: strict
egress_allow:
- chatgpt.com:443
- releases.openai.com:443
opencode:
install_network: strict
egress_allow:
- opencode.ai:443
- registry.npmjs.org:443
Those entries join the container's runtime allowlist too — egress_allow
appends, it has no install-only scope.
9. Claude¶
agents.claude is the preferred spelling. A top-level claude: block is
still accepted as a legacy alias — it's translated into agents.claude
at config-load time, before validation. Defining both in the same
merged config (global + repo combined) is a ConfigError naming both
spellings; pick one, and prefer agents.claude.
Claude carries every generic field from the table in
Writing your own agent — enabled,
autostart, command, install/update, auto_update, install_network,
shared, egress_allow, env — plus Claude-only fields for its deeper
integration (AI-generated PR descriptions, plugin marketplace egress, the
bundled jailbee skills):
plugins_enabledinstall_jailbee_skillsai_pr_descriptionai_pr_branchpr_promptai_pr_modelai_pr_timeout
Full field-by-field descriptions for these live in the
claude section of Configuration reference — that
section stays the authoritative reference for the Claude-only fields; this
page covers the generic agents: mechanism they sit on top of.
Shared credential groups (claude_credentials)¶
Several repos on one host can share a single Claude Code login instead of
each holding its own. Configuration is host-level only — see
claude_credentials in the Configuration reference
for the global.yaml block, the join/leave flow, and jailbee doctor's
report. This section documents the mechanism the feature rests on.
CLAUDE_SECURESTORAGE_CONFIG_DIR is the environment variable jailbee sets
on a member repo's <prefix>-base profile: profiles.claude_securestorage_dir_env
computes the (key, value) pair, and profiles.base_profile_yaml is what
renders it into the profile. The container path is ~/.claude-creds,
bind-mounted from the group's host directory as the claude-creds disk
device. The facts below were measured against Claude Code 2.1.247 by
observing its behavior — none of them are documented by
Anthropic:
CLAUDE_SECURESTORAGE_CONFIG_DIRresolves both.credentials.jsonand the rotation lock.oauth_refresh.lock, independently ofCLAUDE_CONFIG_DIR(which still points at the per-repo~/.claude). Because the lock travels with the credential, containers of different repos rotating the same token stay mutually excluded instead of racing.- The mount is a directory, never a file. Claude Code rewrites
.credentials.jsonatomically (write new inode, rename over the old), so a file-level bind would leave the container holding a handle to the old, now-unlinked inode after the first rotation. This is the same fragility the earlier.claude.jsonrelocation to a directory mount removed for the config file. - An empty
CLAUDE_SECURESTORAGE_CONFIG_DIRis not equivalent to an unset one — Claude Code falls back to~/.claudefor it. jailbee treats this as a hard rule:claude_securestorage_dir_envreturnsNonerather than an empty string, andprofiles.base_profile_yamldrops the key outright if acontainer.envoverride would otherwise render it empty. - Account identity comes from the credential, not from seeding: a member
repo with a fresh
~/.claudepopulates its ownoauthAccountin.claude.jsonthe first time Claude Code runs, without jailbee writing anything. - Deleting the
oauthAccountblock from an established.claude.json— one whose onboarding is already complete — is repaired silently at the next start. Interactiveclaude(not justclaude -p) repopulates the block from the credential before the first user turn: no login prompt, no onboarding flow, andaccountUuid/emailAddress/organizationUuidcome back identical. The repair reads the credential without rotating it —.credentials.jsonkept its mtime and size, and no.oauth_refresh.lockwas created — so it cannot disturb the other members of a credential group. This is what makes deleting the block a safe way to point a member repo at a changed group account. - A stale
oauthAccountin.claude.jsondoes not break authentication, and jailbee does not correct it — only the credential inCLAUDE_SECURESTORAGE_CONFIG_DIRauthenticates. - Only the credential is shared. Each repo keeps its own
~/.claude, so project history, MCP config, sessions and onboarding state never cross repos.