Run OpenCode and Claude Code inside a shared Arch Linux Docker container that closely mirrors a local development environment, while limiting access to sensitive files on the host.
Find a file
Jeena 43dc757b92 container: Use fully qualified image name in FROM
Podman only resolves short image names through an unqualified search
registry, which is not configured by default on Arch (and on Ubuntu
setups without a registries.d file), so builds fail at the first step
with 'short-name "archlinux" did not resolve'. Naming the registry
explicitly makes the image build on any distro without per-distro
configuration.
2026-10-29 08:42:06 +09:00
plugins/opencode plugins: Add OSC 99 desktop notification plugin for OpenCode 2026-04-03 09:36:59 +00:00
.gitignore Combine opencode-container and claude-container into a unified agent-container 2026-03-24 09:39:25 +09:00
agent-container.py container: Fail early on missing subuid/subgid ranges 2026-10-29 08:33:23 +09:00
agent.aliases aliases: Add -local variants to run tools on the host 2026-09-09 09:35:25 +09:00
CHANGELOG.md docs: Add release notes for 1.0.0 2026-10-29 02:15:25 +09:00
Dockerfile container: Use fully qualified image name in FROM 2026-10-29 08:42:06 +09:00
README.md container: Fail early on missing subuid/subgid ranges 2026-10-29 08:33:23 +09:00
setup.sh setup: Show a plan and ask for confirmation before changing anything 2026-09-12 21:40:17 +09:00

agent-container

Run OpenCode, Claude Code, and Pi (pi.dev) inside a shared Arch Linux Podman container that closely mirrors a local development environment, while limiting access to sensitive files on the host.

Features

  • Rootless: Containers run as your own user inside a user namespace via rootless Podman. No privileged daemon, no docker group, nothing that turns a compromised agent into host root (see "Container runtime" below)
  • Tools are not baked into the image: OpenCode, Claude Code, and Pi are bootstrapped into the persistent container home on first use (official installers, no sudo), so pi update, claude update, and opencode upgrade work inside the container
  • Runs as the host user (same username, UID, GID)
  • Per-project isolation: Each project gets its own container (identified by project path hash)
  • Shared persistent home: All containers mount the same home directory from XDG_DATA_HOME, allowing tools and credentials to persist across projects
  • Sudo access: Agents can install project-specific dependencies that persist in the stopped container
  • Hard linking support: Can hard link files like ~/.gitconfig to share configurations with containers
  • Local variants: opencode-local, claude-local, and pi-local run the tools directly on the host, bypassing the container
  • Mounts only the current project directory (same absolute path inside container)
  • Security boundary: No access to SSH keys, passwords, or full $HOME (intentionally prevents remote code pushes)

Container runtime

Containers run with rootless Podman. Nothing about that needs a privileged daemon or a privileged group: the container lives inside a user namespace owned by your own user, so it has exactly your file permissions and no more.

This matters because Docker's docker group is equivalent to unrestricted root on the host. Write access to /var/run/docker.sock lets anyone start a privileged container with -v /:/host and edit any file, including /etc/shadow and your SSH keys. An agent with that access is one bad tool call away from owning the machine. Rootless Podman removes that path entirely: container root maps to a subordinate UID from /etc/subuid, not to host root.

How the details are handled:

  • Containers are created with --userns=keep-id, so your host UID/GID map to the same UID/GID inside. Files in the mounted project and in the container home keep their normal ownership in both directions.
  • sudo inside the container still works (the image has passwordless sudo), but only reaches container root, which is a subordinate UID on the host. Files that container root creates on the bind mounts show up on the host as owned by that subordinate UID (typically 100000:100000), not by root.
  • Container storage (images, writable layers) lives in ~/.local/share/containers/storage, not in /var/lib/docker.
  • Docker is still possible if you really want it: AGENT_CONTAINER_RUNTIME=docker agent-container pi.

Migrating from Docker

Existing ac-* containers and the agent-container:latest image in Docker are not reused; Podman builds and manages its own copy. Remove the old ones once you no longer need them:

docker ps -a --filter 'name=^ac-' --format '{{.Names}}' | xargs -r docker rm -f
docker rmi agent-container:latest

Only after that, and after moving any other user-level services off Docker (see below), leave the docker group:

sudo gpasswd -d "$USER" docker

The change takes effect on your next login (newgrp docker or a fresh session). System services that run Docker as root keep working; only your own access to the socket goes away.

Install

You need rootless Podman on the host. On Arch Linux the packages podman and passt (or slirp4netns) are enough; your user also needs subuid/subgid entries, which usermod --add-subuids 100000-165535 --add-subgids 100000-165535 <user> sets up if the package did not (accounts created before shadow 4.11.1-3 get no entries automatically — see Podman: Set subuid and subgid):

sudo pacman -S podman passt
grep "^$USER:" /etc/subuid /etc/subgid
podman info >/dev/null && echo "rootless podman works"

On a fresh machine, two more steps are all it takes. The agent tools themselves are not part of the image and need no installation step: on first use, agent-container.py automatically runs each tool's official installer inside the container (no sudo), just like it builds the Podman image automatically if it is missing.

cd ~/Projects/
git clone https://git.jeena.net/jeena/agent-container.git

Source the helper file agent.aliases in your shell configuration (.bashrc or .zshrc):

source ~/Projects/agent-container/agent.aliases

This makes the opencode, claude, pi, and agent-container commands available in new sessions, plus the opencode-local, claude-local, and pi-local variants that run the tools directly on the host.

That is the whole install. From any project directory, opencode, claude, and pi now work: the first run installs the tool into the persistent container home, where logins (via /login), settings, and history persist across projects, containers, and image rebuilds.

Optional: share agent state with the host

