feat(agent-images): update Debian and add Podman #452

Merged
didericis merged 9 commits from feat/issue-451-agent-images into main 2026-07-21 14:52:46 -04:00
11 changed files with 255 additions and 10 deletions
+39 -2
View File
@@ -257,7 +257,28 @@ class AgentProvider(ABC):
Default: Debian/node — writes the git-gate insteadOf gitconfig
and sets user.name/email as node. Workspace copy runs through
BottleBackend.provision_workspace against the running bottle."""
from .log import info
from .log import die, info
# Firecracker exports image rootfs files through an unprivileged host
# tar extraction, so image-time ownership of XDG directories is not
# preserved. Git consults ~/.config/git even when the actual config
# is ~/.gitconfig; an unreadable directory there can prevent the
# git-gate insteadOf rules below from taking effect. Repair this at
# runtime, after every backend's copy/export path has completed.
git_xdg_dir = f"{plan.guest_home}/.config/git"
repair = bottle.exec(
f"chown node:node {shlex.quote(plan.guest_home)} && "
f"chmod 755 {shlex.quote(plan.guest_home)} && "
f"mkdir -p {shlex.quote(git_xdg_dir)} && "
f"chown -R node:node {shlex.quote(f'{plan.guest_home}/.config')} && "
f"chmod -R u+rwX,go+rX {shlex.quote(f'{plan.guest_home}/.config')}",
user="root",
)
if repair.returncode != 0:
die(
"git provisioning: could not make the runtime Git config "
f"directory readable: {(repair.stderr or repair.stdout).strip()}"
)
manifest_bottle = plan.manifest.bottle
if manifest_bottle.git:
@@ -280,11 +301,27 @@ class AgentProvider(ABC):
f"{len(manifest_bottle.git)} insteadOf rule(s)"
)
bottle.cp_in(str(config_file), guest_gitconfig)
bottle.exec(
permissions = bottle.exec(
f"chown node:node {shlex.quote(guest_gitconfig)} && "
f"chmod 644 {shlex.quote(guest_gitconfig)}",
user="root",
)
if permissions.returncode != 0:
die(
"git provisioning: could not set ownership on "
f"{guest_gitconfig}: "
f"{(permissions.stderr or permissions.stdout).strip()}"
)
configured = bottle.exec(
"git config --global --get-regexp '^url\\..*\\.insteadof$'",
user="node",
)
if configured.returncode != 0:
die(
"git provisioning: the runtime user cannot read the "
f"git-gate insteadOf rules from {guest_gitconfig}: "
f"{(configured.stderr or configured.stdout).strip()}"
)
gu = manifest_bottle.git_user
if not gu.is_empty():
+5
View File
@@ -242,6 +242,11 @@ def _agent_guest_env(plan: FirecrackerBottlePlan, host_ip: str) -> dict[str, str
"HTTPS_PROXY": proxy_url, "HTTP_PROXY": proxy_url,
"https_proxy": proxy_url, "http_proxy": proxy_url,
"NO_PROXY": no_proxy, "no_proxy": no_proxy,
# Rootfs export can leave Git's implicit XDG paths unreadable even
# after the runtime repair. Bypass that discovery and name the
# provisioned global config explicitly so insteadOf can never fall
# through to the credential-bearing upstream URL.
"GIT_CONFIG_GLOBAL": f"{plan.guest_home}/.gitconfig",
"NODE_EXTRA_CA_CERTS": AGENT_CA_PATH,
"SSL_CERT_FILE": AGENT_CA_BUNDLE,
"REQUESTS_CA_BUNDLE": AGENT_CA_BUNDLE,
+6
View File
@@ -373,6 +373,12 @@ mount -o remount,rw / 2>/dev/null
# scratch dirs there — git worktrees, build temp, `git init /tmp/...`, etc.
mkdir -p /tmp && chmod 1777 /tmp
# Rootfs export also maps the image's original owners to the unprivileged
# host build uid. That uid is not guaranteed to be node's uid in the guest;
# restore the home-directory boundary before any SSH provisioning runs.
chown node:node /home/node 2>/dev/null || true
chmod 755 /home/node 2>/dev/null || true
# Install the per-bottle SSH pubkey from the kernel cmdline.
KEY=$(sed -n 's/.*bb_pubkey=\([^ ]*\).*/\1/p' /proc/cmdline | base64 -d 2>/dev/null)
if [ -n "$KEY" ]; then
+15 -2
View File
@@ -10,7 +10,7 @@
# Current Node LTS; slim variant keeps the image small while still
# providing apt-get for any future additions.
FROM node:22-slim
FROM node:22-trixie-slim
# Install runtime system deps. claude-code shells out to git for several
# features (status checks, commits, PR creation) — without git in the
@@ -21,7 +21,15 @@ FROM node:22-slim
# to it) works against egress's bumped TLS without the agent needing
# local DNS.
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl ripgrep iproute2 dnsutils \
&& apt-get install -y --no-install-recommends \
git \
ca-certificates \
curl \
openssh-client \
podman \
ripgrep \
iproute2 \
dnsutils \
&& rm -rf /var/lib/apt/lists/*
# App-specific deps. Python isn't required by claude-code itself
@@ -39,6 +47,11 @@ RUN apt-get update \
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
&& npm cache clean --force
# Git reads both ~/.gitconfig and ~/.config/git/config. Keep its XDG config
# path traversable by the non-root runtime user so permission errors do not
# suppress bot-bottle's git-gate insteadOf rules.
RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git
# Run as a non-root user. The node image already provides a `node` user
# (uid 1000) with a home directory, which is where claude-code will write
# its session state.
+11 -2
View File
@@ -3,10 +3,17 @@
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
# non-root node user, and the provider CLI installed for that user.
FROM node:22-slim
FROM node:22-trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates curl procps ripgrep \
&& apt-get install -y --no-install-recommends \
git \
ca-certificates \
curl \
openssh-client \
podman \
procps \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
# App-specific deps. Python isn't required by codex itself
@@ -17,6 +24,8 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git
USER node
WORKDIR /home/node
+5 -2
View File
@@ -2,7 +2,7 @@
#
# Node LTS, git/network tooling, and the Pi coding-agent CLI installed globally.
FROM node:22-slim
FROM node:22-trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
@@ -10,6 +10,8 @@ RUN apt-get update \
ca-certificates \
curl \
fd-find \
openssh-client \
podman \
ripgrep \
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
&& rm -rf /var/lib/apt/lists/*
@@ -21,7 +23,8 @@ RUN apt-get update \
RUN npm install -g --ignore-scripts --no-fund --no-audit @earendil-works/pi-coding-agent \
&& npm cache clean --force
RUN mkdir -p /home/node/.pi/agent \
RUN install -d -o node -g node -m 755 /home/node/.config /home/node/.config/git \
&& mkdir -p /home/node/.pi/agent \
/home/node/.pi/context-mode/sessions \
/tmp/pi-subagents-uid-1000 \
&& chown -R node:node /home/node/.pi /tmp \
@@ -0,0 +1,47 @@
# PRD prd-new: Modernize built-in agent images
- **Status:** Draft
- **Author:** Codex
- **Created:** 2026-07-21
- **Issue:** #451
## Summary
Keep every built-in agent provider on Debian's current stable release and make
Podman available inside each image. This gives agents a consistent, modern
userspace and an OCI container tool without requiring per-project setup.
## Problem
The Claude, Codex, and Pi images inherit the generic `node:22-slim` tag. That
tag does not state which Debian release the project supports and currently
leaves the images on the older Bookworm release. None of the built-in images
installs Podman, so tasks that need to inspect or build OCI images must first
modify the bottle or cannot run at all.
## Goals / success criteria
- Every Dockerfile under `bot_bottle/contrib/*/Dockerfile` explicitly inherits
`node:22-trixie-slim`, based on Debian 13 (the current stable release).
- Every built-in agent image installs Podman from Debian stable.
- Every built-in agent image retains an SSH client for Git-over-SSH workflows.
- The non-root agent user owns a traversable XDG Git configuration directory,
so Git can load bot-bottle's global git-gate rewrites without permission
errors.
- A shared test enforces both requirements for current and future built-in
providers.
## Non-goals
- Configuring privileged or nested-container execution for bottles.
- Pinning Podman outside Debian's stable package repository.
- Changing the Node.js or agent CLI release policy.
## Design
Use the explicit `node:22-trixie-slim` base rather than the floating `slim`
variant. Install the `podman` package with each image's existing `apt-get`
dependency layer, so package metadata and caches are still removed in the same
layer. Treat Debian stable as the Podman stability and update channel; this
keeps the images stdlib/distribution-first and avoids adding a third-party
package repository.
+1 -1
View File
@@ -172,7 +172,7 @@ class TestSandboxEscape(unittest.TestCase):
# base image without producing five confusing
# command-not-found failures down the suite.
missing: list[str] = []
for tool in ("curl", "git", "dig"):
for tool in ("curl", "git", "dig", "ssh"):
r = cls._bottle.exec(f"command -v {tool} >/dev/null 2>&1")
if r.returncode != 0:
missing.append(tool)
+49
View File
@@ -0,0 +1,49 @@
"""Unit contracts shared by all built-in agent images."""
from __future__ import annotations
import re
import unittest
from pathlib import Path
_CONTRIB_DIR = Path(__file__).resolve().parents[2] / "bot_bottle/contrib"
_AGENT_DOCKERFILES = tuple(sorted(_CONTRIB_DIR.glob("*/Dockerfile")))
class TestBuiltinAgentImages(unittest.TestCase):
def test_all_use_debian_trixie_stable(self):
self.assertTrue(_AGENT_DOCKERFILES)
for dockerfile in _AGENT_DOCKERFILES:
with self.subTest(provider=dockerfile.parent.name):
self.assertRegex(
dockerfile.read_text(),
r"(?m)^FROM node:22-trixie-slim\s*$",
)
def test_all_install_podman(self):
for dockerfile in _AGENT_DOCKERFILES:
with self.subTest(provider=dockerfile.parent.name):
self.assertRegex(
dockerfile.read_text(),
re.compile(r"(?m)^\s*podman(?:\s|\\|$)"),
)
def test_all_install_ssh_client(self):
for dockerfile in _AGENT_DOCKERFILES:
with self.subTest(provider=dockerfile.parent.name):
self.assertRegex(
dockerfile.read_text(),
re.compile(r"(?m)^\s*openssh-client(?:\s|\\|$)"),
)
def test_all_prepare_node_git_config_directory(self):
for dockerfile in _AGENT_DOCKERFILES:
with self.subTest(provider=dockerfile.parent.name):
dockerfile_text = dockerfile.read_text()
self.assertIn("install -d -o node -g node", dockerfile_text)
self.assertIn("/home/node/.config/git", dockerfile_text)
if __name__ == "__main__":
unittest.main()
@@ -45,12 +45,15 @@ _PROVIDER = _Provider()
def _plan(*, git_user: dict | None = None, # type: ignore
git_repos: dict | None = None, # type: ignore
copy_cwd: bool = False,
user_cwd: str = "/tmp/x",
stage_dir: Path | None = None) -> DockerBottlePlan:
bottle_json: dict = {} # type: ignore
if git_user is not None:
bottle_json["git-gate"] = {"user": git_user}
if git_repos is not None:
bottle_json.setdefault("git-gate", {})["repos"] = git_repos
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
@@ -125,6 +128,62 @@ class TestProvisionGitUser(unittest.TestCase):
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
self.assertEqual([], _git_config_exec_calls(bottle))
def test_repairs_git_xdg_directory_for_runtime_user(self):
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
script, user = next(
(call.args[0], call.kwargs.get("user", "node"))
for call in bottle.exec.call_args_list
if "/home/node/.config/git" in call.args[0]
)
self.assertEqual("root", user)
self.assertIn("chown node:node /home/node", script)
self.assertIn("chmod 755 /home/node", script)
self.assertIn("mkdir -p /home/node/.config/git", script)
self.assertIn("chown -R node:node /home/node/.config", script)
self.assertIn("chmod -R u+rwX,go+rX /home/node/.config", script)
def test_fails_closed_when_home_permissions_cannot_be_repaired(self):
bottle = _make_bottle()
bottle.exec.return_value = ExecResult(1, "", "read-only filesystem")
with self.assertRaises(SystemExit):
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
def _git_plan(self) -> DockerBottlePlan:
return _plan(
git_repos={
"repo": {
"url": "ssh://git@example.com/repo.git",
"key": {"provider": "static", "path": "/dev/null"},
"host_key": "ssh-ed25519 AAAA",
},
},
stage_dir=self.stage,
)
def test_fails_closed_when_gitconfig_permissions_cannot_be_set(self):
bottle = _make_bottle()
bottle.exec.side_effect = [
ExecResult(0, "", ""),
ExecResult(1, "", "chown failed"),
]
with self.assertRaises(SystemExit):
_PROVIDER.provision_git(bottle, self._git_plan())
def test_fails_closed_when_runtime_user_cannot_read_gitconfig(self):
bottle = _make_bottle()
bottle.exec.side_effect = [
ExecResult(0, "", ""),
ExecResult(0, "", ""),
ExecResult(1, "", "permission denied"),
]
with self.assertRaises(SystemExit):
_PROVIDER.provision_git(bottle, self._git_plan())
def test_sets_name_and_email(self):
plan = _plan(
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"},
+18 -1
View File
@@ -61,6 +61,12 @@ class TestNetpoolSlots(unittest.TestCase):
class TestNetpoolRenderers(unittest.TestCase):
def test_guest_init_restores_node_home_boundary(self):
from bot_bottle.backend.firecracker import util
self.assertIn("chown node:node /home/node", util._GUEST_INIT)
self.assertIn("chmod 755 /home/node", util._GUEST_INIT)
def test_nixos_module_is_non_invasive(self):
# The NixOS module must NOT flip the host firewall backend or
# hand interfaces to systemd-networkd; it brings the pool up via
@@ -422,10 +428,15 @@ class TestBottlePlanProperties(unittest.TestCase):
ap.command = "claude"
ap.prompt_mode = "append_file"
ap.template = "claude"
ap.guest_home = "/home/node"
ap.guest_env = {}
egress_plan = cast(Any, MagicMock())
egress_plan.canary = ""
egress_plan.canary_env = ""
fields = dict(
spec=cast(Any, MagicMock()), manifest=cast(Any, MagicMock()),
stage_dir=Path("/stage"), git_gate_plan=cast(Any, MagicMock()),
egress_plan=cast(Any, MagicMock()), supervise_plan=None,
egress_plan=egress_plan, supervise_plan=None,
agent_provision=ap, slug="demo-x", forwarded_env={},
)
fields.update(overrides)
@@ -451,6 +462,12 @@ class TestBottlePlanProperties(unittest.TestCase):
self.assertEqual("10.243.0.0:9420", p.git_gate_insteadof_host)
self.assertEqual("http", p.git_gate_insteadof_scheme)
def test_guest_env_pins_global_git_config(self):
from bot_bottle.backend.firecracker.launch import _agent_guest_env
env = _agent_guest_env(self._plan(), "10.243.0.0")
self.assertEqual("/home/node/.gitconfig", env["GIT_CONFIG_GLOBAL"])
if __name__ == "__main__":
unittest.main()