A failing official installer aborted with a raw CalledProcessError traceback, burying the actual problem the installer had already printed to the terminal. Exit with a short message pointing at that output instead.
551 lines
18 KiB
Python
Executable file
551 lines
18 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
|
|
|
|
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.docker_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" docker 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 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(
|
|
["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)..."
|
|
)
|
|
try:
|
|
subprocess.run(
|
|
[
|
|
"docker", "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(
|
|
["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."""
|
|
subprocess.run(
|
|
[
|
|
"docker",
|
|
"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 = ["docker", "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(
|
|
["docker", "image", "inspect", self.IMAGE],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
).returncode
|
|
== 0
|
|
)
|
|
|
|
def build_image(self, no_cache: bool = False, pull: bool = False) -> None:
|
|
logger.info(
|
|
f"Building image '{self.IMAGE}' with user {self.host_username} ({self.host_uid}:{self.host_gid})"
|
|
)
|
|
cmd = [
|
|
"docker",
|
|
"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.docker_context_dir))
|
|
self._run_cmd(cmd)
|
|
|
|
# =========================
|
|
# Container lifecycle
|
|
# =========================
|
|
|
|
def create_container(self) -> None:
|
|
logger.info(f"Creating container '{self.container_name}'")
|
|
self._run_cmd(
|
|
[
|
|
"docker",
|
|
"create",
|
|
"--name",
|
|
self.container_name,
|
|
"--network",
|
|
"host",
|
|
"--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(
|
|
["docker", "container", "inspect", "-f", "{{.Image}}", self.container_name],
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
current_image = subprocess.run(
|
|
["docker", "image", "inspect", "-f", "{{.Id}}", self.IMAGE],
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
return bool(container_image) and container_image == current_image
|
|
|
|
def start_container(self) -> bool:
|
|
subprocess.run(
|
|
["docker", "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(["docker", "stop", "-t", "0", self.container_name])
|
|
|
|
def remove_container(self) -> None:
|
|
subprocess.run(
|
|
["docker", "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(
|
|
["docker", "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(
|
|
[
|
|
"docker",
|
|
"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(
|
|
["docker", "rm", "-f", name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
# =========================
|
|
# State checks
|
|
# =========================
|
|
|
|
def container_exists(self) -> bool:
|
|
return (
|
|
subprocess.run(
|
|
["docker", "inspect", self.container_name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
).returncode
|
|
== 0
|
|
)
|
|
|
|
def container_running(self) -> bool:
|
|
result = subprocess.run(
|
|
["docker", "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()
|