Skip to main content

Always-on containerized dev environment for AI coding agents, driven over SSH+tmux.

Project description

agent-container

Always-on, containerized development environment for a single operator. Hosts AI coding agents (Claude Code, Codex, pi-coding-agent), nvim, tmux, and git behind OpenSSH. Designed to run on a personal Linux VPS and be attached to over ssh.

Design contract: CLAUDE.md. Runtime + base-image decision: docs/decisions/0001-runtime-and-base-image.md. Credential contract: docs/credentials.md.

How it fits together

laptop                                        VPS (Hetzner / Debian 12)
------                                        ------------------------
~/.config/agent-container/hosts.conf                   user systemd (linger enabled)
  ACME_HOST=vps1.example                        |
  ACME_PORT=2218                                +-- Quadlet: agent-container-acme.container
                                                    |
$ agent-container attach acme                                +-- container: agent-container-acme
   |                                                       +-- sshd  (port 2222 -> host 2218)
   |  ssh -p 2218 dev@vps1.example -t tmux              +-- tmux session "main"
   |     attach -t main                                       +-- nvim
   v                                                          +-- claude
[ you are now inside tmux on the VPS ]                        +-- codex
                                                              +-- /workspace (named volume)
detach (Ctrl-B d)
  -> back on laptop; agents keep running on the VPS

Key property: detach is non-destructive at every layer. Closing the SSH connection leaves tmux running. tmux retains every pane's state. The container stays up because it was launched detached and is either supervised by user systemd (Quadlet) or kept alive by Docker's restart policy. Lingering keeps your user-level systemd alive across SSH logouts. The only way work is lost is if you (or an agent) fail to git push — which is why the design contract forbids that.

Deploy to a Hetzner VPS

This section walks through standing up a fresh always-on environment on a Hetzner Cloud server. Any Debian 12 / Ubuntu 24.04 host works the same way; nothing here is Hetzner-specific beyond Step 1.

Step 1 — provision the VPS

Hetzner Cloud Console → Create Server. The smallest shared-CPU instance is more than enough (the image is ~1.8 GB on disk; idle dev containers cost ~50 MB RAM each). Use Debian 12 or Ubuntu 24.04. Add your laptop's SSH public key during provisioning. You should now be able to ssh root@<vps-ip>.

Step 2 — create an operator user

Don't run dev environments as root. From root@vps:

adduser --gecos "" --disabled-password ondra        # use your own username
usermod -aG sudo ondra
install -d -m 0700 -o ondra -g ondra /home/ondra/.ssh
cp /root/.ssh/authorized_keys /home/ondra/.ssh/
chown ondra:ondra /home/ondra/.ssh/authorized_keys
chmod 0600 /home/ondra/.ssh/authorized_keys

From now on you ssh ondra@<vps-ip>. (Disabling root SSH login by editing /etc/ssh/sshd_config is a worthwhile next step; out of scope here.)

Step 3 — install Podman and enable lingering

sudo apt-get update
sudo apt-get install -y podman git netcat-openbsd
loginctl enable-linger "$USER"

# uv — needed only for the `agent-container` CLI (Quick path below). The Quadlet path
# drives podman via systemd and needs neither uv nor agent-container.
curl -LsSf https://astral.sh/uv/install.sh | sh

enable-linger is load-bearing for the always-on model: it keeps your user-level systemd alive even after you SSH out. Without it, any container managed under user systemd (via Quadlet) gets killed when your SSH session ends. Run it once per user, ever.

Step 4 — clone, configure, build

git clone https://github.com/ondrasek/agent-container.git
cd agent-container
cp .env.example .env
chmod 0600 .env
$EDITOR .env       # fill in GH_TOKEN, GIT_USER_NAME, GIT_USER_EMAIL, agent API keys
uv tool install --editable .   # puts `agent-container` on PATH (editable; needs uv)
agent-container build

(If you prefer not to install the tool, uv run --script bin/agent-container build runs it in place.) First build takes ~5-10 minutes (NodeSource, npm globals, neovim tarball). Subsequent builds reuse cached layers.

Step 5 — start your first container

Two paths. Pick one per container.

Quick pathagent-container up (needs uv + agent-container, installed in Step 4). Runs podman run -d. Survives SSH disconnects (because of enable-linger) but not a VPS reboot. Fine for experimentation and for environments you intentionally want to recreate often:

agent-container up acme
# prints something like:
# [agent-container] name=acme port=2206 env-file=/home/ondra/agent-container/.env

Note the port. You'll need it on the laptop side.

Quadlet path — recommended for "always-on" production use. systemd supervises the container, restarts it on failure, brings it back on reboot, captures its logs in journald:

mkdir -p ~/.config/containers/systemd ~/.config/agent-container
cp .env ~/.config/agent-container/agent-container-acme.env
chmod 0600 ~/.config/agent-container/agent-container-acme.env

sed -e 's/${NAME}/acme/g' \
    -e "s|\${ENV_FILE}|$HOME/.config/agent-container/agent-container-acme.env|g" \
    -e 's/${PORT}/2218/g' \
    orchestration/agent-container.container \
    > ~/.config/containers/systemd/agent-container-acme.container

systemctl --user daemon-reload
systemctl --user start agent-container-acme.service        # Quadlet generates the .service from .container
systemctl --user status agent-container-acme.service

To stop / restart / log:

systemctl --user stop    agent-container-acme.service
systemctl --user restart agent-container-acme.service
journalctl --user -u agent-container-acme.service -f

Step 6 — grant SSH access from your laptop

Nothing operator-specific is baked into the image, so a fresh container has no authorized keys of its own. But the SSH identity — authorized_keys and the host key — now lives on the per-container -ssh volume (mounted at ~/.ssh) and persists across down/up: inject your public key once and it survives every recreate (no more REMOTE HOST IDENTIFICATION HAS CHANGED churn, since the host key is stable too). Pick whichever injection path fits:

At launch — up --authorized-key (and optionally --host-key):

agent-container up acme --authorized-key ~/.ssh/id_ed25519.pub
# fixed host identity too (repeatable --authorized-key):
agent-container up acme --host-key ~/.config/agent-container/acme_host_ed25519_key \
                        --authorized-key ~/.ssh/id_ed25519.pub

The files are bind-mounted read-only and installed onto the ~/.ssh volume by the entrypoint before sshd starts.

Into an already-running container — agent-container keys:

agent-container keys acme --authorized-key ~/.ssh/id_ed25519.pub
agent-container keys acme --host-key ~/.config/agent-container/acme_host_ed25519_key

No recreate: the key is streamed over stdin (never on argv), merged with dedup, and sshd is reloaded in place.

Via the .env file: set SSH_AUTHORIZED_KEYS (newline-separated public keys) and/or SSH_HOST_ED25519_KEY_B64 (base64 of an unencrypted ed25519 private host key); the entrypoint installs them at boot. This is the natural fit for the Quadlet path, whose credentials already flow through the env-file.

The host key is ed25519-only, and its boot precedence is up --host-key bind-mount > env SSH_HOST_ED25519_KEY_B64 > already-persisted key > freshly generated. authorized_keys are a deduped union of the persisted file plus every injected source.

Fallback: copy a key in by hand

If you'd rather not use the first-class paths, you can still write into the ~/.ssh volume directly (it persists just the same):

podman exec -u dev -i agent-container-acme \
    tee -a /home/dev/.ssh/authorized_keys < ~/.ssh/id_ed25519.pub >/dev/null
podman exec -u dev agent-container-acme chmod 0600 /home/dev/.ssh/authorized_keys

Step 7 — set up agent-container on the laptop

On your laptop, not the VPS. Install the same CLI (it runs client-side for attach) and point it at the VPS via hosts.conf:

git clone https://github.com/ondrasek/agent-container.git
uv tool install --editable ./agent-container   # puts `agent-container` on PATH

mkdir -p ~/.config/agent-container
chmod 0700 ~/.config/agent-container
cat >> ~/.config/agent-container/hosts.conf <<EOF
ACME_HOST=<vps-ip-or-dns>
ACME_PORT=2218
EOF
chmod 0600 ~/.config/agent-container/hosts.conf

Step 8 — verify

agent-container attach acme

You should land inside a tmux session named main, prompt is dev@<container-id>:/workspace$. tmux ls shows one session. Detach with Ctrl-B d. Re-attach to confirm everything's still there.

Daily use

Attach to a container

agent-container attach acme            # auto: hosts.conf -> remote, else local state file
agent-container attach --local acme    # local (Lima on macOS); reads port from local state file

Behind the scenes: ssh dev@<host> -p <port> -t tmux attach -t main. The -t allocates a TTY (required for tmux); tmux attach -t main joins the existing session rather than creating a new one (which would mask bugs).

The agent-container CLI

agent-container is the single command for the whole lifecycle — build, start, attach, logs, stop, purge — plus an interactive wizard when run with no arguments. It is a PEP 723 single-file script (bin/agent-container) and needs nothing but uv installed:

agent-container                # interactive menu: build, start, attach, logs, stop, purge
agent-container up acme        # every menu action has a scriptable subcommand
agent-container list --json    # machine-readable state (merges runtime ps + state files)
agent-container attach acme    # hosts.conf -> remote, else local state file; execs ssh
agent-container --self-test    # doctests + port-hash corpus (port hash, key derivation)

It keeps all state on disk (container names, the port hash, <name>.port state files, env-file resolution, hosts.conf) so a container that dies loses nothing. attach resolves a target as remote when the name has a hosts.conf entry, else local from the state file — pass --local/--remote to force one. down/purge confirm before destroying anything, so scripts must pass -y/--yes.

The CLI has a pytest suite in bin/tests/ that pins its on-disk contract (port hash, naming, env-file resolution, hosts.conf parsing, generated run/ssh argv) and the platform-aware runtime default. It needs no container runtime or ssh — only uv:

uv run --no-project --with pytest \
       --with 'typer>=0.12,<1' --with 'questionary>=2.0,<3' --with 'rich>=13,<15' \
       pytest bin/tests

The --with pins mirror the script's PEP 723 inline metadata — keep them in sync when bumping dependencies in bin/agent-container (and in pyproject.toml). --no-project keeps the run hermetic: the root pyproject.toml otherwise puts uv run in project mode and would sync a .venv/ at the repo root.

Install from PyPI

Once published, install agent-container onto your PATH from PyPI — no checkout required:

uv tool install agent-container      # or: pipx install agent-container
agent-container --help

A PyPI install is primarily a client / attach tool: attach, list, logs, down, purge, and completions all work standalone (the completion scripts are bundled as package data). Two commands still need a repo checkout on the host they run on:

  • build needs a checkout as the docker build context.
  • up needs the image localhost/agent-container:latest to already exist locally (it dies otherwise). No prebuilt image is published to any registry, so producing it requires build — hence a checkout. On a fresh host the server side still needs a checkout; a pure-PyPI install alone cannot up a container.

Point build at a checkout explicitly:

agent-container build --context /path/to/agent-container
# or, once, for the session:
export AGENT_CONTAINER_REPO=/path/to/agent-container
agent-container build

AGENT_CONTAINER_REPO (or an auto-detected checkout you happen to run from) also lets the standalone install read the on-disk completions/ instead of the bundled copy — handy when hacking on the completions.

Install as a uv tool (editable, for development)

For working on agent-container, install it editable so git pull keeps the PATH command current and build / completions resolve the live repo files:

uv tool install --editable /path/to/agent-container
#   installs ~/.local/bin/agent-container; `git pull` keeps it current (editable)
uv tool upgrade agent-container     # after dependency bumps
uv tool uninstall agent-container

The uv run --script bin/agent-container path and the oh-my-zsh plugin are unaffected by installing the tool; if the repo's bin/ is also on PATH (e.g. via the plugin), both resolve to the same code — harmless.

Shell completions

