Run OpenAI Codex CLI/ Claude Code/ OpenCode safely inside separate rootless Podman containers while sharing one audited host launcher.
  • Shell 98.3%
  • Dockerfile 0.9%
  • JavaScript 0.5%
  • Makefile 0.3%
Find a file
Motoko Kusanagi e845153502
docs: describe client accounts
The README explains how one identity works with several client logins
through --account or AZKABAN_ACCOUNT, where an account's state lives,
and that an account is shared, hooks and settings included, by every
identity that names it. The adapter guide, the headless contract, and
the identity env example follow.
2026-10-21 14:47:22 +02:00
.agents chore(agents): upgrade Agent Foundry to d5d69df 2026-10-21 12:58:06 +02:00
.claude chore(agents): upgrade Agent Foundry to d5d69df 2026-10-21 12:58:06 +02:00
.codex chore(agents): upgrade Agent Foundry to d5d69df 2026-10-21 12:58:06 +02:00
.opencode chore(agents): upgrade Agent Foundry to d5d69df 2026-10-21 12:58:06 +02:00
bin feat: share client state through --account 2026-10-21 14:46:57 +02:00
clients feat: share client state through --account 2026-10-21 14:46:57 +02:00
containers
docs docs: describe client accounts 2026-10-21 14:47:22 +02:00
examples docs: describe client accounts 2026-10-21 14:47:22 +02:00
lib
tests feat: share client state through --account 2026-10-21 14:46:57 +02:00
.gitignore
.prettierrc
.shellcheckrc
AGENTS.md chore(agents): upgrade Agent Foundry to d5d69df 2026-10-21 12:58:06 +02:00
COPYRIGHT
install.sh
Makefile
README.md docs: describe client accounts 2026-10-21 14:47:22 +02:00

azkaban

azkaban runs Codex CLI, Claude Code, or OpenCode inside separate containers, rootless Podman on Linux and Apple container on macOS, while sharing one audited host launcher. The public commands are codex, claude, and opencode; each keeps its own image, configuration, authentication, and persistent state.

Every client image builds on Ubuntu 26.04 LTS with git, Node.js, ShellCheck, Prettier, and common command-line tools, plus the forge CLIs gh and fj that bot accounts use, and bubblewrap and socat for nested sandboxes; containers/Dockerfile lists the rest.

Security model

Codex and Claude run with their native approval checks disabled because the container is the intended containment boundary. Those bypass options are not safe to use on the host. OpenCode keeps its native permission rules by default; pass run --auto explicitly for unattended work. OpenCode's --auto approves requests while preserving explicit deny rules. On Linux a rootless container reduces host access, but it is not a virtual machine. On macOS each container runs in its own lightweight virtual machine without a user namespace, so root in the container is root in that machine. Neither is a defense against every kernel or container-runtime vulnerability.

By default the client can access its workspace, its client-specific persistent state, and the resources you explicitly expose. It sees the whole workspace, so keep any file it must not read outside that folder. Everything the launcher keeps on the host lives below ~/.azkaban, and the client's state directory, ~/.azkaban/state/<identity>/<client> for the identity the launch runs as, or ~/.azkaban/accounts/<account>/<client> for an account it names, is the only part of it the client can write. Extra mounts, an identity's SSH key or host agent, and networks expand or alter that boundary; review them as security-sensitive configuration. A workspace Dockerfile.<client> or Dockerfile.azkaban changes the image only after you approve its content (see Custom images). azkaban never mounts the engine's socket. When agents need to sign and push, give them a bot account of their own, as described in Run agents as a bot account.

The launcher parses environment files as data: it does not source them or use eval. File-provided container variables reach the container through a private env file that the engine reads, so their values never enter the environment of a program the launcher runs on the host, nor any process argument list. The workspace .env.<client>, which the client can write, holds container variables only (see Environment configuration). Trusted adapters are loaded only from the installed runtime, never from a workspace, PATH, or configuration file.

Nested sandboxes

