Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e467d236a | |||
| d123b99347 | |||
| f7f9892b53 | |||
| d923871fd2 | |||
| 7350494944 | |||
| 4abad499b6 |
@@ -190,7 +190,7 @@ class ActiveAgent:
|
||||
of sidecar daemons currently up for this bottle (`egress`,
|
||||
`git-gate`, `supervise`); the dashboard uses it to
|
||||
gate edit verbs. `backend_name` is the matching key in
|
||||
`_BACKENDS` (`docker` / `smolmachines`) — used by the active-
|
||||
`_BACKENDS` (`docker` / `smolmachines` / `macos-container`) — used by the active-
|
||||
list rendering to disambiguate and by the dashboard's
|
||||
re-attach path."""
|
||||
|
||||
@@ -530,6 +530,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
||||
# via `from . import ...` without hitting a partially-initialized module.
|
||||
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
@@ -539,6 +540,7 @@ from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: dis
|
||||
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
||||
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
||||
"docker": DockerBottleBackend(),
|
||||
"macos-container": MacosContainerBottleBackend(),
|
||||
"smolmachines": SmolmachinesBottleBackend(),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""macOS Apple Container backend.
|
||||
|
||||
Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns
|
||||
the Apple `container` CLI integration; launch remains gated until the
|
||||
sidecar network enforcement shape is implemented.
|
||||
"""
|
||||
|
||||
from .backend import MacosContainerBottleBackend
|
||||
|
||||
__all__ = ["MacosContainerBottleBackend"]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""MacosContainerBottleBackend — Apple Container implementation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Generator, Sequence
|
||||
|
||||
from ...agent_provider import AgentProvisionPlan
|
||||
from ...egress import EgressPlan
|
||||
from ...env import ResolvedEnv
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...supervise import SupervisePlan
|
||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||
from . import cleanup as _cleanup
|
||||
from . import enumerate as _enumerate
|
||||
from . import launch as _launch
|
||||
from . import resolve_plan as _resolve_plan
|
||||
from . import util as _container
|
||||
from .bottle import MacosContainerBottle
|
||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
||||
from .bottle_plan import MacosContainerBottlePlan
|
||||
|
||||
|
||||
class MacosContainerBottleBackend(
|
||||
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"]
|
||||
):
|
||||
"""Experimental Apple Container backend. Selected by
|
||||
`BOT_BOTTLE_BACKEND=macos-container` or
|
||||
`--backend=macos-container`."""
|
||||
|
||||
name = "macos-container"
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return _container.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,
|
||||
*,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
egress_plan: EgressPlan,
|
||||
git_gate_plan: GitGatePlan,
|
||||
supervise_plan: SupervisePlan | None,
|
||||
stage_dir: Path,
|
||||
) -> MacosContainerBottlePlan:
|
||||
return _resolve_plan.resolve_plan(
|
||||
spec,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
git_gate_plan=git_gate_plan,
|
||||
stage_dir=stage_dir,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def launch(
|
||||
self, plan: MacosContainerBottlePlan
|
||||
) -> Generator[MacosContainerBottle, None, None]:
|
||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
||||
yield bottle
|
||||
|
||||
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
|
||||
return _cleanup.prepare_cleanup()
|
||||
|
||||
def cleanup(self, plan: MacosContainerBottleCleanupPlan) -> None:
|
||||
_cleanup.cleanup(plan)
|
||||
|
||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
||||
return _enumerate.enumerate_active()
|
||||
|
||||
def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str:
|
||||
return plan.agent_supervise_url
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Bottle handle for Apple's `container` CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Callable, cast
|
||||
|
||||
from ...agent_provider import PromptMode, prompt_args
|
||||
from .. import Bottle, ExecResult
|
||||
from ..terminal import exec_shell_script
|
||||
|
||||
|
||||
class MacosContainerBottle(Bottle):
|
||||
def __init__(
|
||||
self,
|
||||
container: str,
|
||||
teardown: Callable[[], None],
|
||||
prompt_path_in_container: str | None,
|
||||
*,
|
||||
agent_command: str = "claude",
|
||||
agent_prompt_mode: PromptMode = "append_file",
|
||||
agent_provider_template: str = "claude",
|
||||
terminal_title: str = "",
|
||||
terminal_color: str = "",
|
||||
agent_workdir: str = "/home/node",
|
||||
):
|
||||
self.name = container
|
||||
self._teardown = teardown
|
||||
self.prompt_path = prompt_path_in_container
|
||||
self._agent_prompt_mode = agent_prompt_mode
|
||||
self.agent_command = agent_command
|
||||
self.terminal_title = terminal_title
|
||||
self.terminal_color = terminal_color
|
||||
self.agent_provider_template = agent_provider_template
|
||||
self.agent_workdir = agent_workdir
|
||||
self._closed = False
|
||||
|
||||
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
|
||||
full_argv = list(argv)
|
||||
full_argv.extend(
|
||||
prompt_args(
|
||||
cast(PromptMode, self._agent_prompt_mode),
|
||||
self.prompt_path,
|
||||
argv=full_argv,
|
||||
)
|
||||
)
|
||||
cmd = ["container", "exec"]
|
||||
if tty:
|
||||
cmd.extend(["--interactive", "--tty"])
|
||||
if self.agent_workdir and self.agent_workdir != "/home/node":
|
||||
cmd.extend(["--workdir", self.agent_workdir])
|
||||
cmd.extend([self.name, self.agent_command, *full_argv])
|
||||
return cmd
|
||||
|
||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||
agent_argv = self.agent_argv(argv, tty=tty)
|
||||
script = (
|
||||
exec_shell_script(agent_argv, self.terminal_title, self.terminal_color)
|
||||
if tty else None
|
||||
)
|
||||
if script is None:
|
||||
return subprocess.run(agent_argv, check=False).returncode
|
||||
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
||||
|
||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
||||
result = subprocess.run(
|
||||
["container", "exec", "--user", user, "--interactive",
|
||||
self.name, "sh", "-s"],
|
||||
input=script,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return ExecResult(
|
||||
returncode=result.returncode,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
)
|
||||
|
||||
def cp_in(self, host_path: str, container_path: str) -> None:
|
||||
subprocess.run(
|
||||
["container", "cp", host_path, f"{self.name}:{container_path}"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
check=True,
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._teardown()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Cleanup plan for the macOS Apple Container backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ...log import info
|
||||
from .. import BottleCleanupPlan
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MacosContainerBottleCleanupPlan(BottleCleanupPlan):
|
||||
containers: tuple[str, ...] = ()
|
||||
networks: tuple[str, ...] = ()
|
||||
|
||||
def print(self) -> None:
|
||||
if not self.containers and not self.networks:
|
||||
info("macos-container cleanup: nothing to remove")
|
||||
return
|
||||
for name in self.containers:
|
||||
info(f"macos-container container: {name}")
|
||||
for name in self.networks:
|
||||
info(f"macos-container network: {name}")
|
||||
|
||||
@property
|
||||
def empty(self) -> bool:
|
||||
return not self.containers and not self.networks
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Plan type for the macOS Apple Container backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ...agent_provider import PromptMode
|
||||
from .. import BottlePlan
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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:
|
||||
return self.agent_provision.instance_name
|
||||
|
||||
@property
|
||||
def image(self) -> str:
|
||||
return self.agent_provision.image
|
||||
|
||||
@property
|
||||
def dockerfile_path(self) -> str:
|
||||
return self.agent_provision.dockerfile
|
||||
|
||||
@property
|
||||
def prompt_file(self) -> Path:
|
||||
return self.agent_provision.prompt_file
|
||||
|
||||
@property
|
||||
def agent_command(self) -> str:
|
||||
return self.agent_provision.command
|
||||
|
||||
@property
|
||||
def agent_prompt_mode(self) -> PromptMode:
|
||||
return self.agent_provision.prompt_mode
|
||||
|
||||
@property
|
||||
def agent_provider_template(self) -> str:
|
||||
return self.agent_provision.template
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Cleanup for the macOS Apple Container backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from ...log import info, warn
|
||||
from . import util as container_mod
|
||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
||||
|
||||
_PREFIX = "bot-bottle-"
|
||||
_BUNDLE_PREFIX = "bot-bottle-sidecars-"
|
||||
|
||||
|
||||
def _list_prefixed_containers() -> list[str]:
|
||||
result = subprocess.run(
|
||||
["container", "list", "--all", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
warn(f"container list failed: {result.stderr.strip()}")
|
||||
return []
|
||||
return sorted(
|
||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
||||
if name.startswith(_PREFIX) or name.startswith(_BUNDLE_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def _list_prefixed_networks() -> list[str]:
|
||||
result = subprocess.run(
|
||||
["container", "network", "list", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
return sorted(
|
||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
||||
if name.startswith(_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
def prepare_cleanup() -> MacosContainerBottleCleanupPlan:
|
||||
container_mod.require_container()
|
||||
return MacosContainerBottleCleanupPlan(
|
||||
containers=tuple(_list_prefixed_containers()),
|
||||
networks=tuple(_list_prefixed_networks()),
|
||||
)
|
||||
|
||||
|
||||
def cleanup(plan: MacosContainerBottleCleanupPlan) -> None:
|
||||
for name in plan.containers:
|
||||
info(f"container delete --force {name}")
|
||||
subprocess.run(
|
||||
["container", "delete", "--force", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
for name in plan.networks:
|
||||
info(f"container network delete {name}")
|
||||
subprocess.run(
|
||||
["container", "network", "delete", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Active-agent enumeration for the macOS Apple Container backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
from ...bottle_state import read_metadata
|
||||
from .. import ActiveAgent
|
||||
|
||||
_PREFIX = "bot-bottle-"
|
||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
||||
|
||||
|
||||
def enumerate_active() -> list[ActiveAgent]:
|
||||
result = subprocess.run(
|
||||
["container", "list", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
out: 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(
|
||||
backend_name="macos-container",
|
||||
slug=slug,
|
||||
agent_name=metadata.agent_name if metadata else "?",
|
||||
started_at=metadata.started_at if metadata else "",
|
||||
services=(),
|
||||
label=metadata.label if metadata else "",
|
||||
color=metadata.color if metadata else "",
|
||||
))
|
||||
return out
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Launch flow for the macOS Apple Container backend.
|
||||
|
||||
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
|
||||
|
||||
import dataclasses
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generator
|
||||
|
||||
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]:
|
||||
"""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),
|
||||
)
|
||||
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 '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
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 '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
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 container_mod.dns_server()
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Prepare step for the macOS Apple Container backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from ...agent_provider import AgentProvisionPlan
|
||||
from ...egress import EgressPlan
|
||||
from ...env import ResolvedEnv
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...supervise import SupervisePlan
|
||||
from .. import BottleSpec
|
||||
from . import util as container_mod
|
||||
from .bottle_plan import MacosContainerBottlePlan
|
||||
|
||||
|
||||
def preflight() -> None:
|
||||
container_mod.require_container()
|
||||
|
||||
|
||||
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
return dict(resolved_env.literals)
|
||||
|
||||
|
||||
def resolve_plan(
|
||||
spec: BottleSpec,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
egress_plan: EgressPlan,
|
||||
supervise_plan: SupervisePlan | None,
|
||||
git_gate_plan: GitGatePlan,
|
||||
stage_dir: Path,
|
||||
) -> MacosContainerBottlePlan:
|
||||
return MacosContainerBottlePlan(
|
||||
spec=spec,
|
||||
stage_dir=stage_dir,
|
||||
slug=slug,
|
||||
forwarded_env=dict(resolved_env.forwarded),
|
||||
git_gate_plan=git_gate_plan,
|
||||
egress_plan=egress_plan,
|
||||
supervise_plan=supervise_plan,
|
||||
agent_provision=agent_provision_plan,
|
||||
)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Host-side primitives for Apple's `container` CLI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import ipaddress
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Iterable
|
||||
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
_CONTAINER = "container"
|
||||
_DEFAULT_DNS = "1.1.1.1"
|
||||
|
||||
|
||||
def is_macos() -> bool:
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
return is_macos() and shutil.which(_CONTAINER) is not None
|
||||
|
||||
|
||||
def require_container() -> None:
|
||||
"""Fail with an install pointer if Apple Container is unavailable."""
|
||||
if not is_macos():
|
||||
info("BOT_BOTTLE_BACKEND=macos-container requires macOS.")
|
||||
die("macos-container backend is only supported on macOS")
|
||||
if shutil.which(_CONTAINER) is None:
|
||||
info("Apple Container is required but was not found on PATH.")
|
||||
info("Install: https://github.com/apple/container/releases")
|
||||
die("container not found")
|
||||
|
||||
|
||||
def dns_server() -> str:
|
||||
override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip()
|
||||
if override:
|
||||
return override
|
||||
return _host_ipv4_dns() or _DEFAULT_DNS
|
||||
|
||||
|
||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
"""Build an OCI image with Apple's BuildKit-backed `container build`."""
|
||||
info(
|
||||
f"building image {ref} from {context} with Apple Container "
|
||||
"(layer cache keeps repeat builds fast)"
|
||||
)
|
||||
_ensure_builder_dns()
|
||||
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
||||
if dockerfile:
|
||||
args.extend(["-f", dockerfile])
|
||||
args.append(context)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def _ensure_builder_dns() -> None:
|
||||
dns = dns_server()
|
||||
status = _builder_status()
|
||||
override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip()
|
||||
if _builder_running(status) and _builder_resolves_build_hosts():
|
||||
if override and not _builder_has_dns(status, dns):
|
||||
_restart_builder_with_dns(dns)
|
||||
return
|
||||
_restart_builder_with_dns(dns)
|
||||
|
||||
|
||||
def _restart_builder_with_dns(dns: str) -> None:
|
||||
subprocess.run(
|
||||
[_CONTAINER, "builder", "stop"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
subprocess.run(
|
||||
[_CONTAINER, "builder", "start", "--dns", dns],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def _host_ipv4_dns() -> str:
|
||||
if not is_macos():
|
||||
return ""
|
||||
result = subprocess.run(
|
||||
["scutil", "--dns"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
blocks: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
for line in result.stdout.splitlines():
|
||||
if line.startswith("resolver #") and current:
|
||||
blocks.append(current)
|
||||
current = []
|
||||
current.append(line)
|
||||
if current:
|
||||
blocks.append(current)
|
||||
for direct_only in (True, False):
|
||||
for block in blocks:
|
||||
text = "\n".join(block)
|
||||
if direct_only and "Directly Reachable Address" not in text:
|
||||
continue
|
||||
for line in block:
|
||||
if "nameserver[" not in line or ":" not in line:
|
||||
continue
|
||||
candidate = line.split(":", 1)[1].strip()
|
||||
if _usable_ipv4(candidate):
|
||||
return candidate
|
||||
return ""
|
||||
|
||||
|
||||
def _usable_ipv4(value: str) -> bool:
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return False
|
||||
return (
|
||||
address.version == 4
|
||||
and not address.is_loopback
|
||||
and not address.is_link_local
|
||||
and not address.is_multicast
|
||||
and not address.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def _builder_status() -> list[dict[str, object]]:
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "builder", "status", "--format", "json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(result.stdout or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if isinstance(data, list):
|
||||
return [entry for entry in data if isinstance(entry, dict)]
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
return []
|
||||
|
||||
|
||||
def _builder_running(status: list[dict[str, object]]) -> bool:
|
||||
for entry in status:
|
||||
entry_status = entry.get("status")
|
||||
if isinstance(entry_status, dict) and entry_status.get("state") == "running":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _builder_dns_nameservers(status: list[dict[str, object]]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for entry in status:
|
||||
config = entry.get("configuration")
|
||||
config_dns = config.get("dns") if isinstance(config, dict) else None
|
||||
nameservers = (
|
||||
config_dns.get("nameservers")
|
||||
if isinstance(config_dns, dict)
|
||||
else None
|
||||
)
|
||||
if not isinstance(nameservers, list):
|
||||
continue
|
||||
out.extend(name for name in nameservers if isinstance(name, str))
|
||||
return out
|
||||
|
||||
|
||||
def _builder_has_dns(status: list[dict[str, object]], dns: str) -> bool:
|
||||
return dns in _builder_dns_nameservers(status)
|
||||
|
||||
|
||||
def _builder_resolves_build_hosts() -> bool:
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "exec", "buildkit", "getent", "hosts", "deb.debian.org"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def image_exists(ref: str) -> bool:
|
||||
return _silent_run([_CONTAINER, "image", "inspect", ref]) == 0
|
||||
|
||||
|
||||
def container_exists(name: str) -> bool:
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "list", "--all", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
||||
|
||||
|
||||
def force_remove_container(name: str) -> None:
|
||||
if container_exists(name):
|
||||
subprocess.run(
|
||||
[_CONTAINER, "delete", "--force", name],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
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 '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
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 '<no stderr>'}"
|
||||
)
|
||||
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`.
|
||||
|
||||
The command returns JSON on current Apple Container releases. Keep
|
||||
parsing narrow and fatal so callers do not cache on an empty key.
|
||||
"""
|
||||
import json
|
||||
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "image", "inspect", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container image inspect for {ref!r} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
try:
|
||||
data = json.loads(result.stdout or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
die(f"container image inspect for {ref!r} returned malformed JSON: {exc}")
|
||||
if isinstance(data, list) and data:
|
||||
data = data[0]
|
||||
if isinstance(data, dict):
|
||||
value = data.get("id") or data.get("digest") or data.get("ID")
|
||||
if value:
|
||||
return str(value)
|
||||
die(f"container image inspect for {ref!r} did not include an image id")
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
|
||||
def save(ref: str, output: str) -> None:
|
||||
subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True)
|
||||
|
||||
|
||||
def _silent_run(cmd: Iterable[str]) -> int:
|
||||
return subprocess.run(
|
||||
list(cmd),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode
|
||||
@@ -0,0 +1,189 @@
|
||||
# PRD prd-new: macOS Container backend
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** Codex
|
||||
- **Created:** 2026-06-10
|
||||
- **Issue:** #220
|
||||
|
||||
## Summary
|
||||
|
||||
Add an experimental `macos-container` backend that integrates Apple's
|
||||
`container` CLI as a host runtime on macOS. The first shipped slice
|
||||
registers the backend and implements reusable host primitives
|
||||
(`build`, `exec`, `cp`, image inspection, cleanup, active
|
||||
enumeration). Follow-up slices make launch runnable with the proven
|
||||
two-network sidecar topology and add real-runtime coverage, without
|
||||
weakening bot-bottle's sidecar egress model.
|
||||
|
||||
## Problem
|
||||
|
||||
bot-bottle currently has two local execution paths:
|
||||
|
||||
- `docker`, which runs the whole bottle topology through Docker
|
||||
Compose.
|
||||
- `smolmachines`, which runs the agent in smolvm but still depends on
|
||||
Docker for the sidecar bundle and image-building pipeline.
|
||||
|
||||
Issue #220 explored removing Docker as a host dependency. A follow-up
|
||||
review comment verified that smolvm can publish guest ports back to
|
||||
host loopback and that another smolvm guest can reach that service
|
||||
through the existing per-bottle loopback alias plus `--allow-cidr`
|
||||
path. That keeps the VM-contained sidecar direction viable and rejects
|
||||
the host-process sidecar fallback.
|
||||
|
||||
Apple's `container` CLI is another macOS-native way to run OCI images
|
||||
as lightweight Linux VMs. Its current command surface includes
|
||||
Docker-like `build`, `run`, `exec`, `cp`, port publishing, image
|
||||
inspection, and user-defined networks. That makes it a plausible local
|
||||
backend, but it does not remove the need to preserve bot-bottle's
|
||||
sidecar enforcement property: the agent must not have a direct egress
|
||||
path around the egress sidecar.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `--backend=macos-container` and
|
||||
`BOT_BOTTLE_BACKEND=macos-container` are accepted by the existing
|
||||
backend selector.
|
||||
- Backend availability is true only on macOS hosts with `container` on
|
||||
`PATH`.
|
||||
- The backend has tested wrappers for Apple Container image build,
|
||||
image inspection, container `exec`, container `cp`, cleanup, and
|
||||
active-agent enumeration.
|
||||
- Full launch uses a host-only internal network for the agent and a
|
||||
separate NAT egress network for the sidecar bundle.
|
||||
- The agent container does not attach to the egress network. It reaches
|
||||
allowed outbound hosts through HTTP(S)_PROXY pointing at the
|
||||
sidecar's internal-network IP.
|
||||
- `bottle.git` / git-gate bottles fail loudly on this backend until a
|
||||
safe Apple Container key-delivery path exists.
|
||||
- Real-runtime integration coverage is present and guarded by macOS and
|
||||
Apple Container availability.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Do not remove or deprecate the Docker backend.
|
||||
- Do not change the default backend from `smolmachines`.
|
||||
- Do not run sidecar daemons as host processes.
|
||||
- Do not launch a degraded backend where the agent can bypass the
|
||||
egress sidecar through direct network access.
|
||||
- Do not require Docker Desktop as part of the macOS Container backend.
|
||||
|
||||
## Design
|
||||
|
||||
### Backend name
|
||||
|
||||
The selectable backend name is `macos-container`. The Python package
|
||||
uses `bot_bottle.backend.macos_container` because module names cannot
|
||||
contain hyphens.
|
||||
|
||||
### Availability and preflight
|
||||
|
||||
`MacosContainerBottleBackend.is_available()` returns true only when:
|
||||
|
||||
- `platform.system() == "Darwin"`
|
||||
- `container` is discoverable on `PATH`
|
||||
|
||||
`prepare()` calls `require_container()`, which produces a concrete
|
||||
install pointer and rejects non-macOS hosts.
|
||||
|
||||
### Implemented primitives
|
||||
|
||||
The backend owns an Apple Container wrapper module instead of reusing
|
||||
Docker wrappers. The wrapper maps bot-bottle's backend needs to
|
||||
Apple's CLI:
|
||||
|
||||
| bot-bottle need | Apple Container command |
|
||||
|---|---|
|
||||
| Build provider image | `container build -t <ref> [-f Dockerfile] <context>` |
|
||||
| Run agent commands | `container exec [--interactive --tty] <id> ...` |
|
||||
| Copy files into guest | `container cp <host> <id>:<path>` |
|
||||
| Inspect image identity | `container image inspect <ref>` |
|
||||
| Cleanup stale containers | `container delete --force <id>` |
|
||||
| Cleanup stale networks | `container network delete <name>` |
|
||||
| Active enumeration | `container list --quiet` |
|
||||
|
||||
The bottle handle mirrors `DockerBottle`: it builds a host argv for
|
||||
foreground agent execution, pipes shell snippets through stdin for
|
||||
`Bottle.exec`, and exposes `cp_in` for provisioning.
|
||||
|
||||
### Launch topology
|
||||
|
||||
`launch()` uses Apple Container's two-network topology:
|
||||
|
||||
- create a host-only internal network for the bottle;
|
||||
- create a normal NAT egress network for the sidecar bundle;
|
||||
- start the sidecar bundle attached to the egress network first and the
|
||||
internal network second;
|
||||
- discover the sidecar's internal-network IPv4 address from
|
||||
`container inspect`;
|
||||
- start the agent attached only to the internal network, with
|
||||
HTTP_PROXY / HTTPS_PROXY / lowercase proxy vars pointing at the
|
||||
sidecar IP and egress port.
|
||||
|
||||
This keeps the agent off the outbound network while preserving the
|
||||
proxy-env contract that existing agent tooling already honors. The
|
||||
integration smoke also removes the proxy env in-guest and confirms
|
||||
direct egress fails.
|
||||
|
||||
### Deferred git-gate support
|
||||
|
||||
Apple Container currently rejects single-file bind mounts, and
|
||||
`container cp` into a stopped container is not available. Starting the
|
||||
container earlier would allow `container cp` into a running container,
|
||||
but it would also mean delivering SSH private key material into a live
|
||||
sidecar before the git-gate daemon is ready to own it. Mounting broad
|
||||
host SSH directories is not acceptable.
|
||||
|
||||
For this PRD, `bottle.git` / git-gate support is explicitly deferred on
|
||||
the `macos-container` backend. Bottles with git-gate upstreams fail
|
||||
loudly and should use `docker` or `smolmachines` until a narrower key
|
||||
delivery design lands.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
1. Register `macos-container`, add availability/preflight, bottle
|
||||
handle, utility wrappers, cleanup, active enumeration, unit tests,
|
||||
and this PRD.
|
||||
2. Spike Apple Container networking against real macOS 26 hosts:
|
||||
repeated `--network`, internal network egress behavior, published
|
||||
loopback reachability from another container, DNS behavior, and
|
||||
labels/JSON output stability.
|
||||
3. Implement launch once the enforcement shape is proven. Reuse the
|
||||
existing sidecar bundle image and daemon subset env contract where
|
||||
possible.
|
||||
4. Add real-runtime integration tests guarded by `container` presence
|
||||
and macOS version.
|
||||
5. Consider moving smolmachines sidecar/image-building work to
|
||||
VM-contained or Apple Container-backed execution only after the
|
||||
`macos-container` launch path is trustworthy.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Unit tests cover backend registration through `known_backend_names`.
|
||||
- Unit tests cover availability/preflight behavior without requiring
|
||||
macOS.
|
||||
- Unit tests cover `MacosContainerBottle` command construction and
|
||||
stdin-based shell execution.
|
||||
- Unit tests cover cleanup and active enumeration parsing.
|
||||
- Unit tests cover launch argv/env construction, sidecar mount
|
||||
staging, sidecar IP parsing, and git-gate rejection.
|
||||
- Integration tests run on macOS hosts with Apple Container installed
|
||||
and verify that egress cannot bypass the sidecar. They also preflight
|
||||
Apple Container BuildKit DNS because image builds must resolve
|
||||
package mirrors before a launch smoke can be meaningful. The backend
|
||||
probes the running builder before image builds and leaves it alone
|
||||
when its current resolver works. If the probe fails, or if the
|
||||
operator explicitly sets `BOT_BOTTLE_MACOS_CONTAINER_DNS`, the backend
|
||||
restarts the Apple Container builder with the configured DNS server.
|
||||
Without an explicit override, that server is discovered from the
|
||||
host's directly reachable IPv4 resolver before falling back to a
|
||||
public resolver.
|
||||
|
||||
## References
|
||||
|
||||
- [Issue #220 review comment](https://gitea.dideric.is/didericis/bot-bottle/issues/220#issuecomment-1980):
|
||||
smolvm `--port/-p` can expose a guest service to host loopback, and
|
||||
another smolvm guest can reach it through the existing per-bottle
|
||||
loopback alias path.
|
||||
- Apple Container command reference: `container run`, `build`, `exec`,
|
||||
port publishing, and network commands.
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Integration: macOS Container launch topology.
|
||||
|
||||
End-to-end against Apple's real `container` runtime. The smoke launches
|
||||
a bottle with the experimental macOS Container backend and verifies the
|
||||
properties that make the explicit-proxy launch acceptable:
|
||||
|
||||
- the agent can exec commands after provisioning;
|
||||
- HTTP(S)_PROXY points at the sidecar's internal-network IP;
|
||||
- allowlisted HTTPS reaches the egress sidecar;
|
||||
- direct egress with proxy env removed fails from the internal-only
|
||||
agent network;
|
||||
- non-allowlisted proxy traffic is blocked.
|
||||
|
||||
Skipped under Gitea Actions and on hosts without Apple's `container`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
||||
from bot_bottle.backend.macos_container.util import (
|
||||
dns_server as _container_dns_server,
|
||||
is_available as _container_available,
|
||||
)
|
||||
from bot_bottle.manifest import Manifest
|
||||
|
||||
|
||||
_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief."
|
||||
|
||||
|
||||
def _minimal_agent_dockerfile(path: Path) -> None:
|
||||
path.write_text(
|
||||
"\n".join((
|
||||
"FROM node:22-slim",
|
||||
"RUN apt-get update \\",
|
||||
" && apt-get install -y --no-install-recommends \\",
|
||||
" ca-certificates curl git \\",
|
||||
" && rm -rf /var/lib/apt/lists/*",
|
||||
"USER node",
|
||||
"WORKDIR /home/node",
|
||||
"CMD [\"sleep\", \"infinity\"]",
|
||||
"",
|
||||
)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _minimal_manifest(dockerfile: Path) -> Manifest:
|
||||
return Manifest.from_json_obj({
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"agent_provider": {
|
||||
"template": "pi",
|
||||
"dockerfile": str(dockerfile),
|
||||
"settings": {
|
||||
"provider": "example",
|
||||
"base_url": "https://example.com/v1",
|
||||
"models": ["smoke"],
|
||||
},
|
||||
},
|
||||
"egress": {
|
||||
"routes": [
|
||||
{"host": "example.com"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": [],
|
||||
"prompt": _AGENT_PROMPT,
|
||||
"bottle": "dev",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def _buildkit_dns_available() -> bool:
|
||||
if platform.system() != "Darwin" or not _container_available():
|
||||
return False
|
||||
stage = Path(tempfile.mkdtemp(prefix="cb-container-buildkit-dns."))
|
||||
image = "bot-bottle-buildkit-dns-check:latest"
|
||||
try:
|
||||
dockerfile = stage / "Dockerfile"
|
||||
dockerfile.write_text(
|
||||
"FROM debian:bookworm-slim\n"
|
||||
"RUN getent hosts deb.debian.org\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"container", "build",
|
||||
"--dns", _container_dns_server(),
|
||||
"-t", image,
|
||||
"-f", str(dockerfile),
|
||||
str(stage),
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
finally:
|
||||
subprocess.run(
|
||||
["container", "image", "delete", image],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
shutil.rmtree(stage, ignore_errors=True)
|
||||
|
||||
|
||||
@unittest.skipIf(
|
||||
os.environ.get("GITEA_ACTIONS") == "true",
|
||||
"skipped under act_runner: cannot host Apple Container VMs",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
platform.system() == "Darwin",
|
||||
"Apple Container is macOS-only",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
_container_available(),
|
||||
"Apple Container not on PATH; install from "
|
||||
"https://github.com/apple/container/releases",
|
||||
)
|
||||
@unittest.skipUnless(
|
||||
_buildkit_dns_available(),
|
||||
"Apple Container BuildKit cannot resolve deb.debian.org on this host",
|
||||
)
|
||||
class TestMacosContainerLaunch(unittest.TestCase):
|
||||
"""Launch once and reuse the bottle across probes."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.stage = Path(tempfile.mkdtemp(prefix="cb-macos-container-launch."))
|
||||
cls._launch = None
|
||||
cls.bottle = None
|
||||
dockerfile = cls.stage / "Dockerfile.agent-smoke"
|
||||
_minimal_agent_dockerfile(dockerfile)
|
||||
os.environ["BOT_BOTTLE_BACKEND"] = "macos-container"
|
||||
try:
|
||||
backend = get_bottle_backend()
|
||||
spec = BottleSpec(
|
||||
manifest=_minimal_manifest(dockerfile),
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd=str(cls.stage),
|
||||
)
|
||||
cls.plan = backend.prepare(spec, stage_dir=cls.stage)
|
||||
cls._launch = backend.launch(cls.plan)
|
||||
cls.bottle = cls._launch.__enter__()
|
||||
except BaseException:
|
||||
if cls._launch is not None:
|
||||
cls._launch.__exit__(None, None, None)
|
||||
shutil.rmtree(cls.stage, ignore_errors=True)
|
||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
try:
|
||||
if cls._launch is not None:
|
||||
cls._launch.__exit__(None, None, None)
|
||||
finally:
|
||||
shutil.rmtree(cls.stage, ignore_errors=True)
|
||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
||||
|
||||
def test_smoke_exec_echo(self):
|
||||
r = self.bottle.exec( # type: ignore[union-attr]
|
||||
"echo hello-from-macos-container"
|
||||
)
|
||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
||||
self.assertIn("hello-from-macos-container", r.stdout)
|
||||
|
||||
def test_proxy_env_points_at_sidecar_internal_ip(self):
|
||||
r = self.bottle.exec( # type: ignore[union-attr]
|
||||
"printf '%s\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\" "
|
||||
"\"$NO_PROXY\" \"$NODE_EXTRA_CA_CERTS\""
|
||||
)
|
||||
self.assertEqual(0, r.returncode, msg=r.stderr)
|
||||
values = [line.strip() for line in r.stdout.splitlines()]
|
||||
self.assertEqual(4, len(values), values)
|
||||
self.assertEqual(values[0], values[1], values)
|
||||
self.assertRegex(values[0], r"^http://[0-9.]+:9099$")
|
||||
self.assertNotIn("127.0.0.1", values[0])
|
||||
sidecar_host = values[0].removeprefix("http://").removesuffix(":9099")
|
||||
self.assertIn(sidecar_host, values[2])
|
||||
self.assertEqual(
|
||||
"/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt",
|
||||
values[3],
|
||||
)
|
||||
|
||||
def test_allowlisted_https_reaches_egress_proxy(self):
|
||||
r = self.bottle.exec( # type: ignore[union-attr]
|
||||
"curl -fsS --max-time 20 https://example.com >/dev/null && echo OK"
|
||||
)
|
||||
self.assertEqual(0, r.returncode, msg=r.stderr + r.stdout)
|
||||
self.assertIn("OK", r.stdout)
|
||||
|
||||
def test_direct_egress_bypass_without_proxy_fails(self):
|
||||
r = self.bottle.exec( # type: ignore[union-attr]
|
||||
"env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy "
|
||||
"curl -s --show-error --max-time 5 https://example.com 2>&1 || true"
|
||||
)
|
||||
self.assertTrue(
|
||||
"refused" in r.stdout.lower()
|
||||
or "timed out" in r.stdout.lower()
|
||||
or "unreachable" in r.stdout.lower()
|
||||
or "failed" in r.stdout.lower()
|
||||
or "could not resolve" in r.stdout.lower()
|
||||
or "connection reset" in r.stdout.lower(),
|
||||
f"expected direct egress to fail; got: {r.stdout!r}",
|
||||
)
|
||||
|
||||
def test_non_allowlisted_host_fails_through_proxy(self):
|
||||
r = self.bottle.exec( # type: ignore[union-attr]
|
||||
"curl -s --show-error --max-time 10 https://iana.org 2>&1 || true"
|
||||
)
|
||||
self.assertTrue(
|
||||
"403" in r.stdout
|
||||
or "502" in r.stdout
|
||||
or "blocked" in r.stdout.lower()
|
||||
or "not allowed" in r.stdout.lower()
|
||||
or "not in the bottle's egress.routes allowlist" in r.stdout.lower()
|
||||
or "forbidden" in r.stdout.lower()
|
||||
or "failed" in r.stdout.lower(),
|
||||
f"expected non-allowlisted proxy request to fail; got: {r.stdout!r}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -44,8 +44,11 @@ class TestGetBottleBackend(unittest.TestCase):
|
||||
|
||||
|
||||
class TestKnownBackendNames(unittest.TestCase):
|
||||
def test_returns_both_backends_sorted(self):
|
||||
self.assertEqual(("docker", "smolmachines"), known_backend_names())
|
||||
def test_returns_backends_sorted(self):
|
||||
self.assertEqual(
|
||||
("docker", "macos-container", "smolmachines"),
|
||||
known_backend_names(),
|
||||
)
|
||||
|
||||
|
||||
class TestEnumerateActiveAgents(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Unit: Apple Container bottle command construction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.macos_container.bottle import MacosContainerBottle
|
||||
|
||||
|
||||
class TestMacosContainerBottle(unittest.TestCase):
|
||||
def test_agent_argv_uses_container_exec(self):
|
||||
bottle = MacosContainerBottle(
|
||||
"bot-bottle-dev-abc",
|
||||
lambda: None,
|
||||
None,
|
||||
agent_command="codex",
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"container", "exec", "--interactive", "--tty",
|
||||
"bot-bottle-dev-abc", "codex", "run",
|
||||
],
|
||||
bottle.agent_argv(["run"]),
|
||||
)
|
||||
|
||||
def test_agent_argv_includes_workdir(self):
|
||||
bottle = MacosContainerBottle(
|
||||
"bot-bottle-dev-abc",
|
||||
lambda: None,
|
||||
None,
|
||||
agent_workdir="/home/node/workspace",
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"container", "exec", "--interactive", "--tty",
|
||||
"--workdir", "/home/node/workspace",
|
||||
"bot-bottle-dev-abc", "claude",
|
||||
],
|
||||
bottle.agent_argv([]),
|
||||
)
|
||||
|
||||
def test_exec_pipes_script_to_shell(self):
|
||||
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
|
||||
with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run:
|
||||
run.return_value.returncode = 7
|
||||
run.return_value.stdout = "out"
|
||||
run.return_value.stderr = "err"
|
||||
result = bottle.exec("echo hi", user="root")
|
||||
self.assertEqual(7, result.returncode)
|
||||
self.assertEqual(
|
||||
[
|
||||
"container", "exec", "--user", "root", "--interactive",
|
||||
"bot-bottle-dev-abc", "sh", "-s",
|
||||
],
|
||||
run.call_args.args[0],
|
||||
)
|
||||
self.assertEqual("echo hi", run.call_args.kwargs["input"])
|
||||
|
||||
def test_cp_in_uses_container_cp(self):
|
||||
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
|
||||
with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run:
|
||||
bottle.cp_in("/tmp/src", "/home/node/src")
|
||||
self.assertEqual(
|
||||
["container", "cp", "/tmp/src", "bot-bottle-dev-abc:/home/node/src"],
|
||||
run.call_args.args[0],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Unit: Apple Container cleanup/enumeration helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.macos_container import cleanup, enumerate as enum_mod
|
||||
from bot_bottle.backend.macos_container.bottle_cleanup_plan import (
|
||||
MacosContainerBottleCleanupPlan,
|
||||
)
|
||||
|
||||
|
||||
class TestMacosContainerCleanup(unittest.TestCase):
|
||||
def test_lists_prefixed_containers(self):
|
||||
completed = cleanup.subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n",
|
||||
stderr="",
|
||||
)
|
||||
with patch.object(cleanup.subprocess, "run", return_value=completed):
|
||||
self.assertEqual(
|
||||
["bot-bottle-a", "bot-bottle-sidecars-a"],
|
||||
cleanup._list_prefixed_containers(),
|
||||
)
|
||||
|
||||
def test_cleanup_deletes_containers_and_networks(self):
|
||||
plan = MacosContainerBottleCleanupPlan(
|
||||
containers=("bot-bottle-a",),
|
||||
networks=("bot-bottle-net-a",),
|
||||
)
|
||||
with patch.object(cleanup.subprocess, "run") as run:
|
||||
cleanup.cleanup(plan)
|
||||
self.assertEqual(
|
||||
["container", "delete", "--force", "bot-bottle-a"],
|
||||
run.call_args_list[0].args[0],
|
||||
)
|
||||
self.assertEqual(
|
||||
["container", "network", "delete", "bot-bottle-net-a"],
|
||||
run.call_args_list[1].args[0],
|
||||
)
|
||||
|
||||
|
||||
class TestMacosContainerEnumerate(unittest.TestCase):
|
||||
def test_enumerate_active_reads_metadata(self):
|
||||
completed = enum_mod.subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
class _Metadata:
|
||||
agent_name = "impl"
|
||||
started_at = "2026-06-10T00:00:00Z"
|
||||
label = "Implement"
|
||||
color = "blue"
|
||||
|
||||
with patch.object(enum_mod.subprocess, "run", return_value=completed), \
|
||||
patch.object(enum_mod, "read_metadata", return_value=_Metadata()):
|
||||
agents = enum_mod.enumerate_active()
|
||||
self.assertEqual(1, len(agents))
|
||||
self.assertEqual("macos-container", agents[0].backend_name)
|
||||
self.assertEqual("a", agents[0].slug)
|
||||
self.assertEqual("impl", agents[0].agent_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Unit: Apple Container utility helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend.macos_container import util
|
||||
|
||||
|
||||
class TestMacosContainerAvailability(unittest.TestCase):
|
||||
def test_available_only_on_macos_with_container(self):
|
||||
with patch.object(util.platform, "system", return_value="Darwin"), \
|
||||
patch.object(util.shutil, "which", return_value="/usr/local/bin/container"):
|
||||
self.assertTrue(util.is_available())
|
||||
|
||||
def test_not_available_off_macos(self):
|
||||
with patch.object(util.platform, "system", return_value="Linux"), \
|
||||
patch.object(util.shutil, "which", return_value="/usr/local/bin/container"):
|
||||
self.assertFalse(util.is_available())
|
||||
|
||||
def test_require_container_dies_when_missing(self):
|
||||
with patch.object(util.platform, "system", return_value="Darwin"), \
|
||||
patch.object(util.shutil, "which", return_value=None), \
|
||||
patch.object(util, "die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
util.require_container()
|
||||
|
||||
|
||||
class TestMacosContainerCommands(unittest.TestCase):
|
||||
def test_dns_server_prefers_direct_host_ipv4_resolver(self):
|
||||
scutil = util.subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout="""
|
||||
resolver #1
|
||||
nameserver[0] : 100.100.100.100
|
||||
reach : 0x00000003 (Reachable,Transient Connection)
|
||||
|
||||
resolver #2
|
||||
nameserver[0] : 2600:4041:5c43:b900::1
|
||||
nameserver[1] : 192.168.1.1
|
||||
reach : 0x00020002 (Reachable,Directly Reachable Address)
|
||||
""",
|
||||
stderr="",
|
||||
)
|
||||
with patch.object(util.os, "environ", {}), \
|
||||
patch.object(util.platform, "system", return_value="Darwin"), \
|
||||
patch.object(util.subprocess, "run", return_value=scutil):
|
||||
self.assertEqual("192.168.1.1", util.dns_server())
|
||||
|
||||
def test_build_image(self):
|
||||
status = util.subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout=(
|
||||
'[{"status":{"state":"running"},'
|
||||
'"configuration":{"dns":{"nameservers":["9.9.9.9"]}}}]'
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
with patch.object(util.subprocess, "run", return_value=status) as run, \
|
||||
patch.object(util.os, "environ", {
|
||||
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
|
||||
}):
|
||||
util.build_image("bot-bottle-agent:latest", "/repo", dockerfile="/repo/Dockerfile")
|
||||
self.assertEqual(
|
||||
[
|
||||
"container", "build", "-t", "bot-bottle-agent:latest",
|
||||
"--dns", "9.9.9.9", "-f", "/repo/Dockerfile", "/repo",
|
||||
],
|
||||
run.call_args_list[-1].args[0],
|
||||
)
|
||||
self.assertTrue(run.call_args_list[-1].kwargs["check"])
|
||||
|
||||
def test_build_image_restarts_builder_when_dns_mismatches(self):
|
||||
status = util.subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout=(
|
||||
'[{"status":{"state":"running"},'
|
||||
'"configuration":{"dns":{"nameservers":[]}}}]'
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
with patch.object(util.subprocess, "run", return_value=status) as run, \
|
||||
patch.object(util.os, "environ", {
|
||||
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
|
||||
}):
|
||||
util.build_image("bot-bottle-agent:latest", "/repo")
|
||||
calls = [c.args[0] for c in run.call_args_list]
|
||||
self.assertIn(["container", "builder", "stop"], calls)
|
||||
self.assertIn(
|
||||
["container", "builder", "start", "--dns", "9.9.9.9"],
|
||||
calls,
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"container", "build", "-t", "bot-bottle-agent:latest",
|
||||
"--dns", "9.9.9.9", "/repo",
|
||||
],
|
||||
calls[-1],
|
||||
)
|
||||
|
||||
def test_build_image_leaves_working_builder_with_different_dns_alone(self):
|
||||
status = util.subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout=(
|
||||
'[{"status":{"state":"running"},'
|
||||
'"configuration":{"dns":{"nameservers":["8.8.8.8"]}}}]'
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
probe = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="", stderr="",
|
||||
)
|
||||
build = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="", stderr="",
|
||||
)
|
||||
with patch.object(util, "dns_server", return_value="192.168.1.1"), \
|
||||
patch.object(util.os, "environ", {}), \
|
||||
patch.object(util.subprocess, "run", side_effect=[status, probe, build]) as run:
|
||||
util.build_image("bot-bottle-agent:latest", "/repo")
|
||||
calls = [c.args[0] for c in run.call_args_list]
|
||||
self.assertNotIn(["container", "builder", "stop"], calls)
|
||||
self.assertNotIn(
|
||||
["container", "builder", "start", "--dns", "192.168.1.1"],
|
||||
calls,
|
||||
)
|
||||
|
||||
def test_build_image_restarts_builder_when_dns_probe_fails(self):
|
||||
status = util.subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout=(
|
||||
'[{"status":{"state":"running"},'
|
||||
'"configuration":{"dns":{"nameservers":["8.8.8.8"]}}}]'
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
failed_probe = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=2, stdout="", stderr="",
|
||||
)
|
||||
ok = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="", stderr="",
|
||||
)
|
||||
with patch.object(util, "dns_server", return_value="192.168.1.1"), \
|
||||
patch.object(util.os, "environ", {}), \
|
||||
patch.object(
|
||||
util.subprocess,
|
||||
"run",
|
||||
side_effect=[status, failed_probe, ok, ok, ok],
|
||||
) as run:
|
||||
util.build_image("bot-bottle-agent:latest", "/repo")
|
||||
calls = [c.args[0] for c in run.call_args_list]
|
||||
self.assertIn(["container", "builder", "stop"], calls)
|
||||
self.assertIn(
|
||||
["container", "builder", "start", "--dns", "192.168.1.1"],
|
||||
calls,
|
||||
)
|
||||
|
||||
def test_container_exists_parses_quiet_list(self):
|
||||
completed = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout="bot-bottle-a\nother\n", stderr="",
|
||||
)
|
||||
with patch.object(util.subprocess, "run", return_value=completed):
|
||||
self.assertTrue(util.container_exists("bot-bottle-a"))
|
||||
self.assertFalse(util.container_exists("bot-bottle-b"))
|
||||
|
||||
def test_image_id_reads_json_digest(self):
|
||||
completed = util.subprocess.CompletedProcess(
|
||||
args=[], returncode=0, stdout='{"digest":"sha256:abc"}', stderr="",
|
||||
)
|
||||
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()
|
||||
Reference in New Issue
Block a user