diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 2915360..f7766f2 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -290,19 +290,19 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): manifest = spec.manifest manifest_bottle = manifest.bottle_for(spec.agent_name) - manfiest_agent_provider = manifest_bottle.agent_provider - agent_provider = get_provider(manfiest_agent_provider.template) + manifest_agent_provider = manifest_bottle.agent_provider + agent_provider = get_provider(manifest_agent_provider.template) resolved_env = resolve_env(manifest, spec.agent_name) slug = mint_slug(spec) - write_launch_metadata(slug, spec, compose_project="", backend="smolmachines") + write_launch_metadata(slug, spec, compose_project="", backend=self.name) # Manifest may override the Dockerfile per-bottle; otherwise fall # back to the provider plugin's bundled Dockerfile (next to its # agent_provider.py module). - if manfiest_agent_provider.dockerfile: + if manifest_agent_provider.dockerfile: agent_dockerfile_path = resolve_manifest_dockerfile( - manfiest_agent_provider.dockerfile, spec, + manifest_agent_provider.dockerfile, spec, ) else: agent_dockerfile_path = str(agent_provider.dockerfile) @@ -310,14 +310,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): agent_dir, prompt_file = prepare_agent_state_dir(slug, spec) agent_provision_plan = build_agent_provision_plan( - template=manfiest_agent_provider.template, + template=manifest_agent_provider.template, dockerfile=agent_dockerfile_path, state_dir=agent_dir, instance_name=f"bot-bottle-{slug}", prompt_file=prompt_file, guest_env=self._build_guest_env(resolved_env), - forward_host_credentials=manfiest_agent_provider.forward_host_credentials, - auth_token=manfiest_agent_provider.auth_token, + forward_host_credentials=manifest_agent_provider.forward_host_credentials, + auth_token=manifest_agent_provider.auth_token, host_env=dict(os.environ), # trusted_project_path=workspace_plan.workdir, label=spec.label, diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index cc899ff..2ebcf42 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -2,10 +2,10 @@ This module is a thin façade. The real work lives in four siblings: - - prepare.py — host-side resolution into a DockerBottlePlan - - launch.py — bring-up + teardown context manager - - cleanup.py — orphan enumeration + removal - - enumerate.py — active-agent listing + - resolve_plan.py — Docker-specific resolution into a DockerBottlePlan + - launch.py — bring-up + teardown context manager + - cleanup.py — orphan enumeration + removal + - enumerate.py — active-agent listing The base class's `prepare` template runs cross-backend host-side validation before calling `_resolve_plan` here. @@ -53,6 +53,12 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup launch.""" return shutil.which("docker") is not None + def _preflight(self) -> None: + _resolve_plan.preflight() + + def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: + return _resolve_plan.build_guest_env(resolved_env) + def _resolve_plan( self, spec: BottleSpec, diff --git a/bot_bottle/backend/docker/resolve_plan.py b/bot_bottle/backend/docker/resolve_plan.py index 739584b..c38cb69 100644 --- a/bot_bottle/backend/docker/resolve_plan.py +++ b/bot_bottle/backend/docker/resolve_plan.py @@ -21,12 +21,11 @@ from ...egress import EgressPlan from ...supervise import SupervisePlan from ...git_gate import GitGatePlan -def preflight(): +def preflight() -> None: docker_mod.require_docker() -def build_guest_env(resolved_env: ResolvedEnv): - # resolved = resolve_env(spec.manifest, spec.agent_name) - # forwarded_env: dict[str, str] = dict(resolved.forwarded) + +def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]: return dict(resolved_env.literals) @@ -59,4 +58,3 @@ def resolve_plan( agent_provision=agent_provision_plan, # workspace_plan=workspace_plan, ) - diff --git a/bot_bottle/backend/smolmachines/backend.py b/bot_bottle/backend/smolmachines/backend.py index f8b0d61..41e1aca 100644 --- a/bot_bottle/backend/smolmachines/backend.py +++ b/bot_bottle/backend/smolmachines/backend.py @@ -46,6 +46,12 @@ class SmolmachinesBottleBackend( runtime check happens at `prepare`.""" return _smolvm.is_available() + def _preflight(self) -> None: + _resolve_plan.preflight() + + def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: + return _resolve_plan.build_guest_env(resolved_env) + def _resolve_plan( self, spec: BottleSpec, diff --git a/bot_bottle/backend/smolmachines/resolve_plan.py b/bot_bottle/backend/smolmachines/resolve_plan.py index 5d0f7d2..ebda2b9 100644 --- a/bot_bottle/backend/smolmachines/resolve_plan.py +++ b/bot_bottle/backend/smolmachines/resolve_plan.py @@ -23,10 +23,11 @@ from ...git_gate import GitGatePlan from .bottle_plan import SmolmachinesBottlePlan from .util import smolmachines_bundle_subnet, smolmachines_preflight -def preflight(): +def preflight() -> None: smolmachines_preflight() -def build_guest_env(resolved_env: ResolvedEnv): + +def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]: # Agent's env: resolve through resolve_env() so ?prompt entries # are prompted and ${HOST_VAR} entries are interpolated — matching # the Docker backend's contract. Forwarded (secret/interpolated) diff --git a/tests/unit/test_backend_prepare.py b/tests/unit/test_backend_prepare.py new file mode 100644 index 0000000..8a3eeaf --- /dev/null +++ b/tests/unit/test_backend_prepare.py @@ -0,0 +1,117 @@ +"""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 bot_bottle import supervise +from bot_bottle.backend import BottleSpec +from bot_bottle.backend.docker import DockerBottleBackend +from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend +from bot_bottle.manifest import Manifest + + +def _manifest() -> Manifest: + return Manifest.from_json_obj({ + "bottles": { + "dev": { + "env": { + "LITERAL_ENV": "literal-value", + "FORWARDED_ENV": "${HOST_SECRET_ENV}", + }, + }, + }, + "agents": { + "demo": { + "bottle": "dev", + "skills": [], + "prompt": "hello", + }, + }, + }) + + +def _spec(tmp: Path, *, identity: str) -> BottleSpec: + return BottleSpec( + manifest=_manifest(), + 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.original_root = supervise.bot_bottle_root + supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment] + + def tearDown(self) -> None: + supervise.bot_bottle_root = self.original_root # type: ignore[assignment] + 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) + 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 TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase): + def test_records_backend_and_builds_guest_env(self) -> None: + backend = SmolmachinesBottleBackend() + spec = _spec(Path(self.tmp.name), identity="demo-smol") + + with ( + patch.dict("os.environ", {"HOST_SECRET_ENV": "secret-value"}), + patch( + "bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight", + ) as preflight, + ): + plan = backend.prepare(spec, Path(self.tmp.name) / "stage") + + preflight.assert_called_once_with() + metadata = bottle_state.read_metadata("demo-smol") + self.assertIsNotNone(metadata) + self.assertEqual("smolmachines", metadata.backend) + self.assertEqual("literal-value", plan.guest_env["LITERAL_ENV"]) + self.assertEqual("secret-value", plan.guest_env["FORWARDED_ENV"]) + self.assertEqual( + "/etc/ssl/certs/ca-certificates.crt", + plan.guest_env["SSL_CERT_FILE"], + ) + + +if __name__ == "__main__": + unittest.main()