agent-container ships bash and zsh completions under completions/: subcommands, per-subcommand flags (including the repeatable --mount), and container-name completion for up / down / attach / logs / purge. Names are gathered directly in the shell from your state files ($XDG_STATE_HOME/agent-container/*.port) and hosts.conf — no docker, podman, or uv is spawned on Tab, so completion stays instant and works offline.

Completion triggers on the command name, so put the tool on your PATH (this also lets the build / completions subcommands find the repo):

# add the repo's bin/ to PATH (in ~/.bashrc or ~/.zshrc)
export PATH="$HOME/agent-container/bin:$PATH"

bash — source the script (works with or without the bash-completion package):

# ~/.bashrc
source "$HOME/agent-container/completions/agent-container.bash"
# or generate it: agent-container completions bash > ~/.local/share/bash-completion/completions/agent-container

zsh — drop the script onto $fpath as _agent-container, then compinit:

mkdir -p ~/.zfunc
agent-container completions zsh > ~/.zfunc/_agent-container
# ~/.zshrc, before compinit:
fpath=(~/.zfunc $fpath)
autoload -Uz compinit && compinit

oh-my-zsh — a plugin under completions/oh-my-zsh/agent-container/ bundles PATH wiring, the completion, and aliases (ae, aeu, aea, ael). Symlink it into your custom plugins dir and enable it:

ln -s "$HOME/Git/ondrasek/agent-container/completions/oh-my-zsh/agent-container" \
      "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/agent-container"
# then add `agent-container` to plugins=(...) in ~/.zshrc:
#   plugins=(git agent-container)

AGENT_CONTAINER_REPO is the path to this repo checkout; the plugin auto-detects it from its own symlink-resolved location, so a symlink install needs no configuration. (If you copy the plugin dir instead of symlinking, set AGENT_CONTAINER_REPO=<repo> in ~/.zshrc before oh-my-zsh loads.)

For PATH, the plugin prefers the canonical user bin dir — $XDG_BIN_HOME, or ~/.local/bin when that's unset. If agent-container is symlinked there (e.g. ln -s "$AGENT_CONTAINER_REPO/bin/agent-container" "${XDG_BIN_HOME:-$HOME/.local/bin}/") it puts that dir on PATH; otherwise it falls back to the repo's own bin/, so the plugin works with or without a separate install step. This alone makes agent-container callable with completions — no manual PATH or ~/.zfunc edits.

The completion script and the oh-my-zsh plugin are covered by bin/tests/test_completions.sh (needs only bash; the zsh/omz cases are skipped when zsh is absent).

Working inside tmux

Ctrl-B is the tmux prefix. Cheat sheet:

Keys Action
Ctrl-B c New window (a fresh shell)
Ctrl-B , Rename current window
Ctrl-B n / p Next / previous window
Ctrl-B N Jump to window N (Ctrl-B 09)
Ctrl-B % Split pane vertically
Ctrl-B " Split pane horizontally
Ctrl-B arrow Move between panes
Ctrl-B z Zoom current pane to full window
Ctrl-B [ Enter copy/scrollback mode (q to exit)
Ctrl-B d Detach (the magic key)

Typical workflow once attached:

cd /workspace
git clone https://github.com/me/my-project.git
cd my-project

# Window "edit"
Ctrl-B ,    edit
nvim .

# Window "claude"
Ctrl-B c
Ctrl-B ,    claude
cd /workspace/my-project && claude

# Window "codex"
Ctrl-B c
Ctrl-B ,    codex
cd /workspace/my-project && codex

You now have three concurrent windows: nvim, Claude Code, Codex — all running against the same checkout, all surviving SSH disconnects.

Detach — this is the point

Press Ctrl-B d. SSH closes, your laptop shell returns. Everything you started inside tmux keeps running on the VPS:

  • nvim stays open with its unsaved buffers.
  • Agents keep processing whatever you had them on.
  • Background commands (make, pytest --watch, anything) keep going.

You can close the laptop lid, switch networks, reboot the laptop, or fly to another continent. Reconnect later with agent-container attach acme and everything is exactly where you left it.

The chain that makes this work:

  1. ssh is execed by agent-container attach, not backgrounded — closing it cleanly drops the TTY without killing remote processes.
  2. tmux session main was started detached by the container's entrypoint; it has no parent process tied to your SSH session.
  3. The container was started detached (podman run -d) and stays running independent of any login.
  4. loginctl enable-linger keeps user-level systemd (and therefore the Quadlet-supervised container) alive across all logins / logouts of your VPS user.
  5. The VPS itself is, well, always on. That's what VPSes do.

View what's running

On the VPS:

agent-container list                                       # agent-container-managed containers + their ports
systemctl --user list-units 'agent-container-*.service'           # Quadlet-supervised services

Inside the container (after attach):

tmux ls                                                  # tmux sessions (just "main" by default)
tmux lsw -t main                                         # windows in main (default: shell, edit, agents)
ps -ef                                                   # everything alive in the container

Run multiple environments in parallel

Each project gets its own container with its own workspace, SSH port, tmux session, and host keys. They don't share state. Spin up a second one:

# on the VPS
agent-container up blog
# or via Quadlet (repeat Step 5 Quadlet recipe with NAME=blog, a different PORT)

# on the laptop
cat >> ~/.config/agent-container/hosts.conf <<EOF
BLOG_HOST=<vps-ip-or-dns>
BLOG_PORT=2247
EOF

agent-container attach blog                                       # totally separate session

agent-container up allocates ports deterministically from the container name (hash → 2200-2299 range) so the same name always gets the same port across rebuilds.

Lose a container, keep your work

The hard constraint that drives the design: every agent commits AND pushes every change. So even on catastrophic container loss, your work lives on GitHub.

  • agent-container down acme — stops + removes the container. All per-container volumes are kept/workspace, plus the agent-login volumes (~/.claude, ~/.codex, ~/.pi), the shell-env volume (~/.agent-container), the tmux-config volume (~/.config/tmux), and the SSH-identity volume (~/.ssh). agent-container up acme later restores the same /workspace contents and your agent logins and your tmux.conf and your SSH host key + authorized_keys.
  • agent-container down acme --purge — also drops every per-container volume (workspace + claude + codex + pi + shellenv + tmux + ssh). Use for a true clean slate; you will re-login to the agents afterward and re-inject your SSH key.
  • VPS reboot — if you used the Quadlet path, the container comes back automatically. If you used the quick path, run agent-container up acme again. Pushed commits are unaffected either way.
  • Quadlet service crashed — systemctl --user restart agent-container-acme.service. Look at journalctl --user -u agent-container-acme.service first.

Log in to agents (persists across restarts)

You don't have to put provider API keys in .env. Each container has its own persistent volume for each agent's credentials, so you can log in once, interactively, inside the container and it survives down/up and crashes:

agent-container attach acme        # or: agent-container attach --local acme
# then, inside the tmux session:
claude          # run /login and follow the prompt
codex login

The headless SSH login flow shows a URL — open it in your laptop's browser, authorize, and paste the code back into the container. The credential lands on that container's ~/.claude / ~/.codex / ~/.pi volume and the agent auto-refreshes it, so "log in once" effectively means "indefinitely" — strictly better than a static key in .env, which never refreshes.

Per-container = per-account. Because each container name has its own credential volumes, agent-container up work and agent-container up personal can be logged into different Claude/Codex accounts at the same time with no cross-talk. (You can still set ANTHROPIC_API_KEY / OPENAI_API_KEY in .env instead — they're now optional. GH_TOKEN and git identity remain required.)

The login credential persists on a host-side named volume (inside the Lima VM on macOS). That's an accepted trade-off — see docs/credentials.md. down --purge deletes it.

Persistent shell environment

Each container mounts a ~/.agent-container volume holding an env file that is sourced into every bash and zsh session (login, SSH, and tmux panes). Use it for per-container exports, aliases, or extra secrets that should outlive the container:

# inside the container — the file is seeded with a commented template on first boot
nvim ~/.agent-container/env       # add lines like:  export FOO=bar
# new shells (or: source ~/.agent-container/env) pick it up; it survives down/up.

It's read with set -a semantics, so plain KEY=VALUE lines are exported. A malformed file can't break your shell — the source hook is guarded.

Persistent tmux config

Each container also mounts a ~/.config/tmux volume (XDG standard; tmux 3.x reads ~/.config/tmux/tmux.conf). Drop your tmux.conf and any tpm plugins there and they survive down/up:

# inside the container
nvim ~/.config/tmux/tmux.conf    # e.g. set -g mouse on
tmux source ~/.config/tmux/tmux.conf   # or start a fresh session to pick it up

The default window layout (shell edit agents) is set by the entrypoint via AGENT_CONTAINER_TMUX_WINDOWS; see Entrypoint behavior.

Mount a host directory (optional)

To give a container read/write access to a directory on your machine, pass --mount at up (repeatable). With no --mount, nothing extra is mounted:

agent-container up acme --mount ~/code/myproject
#   -> appears inside the container at /workspace/myproject (read/write)

# explicit target, and more than one:
agent-container up acme --mount ~/code/myproject:/workspace/proj --mount ~/data

macOS / Lima prerequisite: the host directory must sit inside a writable Lima mount, or the container sees it read-only / not at all. If R/W fails, add the path to your Lima VM's config under mounts: with writable: true and restart the VM (limactl edit <vm> then limactl restart <vm>). The commit- and-push discipline still applies to any git repo you mount this way.

Update the image

# on the VPS
cd ~/agent-container
git pull
agent-container build

# then restart whichever path you used
systemctl --user restart agent-container-acme.service              # Quadlet path
# OR
agent-container down acme && agent-container up acme            # quick path

The workspace volume is independent of the image, so a rebuild does not disturb the contents of /workspace.

Rotate the GitHub PAT

When your GH_TOKEN expires:

  1. Generate a new PAT on GitHub (same repo scope, new expiration).
  2. Update the .env file (the one you launched the container from — ~/.config/agent-container/agent-container-acme.env for Quadlet, or ./.env for the quick path).
  3. Restart the container so the new value is loaded into env:
    systemctl --user restart agent-container-acme.service
    # OR
    agent-container down acme && agent-container up acme
    

The credential helper reads $GH_TOKEN fresh on every push, so the next git push after the restart uses the new token.

Container image

The image is built from a single Dockerfile at the repo root. The same file works under both docker build (operator's local Lima + docker-cli setup) and podman build (the VPS runtime per ADR 0001).

Build

docker build -t agent-container:latest .
# or, on the VPS:
podman build -t agent-container:latest .

No build args. No secrets. Credentials are injected only at run time via --env-file .env (see docs/credentials.md).

Build sanity check

The entrypoint requires credentials (GH_TOKEN, GIT_USER_NAME, GIT_USER_EMAIL) and exits immediately without them, so a bare docker run won't stay up. To verify the image built and the tooling is present without wiring up an .env, use the entrypoint's debug override — any arguments passed after the image are exec'd instead of the sshd + tmux flow:

docker build -t agent-container:latest .
docker run --rm agent-container:latest \
  bash -lc 'nvim --version | head -n1; node --version; claude --version || true'

For a full end-to-end check (build → launch with credentials → in-container HTTPS git push → host-side verify → teardown), run scripts/smoke-test.sh — see Smoke test and docs/smoke-test.md.

Layering rationale

Layers are ordered cheapest-to-rebuild last, so an edit to the entrypoint (which changes most often) doesn't bust the expensive apt + NodeSource layers:

  1. apt base packagesca-certificates, curl, gnupg, git, openssh-server, tmux, zsh, locales, less, jq, build-essential, python3, python3-pip, python3-venv. No sudo — the runtime is rootless. Single RUN; cache cleaned in the same layer.
  2. Node 22 LTS via NodeSourcesetup_22.x then apt-get install nodejs. Cache cleaned in the same layer.
  3. Agent CLIs (global npm installs)@anthropic-ai/claude-code, @openai/codex, --ignore-scripts @earendil-works/pi-coding-agent. Changes when an agent releases.
  4. Neovim upstream tarball — fetched from https://github.com/neovim/neovim/releases/latest, extracted to /usr/local. Picks the asset matching dpkg --print-architecture: nvim-linux-x86_64.tar.gz (with fallback to nvim-linux64.tar.gz for older releases) on amd64, nvim-linux-arm64.tar.gz on arm64. Debian's repo nvim is too old.
  5. User + rootless sshd config — non-root dev (uid 1000, home /home/dev), no sudo/root at runtime, /workspace owned by dev:dev. sshd is configured for key-based dev-only login on the unprivileged port 2222 (UsePAM no, HostKey/PidFile under the dev-owned ~/.ssh volume), and is started by dev. No host key is baked into the image — the entrypoint installs an injected key or generates an ed25519 one onto the persisted -ssh volume, so each container has a distinct but stable SSH identity across down/up (a hard requirement for parallel-container safety).
  6. entrypoint.sh — last COPY, since this is the most-frequently-edited file during development.

What is NOT in the image (by design)

  • No .env content, GH_TOKEN, API keys, or any other secret. Credentials are injected at run time only.
  • No SSH host key baked into the image. The entrypoint generates an ed25519 key (or installs an injected one) onto the per-container -ssh volume, so the identity persists across down/up instead of being regenerated each launch.
  • No ~/.ssh/authorized_keys content for dev in the image. The operator injects it at run time — up --authorized-key, agent-container keys <name>, or SSH_AUTHORIZED_KEYS in the env-file — and it too persists on the -ssh volume (inject once, not on every recreate).
  • No sudo / root at runtime. The image is rootless: sshd runs as dev on port 2222 and all dependencies are baked at build time (agents never apt install), so root is never needed.
  • No .devcontainer/ configs. SSH + tmux is the only supported attach path.

Image size

Measured on first build (docker images localhost/agent-container:latest):

  • Uncompressed (DISK USAGE): ~1.84 GB
  • Compressed (CONTENT SIZE): ~432 MB

build-essential and python3-* dominate the apt layer. They are kept because pi-coding-agent and most npm packages with native addons need a working C toolchain at install or runtime. Trimming is a future optimization, not an MVP concern.

Runtime contract (preview, item C)

The container expects to be launched with:

  • --env-file .env (local docker) or EnvironmentFile= (VPS Quadlet) supplying at least GH_TOKEN, GIT_USER_NAME, GIT_USER_EMAIL, ANTHROPIC_API_KEY, OPENAI_API_KEY. See .env.example.
  • A mounted workspace at /workspace (so committed work survives container recreation).
  • Container-internal port 2222 (rootless sshd) mapped to a host port chosen by the orchestration layer (item E).

The image itself enforces none of this — that's item C's entrypoint and item E's orchestration. The image is the substrate.

Entrypoint behavior

entrypoint.sh runs as PID 1 inside the container, as the non-root dev user. The container is fully rootless: no sudo package, no root at runtime — all system dependencies are baked at build time (agents never apt install at runtime), so root buys nothing and is dropped. It is idempotent — restarting the container reruns it safely.

Execution order:

  1. Debug override. If the operator passes arguments (docker run image bash), the entrypoint execs them and the rest of the flow is skipped.
  2. Env-var validation. Required vars must be set and non-empty; missing ones cause an immediate non-zero exit with a message naming the offender. Values are never logged.
  3. SSH host key + authorized_keys (rootless). The host key is an ed25519 key under ~/.ssh/hostkeys — dev-owned, on the persisted -ssh volume — so a container keeps a stable identity across down/up while different containers differ. Boot precedence: a bind-mounted key (up --host-key) > SSH_HOST_ED25519_KEY_B64 (env) > the already-persisted key > a freshly generated one; only the last two are auto-created, an injected/persisted key is left untouched. authorized_keys is assembled as a deduped union of the persisted file plus any injected source (up --authorized-key, SSH_AUTHORIZED_KEYS). No root or sudo is involved.
  4. Git identity + credential helper. Configures user.name, user.email, init.defaultBranch=main, pull.rebase=false, and the HTTPS credential helper that returns ${GH_TOKEN} from process env. The helper is a shell function stored verbatim in ~/.gitconfig and scoped to https://github.com (credential.https://github.com.helper) so the token is never handed to any other host; the token itself is never written to disk in the container.
  5. sshd. Started in the background as the dev user (rootless — no sudo), daemonized (not -D). Listens on the unprivileged port 2222 inside the container, using the host key + pidfile under the dev-owned ~/.ssh volume; the orchestration layer maps this to the operator-facing host port (the hashed 2200 + value, unchanged).
  6. tmux session. A detached session named main is created on first launch. Its windows are built from AGENT_CONTAINER_TMUX_WINDOWS (space-separated names, default shell edit agents); each window is a bare shell (no agent is auto-started). Set AGENT_CONTAINER_TMUX_WINDOWS="" (empty) to opt out and get a single window. Window names are validated against [A-Za-z0-9._-]+; invalid ones are skipped. The layout is built only when the session is first created, so a container restart never duplicates windows. Attach from a client with ssh -t user@host -p <port> tmux attach -t main (or agent-container attach <name> --window <w> to land in a specific window). The tmux config dir ~/.config/tmux is a per-container volume, so a tmux.conf (and tpm plugins) you drop there persist across down/up.
  7. PID 1 lifecycle. The script waits on a background tail -f /dev/null, keeping PID 1 alive. SIGTERM / SIGINT trigger a clean shutdown: tmux kill-server, then pkill -TERM -x sshd (dev signals its own rootless sshd — no sudo), then exit 0.

Required env vars (entrypoint exits non-zero if missing):

Variable Purpose
GH_TOKEN GitHub PAT used by the git credential helper for HTTPS push.
GIT_USER_NAME user.name in the container's gitconfig.
GIT_USER_EMAIL user.email in the container's gitconfig.

Optional env vars (warned-but-not-failed):

Variable Purpose
ANTHROPIC_API_KEY Claude Code authentication.
OPENAI_API_KEY Codex (@openai/codex) authentication.

The agents themselves enforce their own keys at run time; the entrypoint just surfaces a warning so the operator notices before they ssh in.

Orchestration

Host-side orchestration is the single agent-container CLI, plus two deployment templates. Full doc: docs/orchestration.md.

agent-container build                  # build the image
agent-container up alpha               # start container agent-container-alpha (detached)
agent-container up bravo               # start another, in parallel, on a different port
agent-container list                   # see what's running
agent-container attach alpha           # ssh + tmux attach
agent-container attach alpha --window edit  # attach and select the 'edit' window
agent-container logs alpha             # tail container logs
agent-container down alpha             # stop + remove (all volumes preserved)
agent-container down alpha --purge     # stop + remove + delete ALL per-container volumes

Runtime auto-detection: the default is platform-aware — on macOS (Lima + docker-cli) agent-container prefers docker, on Linux (the VPS) it prefers podman, falling back to the other. Override with AGENT_CONTAINER_RUNTIME=docker|podman.

Templates:

  • orchestration/compose.yaml — Docker Compose, for the local Lima + docker-cli path.
  • orchestration/agent-container.container — Podman Quadlet template, instantiated per container on the VPS.

Client-side attach

agent-container attach resolves a symbolic container name to the right ssh + tmux invocation. It runs on your laptop (reading hosts.conf and local state files) and hands over to ssh:

agent-container attach acme                 # remote: read ACME_HOST + ACME_PORT from hosts.conf
agent-container attach --local alpha        # local:  read port from XDG_STATE_HOME/agent-container/alpha.port
agent-container attach --window edit acme   # select the 'edit' tmux window on attach

--window/-w NAME selects a tmux window in session main before attaching, so you land where you want. The name is validated against [A-Za-z0-9._-]+. If the window does not exist, tmux stays on the current one and still attaches.

Detach is Ctrl-B d (tmux default) and returns you to your local shell — ssh is execed with -t, so signals and exit codes propagate through.

Remote config~/.config/agent-container/hosts.conf (or $XDG_CONFIG_HOME/agent-container/hosts.conf).

Flat KEY=VALUE file. For each container name foo, set FOO_HOST and FOO_PORT. The name argument is uppercased (and hyphens become underscores) before lookup, so agent-container attach my-box reads MY_BOX_HOST / MY_BOX_PORT. Template: docs/agent-container-hosts.example.

ACME_HOST=vps1.example.com
ACME_PORT=2231
BLOG_HOST=vps1.example.com
BLOG_PORT=2247

Why this format: trivial to hand-edit and the same primitives a shell user already knows. agent-container parses it line-by-line and never sources or executes it (values with $ or backticks are taken literally, with a one-time warning).

Local modeagent-container attach --local <name> connects to localhost using the port written by agent-container up at $XDG_STATE_HOME/agent-container/<name>.port. This is the path for running the container under Lima on macOS while attaching from the same laptop.

Env overrides: AGENT_CONTAINER_USER=<user> (default dev), AGENT_CONTAINER_HOST=<host> (default localhost for local targets).

Errors are actionable — missing config, missing keys, and missing local state each print the exact file path you need to create or fix. SSH's own exit code is propagated on connection failure.

Smoke test

scripts/smoke-test.sh exercises the full happy path end-to-end: build, up, in-container HTTPS git push via the credential helper, host-side push verification, and torn-down cleanup. It retroactively verifies the deferred acceptance criteria of the credential contract (item D).

AGENT_CONTAINER_SMOKE_REPO=your-handle/agent-container-smoke-target ./scripts/smoke-test.sh

Pre-flight refuses to run without docker/podman, uv plus bin/agent-container, a populated .env, and a target repo your GH_TOKEN can push to. Full details, safety properties, and what is intentionally not covered: docs/smoke-test.md.

Releasing — Continuous Deployment

Releases are fully automated — there is no manual tagging and no release PR. Every substantive merge to main is a release.

  1. Land a change on main with a Conventional Commit message: feat: → minor, fix: → patch, feat!:/BREAKING CHANGE: → minor (while pre-1.0). docs:/ci:/chore:/test:/style: merges cut no release.
  2. ci.yml runs the full pipeline (lint · test matrix · shell · build · acceptance).
  3. Once ci is green on main, publish.yml fires (via workflow_run). python-semantic-release computes the next version from the commits, bumps pyproject.toml + CHANGELOG.md, commits chore(release): X.Y.Z [skip ci], tags vX.Y.Z, creates the GitHub Release, and publishes the wheel + sdist to PyPI via Trusted Publishing (OIDC — no stored token). A red pipeline never ships (the release is gated on ci success).

The version is single-sourced in pyproject.toml; check the installed version with agent-container --version.

One-time operator setup (arming CD):

  1. Configure the PyPI trusted publisher for the agent-container project (owner ondrasek, repo agent-container, workflow publish.yml, environment release).
  2. Arm the pipeline: gh variable set RELEASE_ENABLED --body true.

Until RELEASE_ENABLED is set, publish.yml stays dormant (so a release can't half-fire before PyPI is ready). After both steps, releases are automatic and need no stored secrets.

License

MIT — see LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

agent_container-0.2.0.tar.gz (146.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

agent_container-0.2.0-py3-none-any.whl (40.3 kB view details)

Uploaded Python 3

File details

Details for the file agent_container-0.2.0.tar.gz.

File metadata

  • Download URL: agent_container-0.2.0.tar.gz
  • Upload date:
  • Size: 146.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for agent_container-0.2.0.tar.gz
Algorithm Hash digest
SHA256 55251b52cb1ede7b07faff5257261cfeefce8d38d8d32e33a77058f1baa48717
MD5 5671eab52dc5b5c5c8e483d4de7b90c6
BLAKE2b-256 5f14834455c3c34a84a931e302fa24b2679baab66d059797e5e7c5d7ffa6f072

See more details on using hashes here.

Provenance

The following attestation bundles were made for agent_container-0.2.0.tar.gz:

Publisher: publish.yml on ondrasek/agent-container

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file agent_container-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for agent_container-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 09abc6e15b93f2b53ce95eff71934d34d507605b50244fab1cc846f8a39eb954
MD5 66b5fa950adb4d5c597fb7b75670142b
BLAKE2b-256 aa868e012a65c4ea0fc57b6ec1bfad33519d8be0ae2c83efa7925df3c3befbd0

See more details on using hashes here.

Provenance

The following attestation bundles were made for agent_container-0.2.0-py3-none-any.whl:

Publisher: publish.yml on ondrasek/agent-container

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page