Inside a client container on Linux, a tool can isolate folders with bwrap, which runs unprivileged through user namespaces: it can bind folders read-only or writable, hide others, and unshare the network and process namespaces. The container refuses a fresh /proc mount, so a sandbox binds the container's /proc instead, and commands in it still see the container's processes. When you turn on Claude Code's own sandbox, which also uses socat, also set sandbox.enableWeakerNestedSandbox to true in its settings. Codex's sandbox stays off, since its image starts it with --dangerously-bypass-approvals-and-sandbox. Ubuntu 26.04's bwrap carries the unfixed CVE-2026-87766, so a nested sandbox keeps a cooperative command to its folders but cannot hold one crafted to escape it; the container stays the boundary toward the host.

Prerequisites

  • Bash 4.4 or newer (macOS ships 3.2; see macOS setup)
  • Rootless Podman 4.3 or newer on Linux; on macOS, an Apple silicon Mac with macOS 26 and Apple container 1.5.0 or newer
  • GNU or BSD userland tools used by the launcher
  • Network access while building the selected client image

When the engine is missing, the installer installs it: Podman with the first package manager it finds (apt-get, dnf, or pacman through sudo, or Homebrew), Apple container with Homebrew. It builds locally and does not publish images to a registry.

macOS setup

macOS support has not yet been verified on a real Mac. The macOS section describes how launches differ there.

The launcher runs with Homebrew's Bash and Apple container:

brew install bash container
container system start --enable-kernel-install

The first container system start downloads the Linux kernel that containers boot; --enable-kernel-install accepts it without a prompt. Put Homebrew's bin ahead of /bin on PATH; the scripts refuse the system Bash 3.2 with a message. The installer checks the version and that the service is running before it builds, and prints the start command when it is down. It never starts the service.

Install

Choose one or more clients, or use the all selector:

./install.sh codex
./install.sh claude
./install.sh opencode
./install.sh codex claude opencode
./install.sh all

Each selected client is built as its default image (codex-cli:latest, claude-code:latest, or opencode-cli:latest), which receives an additional version tag when native version detection succeeds. --no-cache applies to every selected build, and --tz ZONE sets the image timezone (Etc/UTC by default):

./install.sh --no-cache --tz Europe/Rome all

Images under other names come from the make targets in Separate image builds.

Installing one client later does not remove or replace another client's public link, image, configuration, or state. The installer builds every selected image before it changes the installation, so a failed build leaves the current one in place. The folders it writes under ~/.local must be real directories: a symlink among them stops the installation before any build. An interrupted installation is completed by running the installer again.

Add the user-local binary directory to PATH if it is not already present:

export PATH="$HOME/.local/bin:$PATH"

Put that line in your shell startup file to make it persistent.

Commands

Run the commands directly:

codex .
claude .
opencode ./project
opencode ./project -- run --auto "Explain the test suite"
opencode ./project -- --session SESSION_ID

The first positional argument is the workspace and must precede native-client arguments. Use -- to stop wrapper parsing explicitly:

codex ./project -- --native-option
claude ./project -- --native-option
opencode ./project -- --native-option

Common wrapper options

Every command accepts these options:

Option Purpose
-h, --help Show command help.
--network NAME Attach to an existing network.
--mount SRC:DST[:OPTIONS] Add a bind mount; repeatable.
--max-memory VALUE Set the container memory limit; without one, Podman sets none; on macOS see macOS.
--image NAME Override the client image.
--skip-custom-images Run the image without building a workspace Dockerfile (Custom images).
--cidfile PATH Write the container ID to a host file outside the workspace.
--identity NAME Run as an identity; default when omitted.
--account NAME Keep the client's state in an account instead of the identity's.
--no-tty Disable TTY allocation; without it, a TTY is allocated when standard input and output are terminals.
--dry-run Print one shell-escaped launch command without launching.
-- Forward all remaining arguments to the native client.

Scripts and supervisors that launch clients headless can rely on the guarantees of the Headless contract.

Authentication and state

Each client keeps its authentication, settings, and history in the state folder of the identity a launch runs as, ~/.azkaban/state/<identity>/<client>, such as ~/.azkaban/state/default/claude, or of the account it names. The launcher creates what a client needs there on its first real run and never overwrites what exists.

