Metadata-Version: 2.4
Name: agent-container
Version: 0.3.0
Summary: Always-on containerized dev environment for AI coding agents, driven over SSH+tmux.
Project-URL: Homepage, https://github.com/ondrasek/agent-container
Project-URL: Repository, https://github.com/ondrasek/agent-container
Project-URL: Issues, https://github.com/ondrasek/agent-container/issues
Author-email: Ondrej Krajicek <ondrej.krajicek@ideastatica.com>
License-Expression: MIT
License-File: LICENSE
Keywords: ai-agents,cli,dev-environment,docker,podman,remote-development,ssh,tmux
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development
Classifier: Topic :: Utilities
Requires-Python: >=3.14
Requires-Dist: questionary<3,>=2.0
Requires-Dist: rich<15,>=13
Requires-Dist: typer<1,>=0.12
Description-Content-Type: text/markdown

# 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`](CLAUDE.md).
Runtime + base-image decision: [`docs/decisions/0001-runtime-and-base-image.md`](docs/decisions/0001-runtime-and-base-image.md).
Credential contract: [`docs/credentials.md`](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`:

```bash
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

```bash
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

```bash
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 path** — `agent-container up` (needs `uv` + `agent-container`, installed in Step 4, plus a **Compose v2**-capable runtime). Generates a compose project and runs it on the host (building the image there). Survives SSH disconnects (because of `enable-linger`) but **not** a VPS reboot. Fine for experimentation and for environments you intentionally want to recreate often:

```bash
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`:

```bash
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:

```bash
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`):**

```bash
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`:**

```bash
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.

<details>
<summary>Fallback: copy a key in by hand</summary>

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

```bash
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
```
</details>

### 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`:

```bash
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

```bash
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

```bash
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](https://docs.astral.sh/uv/) installed:

```bash
agent-container                # interactive menu: build, start, attach, logs, stop, purge
agent-container host add local --docker-context lima-docker --default  # register a host
agent-container host ls        # list registered hosts (where containers run)
agent-container up acme        # deploy to the default host; --host NAME picks another
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)
```

**Hosts and the run mechanism.** A *host* is a named target where containers run — a
local or remote container-runtime context, registered with `host add` and stored in
`~/.config/agent-container/hosts.json` (the registry supersedes the older `hosts.conf`
address book, which is still read for attach-only legacy targets). `up`/`down` take
`--host NAME` (default: the registry default). Deployment is **compose-based**: `up`
generates an inspectable compose project under
`$XDG_STATE_HOME/agent-container/<host>/<name>.compose.yaml` and runs it on the host,
building the image **on the host** — so a **Compose v2**-capable runtime is required
(`docker compose` / `podman compose`). Injected SSH identity travels as compose
secrets/configs (so it works over a remote context, not just locally).

It keeps all state on disk, namespaced per host (container names, the port hash,
`<host>/<name>.port` state files, env-file resolution, the host registry) 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:

```bash
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:

```bash
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:

```bash
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:

```bash
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/`](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 per-host state files
(`$XDG_STATE_HOME/agent-container/<host>/*.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):

```bash
# 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):

```bash
# ~/.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`:

```zsh
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/`](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:

```zsh
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 0` … `9`)     |
| `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:

```bash
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 `exec`ed 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:

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

Inside the container (after attach):

```bash
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:

```bash
# 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:

```bash
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`](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:

```bash
# 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](https://github.com/tmux-plugins/tpm) plugins there and they survive
`down`/`up`:

```bash
# 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](#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:

```bash
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

```bash
# 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:
   ```bash
   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

```bash
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`](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:

```bash
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](#smoke-test) and [`docs/smoke-test.md`](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 packages** — `ca-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 NodeSource** — `setup_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`](.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 `exec`s 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 `wait`s 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`](docs/orchestration.md).

```bash
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`:

```bash
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 `exec`ed 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`](docs/agent-container-hosts.example).

```ini
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 mode** — `agent-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).

```bash
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`](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](https://www.conventionalcommits.org/)
   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](https://python-semantic-release.readthedocs.io/) 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](https://docs.pypi.org/trusted-publishers/)
   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`](LICENSE).
