fix(firecracker): fold guest init into the agent-rootfs cache key
tracker-policy-pr / check-pr (pull_request) Successful in 18s
test / integration-docker (pull_request) Successful in 35s
test / unit (pull_request) Successful in 40s
lint / lint (push) Failing after 45s
test / integration-firecracker (pull_request) Successful in 2m50s
test / coverage (pull_request) Successful in 2m52s

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-<dockerfilehash> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9qa3xoavjQScufDfZaXKR
This commit is contained in:
2026-07-19 01:21:23 -04:00
parent a589604aa0
commit d1aec706e3
2 changed files with 32 additions and 7 deletions
@@ -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}")
+12 -1
View File
@@ -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):