That state is shared across every workspace that uses it, so treat those projects as mutually trusted: a hook, setting, plugin, cache, or credential one session writes there affects later runs in another project. Give projects that must stay apart identities of their own, with no account in common. Every workspace appears at /workspace, so Claude and OpenCode file the history of all your projects under that one path.

Codex

The state folder mounts at /home/codex/.codex, where the native client keeps authentication, configuration, history, and logs. Authenticate interactively through the native client or put OPENAI_API_KEY in the identity's env.

Claude

The state folder's config directory mounts at /home/claude/.claude and its .claude.json at /home/claude/.claude.json. On the first real run, the launcher creates config/settings.json, trusting /workspace, and an empty .claude.json, each only when absent. Authenticate interactively through the native client or put ANTHROPIC_API_KEY in the identity's env.

OpenCode

The state folder holds config, data, cache, and state, mapped to the native XDG locations below /home/opencode; the native client stores provider credentials in data/auth.json. Authenticate, inspect configured providers, and list native sessions with:

opencode . -- auth login
opencode . -- auth list
opencode . -- session list

The launcher does not copy credentials from a host OpenCode installation, and native project grouping stays under OpenCode's control. Browser-based web, desktop, and remote-server integration, including OAuth flows that require a callback into the container, are out of scope.

Accounts

An account is a client state folder of its own, ~/.azkaban/accounts/<name>/<client>, that a launch uses instead of its identity's, so one identity can work with several logins of a client, such as a personal and a company Claude subscription. Name it for one launch with --account NAME, or for an identity with AZKABAN_ACCOUNT in its env.<client> or env, as Environment configuration describes; an empty AZKABAN_ACCOUNT= keeps the identity's own state. The first real launch with a new name creates its folder, and you log in there once:

claude . --account personal
claude . --account work

An account holds what the client stores, such as its login; an API key in the identity's env reaches the launches of every account, so a key that serves one account only goes in an identity of its own.

Every identity that names an account shares its whole state, settings, hooks, plugins, and history included, so a session of one identity can change what the next sessions of another run. Give identities that must stay apart different accounts, such as work and work-2. A launch with an account names it on standard error, as in claude: account: work: state /home/you/.azkaban/accounts/work/claude.

Identities

An identity is what a launch runs as; its folder below ~/.azkaban/identities/ holds its credentials, settings, and goals. The identity's credentials set what it can do, because the forge enforces them; its goals tell its sessions what to work toward.

A launch selects an identity with --identity NAME; a shell alias keeps a frequent one short. With no name, the launch runs as default, which needs no folder. No env file selects an identity, because the identity decides which files load. Any other name without a folder fails. The identity line a launch prints on standard error names the identity first, for example codex: identity: triage: home …, goals …, so a supervisor's logs show which bot ran.

Identities, their client state, and the trees they share lie below ~/.azkaban. Each piece of an identity's folder is optional and used when it exists:

~/.azkaban/
├── identities/NAME/         what you write for one identity
│   ├── env                  settings and container variables for every client
│   ├── env.<client>         settings and container variables for one client
│   ├── key                  the bot's SSH private key
│   ├── home/                files for the container's home
│   └── goals.md             the bot's goals (below)
├── state/NAME/<client>/     what that client's sessions write
├── accounts/NAME/<client>/  what that client's sessions write in an account
├── mcp/                     MCP binaries
└── approved/                custom image approvals

Run agents as a bot account sets up the key and the SSH, git, and forge CLI files in home/.

A present piece must be the file or folder itself, not a symlink, and lie outside the workspace, or the launch stops before the engine starts; an extra mount onto the goals stops it as well.

Each file below home/ mounts read-only at the same path under the container's home, so any tool's configuration goes in by copying it there: home/.config/gh/hosts.yml appears at ~/.config/gh/hosts.yml. Every entry below home/ must be a readable plain folder or file whose path holds no :, and no file may land on or inside another mount, such as the client's state or an extra mount, or the launch stops before the engine starts. A launch mounts the files present when it starts, so a file added, removed, or replaced while it runs counts from the next launch. Podman creates a folder the image lacks, such as ~/.config/gh, owned by root, so a tool cannot add files beside its mounted ones there.