By default, host and container keep separate state: the tools live entirely in the container home, and nothing in your real $HOME is moved or linked. If you also run the tools on the host (the *-local variants) and want shared logins, settings, and history, run the setup script once:

~/Projects/agent-container/setup.sh

It shows a plan first and asks for confirmation before moving anything (--dry-run previews without changes, --yes skips the question). Existing state is moved into the container home and symlinked back into $HOME. See "Sharing agent state between host and container via symlinks" below. The script is safe to re-run and skips items it cannot handle safely.

Optional extras

# Share your git config with the containers (hard link):
ln ~/.gitconfig ~/.local/share/agent-container/container-home/.gitconfig

# Desktop notifications when OpenCode needs attention:
mkdir -p ~/.local/share/agent-container/container-home/.config/opencode/plugins
cp plugins/opencode/osc99-notify.js \
   ~/.local/share/agent-container/container-home/.config/opencode/plugins/

The container home directory at $XDG_DATA_HOME/agent-container/container-home/ serves as a central $HOME inside every container, independent of which project directory you start in. Everything written to $HOME inside the container persists there.

Environment Variables

Host environment variables matching the following prefixes are automatically forwarded into the container:

  • OPENCODE_* -- passed through to OpenCode
  • ANTHROPIC_*, CLAUDE_* -- passed through to Claude Code
  • PI_* plus provider key prefixes (ANTHROPIC_*, OPENAI_*, GEMINI_*, GROQ_*, OLLAMA_*, and others) -- passed through to Pi. Alternatively log in once with /login inside the container; credentials persist in the shared container home.

This means inline overrides work as expected: OPENCODE_CONFIG=foo opencode

For persistent environment variables, add them to container-home/.bashrc (or .bash_profile / .profile). Tools run through bash -l inside the container, so standard shell config files are sourced automatically. Default shell config files from /etc/skel are seeded into the container home on first run.

  • XDG_DATA_HOME: Override default data directory (default: ~/.local/share)
  • AGENT_CONTAINER_RUNTIME: Container runtime to use (default: podman, set to docker for the old behaviour)
  • AGENT_CONTAINER=1: Set inside every container, so tools and skills running there can tell they are not on the host. The environment is fixed at container creation time, so containers that predate the marker are recreated on the next run.

Usage

From any project directory:

# Run OpenCode
opencode

# Run Claude Code
claude

# Run Pi
pi

# Run a tool directly on the host, bypassing the container
opencode-local

The image is built automatically on first use if it does not already exist. The tool starts inside the container with the current directory mounted and set as the working directory.

Updating

The tools are not part of the image: on first use, each tool's official installer runs inside the container (no sudo) and installs into the persistent container home. They update themselves there, and updates survive container recreation and image rebuilds:

agent-container pi update        # or run `pi update` inside a session
agent-container claude update
agent-container opencode upgrade

To rebuild the base image with a fresh Arch Linux userland:

agent-container update

This rebuilds the image with a freshly pulled base but keeps all existing containers and everything installed in them. The persistent home directory is not affected.

Release notes for each tagged version, including what to do when upgrading, are in CHANGELOG.md.

Purge

To remove all containers, the image, and the persistent home directory:

agent-container purge

Note that this also deletes the shared agent state that lives in the container home (.claude, .pi, ...). The symlinks in your $HOME will dangle afterwards. Podman's own storage directory is left alone; other Podman images and containers are not touched.

The container home at ~/.local/share/agent-container/container-home/ is mounted as /home/<username> inside every container. You can hard link files from your real $HOME into this directory so the container sees them without copying or syncing.

Because both paths live on the same filesystem, a hard link means they are literally the same file -- changes from either side are instantly reflected.

Example for .gitconfig:

mkdir -p ~/.local/share/agent-container/container-home
ln ~/.gitconfig ~/.local/share/agent-container/container-home/.gitconfig

Avoid linking sensitive files such as ~/.ssh/id_* or ~/.gnupg/ -- keeping those out of the container is an intentional security boundary.

The repository ships a setup.sh script that automates the following for .claude, .claude.json, .pi, .agents, and the OpenCode state directories. The manual recipe for any other directory looks like this:

State directories such as .claude/, .pi/, or .agents/ can be shared between the containerized tools (claude, pi, opencode) and their local variants (claude-local, pi-local, opencode-local). Keep the real data in the container home and symlink to it from your real $HOME:

mkdir -p ~/.local/share/agent-container/container-home
mv ~/.claude ~/.local/share/agent-container/container-home/.claude
ln -s ~/.local/share/agent-container/container-home/.claude ~/.claude

The direction matters: the real directory must live in the container home, and the symlink must be in the host $HOME. The container mounts the container home as /home/<username>, so inside the container the path is a regular directory. The host $HOME is never mounted into the container, so a symlink pointing the other way would not resolve inside the container.

On the host, ~/.claude resolves to the same data the container sees, so logins, settings, and history are shared by both sides without copying or syncing. The same works for single files like .claude.json if a tool needs them on the host too. Repeat the two steps for other state directories (.pi, .agents, .config/opencode, ...) as needed.

If XDG_DATA_HOME is set to a non-default location, use that path instead. As with hard links, never share sensitive files such as ~/.ssh/id_* or ~/.gnupg/.

OpenCode plugins

The repository ships an OpenCode plugin in plugins/opencode/:

  • osc99-notify.js: sends desktop notifications via the OSC 99 escape sequence when OpenCode needs attention (idle, question, permission prompt, error). Notifications only fire while the terminal is unfocused. Tested with Kitty.

To enable it inside the container, copy it into the OpenCode plugin directory of the container home:

mkdir -p ~/.local/share/agent-container/container-home/.config/opencode/plugins
cp plugins/opencode/osc99-notify.js \
   ~/.local/share/agent-container/container-home/.config/opencode/plugins/