Files
bot-bottle/tests/unit/test_backend_prepare.py
T
didericis ba25845886
tracker-policy-pr / check-pr (pull_request) Successful in 12s
lint / lint (push) Successful in 51s
test / integration-docker (pull_request) Successful in 40s
test / unit (pull_request) Successful in 46s
test / integration-firecracker (pull_request) Successful in 3m35s
test / coverage (pull_request) Successful in 43s
test / publish-infra (pull_request) Has been skipped
feat(macos): run containers inside a bottle via guest-local podman
Adds the `nested_containers` bottle flag. On the macOS backend it starts
a rootless podman service inside the bottle and exposes its
Docker-compatible API socket, so the agent still runs `docker` and
`docker compose`. No host daemon socket is mounted and the guest gains
no capabilities; backends that cannot run a guest-local engine reject
the flag in the shared prepare template rather than ignoring it.

Podman rather than rootless Docker because Apple Container's capability
bounding set omits CAP_SYS_ADMIN, which the kernel requires to write a
multi-range uid_map via newuidmap. With no subordinate UID range podman
falls back to a single-UID self-mapping an unprivileged process may
write itself, so the image build strips /etc/subuid and /etc/subgid
entries rather than adding them.

That mapping is also why nested containers are not an isolation layer:
root inside one is the agent user outside it. They are a build/test
convenience; the bottle remains the boundary.

Ports the spike branch onto main, renaming docker_access — it implied
Docker and granted access to nothing on the host — and drops podman
from the derived layer now that every built-in image ships it (#451).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 15:39:11 -04:00

188 lines
6.6 KiB
Python

"""Unit: shared backend prepare wiring.
These tests keep the base `BottleBackend.prepare` template honest:
backend-specific preflight/env hooks must be wired through, and launch
metadata must record the backend that actually prepared the plan.
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from bot_bottle import bottle_state
from tests.unit import use_bottle_root
from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.resolve_common import mint_slug
from bot_bottle.backend.firecracker import FirecrackerBottleBackend
from bot_bottle.manifest import ManifestIndex
def _manifest(*, nested_containers: bool = False) -> ManifestIndex:
return ManifestIndex.from_json_obj({
"bottles": {
"dev": {
"env": {
"LITERAL_ENV": "literal-value",
"FORWARDED_ENV": "${HOST_SECRET_ENV}",
},
"nested_containers": nested_containers,
},
},
"agents": {
"demo": {
"bottle": "dev",
"skills": [],
"prompt": "hello",
},
},
})
def _spec(
tmp: Path, *, identity: str, nested_containers: bool = False,
) -> BottleSpec:
return BottleSpec(
manifest=_manifest(nested_containers=nested_containers),
agent_name="demo",
copy_cwd=False,
user_cwd=str(tmp),
identity=identity,
)
class _FakeStateMixin:
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.")
self.root = Path(self.tmp.name) / ".bot-bottle"
self._restore = use_bottle_root(self.root)
def tearDown(self) -> None:
self._restore()
self.tmp.cleanup()
class TestDockerPrepare(_FakeStateMixin, unittest.TestCase):
def test_records_backend_and_preserves_env_split(self) -> None:
backend = DockerBottleBackend()
spec = _spec(Path(self.tmp.name), identity="demo-docker")
with (
patch.dict("os.environ", {"HOST_SECRET_ENV": "secret-value"}),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker",
) as require_docker,
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(spec, Path(self.tmp.name) / "stage")
require_docker.assert_called_once_with()
metadata = bottle_state.read_metadata("demo-docker")
self.assertIsNotNone(metadata)
assert metadata is not None
self.assertEqual("docker", metadata.backend)
self.assertEqual({"FORWARDED_ENV": "secret-value"}, plan.forwarded_env)
self.assertEqual("literal-value", plan.agent_provision.guest_env["LITERAL_ENV"])
self.assertNotIn("FORWARDED_ENV", plan.agent_provision.guest_env)
class TestFirecrackerPrepare(_FakeStateMixin, unittest.TestCase):
def test_records_backend_and_builds_guest_env(self) -> None:
backend = FirecrackerBottleBackend()
spec = _spec(Path(self.tmp.name), identity="demo-fc")
with (
patch.dict("os.environ", {"HOST_SECRET_ENV": "secret-value"}),
patch(
"bot_bottle.backend.firecracker.resolve_plan.util.require_firecracker",
) as preflight,
):
plan = backend.prepare(spec, Path(self.tmp.name) / "stage")
preflight.assert_called_once_with()
metadata = bottle_state.read_metadata("demo-fc")
self.assertIsNotNone(metadata)
assert metadata is not None
self.assertEqual("firecracker", metadata.backend)
self.assertEqual(
"literal-value", plan.agent_provision.guest_env["LITERAL_ENV"],
)
self.assertEqual({"FORWARDED_ENV": "secret-value"}, plan.forwarded_env)
class TestNestedContainersRejection(_FakeStateMixin, unittest.TestCase):
"""A backend with no guest-local engine must refuse the flag outright.
Ignoring it would leave the agent without `docker`, and the only ways to
fake it here (a host daemon socket, a privileged container) are what
issue #392 rules out.
"""
def test_docker_backend_refuses_the_flag(self) -> None:
backend = DockerBottleBackend()
spec = _spec(
Path(self.tmp.name), identity="demo-docker", nested_containers=True,
)
with (
patch(
"bot_bottle.backend.resolve_common.die", side_effect=RuntimeError,
) as die,
self.assertRaises(RuntimeError),
):
backend.prepare(spec, Path(self.tmp.name) / "stage")
self.assertIn("nested_containers", die.call_args.args[0])
self.assertIn("docker", die.call_args.args[0])
def test_firecracker_backend_refuses_the_flag(self) -> None:
backend = FirecrackerBottleBackend()
spec = _spec(Path(self.tmp.name), identity="demo-fc", nested_containers=True)
with (
patch(
"bot_bottle.backend.resolve_common.die", side_effect=RuntimeError,
) as die,
self.assertRaises(RuntimeError),
):
backend.prepare(spec, Path(self.tmp.name) / "stage")
self.assertIn("firecracker", die.call_args.args[0])
class TestMintSlug(unittest.TestCase):
def _spec(self, *, label: str = "", identity: str = "") -> BottleSpec:
manifest = _manifest()
return BottleSpec(
manifest=manifest,
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp",
label=label,
identity=identity,
)
def test_no_label_uses_agent_name_with_random_suffix(self) -> None:
slug = mint_slug(self._spec(label=""))
self.assertTrue(slug.startswith("demo-"), slug)
# random suffix present — slug is longer than just "demo"
self.assertGreater(len(slug), len("demo-"))
def test_label_becomes_exact_slug(self) -> None:
slug = mint_slug(self._spec(label="my-run"))
self.assertEqual("my-run", slug)
def test_label_with_spaces_slugified_no_suffix(self) -> None:
slug = mint_slug(self._spec(label="My Feature Run"))
self.assertEqual("my-feature-run", slug)
def test_identity_takes_precedence_over_label(self) -> None:
slug = mint_slug(self._spec(label="my-run", identity="fixed-id"))
self.assertEqual("fixed-id", slug)
if __name__ == "__main__":
unittest.main()