An identity reads its configuration from its own folder only, so no key or token in that folder crosses from one bot to another, while an account shares its login with every identity that names it; copy the settings every identity needs into each identity's files, such as a network into its env or a client's image into its env.<client>. Those files load above the workspace .env.<client>, so a project cannot replace the identity's credentials, and an empty value such as GH_TOKEN= keeps a project's value out.

Each identity has a state folder of its own, so a hook or setting one bot's session writes stays out of another bot's sessions, and removing ~/.azkaban/state/NAME/ resets the bot; an account is the exception, shared by every identity that names it. Log each identity into each client it uses once, for example claude . --identity coder, or put the client's API key in its env. The MCP directory and custom image approvals stay shared, since both record your decisions and containers only read them.

goals.md mounts read-only as the client's user-level instructions, ~/.claude/CLAUDE.md, ~/.codex/AGENTS.override.md, or ~/.config/opencode/AGENTS.md, which every session reads alongside the project's own AGENTS.md, interactive and headless alike (on macOS goals are not served; see macOS). Codex reads up to 32 KiB of the goals and skips an empty file, so keep goals.md non-empty. A session can add instructions of its own in its state, but cannot remove the goals.

The workspace is container-writable for every identity, so point identities of different trust at different checkouts: a git hook or project setting that one bot plants would otherwise run in the other bot's session.

Environment configuration

Every launch runs as an identity, default unless you name another, and reads its settings from that identity's folder: env for every client and env.<client> for one. A workspace may add container variables of its own in .env.codex, .env.claude, or .env.opencode:

mkdir -p -m 700 ~/.azkaban/identities ~/.azkaban/identities/default
cp examples/env.azkaban.example ~/.azkaban/identities/default/env
cp examples/env.codex.example ~/.azkaban/identities/default/env.codex
printf 'PROJECT_MODE=development\n' >.env.codex

The caller's environment supplies no settings and no container variables, only the terminal type the launcher passes on as TERM and, for an identity that sets AZKABAN_SSH_AGENT=host, the agent socket SSH_AUTH_SOCK names, so a launch's command line and these files describe it completely, interactive or headless. A proxy the container needs, such as HTTPS_PROXY, goes in the identity's env like any other variable.

Every launcher control is named AZKABAN_<NAME>, the same for every client. The controls are IMAGE, NETWORK, MOUNTS, MAX_MEMORY, SKIP_CUSTOM_IMAGES (see Custom images), SSH_AGENT (see Your own agent), and ACCOUNT (see Accounts). A control takes its value from the nearest layer that sets it, highest priority first:

command-line option > the identity's env.<client> > the identity's env
> built-in default

A line in the identity's env serves every client; the same line in its env.<client> scopes the setting to that client and wins over env for it. For example, AZKABAN_IMAGE=private/claude in env.claude changes Claude's image and leaves the other clients on their own. The whole AZKABAN_ prefix is reserved for launcher controls and never reaches a container; any other AZKABAN_ name in the identity's files, such as AZKABAN_SESSION, is dropped with a warning.

The workspace file holds container variables only. The client inside the container can write it, so any AZKABAN_ name in it stops the launch before the engine starts; set launcher controls with command-line options or the identity's files.

Other keys are ordinary container variables, taken from these files only, highest priority first:

the identity's env.<client> > the identity's env > the workspace .env.<client>

The launcher drops, with a warning, names that configure the host side of a launch, such as LD_*, PODMAN_*, SSH_AUTH_SOCK, TMPDIR, and TERM, and the client's workspace marker, such as CODEX_WORKSPACE, which it sets itself.

API keys belong in the identity's env, as OPENAI_API_KEY or ANTHROPIC_API_KEY; keep credentials out of workspace files. Another registered client's API-key variable is consumed and dropped in every file layer. An identity's env may therefore hold both OPENAI_API_KEY and ANTHROPIC_API_KEY; Codex and Claude receive only their own, and OpenCode receives neither. This is not a universal credential filter: ordinary provider variables intentionally configured under other names still follow the normal forwarding rules, and the client can read credential files inside its mounted workspace.

The launcher reads these files but never creates them. The first valid definition in a file wins. Values are literal except that one matching outer pair of single or double quotes is removed. This is not shell syntax.

