diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 3ef816b..c3d94d0 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -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(): diff --git a/bot_bottle/backend/firecracker/launch.py b/bot_bottle/backend/firecracker/launch.py index a5efc00..b87b44b 100644 --- a/bot_bottle/backend/firecracker/launch.py +++ b/bot_bottle/backend/firecracker/launch.py @@ -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, diff --git a/bot_bottle/backend/firecracker/util.py b/bot_bottle/backend/firecracker/util.py index cf30a18..0d9aad8 100644 --- a/bot_bottle/backend/firecracker/util.py +++ b/bot_bottle/backend/firecracker/util.py @@ -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 diff --git a/bot_bottle/contrib/claude/Dockerfile b/bot_bottle/contrib/claude/Dockerfile index 4834dd3..fd8520d 100644 --- a/bot_bottle/contrib/claude/Dockerfile +++ b/bot_bottle/contrib/claude/Dockerfile @@ -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. diff --git a/bot_bottle/contrib/codex/Dockerfile b/bot_bottle/contrib/codex/Dockerfile index c966b8d..56216c2 100644 --- a/bot_bottle/contrib/codex/Dockerfile +++ b/bot_bottle/contrib/codex/Dockerfile @@ -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 diff --git a/bot_bottle/contrib/pi/Dockerfile b/bot_bottle/contrib/pi/Dockerfile index 20a5f29..02564fd 100644 --- a/bot_bottle/contrib/pi/Dockerfile +++ b/bot_bottle/contrib/pi/Dockerfile @@ -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 \ diff --git a/docs/prds/prd-new-modernize-built-in-agent-images.md b/docs/prds/prd-new-modernize-built-in-agent-images.md new file mode 100644 index 0000000..a0c4843 --- /dev/null +++ b/docs/prds/prd-new-modernize-built-in-agent-images.md @@ -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. diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index 29b7608..de52e5f 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -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) diff --git a/tests/unit/test_builtin_agent_images.py b/tests/unit/test_builtin_agent_images.py new file mode 100644 index 0000000..61fa961 --- /dev/null +++ b/tests/unit/test_builtin_agent_images.py @@ -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() diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index 081bfce..d193e9f 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -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"}, diff --git a/tests/unit/test_firecracker_backend.py b/tests/unit/test_firecracker_backend.py index e29c972..d72d795 100644 --- a/tests/unit/test_firecracker_backend.py +++ b/tests/unit/test_firecracker_backend.py @@ -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()