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:
Jeena 2026-09-18 08:38:23 +09:00
parent 70699026c3
commit e91b74ea38
3 changed files with 140 additions and 43 deletions

View file

@ -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...")