Extra mounts and networks

Extra mounts deliberately expand client access, for example to share a folder of reference material read-only:

claude . --mount "$HOME/reference:/reference:ro"

A destination of ~ or ~/... expands to the active client's container home, so one shared line covers all clients:

# in ~/.azkaban/identities/default/env
AZKABAN_MOUNTS=~/reference:~/reference:ro

--mount is repeatable and takes precedence over the comma-separated AZKABAN_MOUNTS control. A source must exist, and only a leading ~, ~/, or a literal $HOME is expanded on the source side; $HOME is not expanded on the destination side because it would be ambiguous. A destination such as ~user/path is not expanded either and is rejected as a non-absolute destination. Use :ro wherever write access is unnecessary.

Use --network NAME only when the client must reach services on that network.

MCP binaries

Place executable MCP server binaries in ~/.azkaban/mcp/. Every client shares that directory and sees it read-only at /opt/mcp; the launcher creates it, private, on the first real launch of any client. Register a server in the client's own configuration by its path there, such as /opt/mcp/search.

A server published as a single-binary OCI image can be copied out of a container that is created but never started. The binary is the image's entrypoint; for an image without one, read .Config.Cmd instead. Run the same commands again to update it:

mkdir -p -m 700 ~/.azkaban/mcp
ref=registry.example/mcp/search:latest
podman pull "$ref"
bin="$(podman image inspect --format '{{index .Config.Entrypoint 0}}' "$ref")"
id="$(podman create "$ref")"
podman cp "${id}:${bin}" ~/.azkaban/mcp/search
podman rm "$id"

The commands use Podman; on macOS, put a Linux arm64 build of the server in ~/.azkaban/mcp by other means, such as its release downloads.

Custom images

A workspace that needs more tools than the client image ships can carry its own layer as Dockerfile.codex, Dockerfile.claude, or Dockerfile.opencode at the workspace root. When that file is present and no image is named with --image or the IMAGE control, every launch builds it and runs the result in place of the default image. Start it from the installed client image by its localhost/ name, which on macOS the installer adds beside the plain one:

# azkaban-image: website
FROM localhost/claude-code:latest
USER root
RUN apt-get update && apt-get install -y --no-install-recommends ruby-full \
    && rm -rf /var/lib/apt/lists/*
USER claude

A Dockerfile.azkaban serves every client that has no file of its own: each launch builds it on the installed image of the client it runs, replacing the file's first FROM, so one file gives every client an image of its own. That FROM can name any client image, such as localhost/claude-code:latest, so the file also builds by itself; only the first FROM changes. Return to the client's user with USER 1000, the ID that user has in every client image under a different name.

The agent can write to its workspace, so the launcher builds only content you approved. A launch that finds a new or changed file asks on the terminal, defaulting to no:

claude: Dockerfile.claude is new. Build it as localhost/claude-code-website:latest and run it? [y/N]

Answering y stores a copy of the file as the workspace's approval record in ~/.azkaban/approved/<client>/, outside anything a container sees, and the build reads that copy rather than the workspace file. The shared file's record lies in ~/.azkaban/approved/azkaban/ and serves every client, as its prompt says. Any other answer runs the default image for that launch and asks again next time. Later launches with unchanged content build without asking. A launch without a terminal skips a file that is not approved, with a warning, and runs the default image. Each workspace's record is named by the first 12 hexadecimal characters of the SHA-256 of the path on the launch's Workspace: line, as printf '%s' /path/to/workspace | sha256sum | cut -c1-12 prints it, and deleting the record revokes the approval.

The image is named localhost/<client image>-<name>:latest. A # azkaban-image: <name> line chooses the name from lowercase letters and digits joined by single ., _, or -; the first such line counts, and an invalid name stops the launch. Without the line, the name comes from the workspace folder name. The client image prefix keeps any name from replacing the installed image. Workspaces that resolve to the same name share one image, which each launch rebuilds before it runs.

The engine's layer cache keeps an unchanged build short, while an edited file, a changed file the build copies, or a client image refreshed by install.sh rebuilds the affected layers on the next launch. A failed build stops the launch before the container starts. Each rebuild leaves the previous image untagged, and renaming leaves the old image in place; podman image prune and podman rmi reclaim them on Linux, and container image prune and container image delete on macOS.

The build runs on the host's engine with the workspace as its context and on the engine's default network, so its network follows the engine rather than the NETWORK control. Copy only the files the image needs: the approved COPY lines are the guard that holds, since an ignore file sits in the workspace, where the agent can change it without a new approval.

AZKABAN_SKIP_CUSTOM_IMAGES=1 in the identity's env turns custom images off, and --skip-custom-images turns them off for one launch. A dry run neither builds nor records; it says on standard error what a real launch would build or ask, and names the custom image in the printed command. Without a terminal, a dry run with an unapproved file prints the real launch's warning and keeps the default image.

Dry run

Inspect the exact launch without starting the engine or creating persistent state:

codex . --dry-run
claude . --dry-run
opencode . --dry-run

Standard output contains one shell-escaped command. Diagnostics go to standard error. File-provided container variables reach a real launch through a private env file that exists only while it starts, so the printed command leaves them out and standard error names them. Before the first real Claude launch of an identity or account, a Claude dry run warns that its printed state bind sources do not exist yet; run once without --dry-run to create them before reusing the printed command.

macOS

Each launch runs container run instead of podman run, so a few things differ from Linux. macOS setup installs and starts the engine.

  • Mounts. Any host path mounts. The launcher resolves the workspace to its physical path, so /tmp/project is mounted as /private/tmp/project. A mount of anything that is not a directory, such as a file or /dev/null, whose destination lies inside another mount stops the launch, because container would silently drop the enclosing mount (apple/container#2148). Mount such a file at a path outside the other mounts. A single-file extra mount shares the file's whole parent directory with the container's virtual machine; only the container's mount hides the siblings, so keep such files in a folder of their own.
  • Identities. An identity's key, host agent, and goals.md are not served. A launch goes ahead without them and says so on standard error. Signing and pushing over SSH need an agent, so run work that pushes from a Linux host.
  • Memory. Without --max-memory, container gives each container 1 GiB. Pass --max-memory 4g, or raise memory under [container] in container's config.toml.
  • Terminal. An interactive launch clears the terminal's newline translation (ONLCR) around the client, because container leaves it on and garbles full-screen clients (apple/container#2299), and restores the saved settings afterwards. --no-tty skips this.
  • Service. Every container command needs the service, so each launch checks it, --dry-run included.
  • Stopping. A supervisor stops a headless launch differently; see the Headless contract.

Known issue: Claude Code walking a large tree in a container has exhausted the host's file descriptors and crashed other applications (apple/container#1097). No workaround is known.

Troubleshooting

  • command not found: add $HOME/.local/bin to PATH and start a new shell.
  • Podman version failure (Linux): install or upgrade rootless Podman to 4.3 or newer.
  • No TTY or noisy terminal control: pass --no-tty.
  • building Dockerfile.<client> failed or building Dockerfile.azkaban failed: read the build output above the message and fix the file, or run without it: launch with --skip-custom-images, or set AZKABAN_SKIP_CUSTOM_IMAGES=1 in the identity's env.
  • 'NAME' in '.../.env.<client>' is a launcher setting: move the line to the identity's env or env.<client>, or use a command-line option.
  • Missing mount source: use an existing host path.
  • Bash 4.4 or newer is required: install Bash with Homebrew and put it ahead of /bin/bash on PATH.
  • container system service is not running; start it with: container system start --enable-kernel-install: run that command, then rerun.
  • Apple container 1.5.0 or newer is required (found: ...): the installer found an older release; upgrade with brew upgrade container, then rerun the installer.
  • mount '...' at '...' lies inside the mount of '...' at '...', which container would drop: move the extra mount's destination outside the other mount, as macOS explains.

Use codex --help, claude --help, opencode --help, or ./install.sh --help for the current command surface.

Development

Working on azkaban itself starts at Development, which covers the runtime layout, the image builds, and the checks every change passes; Adding a client holds the contract a new client follows.

License

COPYRIGHT holds the license and attribution details.