From d1aec706e3d256efd655a81e9e3063e917848b06 Mon Sep 17 00:00:00 2001 From: didericis Date: Sun, 19 Jul 2026 01:21:23 -0400 Subject: [PATCH] fix(firecracker): fold guest init into the agent-rootfs cache key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_agent_rootfs_dir cached the built rootfs by Dockerfile content alone, but util.inject_guest_boot then writes util._GUEST_INIT into it. So a fix to the init — making /tmp world-writable (1777) so the agent can create scratch dirs / git worktrees there — did NOT bust the cache: the KVM runner kept reusing a stale agent- rootfs built with the old init, and the sandbox-escape README-push test kept failing at `git init /tmp/...` with "Permission denied". Key the cache on Dockerfile content AND the injected init (_rootfs_digest), so an init change rebuilds. Self-busting: the new key yields a fresh cache dir, so no manual cache clear on the runner. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR --- .../backend/firecracker/image_builder.py | 26 ++++++++++++++----- tests/unit/test_firecracker_image_builder.py | 13 +++++++++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/bot_bottle/backend/firecracker/image_builder.py b/bot_bottle/backend/firecracker/image_builder.py index 176ce31..bf5a473 100644 --- a/bot_bottle/backend/firecracker/image_builder.py +++ b/bot_bottle/backend/firecracker/image_builder.py @@ -38,25 +38,39 @@ _BUILD_TIMEOUT_SECONDS = 900.0 def _dockerfile_hash(dockerfile: Path) -> str: - """Cache key: the Dockerfile's content. The shipped agent Dockerfiles - COPY nothing from the build context (see .dockerignore), so their content - fully determines the image; a Dockerfile that adds COPY will want the + """The Dockerfile's content hash. The shipped agent Dockerfiles COPY + nothing from the build context (see .dockerignore), so their content fully + determines the built image; a Dockerfile that adds COPY will want the context folded in here too.""" return hashlib.sha256(dockerfile.read_bytes()).hexdigest()[:16] +def _rootfs_digest(dockerfile: Path) -> str: + """Cache key for the built AND boot-injected agent rootfs. Two inputs + determine the on-disk rootfs: the Dockerfile (the image) and the guest init + injected into it (`util._GUEST_INIT`). Folding the init in means a fix to + it — e.g. making /tmp world-writable — busts the cache instead of silently + reusing a stale rootfs built with the old init.""" + h = hashlib.sha256() + h.update(_dockerfile_hash(dockerfile).encode()) + h.update(b"\0") + h.update(util._GUEST_INIT.encode()) + return h.hexdigest()[:16] + + def build_agent_rootfs_dir( dockerfile: Path, *, image_tag: str, smoke_test: tuple[str, ...] = (), ) -> Path: """Build `dockerfile` in the infra VM (buildah, no host docker), export its rootfs, inject the guest boot bits, and return the cached base dir — the - same shape `util.build_rootfs_ext4` consumes. Cached by Dockerfile content, - so a repeat launch skips the rebuild. + same shape `util.build_rootfs_ext4` consumes. Cached by Dockerfile content + + injected guest init, so a repeat launch skips the rebuild but an init or + Dockerfile change rebuilds. `smoke_test` (the provider's declared argv, e.g. `("claude","--version")`) is run in the freshly built image before export, catching an npm silent-failure image at build time rather than at first agent use.""" - digest = _dockerfile_hash(dockerfile) + digest = _rootfs_digest(dockerfile) base = util.cache_dir() / "rootfs" / f"agent-{digest}" if (base / ".bb-ready").is_file(): info(f"using cached agent rootfs {base.name}") diff --git a/tests/unit/test_firecracker_image_builder.py b/tests/unit/test_firecracker_image_builder.py index 40e132c..7191e6a 100644 --- a/tests/unit/test_firecracker_image_builder.py +++ b/tests/unit/test_firecracker_image_builder.py @@ -24,7 +24,7 @@ class TestBuildAgentRootfsDir(unittest.TestCase): self.addCleanup(self._tmp.cleanup) def test_cache_hit_skips_rebuild(self): - digest = image_builder._dockerfile_hash(self.dockerfile) + digest = image_builder._rootfs_digest(self.dockerfile) base = self.cache / "rootfs" / f"agent-{digest}" base.mkdir(parents=True) (base / ".bb-ready").write_text("ok\n") @@ -55,6 +55,17 @@ class TestBuildAgentRootfsDir(unittest.TestCase): image_builder._dockerfile_hash(other), ) + def test_rootfs_digest_tracks_dockerfile_and_init(self): + # Same Dockerfile, different injected init -> different rootfs key, so + # an init fix (e.g. /tmp perms) rebuilds instead of reusing a stale + # rootfs; different Dockerfiles also differ. + base = image_builder._rootfs_digest(self.dockerfile) + with patch.object(image_builder.util, "_GUEST_INIT", "#!/bin/sh\n# changed\n"): + self.assertNotEqual(base, image_builder._rootfs_digest(self.dockerfile)) + other = self.cache / "Dockerfile2" + other.write_text("FROM python:3.12-slim\n") + self.assertNotEqual(base, image_builder._rootfs_digest(other)) + class TestSmokeTest(unittest.TestCase): def test_empty_argv_is_noop(self):