From d923871fd25389462abcd36da8d831e7f65bdd2e Mon Sep 17 00:00:00 2001 From: didericis Date: Wed, 10 Jun 2026 19:46:39 -0400 Subject: [PATCH] feat(macos-container): launch explicit-proxy bottles --- bot_bottle/backend/macos_container/backend.py | 3 + .../backend/macos_container/bottle_plan.py | 3 + .../backend/macos_container/enumerate.py | 3 + bot_bottle/backend/macos_container/launch.py | 358 +++++++++++++++++- bot_bottle/backend/macos_container/util.py | 76 ++++ tests/unit/test_macos_container_cleanup.py | 5 +- tests/unit/test_macos_container_launch.py | 163 ++++++++ tests/unit/test_macos_container_util.py | 23 ++ 8 files changed, 618 insertions(+), 16 deletions(-) create mode 100644 tests/unit/test_macos_container_launch.py diff --git a/bot_bottle/backend/macos_container/backend.py b/bot_bottle/backend/macos_container/backend.py index 1f6423b..8826aa3 100644 --- a/bot_bottle/backend/macos_container/backend.py +++ b/bot_bottle/backend/macos_container/backend.py @@ -79,3 +79,6 @@ class MacosContainerBottleBackend( def enumerate_active(self) -> Sequence[ActiveAgent]: return _enumerate.enumerate_active() + + def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str: + return plan.agent_supervise_url diff --git a/bot_bottle/backend/macos_container/bottle_plan.py b/bot_bottle/backend/macos_container/bottle_plan.py index ca01073..d128d55 100644 --- a/bot_bottle/backend/macos_container/bottle_plan.py +++ b/bot_bottle/backend/macos_container/bottle_plan.py @@ -13,6 +13,9 @@ from .. import BottlePlan class MacosContainerBottlePlan(BottlePlan): slug: str forwarded_env: dict[str, str] = field(repr=False) + agent_proxy_url: str = "" + agent_git_gate_url: str = "" + agent_supervise_url: str = "" @property def container_name(self) -> str: diff --git a/bot_bottle/backend/macos_container/enumerate.py b/bot_bottle/backend/macos_container/enumerate.py index d6365a1..b7d261d 100644 --- a/bot_bottle/backend/macos_container/enumerate.py +++ b/bot_bottle/backend/macos_container/enumerate.py @@ -8,6 +8,7 @@ from ...bottle_state import read_metadata from .. import ActiveAgent _PREFIX = "bot-bottle-" +_SIDECAR_PREFIX = "bot-bottle-sidecars-" def enumerate_active() -> list[ActiveAgent]: @@ -23,6 +24,8 @@ def enumerate_active() -> list[ActiveAgent]: for name in sorted(line.strip() for line in result.stdout.splitlines()): if not name.startswith(_PREFIX): continue + if name.startswith(_SIDECAR_PREFIX): + continue slug = name[len(_PREFIX):] metadata = read_metadata(slug) out.append(ActiveAgent( diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index de9e053..2641e2d 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -1,34 +1,362 @@ """Launch flow for the macOS Apple Container backend. -The backend is registered and its host primitives are implemented, but -full launch is intentionally blocked until the sidecar network -enforcement design is finished. Apple Container can publish ports and -create networks, but bot-bottle's Docker topology relies on an agent -container attached only to an internal network while the sidecar bundle -also has egress. The first runnable version must preserve that -no-direct-egress property. +This backend keeps the explicit proxy-env enforcement model for v1: +the agent container is attached only to a host-only Apple Container +network, while the sidecar bundle is attached to a NAT network first +and the host-only network second. The sidecar's host-only IP is +discovered from `container inspect` and stamped into the agent's +HTTP_PROXY / HTTPS_PROXY env vars. """ from __future__ import annotations -from contextlib import contextmanager +import dataclasses +import os +import shutil +import subprocess +from contextlib import ExitStack, contextmanager +from pathlib import Path from typing import Callable, Generator -from ...log import die +from ...bottle_state import egress_state_dir, git_gate_state_dir +from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values +from ...git_gate import revoke_git_gate_provisioned_keys +from ...log import die, info, warn +from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT +from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT +from ..docker.sidecar_bundle import ( + SIDECAR_BUNDLE_DOCKERFILE, + SIDECAR_BUNDLE_IMAGE, +) +from ..docker.egress import egress_tls_init +from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH +from . import util as container_mod from .bottle import MacosContainerBottle from .bottle_plan import MacosContainerBottlePlan +_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) +_SIDECAR_SLEEP_SECONDS = "2147483647" + + +def internal_network_name(slug: str) -> str: + return f"bot-bottle-net-{slug}" + + +def egress_network_name(slug: str) -> str: + return f"bot-bottle-egress-{slug}" + + +def sidecar_container_name(slug: str) -> str: + return f"bot-bottle-sidecars-{slug}" + + @contextmanager def launch( plan: MacosContainerBottlePlan, *, provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None], ) -> Generator[MacosContainerBottle, None, None]: - del provision - die( - "macos-container backend launch is not enabled yet: " - "the backend primitives are present, but sidecar network " - "enforcement still needs implementation." + """Build, run, provision, and yield an Apple Container bottle.""" + stack = ExitStack() + bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name) + git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) + + def teardown() -> None: + teardown_exc: BaseException | None = None + try: + stack.close() + except BaseException as exc: # noqa: W0718 - teardown must continue + teardown_exc = exc + warn(f"macos-container teardown failed: {exc!r}") + revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke) + if teardown_exc is not None: + raise teardown_exc + + try: + _validate_supported_plan(plan) + plan = _mint_certs(plan) + _build_images(plan) + + internal_network = internal_network_name(plan.slug) + egress_network = egress_network_name(plan.slug) + _create_networks(internal_network, egress_network, stack) + + sidecar_name = sidecar_container_name(plan.slug) + container_mod.force_remove_container(sidecar_name) + _start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network) + stack.callback(container_mod.force_remove_container, sidecar_name) + + sidecar_ip = container_mod.container_ipv4_on_network( + sidecar_name, internal_network, + ) + plan = _stamp_agent_urls(plan, sidecar_ip) + + container_mod.force_remove_container(plan.container_name) + _start_agent(plan, internal_network, sidecar_ip) + stack.callback(container_mod.force_remove_container, plan.container_name) + + bottle = MacosContainerBottle( + plan.container_name, + teardown, + None, + agent_command=plan.agent_command, + agent_prompt_mode=plan.agent_prompt_mode, + agent_provider_template=plan.agent_provider_template, + terminal_title=plan.spec.label or plan.spec.agent_name, + terminal_color=plan.spec.color, + agent_workdir=plan.workspace_plan.workdir, + ) + bottle.prompt_path = provision(plan, bottle) + + yield bottle + finally: + teardown() + + +def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan: + egress_ca_host, egress_ca_cert_only = egress_tls_init( + egress_state_dir(plan.slug), ) - yield # pragma: no cover + egress_plan = dataclasses.replace( + plan.egress_plan, + mitmproxy_ca_host_path=egress_ca_host, + mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, + ) + return dataclasses.replace(plan, egress_plan=egress_plan) + + +def _validate_supported_plan(plan: MacosContainerBottlePlan) -> None: + if plan.git_gate_plan.upstreams: + die( + "macos-container backend launch does not support bottle.git yet: " + "Apple Container cannot bind-mount individual SSH key files, " + "and this backend will not mount broad host key directories. " + "Use docker/smolmachines for git-gate bottles until a safe key " + "delivery path lands." + ) + + +def _build_images(plan: MacosContainerBottlePlan) -> None: + container_mod.build_image( + SIDECAR_BUNDLE_IMAGE, + _REPO_DIR, + dockerfile=SIDECAR_BUNDLE_DOCKERFILE, + ) + container_mod.build_image( + plan.image, + _REPO_DIR, + dockerfile=plan.dockerfile_path, + ) + + +def _create_networks( + internal_network: str, + egress_network: str, + stack: ExitStack, +) -> None: + container_mod.create_network(internal_network, internal=True) + stack.callback(container_mod.remove_network, internal_network) + container_mod.create_network(egress_network) + stack.callback(container_mod.remove_network, egress_network) + + +def _start_sidecar_bundle( + plan: MacosContainerBottlePlan, + sidecar_name: str, + internal_network: str, + egress_network: str, +) -> None: + argv = _sidecar_run_argv(plan, sidecar_name, internal_network, egress_network) + effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env} + token_values = egress_resolve_token_values( + plan.egress_plan.token_env_map, effective_env, + ) + env = {**os.environ, **token_values} + info(f"container run sidecar bundle {sidecar_name}") + result = subprocess.run( + argv, capture_output=True, text=True, env=env, check=False, + ) + if result.returncode != 0: + die( + f"container run for sidecar bundle {sidecar_name} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + + +def _start_agent( + plan: MacosContainerBottlePlan, + internal_network: str, + sidecar_ip: str, +) -> None: + argv = _agent_run_argv(plan, internal_network, sidecar_ip) + env = { + **os.environ, + **plan.forwarded_env, + } + info(f"container run agent {plan.container_name}") + result = subprocess.run( + argv, capture_output=True, text=True, env=env, check=False, + ) + if result.returncode != 0: + die( + f"container run for agent {plan.container_name} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + + +def _stamp_agent_urls( + plan: MacosContainerBottlePlan, + sidecar_ip: str, +) -> MacosContainerBottlePlan: + proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}" + supervise_url = "" + if plan.supervise_plan is not None: + supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/" + return dataclasses.replace( + plan, + agent_proxy_url=proxy_url, + agent_git_gate_url="", + agent_supervise_url=supervise_url, + ) + + +def _sidecar_run_argv( + plan: MacosContainerBottlePlan, + sidecar_name: str, + internal_network: str, + egress_network: str, +) -> list[str]: + argv = [ + "container", "run", + "--name", sidecar_name, + "--detach", + "--rm", + "--network", egress_network, + "--network", internal_network, + "--dns", _sidecar_dns(), + "--env", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}", + ] + for entry in _sidecar_env_entries(plan): + argv += ["--env", entry] + for host_path, container_path, read_only in _sidecar_mounts(plan): + argv += ["--mount", _mount_spec(host_path, container_path, read_only)] + argv.append(SIDECAR_BUNDLE_IMAGE) + return argv + + +def _agent_run_argv( + plan: MacosContainerBottlePlan, + internal_network: str, + sidecar_ip: str, +) -> list[str]: + argv = [ + "container", "run", + "--name", plan.container_name, + "--detach", + "--rm", + "--network", internal_network, + ] + for entry in _agent_env_entries(plan, sidecar_ip): + argv += ["--env", entry] + argv += [plan.image, "sleep", _SIDECAR_SLEEP_SECONDS] + return argv + + +def _sidecar_dns() -> str: + return os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "1.1.1.1") + + +def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]: + daemons = ["egress"] + if plan.supervise_plan is not None: + daemons.append("supervise") + return tuple(daemons) + + +def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]: + env: list[str] = [] + if plan.egress_plan.routes: + env.extend(sorted(plan.egress_plan.token_env_map.keys())) + if plan.supervise_plan is not None: + env += [ + f"SUPERVISE_BOTTLE_SLUG={plan.slug}", + f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}", + f"SUPERVISE_PORT={SUPERVISE_PORT}", + ] + return tuple(env) + + +def _sidecar_mounts( + plan: MacosContainerBottlePlan, +) -> tuple[tuple[str, str, bool], ...]: + mounts: list[tuple[str, str, bool]] = [] + + ep = plan.egress_plan + mounts.append(( + str(ep.mitmproxy_ca_host_path.parent), + str(Path(EGRESS_CA_IN_CONTAINER).parent), + False, + )) + if ep.routes: + mounts.append(( + str(_stage_routes_dir(plan)), + str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), + True, + )) + + sp = plan.supervise_plan + if sp is not None: + mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False)) + + return tuple(mounts) + + +def _stage_routes_dir(plan: MacosContainerBottlePlan) -> Path: + routes_dir = plan.stage_dir / "macos-container-egress" + routes_dir.mkdir(parents=True, exist_ok=True) + shutil.copyfile( + plan.egress_plan.routes_path, + routes_dir / Path(EGRESS_ROUTES_IN_CONTAINER).name, + ) + return routes_dir + + +def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str: + spec = f"type=bind,source={host_path},target={container_path}" + if read_only: + spec += ",readonly" + return spec + + +def _agent_env_entries( + plan: MacosContainerBottlePlan, + sidecar_ip: str, +) -> tuple[str, ...]: + proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}" + no_proxy = _agent_no_proxy(plan, sidecar_ip) + env = [ + f"HTTPS_PROXY={proxy_url}", + f"HTTP_PROXY={proxy_url}", + f"https_proxy={proxy_url}", + f"http_proxy={proxy_url}", + f"NO_PROXY={no_proxy}", + f"no_proxy={no_proxy}", + f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}", + f"SSL_CERT_FILE={AGENT_CA_BUNDLE}", + f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}", + ] + if plan.agent_git_gate_url: + env.append(f"GIT_GATE_URL={plan.agent_git_gate_url}") + if plan.agent_supervise_url: + env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}") + for name, value in sorted(plan.agent_provision.guest_env.items()): + env.append(f"{name}={value}") + for name in sorted(plan.forwarded_env.keys()): + env.append(name) + return tuple(env) + + +def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str: + hosts = ["localhost", "127.0.0.1", sidecar_ip] + return ",".join(hosts) diff --git a/bot_bottle/backend/macos_container/util.py b/bot_bottle/backend/macos_container/util.py index 775fbe2..828d3cf 100644 --- a/bot_bottle/backend/macos_container/util.py +++ b/bot_bottle/backend/macos_container/util.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import platform import shutil import subprocess @@ -71,6 +72,81 @@ def force_remove_container(name: str) -> None: ) +def create_network(name: str, *, internal: bool = False) -> None: + args = [ + _CONTAINER, "network", "create", + "--label", "bot-bottle.backend=macos-container", + ] + if internal: + args.append("--internal") + args.append(name) + result = subprocess.run( + args, capture_output=True, text=True, check=False, + ) + if result.returncode == 0: + return + if "already exists" in (result.stderr or "").lower(): + return + die( + f"container network create {name} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + + +def remove_network(name: str) -> None: + result = subprocess.run( + [_CONTAINER, "network", "delete", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if result.returncode != 0: + return + + +def inspect_container(name: str) -> dict[str, object]: + result = subprocess.run( + [_CONTAINER, "inspect", name], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + die( + f"container inspect {name} failed: " + f"{(result.stderr or '').strip() or ''}" + ) + try: + data = json.loads(result.stdout or "[]") + except json.JSONDecodeError as exc: + die(f"container inspect {name} returned malformed JSON: {exc}") + if isinstance(data, list) and data and isinstance(data[0], dict): + return data[0] + if isinstance(data, dict): + return data + die(f"container inspect {name} returned an unexpected shape") + raise AssertionError("unreachable") + + +def container_ipv4_on_network(name: str, network: str) -> str: + data = inspect_container(name) + status = data.get("status") + networks = status.get("networks") if isinstance(status, dict) else None + if not isinstance(networks, list): + die(f"container inspect {name} did not include status.networks") + for entry in networks: + if not isinstance(entry, dict): + continue + if entry.get("network") != network: + continue + raw = entry.get("ipv4Address") + if not isinstance(raw, str) or not raw: + die(f"container {name} has no IPv4 address on {network}") + return raw.split("/", 1)[0] + die(f"container {name} is not attached to network {network}") + raise AssertionError("unreachable") + + def image_id(ref: str) -> str: """Return the image digest/ID from `container image inspect`. diff --git a/tests/unit/test_macos_container_cleanup.py b/tests/unit/test_macos_container_cleanup.py index d1502f2..fc2d980 100644 --- a/tests/unit/test_macos_container_cleanup.py +++ b/tests/unit/test_macos_container_cleanup.py @@ -45,7 +45,10 @@ class TestMacosContainerCleanup(unittest.TestCase): class TestMacosContainerEnumerate(unittest.TestCase): def test_enumerate_active_reads_metadata(self): completed = enum_mod.subprocess.CompletedProcess( - args=[], returncode=0, stdout="bot-bottle-a\nother\n", stderr="", + args=[], + returncode=0, + stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n", + stderr="", ) class _Metadata: diff --git a/tests/unit/test_macos_container_launch.py b/tests/unit/test_macos_container_launch.py new file mode 100644 index 0000000..27e41b6 --- /dev/null +++ b/tests/unit/test_macos_container_launch.py @@ -0,0 +1,163 @@ +"""Unit: Apple Container launch argv construction.""" + +from __future__ import annotations + +import unittest +import tempfile +from pathlib import Path +from types import SimpleNamespace +from typing import cast +from unittest.mock import patch + +from bot_bottle.backend.macos_container import launch +from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan + + +def _plan( + *, + stage_dir: Path, + git: bool = False, + supervise: bool = False, + agent_git_gate_url: str = "", + agent_supervise_url: str = "", +) -> MacosContainerBottlePlan: + routes_path = stage_dir / "source-routes.yaml" + routes_path.write_text("routes: []\n", encoding="utf-8") + ca_dir = stage_dir / "egress-ca" + ca_dir.mkdir(exist_ok=True) + ca_path = ca_dir / "mitmproxy-ca.pem" + ca_path.write_text("ca\n", encoding="utf-8") + egress_plan = SimpleNamespace( + mitmproxy_ca_host_path=ca_path, + routes_path=routes_path, + routes=("route",), + token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"}, + ) + if git: + upstream = SimpleNamespace( + name="origin", + identity_file="/host/key", + known_hosts_file=Path("/host/known_hosts"), + ) + git_gate_plan = SimpleNamespace( + upstreams=(upstream,), + entrypoint_script=Path("/state/git/entrypoint.sh"), + hook_script=Path("/state/git/pre-receive"), + access_hook_script=Path("/state/git/access.sh"), + ) + else: + git_gate_plan = SimpleNamespace(upstreams=()) + supervise_plan = ( + SimpleNamespace(queue_dir=Path("/state/supervise/queue")) + if supervise else None + ) + agent_provision = SimpleNamespace( + guest_env={"LITERAL": "value"}, + provisioned_env={"CODEX_HOME": "/run/codex-home"}, + ) + return cast(MacosContainerBottlePlan, SimpleNamespace( + stage_dir=stage_dir, + slug="dev-abc", + container_name="bot-bottle-dev-abc", + image="bot-bottle-agent:latest", + forwarded_env={"OAUTH_TOKEN": "host-value"}, + egress_plan=egress_plan, + git_gate_plan=git_gate_plan, + supervise_plan=supervise_plan, + agent_provision=agent_provision, + agent_git_gate_url=agent_git_gate_url, + agent_supervise_url=agent_supervise_url, + )) + + +class TestMacosContainerLaunchArgv(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.stage_dir = Path(self._tmp.name) + + def tearDown(self): + self._tmp.cleanup() + + def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self): + plan = _plan(stage_dir=self.stage_dir, supervise=True) + with patch.object(launch.os, "environ", { + "BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9", + }): + argv = launch._sidecar_run_argv( + plan, + "bot-bottle-sidecars-dev-abc", + "bot-bottle-net-dev-abc", + "bot-bottle-egress-dev-abc", + ) + self.assertEqual( + [ + "--network", "bot-bottle-egress-dev-abc", + "--network", "bot-bottle-net-dev-abc", + ], + argv[argv.index("--network"):argv.index("--dns")], + ) + self.assertIn("--dns", argv) + self.assertEqual("9.9.9.9", argv[argv.index("--dns") + 1]) + self.assertIn( + "BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise", + argv, + ) + self.assertIn("EGRESS_TOKEN_0", argv) + self.assertIn( + f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy", + argv, + ) + routes_dir = self.stage_dir / "macos-container-egress" + self.assertIn( + f"type=bind,source={routes_dir},target=/etc/egress,readonly", + argv, + ) + self.assertEqual( + "routes: []\n", + (routes_dir / "routes.yaml").read_text(encoding="utf-8"), + ) + self.assertIn( + "type=bind,source=/state/supervise/queue,target=/run/supervise/queue", + argv, + ) + + def test_agent_env_points_proxy_at_sidecar_ip(self): + plan = _plan( + stage_dir=self.stage_dir, + agent_git_gate_url="http://192.168.128.2:9420", + agent_supervise_url="http://192.168.128.2:9100/", + ) + env = launch._agent_env_entries(plan, "192.168.128.2") + self.assertIn("HTTPS_PROXY=http://192.168.128.2:9099", env) + self.assertIn("HTTP_PROXY=http://192.168.128.2:9099", env) + self.assertIn("https_proxy=http://192.168.128.2:9099", env) + self.assertIn("http_proxy=http://192.168.128.2:9099", env) + self.assertIn("NO_PROXY=localhost,127.0.0.1,192.168.128.2", env) + self.assertIn("NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt", env) + self.assertIn("SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", env) + self.assertIn("REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt", env) + self.assertIn("GIT_GATE_URL=http://192.168.128.2:9420", env) + self.assertIn("MCP_SUPERVISE_URL=http://192.168.128.2:9100/", env) + self.assertIn("LITERAL=value", env) + self.assertIn("OAUTH_TOKEN", env) + self.assertNotIn("CODEX_HOME", env) + + def test_agent_run_uses_internal_network_only(self): + plan = _plan(stage_dir=self.stage_dir) + argv = launch._agent_run_argv( + plan, "bot-bottle-net-dev-abc", "192.168.128.2", + ) + self.assertIn("--network", argv) + self.assertEqual("bot-bottle-net-dev-abc", argv[argv.index("--network") + 1]) + self.assertNotIn("bot-bottle-egress-dev-abc", argv) + self.assertEqual(["bot-bottle-agent:latest", "sleep", "2147483647"], argv[-3:]) + + def test_git_gate_is_blocked_until_safe_key_delivery_exists(self): + plan = _plan(stage_dir=self.stage_dir, git=True) + with patch.object(launch, "die", side_effect=SystemExit("die")): + with self.assertRaises(SystemExit): + launch._validate_supported_plan(plan) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_macos_container_util.py b/tests/unit/test_macos_container_util.py index db58378..ce4cceb 100644 --- a/tests/unit/test_macos_container_util.py +++ b/tests/unit/test_macos_container_util.py @@ -55,6 +55,29 @@ class TestMacosContainerCommands(unittest.TestCase): with patch.object(util.subprocess, "run", return_value=completed): self.assertEqual("sha256:abc", util.image_id("demo:latest")) + def test_container_ipv4_on_network_reads_inspect_json(self): + payload = """[{ + "status": { + "networks": [ + { + "network": "bot-bottle-net-demo", + "ipv4Address": "192.168.128.2/24" + } + ] + } + }]""" + completed = util.subprocess.CompletedProcess( + args=[], returncode=0, stdout=payload, stderr="", + ) + with patch.object(util.subprocess, "run", return_value=completed): + self.assertEqual( + "192.168.128.2", + util.container_ipv4_on_network( + "bot-bottle-sidecars-demo", + "bot-bottle-net-demo", + ), + ) + if __name__ == "__main__": unittest.main()