agent-container/agent-container.py
Jeena 8a7af06983 container: Fail early on missing subuid/subgid ranges
Rootless Podman falls back to a single-UID user namespace when the
invoking user has no /etc/subuid and /etc/subgid entry, and then any
chown to an unmapped UID fails with EINVAL. Newer pacman versions hit
this during builds and updates: their download sandbox chowns its
temporary download directory to the 'alpm' user, which made image
builds fail with a cryptic traceback after several minutes of output.

Accounts created manually or before shadow 4.11.1-3 (October 2022) get
no subordinate ID ranges automatically, while freshly created ones do.
Rather than working around this in the image (disabling pacman's
sandbox would silently drop a security feature), detect the missing
ranges before building and stop with the exact fix, using the first
unclaimed 65536-ID block so the printed command never collides with
other users' ranges. Link the Arch Wiki section and Podman's rootless
tutorial for background.
2026-10-29 08:33:23 +09:00

683 lines
24 KiB
Python
Executable file

#!/usr/bin/env python3
import hashlib
import logging
import os
import re
import shlex
import signal
import subprocess
import sys
import time
from pathlib import Path
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stderr)
logger = logging.getLogger(__name__)
USAGE = """\
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 the base image (keeps containers)
agent-container purge Remove all containers, image, and data
"""
class AgentContainer:
IMAGE = "agent-container:latest"
CONTAINER_PREFIX = "ac-"
DATA_DIR_NAME = "agent-container"
START_TIMEOUT = 3.0
# Rootless Podman is the default. It needs neither a privileged daemon
# nor membership of a privileged group, so an agent inside the container
# cannot escalate to host root the way it can through the Docker socket.
# Set AGENT_CONTAINER_RUNTIME=docker to use Docker instead.
RUNTIME = os.environ.get("AGENT_CONTAINER_RUNTIME", "podman")
def __init__(self):
self.project_path = Path.cwd().resolve()
self.project_id = self._project_id(self.project_path)
self.host_username = os.environ.get("USER", "dev")
self.host_uid = os.getuid()
self.host_gid = os.getgid()
safe_name = re.sub(r"[^a-zA-Z0-9_.-]+", "_", self.project_path.name).strip("_.-")
self.container_name = (
f"{self.CONTAINER_PREFIX}{safe_name}-{self.project_id}"
)
self.context_dir = Path(__file__).resolve().parent
def _get_xdg_data_home(self) -> Path:
"""Get XDG_DATA_HOME with fallback to ~/.local/share"""
xdg_data = os.environ.get("XDG_DATA_HOME")
if xdg_data:
return Path(xdg_data)
return Path.home() / ".local" / "share"
@property
def container_home_path(self) -> Path:
"""Container home directory in XDG_DATA_HOME"""
return self._get_xdg_data_home() / self.DATA_DIR_NAME / "container-home"
# =========================
# Subcommand dispatch
# =========================
def dispatch(self, argv: list[str]) -> None:
if not argv:
print(USAGE, file=sys.stderr)
sys.exit(1)
command = argv[0]
args = argv[1:]
if command == "opencode":
self._run_tool(
"opencode",
args,
env_prefixes=["OPENCODE_"],
)
elif command == "claude":
self._run_tool(
"claude",
args,
env_prefixes=["ANTHROPIC_", "CLAUDE_"],
extra_env={"DISABLE_AUTOUPDATER": "1"},
)
elif command == "pi":
# Forward pi's own variables plus the API key prefixes of its
# supported providers (see pi docs: providers.md). Auth via
# /login persists in the shared container home instead.
self._run_tool(
"pi",
args,
env_prefixes=[
"PI_",
"ANTHROPIC_",
"ANT_LING_",
"OPENAI_",
"AZURE_",
"GOOGLE_",
"GEMINI_",
"DEEPSEEK_",
"NVIDIA_",
"MISTRAL_",
"GROQ_",
"CEREBRAS_",
"CLOUDFLARE_",
"XAI_",
"OPENROUTER_",
"AI_GATEWAY_",
"ZAI_",
"KIMI_",
"MINIMAX_",
"QWEN_",
"OPENCODE_",
"FIREWORKS_",
"TOGETHER_",
"BASETEN_",
"OLLAMA_",
],
)
elif command == "update":
self.update()
elif command == "purge":
self.purge()
else:
logger.error(f"Unknown command: {command}")
print(USAGE, file=sys.stderr)
sys.exit(1)
# =========================
# Run a tool inside the container
# =========================
def _run_tool(
self,
tool: str,
args: list[str],
env_prefixes: list[str],
extra_env: dict[str, str] | None = None,
) -> None:
self.container_home_path.mkdir(parents=True, exist_ok=True)
if self.container_exists() and self.container_running():
logger.error(
f"Project '{self.project_path.name}' already has a running agent container."
)
logger.error(f"Container name: {self.container_name}")
logger.error(
"Wait for the current instance to finish or manually stop it with:"
)
logger.error(f" {self.RUNTIME} stop {self.container_name}")
sys.exit(1)
# Pre-create project directory structure to prevent root-owned directories
try:
relative_path = self.project_path.relative_to(Path.home())
(self.container_home_path / relative_path).mkdir(
parents=True, exist_ok=True
)
except ValueError:
pass
if not self.image_exists():
self.build_image()
if self.container_exists() and not self._container_image_matches():
logger.info(
"Recreating container: it was created from an outdated image"
)
self.remove_container()
if self.container_exists() and not self._container_env_matches():
logger.info(
"Recreating container: it predates the AGENT_CONTAINER env marker"
)
self.remove_container()
if not self.container_exists():
self.create_container()
if not self.start_container():
logger.warning("Recreating container due to failed start")
self.remove_container()
self.create_container()
if not self.start_container():
raise RuntimeError("Container failed to start after recreation")
# Seed container home with default shell config from /etc/skel
# (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 {})
finally:
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(
[self.RUNTIME, "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)..."
)
try:
subprocess.run(
[
self.RUNTIME, "exec", self.container_name, "bash", "-lc",
spec["install"],
],
check=True,
)
except subprocess.CalledProcessError as e:
logger.error(
f"Installing {tool} failed with exit status "
f"{e.returncode}; see the installer output above."
)
sys.exit(1)
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(
[self.RUNTIME, "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."""
subprocess.run(
[
self.RUNTIME,
"exec",
self.container_name,
"bash",
"-c",
'for f in /etc/skel/.*; do '
'[ -f "$f" ] && [ ! -e "$HOME/$(basename "$f")" ] && '
'cp "$f" "$HOME/"; done',
],
)
def _exec_tool(
self,
tool: str,
args: list[str],
env_prefixes: list[str],
extra_env: dict[str, str],
) -> None:
env_args: list[str] = []
# Pass through host environment variables matching any prefix
for key, val in os.environ.items():
if any(key.startswith(prefix) for prefix in env_prefixes):
env_args += ["-e", f"{key}={val}"]
# Set additional environment variables
for key, val in extra_env.items():
env_args += ["-e", f"{key}={val}"]
# 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 = [self.RUNTIME, "exec", "-i"]
if sys.stdin.isatty():
exec_cmd.append("-t")
result = subprocess.run(
[
*exec_cmd,
*env_args,
"-w",
str(self.project_path),
self.container_name,
"bash",
"-lc",
f"exec {cmd_str}",
],
)
if result.returncode != 0:
sys.exit(result.returncode)
# =========================
# Image handling
# =========================
def image_exists(self) -> bool:
return (
subprocess.run(
[self.RUNTIME, "image", "inspect", self.IMAGE],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode
== 0
)
# Subordinate ID block size; rootless Podman needs at least 65536 IDs so
# every container UID is mappable. 100000 is the default first block the
# shadow suite allocates on Arch (and the number the Arch Wiki suggests).
SUBID_SIZE = 65536
SUBID_START = 100000
@classmethod
def _free_subid_range(cls) -> str:
"""Return the first unclaimed 65536-ID block as "start-end".
Ranges in /etc/subuid and /etc/subgid must never overlap between
users (a shared range would let one user impersonate another inside
containers), so both files' claims are considered.
"""
taken = []
for path in ("/etc/subuid", "/etc/subgid"):
try:
with open(path) as f:
for line in f:
parts = line.split(":")
if len(parts) < 3:
continue
try:
taken.append(
(int(parts[1]), int(parts[1]) + int(parts[2]))
)
except ValueError:
continue
except FileNotFoundError:
pass
taken.sort()
start = cls.SUBID_START
for lo, hi in taken:
if hi <= start:
continue
if lo - start >= cls.SUBID_SIZE:
break
start = max(start, hi)
return f"{start}-{start + cls.SUBID_SIZE - 1}"
def _subid_guard(self) -> None:
"""Warn when rootless Podman has no subuid/subgid ranges for this user.
Without a range in /etc/subuid and /etc/subgid, Podman falls back to a
single-UID user namespace: the container only has the invoking user's
UID, and chown to any other UID fails with EINVAL. This breaks e.g.
pacman's download sandbox, which chowns its download directory to the
'alpm' user. Distros like Fedora allocate ranges for every user
automatically, but accounts created manually (e.g. on Arch) may not
have one.
"""
if self.RUNTIME != "podman" or os.geteuid() == 0:
return
def has_entry(path: str) -> bool:
try:
with open(path) as f:
return any(line.split(":", 1)[0] == self.host_username for line in f)
except FileNotFoundError:
return False
missing = [
p for p in ("/etc/subuid", "/etc/subgid") if not has_entry(p)
]
if not missing:
return
range_str = self._free_subid_range()
logger.error(
f"No subuid/subgid entry for user '{self.host_username}' in"
" /etc/subuid, /etc/subgid — rootless Podman has only a single UID"
" available, so operations inside containers that chown to other"
" UIDs (e.g. pacman's alpm download sandbox) fail with 'Invalid"
" argument'. Fix once per user:\n"
f" sudo usermod --add-subuids {range_str} --add-subgids {range_str} {self.host_username}\n"
" podman system migrate\n"
" See https://wiki.archlinux.org/title/Podman#Set_subuid_and_subgid"
" or https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md"
)
sys.exit(1)
def build_image(self, no_cache: bool = False, pull: bool = False) -> None:
self._subid_guard()
logger.info(
f"Building image '{self.IMAGE}' with user {self.host_username} ({self.host_uid}:{self.host_gid})"
)
cmd = [
self.RUNTIME,
"build",
# Use host networking during build so the build works on hosts
# where the default bridge network cannot create veth pairs
"--network",
"host",
"--build-arg",
f"USERNAME={self.host_username}",
"--build-arg",
f"UID={self.host_uid}",
"--build-arg",
f"GID={self.host_gid}",
"-t",
self.IMAGE,
]
if no_cache:
cmd.append("--no-cache")
if pull:
cmd.append("--pull")
cmd.append(str(self.context_dir))
self._run_cmd(cmd)
# =========================
# Container lifecycle
# =========================
def _userns_args(self) -> list[str]:
"""Make container UIDs match host UIDs (rootless Podman only).
Rootless Podman maps container root to the user's subuid range, so
the container's UID 1000 would otherwise own nothing in the bind
mounted project. `keep-id` maps the host UID/GID to the same values
inside the container, and container root to a subuid instead of
host root -- which is exactly the isolation we want.
"""
if self.RUNTIME != "podman" or os.geteuid() == 0:
return []
return ["--userns=keep-id"]
def create_container(self) -> None:
logger.info(f"Creating container '{self.container_name}'")
self._run_cmd(
[
self.RUNTIME,
"create",
"--name",
self.container_name,
# Lets tools/scripts running inside detect that they are in
# an agent container (rather than a bare host).
"--env",
"AGENT_CONTAINER=1",
"--network",
"host",
*self._userns_args(),
"--user",
f"{self.host_uid}:{self.host_gid}",
"--volume",
f"{self.project_path}:{self.project_path}",
"--volume",
f"{self.container_home_path}:/home/{self.host_username}",
self.IMAGE,
"sh",
"-c",
"sleep infinity",
]
)
def _container_image_matches(self) -> bool:
"""Whether the existing container was created from the current image.
After an image rebuild the old container keeps running the stale
image, so tools baked into previous builds (or missing from new
ones) linger. Comparing image IDs catches that."""
container_image = subprocess.run(
[self.RUNTIME, "container", "inspect", "-f", "{{.Image}}", self.container_name],
capture_output=True,
text=True,
).stdout.strip()
current_image = subprocess.run(
[self.RUNTIME, "image", "inspect", "-f", "{{.Id}}", self.IMAGE],
capture_output=True,
text=True,
).stdout.strip()
return bool(container_image) and container_image == current_image
def _container_env_matches(self) -> bool:
"""Whether the existing container was created with the env vars the
current script sets at create time. Containers predating the
AGENT_CONTAINER marker keep running without it, so in-container
detection (see the install-in-container skill) fails. Comparing
against Config.Env catches that."""
env = subprocess.run(
[
self.RUNTIME,
"container",
"inspect",
"-f",
"{{.Config.Env}}",
self.container_name,
],
capture_output=True,
text=True,
).stdout
return "AGENT_CONTAINER=1" in env
def start_container(self) -> bool:
subprocess.run(
[self.RUNTIME, "start", self.container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return self.wait_until_running()
def stop_container(self) -> None:
if self.container_running():
self._run_cmd([self.RUNTIME, "stop", "-t", "0", self.container_name])
def remove_container(self) -> None:
subprocess.run(
[self.RUNTIME, "rm", "-f", self.container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# =========================
# Update & cleanup
# =========================
def update(self) -> None:
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...")
self._remove_all_containers()
if self.image_exists():
logger.info(f"Removing image '{self.IMAGE}'...")
subprocess.run(
[self.RUNTIME, "rmi", self.IMAGE],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
data_dir = self._get_xdg_data_home() / self.DATA_DIR_NAME
if data_dir.exists():
logger.info(f"Removing {data_dir}...")
import shutil
shutil.rmtree(data_dir)
logger.info("Force cleanup complete.")
def _remove_all_containers(self) -> None:
logger.info("Removing existing containers...")
result = subprocess.run(
[
self.RUNTIME,
"ps",
"-a",
"--filter",
f"name=^{self.CONTAINER_PREFIX}",
"--format",
"{{.Names}}",
],
capture_output=True,
text=True,
)
for name in result.stdout.strip().splitlines():
if name:
logger.info(f" Removing container '{name}'")
subprocess.run(
[self.RUNTIME, "rm", "-f", name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# =========================
# State checks
# =========================
def container_exists(self) -> bool:
return (
subprocess.run(
[self.RUNTIME, "inspect", self.container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode
== 0
)
def container_running(self) -> bool:
result = subprocess.run(
[self.RUNTIME, "inspect", "-f", "{{.State.Running}}", self.container_name],
capture_output=True,
text=True,
)
return result.returncode == 0 and result.stdout.strip() == "true"
def wait_until_running(self) -> bool:
deadline = time.time() + self.START_TIMEOUT
while time.time() < deadline:
if self.container_running():
return True
time.sleep(0.1)
return False
# =========================
# Helpers
# =========================
@staticmethod
def _project_id(path: Path) -> str:
return hashlib.sha256(str(path).encode()).hexdigest()[:12]
@staticmethod
def _run_cmd(cmd: list[str]) -> None:
subprocess.run(cmd, check=True)
def main() -> None:
AgentContainer().dispatch(sys.argv[1:])
if __name__ == "__main__":
main()