tools: Install on first use instead of baking them into the image
Baked installs had two failure modes: anything installed into $HOME at build time is shadowed by the container-home bind mount at runtime, and system-wide installs are root-owned, so the tools' own update commands (pi update, claude update, opencode upgrade) failed with an unwritable install path. With pi releasing new versions almost daily, the only remedy, a full image rebuild that also deleted every container, was too heavy for that cadence. Now each tool is installed at runtime into the persistent container home by its official installer (no sudo), the same way the Docker image itself is built on first use. Self-updates work inside the container and survive image rebuilds and container recreation. The image shrinks to a plain Arch base, and agent-container update no longer removes containers, so sudo-installed project dependencies survive it too. docker exec now attaches a TTY only when stdin is one, so scripted runs like 'pi -p' work without a terminal.
This commit is contained in:
parent
70699026c3
commit
e91b74ea38
3 changed files with 140 additions and 43 deletions
29
Dockerfile
29
Dockerfile
|
|
@ -20,27 +20,14 @@ RUN pacman -Syu --noconfirm \
|
|||
echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
# Install OpenCode from AUR
|
||||
WORKDIR /tmp
|
||||
# OpenCode, Claude Code, and Pi are intentionally NOT baked into the image:
|
||||
# an install into $HOME at build time would be shadowed by the container-home
|
||||
# bind mount, and system-wide installs are root-owned and not self-updatable.
|
||||
# On first use, agent-container.py runs each tool's official installer (no
|
||||
# sudo) against the persistent container home instead, so `pi update`,
|
||||
# `claude update`, and `opencode upgrade` work inside the container and
|
||||
# survive image rebuilds and container recreation. See _bootstrap_tool() in
|
||||
# agent-container.py.
|
||||
USER ${USERNAME}
|
||||
|
||||
RUN git clone https://aur.archlinux.org/opencode-bin.git && \
|
||||
cd opencode-bin && \
|
||||
makepkg --syncdeps --noconfirm --install && \
|
||||
sudo rm -rf /tmp/opencode-bin && \
|
||||
sudo pacman -Scc --noconfirm
|
||||
|
||||
# Install Claude Code using the native installer, then copy the binary
|
||||
# to a system-wide location so it survives the home directory bind mount
|
||||
RUN curl -fsSL https://claude.ai/install.sh | bash && \
|
||||
sudo cp ~/.local/bin/claude /usr/local/bin/claude && \
|
||||
rm -rf ~/.local/share/claude ~/.local/bin/claude ~/.claude ~/.claude.json \
|
||||
~/.cache/claude
|
||||
|
||||
# Install Pi Coding Agent globally with npm so it lands in the system
|
||||
# prefix and survives the home directory bind mount (same approach as
|
||||
# pi's own docs/containerization.md)
|
||||
RUN sudo npm install -g --ignore-scripts @earendil-works/pi-coding-agent && \
|
||||
sudo npm cache clean --force
|
||||
|
||||
WORKDIR /home/${USERNAME}
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -4,7 +4,10 @@ Run OpenCode, Claude Code, and Pi (pi.dev) inside a shared Arch Linux Docker con
|
|||
|
||||
## Features
|
||||
|
||||
- Arch Linux-based image with OpenCode, Claude Code, and Pi pre-installed
|
||||
- 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
|
||||
|
|
@ -16,7 +19,7 @@ Run OpenCode, Claude Code, and Pi (pi.dev) inside a shared Arch Linux Docker con
|
|||
|
||||
## Install
|
||||
|
||||
Clone the repository:
|
||||
On a fresh machine, two 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 Docker image automatically if it is missing.
|
||||
|
||||
```sh
|
||||
cd ~/Projects/
|
||||
|
|
@ -31,13 +34,29 @@ 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.
|
||||
|
||||
Optionally run the setup script to share agent state (`.claude`, `.pi`, `.agents`, OpenCode state, ...) between the container and 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:
|
||||
|
||||
```sh
|
||||
~/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`, so containerized tools and the `*-local` variants share logins, settings, and history. 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.
|
||||
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
|
||||
|
||||
```sh
|
||||
# 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.
|
||||
|
||||
|
|
@ -77,13 +96,21 @@ The image is built automatically on first use if it does not already exist. The
|
|||
|
||||
### Updating
|
||||
|
||||
To rebuild the image with the latest versions of OpenCode, Claude Code, and Pi:
|
||||
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:
|
||||
|
||||
```sh
|
||||
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:
|
||||
|
||||
```sh
|
||||
agent-container update
|
||||
```
|
||||
|
||||
This removes all existing containers and rebuilds the image from scratch. Containers are recreated automatically on the next run. The persistent home directory is not affected.
|
||||
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.
|
||||
|
||||
### Purge
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Usage:
|
|||
agent-container opencode [args...] Run OpenCode in the container
|
||||
agent-container claude [args...] Run Claude Code in the container
|
||||
agent-container pi [args...] Run Pi in the container
|
||||
agent-container update Rebuild image with latest versions
|
||||
agent-container update Rebuild the base image (keeps containers)
|
||||
agent-container purge Remove all containers, image, and data
|
||||
"""
|
||||
|
||||
|
|
@ -175,6 +175,11 @@ class AgentContainer:
|
|||
# (only copies files that don't already exist)
|
||||
self._seed_home()
|
||||
|
||||
# Tools live in the persistent container home, not in the image, so
|
||||
# their self-update commands work without sudo and survive image
|
||||
# rebuilds and container recreation.
|
||||
self._bootstrap_tool(tool)
|
||||
|
||||
try:
|
||||
signal.signal(signal.SIGTSTP, signal.SIG_IGN)
|
||||
self._exec_tool(tool, args, env_prefixes, extra_env or {})
|
||||
|
|
@ -182,6 +187,86 @@ class AgentContainer:
|
|||
signal.signal(signal.SIGTSTP, signal.SIG_DFL)
|
||||
self.stop_container()
|
||||
|
||||
# Where each tool's official installer puts its binary and which PATH
|
||||
# entry it needs. Installs land in the persistent container home (the
|
||||
# bind mount at /home/<user>), so they survive image rebuilds and
|
||||
# container recreation, and the tools' self-update commands work
|
||||
# without sudo.
|
||||
TOOL_INSTALLERS = {
|
||||
"pi": {
|
||||
"probe": '[ -x "$HOME/.local/bin/pi" ] || [ -d "$HOME/.local/share/pi-node/current" ]',
|
||||
"install": "curl -fsSL https://pi.dev/install.sh | sh",
|
||||
# The installer picks npm mode (~/.local) when a system node is
|
||||
# present (the image has one) and standalone pi-node otherwise.
|
||||
"path": ['"$HOME/.local/bin"', '"$HOME/.local/share/pi-node/current/bin"'],
|
||||
},
|
||||
"claude": {
|
||||
"probe": '[ -x "$HOME/.local/bin/claude" ]',
|
||||
"install": "curl -fsSL https://claude.ai/install.sh | bash",
|
||||
"path": '"$HOME/.local/bin"',
|
||||
},
|
||||
"opencode": {
|
||||
"probe": '[ -x "$HOME/.opencode/bin/opencode" ]',
|
||||
"install": "curl -fsSL https://opencode.ai/install | bash",
|
||||
"path": '"$HOME/.opencode/bin"',
|
||||
},
|
||||
}
|
||||
|
||||
def _bootstrap_tool(self, tool: str) -> None:
|
||||
"""Install a tool into the persistent container home on first use.
|
||||
|
||||
Baking tools into the image at build time either gets shadowed by
|
||||
the container-home bind mount ($HOME) or lands root-owned in the
|
||||
system prefix, where the tools cannot self-update. Running the
|
||||
official installers at runtime (no sudo) keeps them in the
|
||||
container home, where `pi update`, `claude update`, and
|
||||
`opencode upgrade` work and survive image rebuilds and container
|
||||
recreation.
|
||||
"""
|
||||
spec = self.TOOL_INSTALLERS.get(tool)
|
||||
if spec is None:
|
||||
return
|
||||
|
||||
probe = subprocess.run(
|
||||
["docker", "exec", self.container_name, "bash", "-lc", spec["probe"]],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
logger.info(
|
||||
f"Installing {tool} into the container home "
|
||||
"(official installer, no sudo)..."
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"docker", "exec", self.container_name, "bash", "-lc",
|
||||
spec["install"],
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
paths = spec["path"] if isinstance(spec["path"], list) else [spec["path"]]
|
||||
for path_expr in paths:
|
||||
self._ensure_path_entry(path_expr)
|
||||
|
||||
def _ensure_path_entry(self, path_expr: str) -> None:
|
||||
"""Idempotently add an export PATH line to the container home's
|
||||
.bash_profile. Tools run via `bash -lc`, and the seeded skel
|
||||
.bashrc returns early for non-interactive shells, so .bash_profile
|
||||
is the reliable place."""
|
||||
export_line = f"export PATH={path_expr}:$PATH"
|
||||
script = (
|
||||
f"line={shlex.quote(export_line)}\n"
|
||||
'grep -qF "$line" "$HOME/.bash_profile" 2>/dev/null || '
|
||||
'{ printf "\\n# added by agent-container\\n"; '
|
||||
'printf "%s\\n" "$line"; } >> "$HOME/.bash_profile"'
|
||||
)
|
||||
subprocess.run(
|
||||
["docker", "exec", self.container_name, "bash", "-lc", script],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
|
||||
def _seed_home(self) -> None:
|
||||
"""Copy default shell config from /etc/skel into the container
|
||||
home directory, skipping files that already exist."""
|
||||
|
|
@ -219,11 +304,14 @@ class AgentContainer:
|
|||
# Build the shell command with proper quoting
|
||||
cmd_str = " ".join(shlex.quote(a) for a in [tool, *args])
|
||||
|
||||
# Attach a TTY only when stdin is one, so scripted runs (`pi -p`,
|
||||
# cron, CI) work without a terminal.
|
||||
exec_cmd = ["docker", "exec", "-i"]
|
||||
if sys.stdin.isatty():
|
||||
exec_cmd.append("-t")
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
"-it",
|
||||
*exec_cmd,
|
||||
*env_args,
|
||||
"-w",
|
||||
str(self.project_path),
|
||||
|
|
@ -328,18 +416,13 @@ class AgentContainer:
|
|||
# =========================
|
||||
|
||||
def update(self) -> None:
|
||||
logger.info("Updating agent-container...")
|
||||
self._remove_all_containers()
|
||||
if self.image_exists():
|
||||
logger.info(f"Removing image '{self.IMAGE}'...")
|
||||
subprocess.run(
|
||||
["docker", "rmi", self.IMAGE],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
logger.info("Rebuilding image with latest versions...")
|
||||
self.build_image(no_cache=True, pull=True)
|
||||
logger.info("Update complete. Containers will be recreated on next run.")
|
||||
logger.info("Updating agent-container base image...")
|
||||
# Nothing tool-specific is baked into the image anymore, so existing
|
||||
# containers are kept: their writable layer (sudo-installed project
|
||||
# dependencies) survives, and the tools update themselves from the
|
||||
# persistent container home.
|
||||
self.build_image(pull=True)
|
||||
logger.info("Update complete. Existing containers were kept.")
|
||||
|
||||
def purge(self) -> None:
|
||||
logger.info("Purging all containers, image, and data...")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue