Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16020a6a09 | |||
| 6e67b9c771 | |||
| 3484c3aed0 |
@@ -5,7 +5,7 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://github.com/PyCQA/pylint)
|
[](https://github.com/PyCQA/pylint)
|
||||||
[](https://github.com/microsoft/pyright)
|
[](https://github.com/microsoft/pyright)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
|
|||||||
@@ -526,11 +526,6 @@ from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-i
|
|||||||
from .macos_container import MacosContainerBottleBackend # 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
|
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
|
|
||||||
# Freezer is imported after the backend classes for the same reason:
|
|
||||||
# Freezer.commit_slug constructs ActiveAgent, which must be fully
|
|
||||||
# defined first.
|
|
||||||
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
|
|
||||||
|
|
||||||
|
|
||||||
# The dict is heterogeneous: each value is a BottleBackend specialized
|
# The dict is heterogeneous: each value is a BottleBackend specialized
|
||||||
# over its own plan type. Concrete plan types are erased here because
|
# over its own plan type. Concrete plan types are erased here because
|
||||||
@@ -618,12 +613,9 @@ __all__ = [
|
|||||||
"BottleCleanupPlan",
|
"BottleCleanupPlan",
|
||||||
"BottlePlan",
|
"BottlePlan",
|
||||||
"BottleSpec",
|
"BottleSpec",
|
||||||
"CommitCancelled",
|
|
||||||
"ExecResult",
|
"ExecResult",
|
||||||
"Freezer",
|
|
||||||
"enumerate_active_agents",
|
"enumerate_active_agents",
|
||||||
"get_bottle_backend",
|
"get_bottle_backend",
|
||||||
"get_freezer",
|
|
||||||
"has_backend",
|
"has_backend",
|
||||||
"known_backend_names",
|
"known_backend_names",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
ep = plan.egress_plan
|
ep = plan.egress_plan
|
||||||
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
|
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
|
||||||
if ep.routes:
|
if ep.routes:
|
||||||
volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent)))
|
volumes.append(_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER))
|
||||||
for token_env in sorted(ep.token_env_map.keys()):
|
for token_env in sorted(ep.token_env_map.keys()):
|
||||||
env.append(token_env)
|
env.append(token_env)
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
"""Host-side helper for egress sidecar inspection and live updates.
|
"""Host-side helper for egress sidecar inspection (issue #198).
|
||||||
|
|
||||||
The approve path uses this module to validate a proposed routes file,
|
`_merge_single_route`, `add_route`, and `apply_routes_change` were
|
||||||
write it to the bottle's live egress state dir, and signal the sidecar
|
removed when the egress-block MCP tool was dropped. The remaining
|
||||||
bundle so the mitmproxy addon reloads it.
|
helpers support runtime inspection and validation of the routes file
|
||||||
|
without modifying it at runtime.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
||||||
from ...log import warn
|
from ...egress_addon_core import load_routes
|
||||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
|
||||||
from .sidecar_bundle import sidecar_bundle_container_name
|
from .sidecar_bundle import sidecar_bundle_container_name
|
||||||
|
|
||||||
|
|
||||||
|
class EgressApplyError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def fetch_current_routes(slug: str) -> str:
|
def fetch_current_routes(slug: str) -> str:
|
||||||
container = sidecar_bundle_container_name(slug)
|
container = sidecar_bundle_container_name(slug)
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
@@ -30,31 +33,17 @@ def fetch_current_routes(slug: str) -> str:
|
|||||||
return r.stdout
|
return r.stdout
|
||||||
|
|
||||||
|
|
||||||
class DockerEgressApplicator(EgressApplicator):
|
def validate_routes_content(content: str) -> None:
|
||||||
def _signal_bundle_reload(self, slug: str) -> None:
|
try:
|
||||||
container = sidecar_bundle_container_name(slug)
|
load_routes(content)
|
||||||
result = subprocess.run(
|
except ValueError as e:
|
||||||
["docker", "kill", "--signal", "HUP", container],
|
|
||||||
capture_output=True, text=True, check=False, env=os.environ,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
|
||||||
warn(
|
|
||||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
|
||||||
f"{last_error or 'docker kill failed'}"
|
|
||||||
)
|
|
||||||
raise EgressApplyError(
|
raise EgressApplyError(
|
||||||
f"could not reload egress bundle {container}: "
|
f"proposed routes.yaml is not valid: {e}"
|
||||||
f"{last_error or 'docker kill failed'}"
|
) from e
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
applicator = DockerEgressApplicator()
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DockerEgressApplicator",
|
|
||||||
"EgressApplyError",
|
"EgressApplyError",
|
||||||
"applicator",
|
|
||||||
"fetch_current_routes",
|
"fetch_current_routes",
|
||||||
|
"validate_routes_content",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
"""DockerFreezer — snapshot a Docker bottle via `docker commit`."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ..freeze import Freezer
|
|
||||||
from .util import commit_container
|
|
||||||
from ...log import info
|
|
||||||
|
|
||||||
|
|
||||||
class DockerFreezer(Freezer):
|
|
||||||
"""Freezes a Docker bottle by running `docker commit`."""
|
|
||||||
|
|
||||||
backend_name = "docker"
|
|
||||||
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
container = f"bot-bottle-{agent.slug}"
|
|
||||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
|
||||||
commit_container(container, image_tag)
|
|
||||||
return image_tag
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
info(f"to export for migration: docker save {image_ref} -o {slug}.tar")
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
"""Shared base class for host-side egress apply across backends.
|
|
||||||
|
|
||||||
Each backend subclasses EgressApplicator and overrides _signal_bundle_reload
|
|
||||||
with the backend-specific kill command.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ..bottle_state import egress_state_dir
|
|
||||||
from ..egress import EGRESS_ROUTES_FILENAME
|
|
||||||
from ..egress_addon_core import load_routes
|
|
||||||
|
|
||||||
|
|
||||||
class EgressApplyError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class EgressApplicator(ABC):
|
|
||||||
def apply_routes_change(self, slug: str, content: str) -> tuple[str, str]:
|
|
||||||
"""Persist `content` to the live routes file and reload egress."""
|
|
||||||
self.validate_routes_content(content)
|
|
||||||
routes_path = self._routes_path(slug)
|
|
||||||
routes_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
before = routes_path.read_text(encoding="utf-8") if routes_path.exists() else ""
|
|
||||||
routes_path.write_text(content, encoding="utf-8")
|
|
||||||
routes_path.chmod(0o600)
|
|
||||||
self._signal_bundle_reload(slug)
|
|
||||||
return before, content
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def validate_routes_content(content: str) -> None:
|
|
||||||
try:
|
|
||||||
load_routes(content)
|
|
||||||
except ValueError as e:
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"proposed routes.yaml is not valid: {e}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _routes_path(slug: str) -> Path:
|
|
||||||
return egress_state_dir(slug) / EGRESS_ROUTES_FILENAME
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _signal_bundle_reload(self, slug: str) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["EgressApplicator", "EgressApplyError"]
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""Freezer — snapshot a running bottle to a resumable artifact.
|
|
||||||
|
|
||||||
Follows the same pattern as BottleBackend: a shared base class with
|
|
||||||
common post-freeze steps (write committed-image path, mark preserved,
|
|
||||||
print resume hint) and backend-specific subclasses in their respective
|
|
||||||
backend directories.
|
|
||||||
|
|
||||||
Entry points:
|
|
||||||
Freezer.commit(agent) — freeze by ActiveAgent
|
|
||||||
Freezer.commit_slug(slug) — convenience wrapper for cmd_commit
|
|
||||||
get_freezer(backend_name) — factory
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
from . import ActiveAgent
|
|
||||||
from ..bottle_state import mark_preserved, write_committed_image
|
|
||||||
from ..log import die, info
|
|
||||||
|
|
||||||
|
|
||||||
class CommitCancelled(Exception):
|
|
||||||
"""Raised by Freezer._freeze when the user declines a confirmation prompt."""
|
|
||||||
|
|
||||||
|
|
||||||
class Freezer(ABC):
|
|
||||||
"""Freezes a running bottle to a resumable artifact.
|
|
||||||
|
|
||||||
The base class owns the shared post-commit steps:
|
|
||||||
- write_committed_image — records the artifact path in per-bottle state
|
|
||||||
- mark_preserved — prevents teardown from removing the state dir
|
|
||||||
- resume hint — printed to stderr after the snapshot
|
|
||||||
|
|
||||||
Subclasses implement _freeze with the backend-specific snapshot
|
|
||||||
operation and optionally override _export_hint for migration hints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
backend_name: str
|
|
||||||
|
|
||||||
def commit(self, agent: ActiveAgent) -> None:
|
|
||||||
"""Freeze the bottle for `agent` to a resumable artifact.
|
|
||||||
|
|
||||||
Calls _freeze for the backend-specific snapshot, then writes the
|
|
||||||
committed image reference to per-bottle state and marks the bottle
|
|
||||||
preserved so the next `./cli.py resume` boots from the snapshot.
|
|
||||||
|
|
||||||
Raises CommitCancelled if the user declines an interactive
|
|
||||||
confirmation prompt (e.g. the macos-container stop prompt).
|
|
||||||
"""
|
|
||||||
image_ref = self._freeze(agent)
|
|
||||||
write_committed_image(agent.slug, image_ref)
|
|
||||||
mark_preserved(agent.slug)
|
|
||||||
info(f"to resume from this snapshot: ./cli.py resume {agent.slug}")
|
|
||||||
self._export_hint(agent.slug, image_ref)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
"""Backend-specific snapshot. Returns the image tag or artifact path
|
|
||||||
stored by write_committed_image. Raises CommitCancelled if the user
|
|
||||||
declines a stop-confirmation prompt."""
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
"""Optionally print an export-for-migration hint after committing.
|
|
||||||
Overridden by backends that provide a meaningful export command."""
|
|
||||||
|
|
||||||
def commit_slug(self, slug: str) -> None:
|
|
||||||
"""Convenience entry for cmd_commit when only a slug is available."""
|
|
||||||
from ..bottle_state import read_metadata
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
agent = ActiveAgent(
|
|
||||||
backend_name=self.backend_name,
|
|
||||||
slug=slug,
|
|
||||||
agent_name=metadata.agent_name if metadata else "",
|
|
||||||
started_at=metadata.started_at if metadata else "",
|
|
||||||
services=(),
|
|
||||||
)
|
|
||||||
self.commit(agent)
|
|
||||||
|
|
||||||
|
|
||||||
def get_freezer(backend_name: str) -> Freezer:
|
|
||||||
"""Return the Freezer for the named backend.
|
|
||||||
|
|
||||||
backend_name "" is treated as "docker" for backward compatibility
|
|
||||||
with state dirs written before the backend field was added."""
|
|
||||||
resolved = backend_name or "docker"
|
|
||||||
if resolved == "docker":
|
|
||||||
from .docker.freezer import DockerFreezer
|
|
||||||
return DockerFreezer()
|
|
||||||
if resolved == "macos-container":
|
|
||||||
from .macos_container.freezer import MacosContainerFreezer
|
|
||||||
return MacosContainerFreezer()
|
|
||||||
if resolved == "smolmachines":
|
|
||||||
from .smolmachines.freezer import SmolmachinesFreezer
|
|
||||||
return SmolmachinesFreezer()
|
|
||||||
die(
|
|
||||||
f"commit is only supported for docker, macos-container, and "
|
|
||||||
f"smolmachines; backend {backend_name!r} has no freezer"
|
|
||||||
)
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"""Host-side egress apply for the macos-container backend.
|
|
||||||
|
|
||||||
Uses `container kill --signal HUP` (Apple Container framework) instead
|
|
||||||
of `docker kill` to signal the sidecar bundle.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...log import warn
|
|
||||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
|
||||||
from .launch import sidecar_container_name
|
|
||||||
|
|
||||||
|
|
||||||
class MacOSContainerEgressApplicator(EgressApplicator):
|
|
||||||
def _signal_bundle_reload(self, slug: str) -> None:
|
|
||||||
container = sidecar_container_name(slug)
|
|
||||||
result = subprocess.run(
|
|
||||||
["container", "kill", "--signal", "HUP", container],
|
|
||||||
capture_output=True, text=True, check=False, env=os.environ,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
|
||||||
warn(
|
|
||||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
|
||||||
f"{last_error or 'container kill failed'}"
|
|
||||||
)
|
|
||||||
raise EgressApplyError(
|
|
||||||
f"could not reload egress bundle {container}: "
|
|
||||||
f"{last_error or 'container kill failed'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
applicator = MacOSContainerEgressApplicator()
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["MacOSContainerEgressApplicator", "EgressApplyError", "applicator"]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""MacosContainerFreezer — snapshot a macOS container bottle.
|
|
||||||
|
|
||||||
Apple Container removes containers when they stop, making stop-then-export
|
|
||||||
impossible. Instead, commit_container execs into the running container and
|
|
||||||
streams the root filesystem via tar. The bottle continues running after commit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ..freeze import Freezer
|
|
||||||
from .util import commit_container
|
|
||||||
from ...log import info
|
|
||||||
|
|
||||||
|
|
||||||
class MacosContainerFreezer(Freezer):
|
|
||||||
"""Freezes a macOS-container bottle via exec-tar + image rebuild."""
|
|
||||||
|
|
||||||
backend_name = "macos-container"
|
|
||||||
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
container = f"bot-bottle-{agent.slug}"
|
|
||||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
|
||||||
commit_container(container, image_tag)
|
|
||||||
return image_tag
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
info(
|
|
||||||
f"to export for migration: "
|
|
||||||
f"container image save {image_ref} -o {slug}.tar"
|
|
||||||
)
|
|
||||||
@@ -12,16 +12,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from contextlib import ExitStack, contextmanager
|
from contextlib import ExitStack, contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...bottle_state import (
|
from ...bottle_state import egress_state_dir, git_gate_state_dir
|
||||||
egress_state_dir,
|
|
||||||
git_gate_state_dir,
|
|
||||||
read_committed_image,
|
|
||||||
)
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
|
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
|
||||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
@@ -87,7 +84,7 @@ def launch(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
plan = _mint_certs(plan)
|
plan = _mint_certs(plan)
|
||||||
plan = _build_images(plan)
|
_build_images(plan)
|
||||||
|
|
||||||
internal_network = internal_network_name(plan.slug)
|
internal_network = internal_network_name(plan.slug)
|
||||||
egress_network = egress_network_name(plan.slug)
|
egress_network = egress_network_name(plan.slug)
|
||||||
@@ -138,28 +135,17 @@ def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
|||||||
return dataclasses.replace(plan, egress_plan=egress_plan)
|
return dataclasses.replace(plan, egress_plan=egress_plan)
|
||||||
|
|
||||||
|
|
||||||
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
def _build_images(plan: MacosContainerBottlePlan) -> None:
|
||||||
container_mod.build_image(
|
container_mod.build_image(
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
SIDECAR_BUNDLE_IMAGE,
|
||||||
_REPO_DIR,
|
_REPO_DIR,
|
||||||
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
)
|
)
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed and container_mod.image_exists(committed):
|
|
||||||
info(f"using committed image {committed!r}")
|
|
||||||
return dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_provision=dataclasses.replace(
|
|
||||||
plan.agent_provision,
|
|
||||||
image=committed,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
container_mod.build_image(
|
container_mod.build_image(
|
||||||
plan.image,
|
plan.image,
|
||||||
_REPO_DIR,
|
_REPO_DIR,
|
||||||
dockerfile=plan.dockerfile_path,
|
dockerfile=plan.dockerfile_path,
|
||||||
)
|
)
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
def _create_networks(
|
def _create_networks(
|
||||||
@@ -328,6 +314,7 @@ def _agent_run_argv(
|
|||||||
"container", "run",
|
"container", "run",
|
||||||
"--name", plan.container_name,
|
"--name", plan.container_name,
|
||||||
"--detach",
|
"--detach",
|
||||||
|
"--rm",
|
||||||
"--network", internal_network,
|
"--network", internal_network,
|
||||||
]
|
]
|
||||||
for entry in _agent_env_entries(plan, sidecar_ip):
|
for entry in _agent_env_entries(plan, sidecar_ip):
|
||||||
@@ -377,7 +364,7 @@ def _sidecar_mounts(
|
|||||||
))
|
))
|
||||||
if ep.routes:
|
if ep.routes:
|
||||||
mounts.append((
|
mounts.append((
|
||||||
str(ep.routes_path.parent),
|
str(_stage_routes_dir(plan)),
|
||||||
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
|
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
|
||||||
True,
|
True,
|
||||||
))
|
))
|
||||||
@@ -388,6 +375,17 @@ def _sidecar_mounts(
|
|||||||
|
|
||||||
return tuple(mounts)
|
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:
|
def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str:
|
||||||
spec = f"type=bind,source={host_path},target={container_path}"
|
spec = f"type=bind,source={host_path},target={container_path}"
|
||||||
if read_only:
|
if read_only:
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import ipaddress
|
|||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
|
||||||
import time
|
import time
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
@@ -73,53 +72,6 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|||||||
subprocess.run(args, check=True)
|
subprocess.run(args, check=True)
|
||||||
|
|
||||||
|
|
||||||
def commit_container(container_name: str, image_tag: str) -> None:
|
|
||||||
"""Snapshot a running Apple Container as a local image.
|
|
||||||
|
|
||||||
`container export` requires a stopped container, but Apple Container
|
|
||||||
removes containers when they stop, making stop-then-export impossible.
|
|
||||||
Instead, exec into the running container as root and stream the root
|
|
||||||
filesystem out via tar, then build a new image from that archive.
|
|
||||||
The bottle continues running after commit.
|
|
||||||
"""
|
|
||||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-container-commit.") as tmp:
|
|
||||||
rootfs_tar = os.path.join(tmp, "rootfs.tar")
|
|
||||||
dockerfile = os.path.join(tmp, "Dockerfile")
|
|
||||||
with open(rootfs_tar, "wb") as tar_out:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
_CONTAINER, "exec",
|
|
||||||
"--user", "root",
|
|
||||||
container_name,
|
|
||||||
"tar", "--create",
|
|
||||||
"--exclude=./proc",
|
|
||||||
"--exclude=./sys",
|
|
||||||
"--exclude=./dev",
|
|
||||||
"--exclude=./run",
|
|
||||||
"--file=-",
|
|
||||||
"--directory=/",
|
|
||||||
".",
|
|
||||||
],
|
|
||||||
stdout=tar_out,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container exec tar {container_name!r} failed: "
|
|
||||||
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
with open(dockerfile, "w", encoding="utf-8") as f:
|
|
||||||
f.write(
|
|
||||||
"FROM scratch\n"
|
|
||||||
"ADD rootfs.tar /\n"
|
|
||||||
"USER node\n"
|
|
||||||
"WORKDIR /home/node\n"
|
|
||||||
)
|
|
||||||
build_image(image_tag, tmp, dockerfile=dockerfile)
|
|
||||||
info(f"committed {container_name!r} → {image_tag!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_builder_dns() -> None:
|
def _ensure_builder_dns() -> None:
|
||||||
dns = dns_server()
|
dns = dns_server()
|
||||||
status = _builder_status()
|
status = _builder_status()
|
||||||
@@ -266,36 +218,6 @@ def container_exists(name: str) -> bool:
|
|||||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
return name in {line.strip() for line in result.stdout.splitlines()}
|
||||||
|
|
||||||
|
|
||||||
def container_is_running(name: str) -> bool:
|
|
||||||
"""Return True if the named container is currently running.
|
|
||||||
|
|
||||||
`container list` without `--all` lists only running containers."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "list", "--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 stop_container(name: str) -> None:
|
|
||||||
"""Stop the named container without deleting it."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "stop", name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container stop {name!r} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def force_remove_container(name: str) -> None:
|
def force_remove_container(name: str) -> None:
|
||||||
if container_exists(name):
|
if container_exists(name):
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
|
|||||||
@@ -145,12 +145,7 @@ class SmolmachinesBottle(Bottle):
|
|||||||
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
||||||
if script is None:
|
if script is None:
|
||||||
return subprocess.run(agent_argv, check=False).returncode
|
return subprocess.run(agent_argv, check=False).returncode
|
||||||
# Use sh -c (not -lc) so the script inherits PATH from the calling
|
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
||||||
# process. sh -l sources login-shell init files (e.g. /etc/profile)
|
|
||||||
# which may NOT include smolvm's location when it was installed via
|
|
||||||
# homebrew. The calling process (./cli.py) already has smolvm on PATH
|
|
||||||
# (provision steps succeed), so -c is sufficient.
|
|
||||||
return subprocess.run(["sh", "-c", script], check=False).returncode
|
|
||||||
|
|
||||||
# smolvm/libkrun can SIGKILL an otherwise-normal exec during
|
# smolvm/libkrun can SIGKILL an otherwise-normal exec during
|
||||||
# early-VM provisioning. Retry once after a short settle so
|
# early-VM provisioning. Retry once after a short settle so
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Egress apply for the smolmachines backend.
|
|
||||||
|
|
||||||
The smolmachines sidecar bundle runs as a host-side Docker container,
|
|
||||||
so egress signalling is identical to the docker backend.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from ..docker.egress_apply import ( # noqa: F401
|
|
||||||
DockerEgressApplicator,
|
|
||||||
EgressApplyError,
|
|
||||||
applicator,
|
|
||||||
fetch_current_routes,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"DockerEgressApplicator",
|
|
||||||
"EgressApplyError",
|
|
||||||
"applicator",
|
|
||||||
"fetch_current_routes",
|
|
||||||
]
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
"""SmolmachinesFreezer — snapshot a smolmachines bottle.
|
|
||||||
|
|
||||||
`smolvm pack create --from-vm` requires the VM to be stopped, and smolvm
|
|
||||||
removes VMs when stopped (same issue as Apple Container). Instead, exec
|
|
||||||
into the running VM as root to write a gzip-compressed tar of the root
|
|
||||||
filesystem to /var/tmp, then copy it to the host with `smolvm machine cp`,
|
|
||||||
build a Docker image from the archive, convert it to a smolmachine artifact
|
|
||||||
via the existing registry pipeline, and record the sidecar path. The VM
|
|
||||||
stays running throughout."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ..freeze import Freezer
|
|
||||||
from ..docker import util as docker_mod
|
|
||||||
from .local_registry import crane_push_tarball, ephemeral_registry
|
|
||||||
from .smolvm import machine_cp, machine_exec, pack_create
|
|
||||||
from ...bottle_state import bottle_state_dir
|
|
||||||
from ...log import die, info
|
|
||||||
|
|
||||||
|
|
||||||
# Temp file written inside the VM during commit. Lives in /var/tmp
|
|
||||||
# (on-disk, unlike tmpfs /tmp) to survive for machine_cp.
|
|
||||||
_VM_COMMIT_TAR = "/var/tmp/.bot-bottle-commit.tar.gz"
|
|
||||||
|
|
||||||
|
|
||||||
class SmolmachinesFreezer(Freezer):
|
|
||||||
"""Freezes a smolmachines bottle via exec-tar + Docker image + smolmachine pack.
|
|
||||||
|
|
||||||
The VM is NOT stopped. We exec into the running VM to write a compressed
|
|
||||||
tar of the root filesystem to /var/tmp, copy it to the host with
|
|
||||||
machine_cp, build a Docker image (Docker's ADD decompresses .tar.gz
|
|
||||||
automatically), then run the same image→registry→pack_create pipeline
|
|
||||||
that _ensure_smolmachine uses for fresh builds."""
|
|
||||||
|
|
||||||
backend_name = "smolmachines"
|
|
||||||
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
machine = f"bot-bottle-{agent.slug}"
|
|
||||||
image_ref = f"bot-bottle-committed-{agent.slug}:latest"
|
|
||||||
output_dir = bottle_state_dir(agent.slug)
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
binary = output_dir / "committed-smolmachine"
|
|
||||||
sidecar = output_dir / "committed-smolmachine.smolmachine"
|
|
||||||
_snapshot_running_vm(machine, image_ref, binary)
|
|
||||||
return str(sidecar)
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
info(f"to export for migration: cp {image_ref} {slug}.smolmachine")
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_running_vm(machine: str, image_ref: str, binary: Path) -> None:
|
|
||||||
"""Exec-tar the running VM, build a Docker image, and pack to a smolmachine.
|
|
||||||
|
|
||||||
binary: destination for the launcher (sibling .smolmachine is the artifact
|
|
||||||
that machine_create --from consumes, same convention as pack_create).
|
|
||||||
"""
|
|
||||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-vm-commit.") as tmp:
|
|
||||||
tmp_path = Path(tmp)
|
|
||||||
# Use .tar.gz — Docker ADD decompresses automatically and the
|
|
||||||
# compressed archive fits in the VM's /var/tmp more easily.
|
|
||||||
rootfs_tar_gz = tmp_path / "rootfs.tar.gz"
|
|
||||||
dockerfile = tmp_path / "Dockerfile"
|
|
||||||
|
|
||||||
_exec_tar_to_file(machine, rootfs_tar_gz)
|
|
||||||
|
|
||||||
dockerfile.write_text(
|
|
||||||
"FROM scratch\n"
|
|
||||||
"ADD rootfs.tar.gz /\n"
|
|
||||||
"USER node\n"
|
|
||||||
"WORKDIR /home/node\n"
|
|
||||||
)
|
|
||||||
docker_mod.build_image(image_ref, str(tmp_path), dockerfile=str(dockerfile))
|
|
||||||
|
|
||||||
image_tarball = binary.parent / "committed.image.tar"
|
|
||||||
docker_mod.save(image_ref, str(image_tarball))
|
|
||||||
try:
|
|
||||||
with ephemeral_registry() as handle:
|
|
||||||
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
|
|
||||||
push_ref = f"{handle.push_endpoint}/bot-bottle-committed:{digest}"
|
|
||||||
pack_ref = f"{handle.pull_endpoint}/bot-bottle-committed:{digest}"
|
|
||||||
crane_push_tarball(handle, str(image_tarball), push_ref)
|
|
||||||
pack_create(pack_ref, binary)
|
|
||||||
finally:
|
|
||||||
image_tarball.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _exec_tar_to_file(machine: str, dest: Path) -> None:
|
|
||||||
"""Snapshot the running VM's root filesystem to dest (.tar.gz).
|
|
||||||
|
|
||||||
Writes a gzip-compressed tar to _VM_COMMIT_TAR inside the VM via
|
|
||||||
machine_exec (same mechanism as provisioning), then copies it to the
|
|
||||||
host with machine_cp. This avoids binary-stdout piping through the
|
|
||||||
smolvm exec channel, which does not reliably handle large binary output.
|
|
||||||
|
|
||||||
A connectivity probe (machine_exec true) runs first so a concurrent-exec
|
|
||||||
limitation (smolvm may reject a second exec while -i -t is active) is
|
|
||||||
reported clearly rather than as a silent failure."""
|
|
||||||
# Connectivity probe — if smolvm rejects concurrent exec while an
|
|
||||||
# interactive session is running, fail clearly here.
|
|
||||||
probe = machine_exec(machine, ["true"])
|
|
||||||
if probe.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"smolvm exec is not available for {machine!r} "
|
|
||||||
f"(exit {probe.returncode}: {probe.stderr.strip() or probe.stdout.strip() or '<no output>'}). "
|
|
||||||
f"If an interactive session is active, smolvm may not support concurrent exec."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the compressed tar inside the VM.
|
|
||||||
# tar exits 1 when files change during archiving (normal for a live
|
|
||||||
# filesystem); only treat exit > 1 as fatal.
|
|
||||||
tar_result = machine_exec(
|
|
||||||
machine,
|
|
||||||
[
|
|
||||||
"tar", "--create", "--gzip",
|
|
||||||
"--exclude=./proc",
|
|
||||||
"--exclude=./sys",
|
|
||||||
"--exclude=./dev",
|
|
||||||
"--exclude=./run",
|
|
||||||
# /tmp and /var/tmp are ephemeral. Their stale contents
|
|
||||||
# (e.g. /tmp/claude-<uid>) have uid remapped by smolvm's
|
|
||||||
# pack process, causing Claude Code to refuse to use them
|
|
||||||
# on resume. Exclude both; _init_vm recreates them with
|
|
||||||
# mkdir -p + correct ownership on every boot.
|
|
||||||
"--exclude=./tmp",
|
|
||||||
"--exclude=./var/tmp",
|
|
||||||
f"--file={_VM_COMMIT_TAR}",
|
|
||||||
"--directory=/",
|
|
||||||
".",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
if tar_result.returncode > 1:
|
|
||||||
die(
|
|
||||||
f"smolvm exec tar {machine!r} failed (exit {tar_result.returncode}): "
|
|
||||||
f"{tar_result.stderr.strip() or tar_result.stdout.strip() or '<no output>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy from VM to host, then clean up.
|
|
||||||
try:
|
|
||||||
machine_cp(f"{machine}:{_VM_COMMIT_TAR}", str(dest))
|
|
||||||
finally:
|
|
||||||
machine_exec(machine, ["rm", "-f", _VM_COMMIT_TAR])
|
|
||||||
@@ -214,15 +214,11 @@ def _discover_urls(
|
|||||||
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
|
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
|
||||||
|
|
||||||
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
|
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
|
||||||
no_proxy = f"{existing_no_proxy},{loopback_ip}"
|
|
||||||
guest_env = {
|
guest_env = {
|
||||||
**plan.guest_env,
|
**plan.guest_env,
|
||||||
"HTTPS_PROXY": agent_proxy_url,
|
"HTTPS_PROXY": agent_proxy_url,
|
||||||
"HTTP_PROXY": agent_proxy_url,
|
"HTTP_PROXY": agent_proxy_url,
|
||||||
"https_proxy": agent_proxy_url,
|
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
|
||||||
"http_proxy": agent_proxy_url,
|
|
||||||
"NO_PROXY": no_proxy,
|
|
||||||
"no_proxy": no_proxy,
|
|
||||||
}
|
}
|
||||||
if agent_git_gate_host:
|
if agent_git_gate_host:
|
||||||
guest_env["GIT_GATE_URL"] = f"http://{agent_git_gate_host}"
|
guest_env["GIT_GATE_URL"] = f"http://{agent_git_gate_host}"
|
||||||
@@ -276,16 +272,10 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
|
|||||||
All folded into one sh -c to avoid back-to-back exec calls
|
All folded into one sh -c to avoid back-to-back exec calls
|
||||||
immediately after machine_start (libkrun exec-channel race).
|
immediately after machine_start (libkrun exec-channel race).
|
||||||
|
|
||||||
mkdir -p guards: when booting from a committed snapshot, /tmp and
|
|
||||||
/var/tmp are excluded from the archive (they're ephemeral and their
|
|
||||||
stale contents would have wrong uid after smolvm's uid remap). The
|
|
||||||
directories must be created before chown/chmod can set permissions.
|
|
||||||
|
|
||||||
wait_exec_ready polls until the exec channel is ready for the
|
wait_exec_ready polls until the exec channel is ready for the
|
||||||
subsequent provision calls, replacing the empirical sleep."""
|
subsequent provision calls, replacing the empirical sleep."""
|
||||||
_smolvm.machine_exec(plan.machine_name, [
|
_smolvm.machine_exec(plan.machine_name, [
|
||||||
"sh", "-c",
|
"sh", "-c",
|
||||||
"mkdir -p /tmp /var/tmp && "
|
|
||||||
"chown -R node:node /home/node && "
|
"chown -R node:node /home/node && "
|
||||||
"chown root:root /tmp /var/tmp && "
|
"chown root:root /tmp /var/tmp && "
|
||||||
"chmod 1777 /tmp /var/tmp",
|
"chmod 1777 /tmp /var/tmp",
|
||||||
@@ -315,7 +305,7 @@ def _bundle_launch_spec(
|
|||||||
ep = plan.egress_plan
|
ep = plan.egress_plan
|
||||||
volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True))
|
volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True))
|
||||||
if ep.routes:
|
if ep.routes:
|
||||||
volumes.append((str(ep.routes_path.parent), str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True))
|
volumes.append((str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True))
|
||||||
# Bare-name entries for upstream-token slots. Their values
|
# Bare-name entries for upstream-token slots. Their values
|
||||||
# come from the docker-run subprocess env (inherited from
|
# come from the docker-run subprocess env (inherited from
|
||||||
# the operator's shell), never landing on argv.
|
# the operator's shell), never landing on argv.
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ smolvm binary."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
@@ -154,21 +153,6 @@ def machine_create(
|
|||||||
_smolvm(*args)
|
_smolvm(*args)
|
||||||
|
|
||||||
|
|
||||||
def machine_is_running(name: str) -> bool:
|
|
||||||
"""Return True if the named VM is in the 'running' state."""
|
|
||||||
result = _smolvm("machine", "ls", "--json", check=False)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
machines = json.loads(result.stdout or "[]")
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return any(
|
|
||||||
isinstance(m, dict) and m.get("name") == name and m.get("state") == "running"
|
|
||||||
for m in machines
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def machine_start(name: str) -> None:
|
def machine_start(name: str) -> None:
|
||||||
"""`smolvm machine start --name NAME`."""
|
"""`smolvm machine start --name NAME`."""
|
||||||
_smolvm("machine", "start", "--name", name)
|
_smolvm("machine", "start", "--name", name)
|
||||||
|
|||||||
+64
-12
@@ -1,25 +1,53 @@
|
|||||||
"""commit: freeze a running bottle's state to a resumable artifact.
|
"""commit: freeze a running bottle's state to a resumable artifact.
|
||||||
|
|
||||||
Docker bottles are committed to a local Docker image. Macos-container
|
Docker bottles are committed to a local Docker image. Smolmachines
|
||||||
bottles are exported and rebuilt as a local Apple Container image.
|
bottles are packed from the running VM into a `.smolmachine` artifact.
|
||||||
Smolmachines bottles are packed from the running VM into a
|
The resulting reference is stored in per-bottle state so the next
|
||||||
`.smolmachine` artifact. The resulting reference is stored in
|
`./cli.py resume <slug>` boots from the snapshot instead of rebuilding
|
||||||
per-bottle state so the next `./cli.py resume <slug>` boots from the
|
from the Dockerfile.
|
||||||
snapshot instead of rebuilding from the Dockerfile.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from ..backend import enumerate_active_agents
|
from ..backend import enumerate_active_agents
|
||||||
from ..backend.freeze import CommitCancelled, get_freezer
|
from ..backend.docker.util import commit_container
|
||||||
from ..bottle_state import read_metadata
|
from ..backend.smolmachines.smolvm import pack_create_from_vm
|
||||||
from ..log import die
|
from ..bottle_state import bottle_state_dir
|
||||||
|
from ..bottle_state import mark_preserved, read_metadata, write_committed_image
|
||||||
|
from ..log import die, info
|
||||||
from ._common import PROG
|
from ._common import PROG
|
||||||
from . import tui
|
from . import tui
|
||||||
|
|
||||||
|
|
||||||
|
_COMMITTED_IMAGE_PREFIX = "bot-bottle-committed-"
|
||||||
|
_DOCKER_BACKENDS = {"docker", ""}
|
||||||
|
_SMOLMACHINES_BACKEND = "smolmachines"
|
||||||
|
|
||||||
|
|
||||||
|
def _committed_image_tag(slug: str) -> str:
|
||||||
|
return f"{_COMMITTED_IMAGE_PREFIX}{slug}:latest"
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_container_name(slug: str) -> str:
|
||||||
|
return f"bot-bottle-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_machine_name(slug: str) -> str:
|
||||||
|
return f"bot-bottle-{slug}"
|
||||||
|
|
||||||
|
|
||||||
|
def _committed_smolmachine_output(slug: str) -> Path:
|
||||||
|
return bottle_state_dir(slug) / "committed-smolmachine"
|
||||||
|
|
||||||
|
|
||||||
|
def _committed_smolmachine_artifact(slug: str) -> Path:
|
||||||
|
output = _committed_smolmachine_output(slug)
|
||||||
|
return output.with_name(f"{output.name}.smolmachine")
|
||||||
|
|
||||||
|
|
||||||
def cmd_commit(argv: list[str]) -> int:
|
def cmd_commit(argv: list[str]) -> int:
|
||||||
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
|
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -45,9 +73,33 @@ def cmd_commit(argv: list[str]) -> int:
|
|||||||
|
|
||||||
metadata = read_metadata(slug)
|
metadata = read_metadata(slug)
|
||||||
backend = metadata.backend if metadata else ""
|
backend = metadata.backend if metadata else ""
|
||||||
|
if backend in _DOCKER_BACKENDS:
|
||||||
|
container = _agent_container_name(slug)
|
||||||
|
image_tag = _committed_image_tag(slug)
|
||||||
|
|
||||||
try:
|
commit_container(container, image_tag)
|
||||||
get_freezer(backend).commit_slug(slug)
|
write_committed_image(slug, image_tag)
|
||||||
except CommitCancelled:
|
mark_preserved(slug)
|
||||||
|
info(f"to resume from this snapshot: ./cli.py resume {slug}")
|
||||||
|
info(f"to export for migration: docker save {image_tag} -o {slug}.tar")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if backend == _SMOLMACHINES_BACKEND:
|
||||||
|
machine = _agent_machine_name(slug)
|
||||||
|
output = _committed_smolmachine_output(slug)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
pack_create_from_vm(machine, output)
|
||||||
|
artifact = _committed_smolmachine_artifact(slug)
|
||||||
|
write_committed_image(slug, str(artifact))
|
||||||
|
mark_preserved(slug)
|
||||||
|
info(f"to resume from this snapshot: ./cli.py resume {slug}")
|
||||||
|
info(f"to export for migration: cp {artifact} {slug}.smolmachine")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if backend:
|
||||||
|
die(
|
||||||
|
f"commit is only supported for the docker and smolmachines backends; "
|
||||||
|
f"bottle {slug!r} uses {backend!r}"
|
||||||
|
)
|
||||||
|
die(f"commit cannot determine the backend for bottle {slug!r}")
|
||||||
|
return 1
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ act on them (approve / modify / reject).
|
|||||||
|
|
||||||
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
||||||
approval handler wires to PRD 0016 (capability-block), which rebuilds
|
approval handler wires to PRD 0016 (capability-block), which rebuilds
|
||||||
the bottle Dockerfile. Egress proposals are queued for operator review
|
the bottle Dockerfile. The egress-block tool was removed in issue #198.
|
||||||
as full routes.yaml updates.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,21 +20,11 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .. import supervise as _supervise
|
from .. import supervise as _supervise
|
||||||
from ..bottle_state import read_metadata
|
# from ..bottle_state import read_metadata
|
||||||
# from ..backend.docker.capability_apply import (
|
# from ..backend.docker.capability_apply import (
|
||||||
# CapabilityApplyError,
|
# CapabilityApplyError,
|
||||||
# apply_capability_change,
|
# apply_capability_change,
|
||||||
# )
|
# )
|
||||||
from ..backend.docker.egress_apply import (
|
|
||||||
EgressApplyError,
|
|
||||||
applicator as _docker_applicator,
|
|
||||||
)
|
|
||||||
from ..backend.macos_container.egress_apply import (
|
|
||||||
applicator as _macos_applicator,
|
|
||||||
)
|
|
||||||
from ..backend.smolmachines.egress_apply import (
|
|
||||||
applicator as _smolmachines_applicator,
|
|
||||||
)
|
|
||||||
from ..log import Die, error, info
|
from ..log import Die, error, info
|
||||||
|
|
||||||
|
|
||||||
@@ -51,8 +40,6 @@ from ..supervise import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
TOOL_ALLOW,
|
|
||||||
TOOL_EGRESS_BLOCK,
|
|
||||||
archive_proposal,
|
archive_proposal,
|
||||||
list_pending_proposals,
|
list_pending_proposals,
|
||||||
render_diff,
|
render_diff,
|
||||||
@@ -76,17 +63,7 @@ class QueuedProposal:
|
|||||||
# Errors any remediation engine may raise. Caught by the TUI key
|
# Errors any remediation engine may raise. Caught by the TUI key
|
||||||
# handlers and surfaced in the status line so a failed apply keeps
|
# handlers and surfaced in the status line so a failed apply keeps
|
||||||
# the proposal pending rather than crashing curses.
|
# the proposal pending rather than crashing curses.
|
||||||
ApplyError = (CapabilityApplyError, EgressApplyError)
|
ApplyError = (CapabilityApplyError,)
|
||||||
|
|
||||||
|
|
||||||
def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
|
|
||||||
meta = read_metadata(slug)
|
|
||||||
backend = meta.backend if meta is not None else ""
|
|
||||||
if backend == "macos-container":
|
|
||||||
return _macos_applicator.apply_routes_change(slug, content)
|
|
||||||
if backend == "smolmachines":
|
|
||||||
return _smolmachines_applicator.apply_routes_change(slug, content)
|
|
||||||
return _docker_applicator.apply_routes_change(slug, content)
|
|
||||||
|
|
||||||
|
|
||||||
def discover_pending() -> list[QueuedProposal]:
|
def discover_pending() -> list[QueuedProposal]:
|
||||||
@@ -138,8 +115,6 @@ def _detail_lines(
|
|||||||
def _suffix_for_tool(tool: str) -> str:
|
def _suffix_for_tool(tool: str) -> str:
|
||||||
if tool == TOOL_CAPABILITY_BLOCK:
|
if tool == TOOL_CAPABILITY_BLOCK:
|
||||||
return ".dockerfile"
|
return ".dockerfile"
|
||||||
if tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
|
|
||||||
return ".yaml"
|
|
||||||
return ".txt"
|
return ".txt"
|
||||||
|
|
||||||
|
|
||||||
@@ -154,7 +129,6 @@ def approve(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Apply the proposal, write the waiting response, and audit it."""
|
"""Apply the proposal, write the waiting response, and audit it."""
|
||||||
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
|
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
|
||||||
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
|
|
||||||
|
|
||||||
diff_before, diff_after = "", ""
|
diff_before, diff_after = "", ""
|
||||||
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||||
@@ -168,11 +142,6 @@ def approve(
|
|||||||
# diff_before, diff_after = apply_capability_change(
|
# diff_before, diff_after = apply_capability_change(
|
||||||
# qp.proposal.bottle_slug, file_to_apply,
|
# qp.proposal.bottle_slug, file_to_apply,
|
||||||
# )
|
# )
|
||||||
if qp.proposal.tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
|
|
||||||
diff_before, diff_after = apply_routes_change(
|
|
||||||
qp.proposal.bottle_slug,
|
|
||||||
file_to_apply,
|
|
||||||
)
|
|
||||||
|
|
||||||
response = Response(
|
response = Response(
|
||||||
proposal_id=qp.proposal.id,
|
proposal_id=qp.proposal.id,
|
||||||
|
|||||||
@@ -261,8 +261,8 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
return
|
return
|
||||||
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
||||||
r = bottle.exec(
|
r = bottle.exec(
|
||||||
f"codex mcp add {_SUPERVISE_MCP_NAME} --url "
|
f"codex mcp add --transport http "
|
||||||
f"{shlex.quote(supervise_url)}",
|
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
|
||||||
user="node",
|
user="node",
|
||||||
)
|
)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
@@ -270,7 +270,7 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
f"`codex mcp add supervise` failed (exit {r.returncode}): "
|
f"`codex mcp add supervise` failed (exit {r.returncode}): "
|
||||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||||
f"register manually with: "
|
f"register manually with: "
|
||||||
f"codex mcp add supervise --url {shlex.quote(supervise_url)}"
|
f"codex mcp add --transport http supervise {supervise_url}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
|||||||
EGRESS_HOSTNAME = "egress"
|
EGRESS_HOSTNAME = "egress"
|
||||||
|
|
||||||
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
|
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
|
||||||
EGRESS_ROUTES_FILENAME = Path(EGRESS_ROUTES_IN_CONTAINER).name
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -296,7 +295,7 @@ class Egress(ABC):
|
|||||||
) -> EgressPlan:
|
) -> EgressPlan:
|
||||||
routes = egress_routes_for_bottle(bottle, provider_routes)
|
routes = egress_routes_for_bottle(bottle, provider_routes)
|
||||||
log = bottle.egress.Log
|
log = bottle.egress.Log
|
||||||
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
|
routes_path = stage_dir / "egress_routes.yaml"
|
||||||
routes_path.write_text(egress_render_routes(routes, log=log))
|
routes_path.write_text(egress_render_routes(routes, log=log))
|
||||||
routes_path.chmod(0o600)
|
routes_path.chmod(0o600)
|
||||||
return EgressPlan(
|
return EgressPlan(
|
||||||
@@ -310,7 +309,6 @@ class Egress(ABC):
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
||||||
"EGRESS_HOSTNAME",
|
"EGRESS_HOSTNAME",
|
||||||
"EGRESS_ROUTES_FILENAME",
|
|
||||||
"EGRESS_ROUTES_IN_CONTAINER",
|
"EGRESS_ROUTES_IN_CONTAINER",
|
||||||
"Egress",
|
"Egress",
|
||||||
"EgressPlan",
|
"EgressPlan",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ egress container."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
@@ -26,7 +27,6 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
|
|||||||
load_config,
|
load_config,
|
||||||
match_route,
|
match_route,
|
||||||
outbound_scan_headers,
|
outbound_scan_headers,
|
||||||
route_to_yaml_dict,
|
|
||||||
scan_inbound,
|
scan_inbound,
|
||||||
scan_outbound,
|
scan_outbound,
|
||||||
)
|
)
|
||||||
@@ -82,7 +82,7 @@ class EgressAddon:
|
|||||||
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
|
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
|
||||||
if path == "/allowlist":
|
if path == "/allowlist":
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{"routes": [route_to_yaml_dict(r) for r in self.config.routes]},
|
{"routes": [dataclasses.asdict(r) for r in self.config.routes]},
|
||||||
indent=2,
|
indent=2,
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
flow.response = http.Response.make(
|
flow.response = http.Response.make(
|
||||||
|
|||||||
@@ -359,56 +359,6 @@ def _parse_one(idx: int, raw: object) -> Route:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _path_match_to_dict(pm: PathMatch) -> dict[str, object]:
|
|
||||||
d: dict[str, object] = {"value": pm.value}
|
|
||||||
if pm.type != "prefix":
|
|
||||||
d["type"] = pm.type
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _header_match_to_dict(hm: HeaderMatch) -> dict[str, object]:
|
|
||||||
d: dict[str, object] = {"name": hm.name, "value": hm.value}
|
|
||||||
if hm.type != "exact":
|
|
||||||
d["type"] = hm.type
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def _match_entry_to_dict(me: MatchEntry) -> dict[str, object]:
|
|
||||||
d: dict[str, object] = {}
|
|
||||||
if me.paths:
|
|
||||||
d["paths"] = [_path_match_to_dict(p) for p in me.paths]
|
|
||||||
if me.methods:
|
|
||||||
d["methods"] = list(me.methods)
|
|
||||||
if me.headers:
|
|
||||||
d["headers"] = [_header_match_to_dict(h) for h in me.headers]
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def route_to_yaml_dict(r: Route) -> dict[str, object]:
|
|
||||||
"""Serialize a Route to YAML-schema-compatible dict.
|
|
||||||
|
|
||||||
Uses the same field names the YAML parser accepts, so the output
|
|
||||||
can be round-tripped directly into an `allow` or `egress-block`
|
|
||||||
proposal without translation. Fields that are empty/default are
|
|
||||||
omitted so the agent doesn't copy irrelevant keys."""
|
|
||||||
d: dict[str, object] = {"host": r.host}
|
|
||||||
if r.auth_scheme:
|
|
||||||
d["auth_scheme"] = r.auth_scheme
|
|
||||||
d["token_env"] = r.token_env
|
|
||||||
if r.matches:
|
|
||||||
d["matches"] = [_match_entry_to_dict(m) for m in r.matches]
|
|
||||||
if r.git_fetch:
|
|
||||||
d["git"] = {"fetch": True}
|
|
||||||
dlp: dict[str, object] = {}
|
|
||||||
if r.outbound_detectors is not None:
|
|
||||||
dlp["outbound_detectors"] = list(r.outbound_detectors)
|
|
||||||
if r.inbound_detectors is not None:
|
|
||||||
dlp["inbound_detectors"] = list(r.inbound_detectors)
|
|
||||||
if dlp:
|
|
||||||
d["dlp"] = dlp
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def load_routes(text: str) -> tuple[Route, ...]:
|
def load_routes(text: str) -> tuple[Route, ...]:
|
||||||
"""Parse YAML text → routes."""
|
"""Parse YAML text → routes."""
|
||||||
try:
|
try:
|
||||||
@@ -748,7 +698,6 @@ def scan_inbound(
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LOG_BLOCKS",
|
"LOG_BLOCKS",
|
||||||
"route_to_yaml_dict",
|
|
||||||
"LOG_FULL",
|
"LOG_FULL",
|
||||||
"LOG_OFF",
|
"LOG_OFF",
|
||||||
"Config",
|
"Config",
|
||||||
|
|||||||
+12
-19
@@ -5,7 +5,7 @@ queue/audit support. The sidecar (bot_bottle.supervise_server)
|
|||||||
sits on the bottle's internal network and exposes three MCP tools the
|
sits on the bottle's internal network and exposes three MCP tools the
|
||||||
agent calls when it hits a stuck-recovery category:
|
agent calls when it hits a stuck-recovery category:
|
||||||
|
|
||||||
* egress-block / allow — agent proposes a new routes.yaml
|
* egress-block — agent proposes a new routes.yaml
|
||||||
* capability-block — agent proposes a new agent Dockerfile
|
* capability-block — agent proposes a new agent Dockerfile
|
||||||
|
|
||||||
Each tool call: the agent passes the full proposed file plus a
|
Each tool call: the agent passes the full proposed file plus a
|
||||||
@@ -49,34 +49,27 @@ SUPERVISE_HOSTNAME = "supervise"
|
|||||||
SUPERVISE_PORT = 9100
|
SUPERVISE_PORT = 9100
|
||||||
|
|
||||||
TOOL_CAPABILITY_BLOCK = "capability-block"
|
TOOL_CAPABILITY_BLOCK = "capability-block"
|
||||||
TOOL_EGRESS_BLOCK = "egress-block"
|
|
||||||
TOOL_ALLOW = "allow"
|
|
||||||
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
||||||
TOOLS: tuple[str, ...] = (
|
TOOLS: tuple[str, ...] = (
|
||||||
TOOL_ALLOW,
|
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
TOOL_EGRESS_BLOCK,
|
|
||||||
TOOL_LIST_EGRESS_ROUTES,
|
TOOL_LIST_EGRESS_ROUTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
# The supervise sidecar uses these to query egress's
|
# The supervise sidecar uses these to query egress's
|
||||||
# introspection endpoint for the `list-egress-routes` MCP
|
# introspection endpoint for the `list-egress-routes` MCP
|
||||||
# tool. The hostname + port match egress's docker network
|
# tool. The hostname + port match egress's docker network
|
||||||
# listen port (see backend.docker.egress.EGRESS_PORT). The supervise
|
# alias + listen port (see bot_bottle.egress.EGRESS_HOSTNAME
|
||||||
# daemon runs inside the sidecar bundle alongside egress, so loopback
|
# and backend.docker.egress.EGRESS_PORT — the values
|
||||||
# is the stable address across docker, smolmachines, and Apple
|
# are inlined here so the in-container supervise_server doesn't
|
||||||
# Container backends.
|
# need to import the egress package).
|
||||||
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099"
|
EGRESS_FORWARD_PROXY = "http://egress:9099"
|
||||||
EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
|
EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
|
||||||
|
|
||||||
# capability-block has no on-disk config the operator edits in place
|
# capability-block has no on-disk config the operator edits in place
|
||||||
# (the Dockerfile is rebuilt, not patched), so it has no audit log
|
# (the Dockerfile is rebuilt, not patched), so it has no audit log
|
||||||
# here — those changes are captured by git history + the rebuild record
|
# here — those changes are captured by git history + the rebuild
|
||||||
# laid down in PRD 0016.
|
# record laid down in PRD 0016. egress-block was removed in issue #198.
|
||||||
COMPONENT_FOR_TOOL: dict[str, str] = {
|
COMPONENT_FOR_TOOL: dict[str, str] = {}
|
||||||
TOOL_ALLOW: "egress",
|
|
||||||
TOOL_EGRESS_BLOCK: "egress",
|
|
||||||
}
|
|
||||||
|
|
||||||
STATUS_APPROVED = "approved"
|
STATUS_APPROVED = "approved"
|
||||||
STATUS_MODIFIED = "modified"
|
STATUS_MODIFIED = "modified"
|
||||||
@@ -438,9 +431,9 @@ def sha256_hex(content: str) -> str:
|
|||||||
# Dockerfile and propose modifications.
|
# Dockerfile and propose modifications.
|
||||||
#
|
#
|
||||||
# routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
|
# routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
|
||||||
# moved them behind the `list-egress-routes` MCP tool (live state
|
# moved them behind the `list-egress-routes` MCP tool (live
|
||||||
# from egress's introspection endpoint) so the agent always sees
|
# state from egress's introspection endpoint) so the agent
|
||||||
# current data rather than a launch-time snapshot.
|
# always sees current data rather than a launch-time snapshot.
|
||||||
CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
|
CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Supervise sidecar HTTP server (PRD 0013).
|
"""Supervise sidecar HTTP server (PRD 0013).
|
||||||
|
|
||||||
Per-bottle MCP server exposing tools the agent calls to propose config
|
Per-bottle MCP server exposing tools the agent calls to propose config
|
||||||
changes when stuck. The tools are `allow`, `egress-block`,
|
changes when stuck. The egress-block tool was removed in issue #198;
|
||||||
`capability-block`, and `list-egress-routes`.
|
the remaining tools are `capability-block` and `list-egress-routes`.
|
||||||
|
|
||||||
Each queued tool call:
|
Each queued tool call:
|
||||||
|
|
||||||
@@ -44,15 +44,9 @@ import urllib.request
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
# Same-directory import inside the bundle container; `supervise.py`
|
||||||
# Same-directory imports inside the bundle container; these files are
|
# is COPYed alongside this file by Dockerfile.sidecars.
|
||||||
# COPYed flat under /app by Dockerfile.sidecars.
|
|
||||||
from egress_addon_core import load_routes
|
|
||||||
import supervise as _sv
|
import supervise as _sv
|
||||||
except ModuleNotFoundError:
|
|
||||||
# Package imports for host-side tests and tooling.
|
|
||||||
from .egress_addon_core import load_routes
|
|
||||||
from . import supervise as _sv
|
|
||||||
|
|
||||||
|
|
||||||
# --- JSON-RPC / MCP plumbing ----------------------------------------------
|
# --- JSON-RPC / MCP plumbing ----------------------------------------------
|
||||||
@@ -148,9 +142,8 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
"allowlist. Returns JSON with one entry per allowed host, "
|
"allowlist. Returns JSON with one entry per allowed host, "
|
||||||
"each carrying its matches rules (if any) and whether "
|
"each carrying its matches rules (if any) and whether "
|
||||||
"the proxy injects Authorization for the route. Use this "
|
"the proxy injects Authorization for the route. Use this "
|
||||||
"before composing an `allow` or `egress-block` proposal so "
|
"before composing an `egress-block` proposal so the new "
|
||||||
"the new routes file extends the live one rather than "
|
"routes file extends the live one rather than replacing it."
|
||||||
"replacing it."
|
|
||||||
),
|
),
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -158,88 +151,6 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": _sv.TOOL_ALLOW,
|
|
||||||
"description": (
|
|
||||||
"Request operator approval to change the bottle's egress "
|
|
||||||
"allowlist. Pass the full proposed routes.yaml content, not "
|
|
||||||
"just the new host, plus a justification. Use "
|
|
||||||
"`list-egress-routes` first so the proposal preserves existing "
|
|
||||||
"routes."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"routes_yaml": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"Full proposed /etc/egress/routes.yaml content. "
|
|
||||||
"Each route entry accepts these keys:\n"
|
|
||||||
" host: <hostname> (required)\n"
|
|
||||||
" auth_scheme: Bearer|token (must pair with token_env)\n"
|
|
||||||
" token_env: <ENV_VAR_NAME> (must pair with auth_scheme)\n"
|
|
||||||
" matches: (optional list of match entries)\n"
|
|
||||||
" - paths: [{type: prefix|exact|regex, value: /...}]\n"
|
|
||||||
" methods: [GET, POST, ...]\n"
|
|
||||||
" headers: [{name: X-Hdr, value: val, type: exact|regex}]\n"
|
|
||||||
" git: (optional; omit to block git clone/fetch)\n"
|
|
||||||
" fetch: true\n"
|
|
||||||
" dlp: (optional DLP scanner overrides)\n"
|
|
||||||
" outbound_detectors: [token_patterns, known_secrets]\n"
|
|
||||||
" inbound_detectors: [naive_injection_detection]\n"
|
|
||||||
"Omit any key that should use its default. "
|
|
||||||
"`list-egress-routes` returns routes in this same format."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"justification": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Why this egress route is needed.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["routes_yaml", "justification"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": _sv.TOOL_EGRESS_BLOCK,
|
|
||||||
"description": (
|
|
||||||
"Request operator approval to change the bottle's egress "
|
|
||||||
"allowlist after a blocked outbound request. Pass the full "
|
|
||||||
"proposed routes.yaml content plus a justification. Use "
|
|
||||||
"`list-egress-routes` first so the proposal preserves existing "
|
|
||||||
"routes."
|
|
||||||
),
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"routes_yaml": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"Full proposed /etc/egress/routes.yaml content. "
|
|
||||||
"Each route entry accepts these keys:\n"
|
|
||||||
" host: <hostname> (required)\n"
|
|
||||||
" auth_scheme: Bearer|token (must pair with token_env)\n"
|
|
||||||
" token_env: <ENV_VAR_NAME> (must pair with auth_scheme)\n"
|
|
||||||
" matches: (optional list of match entries)\n"
|
|
||||||
" - paths: [{type: prefix|exact|regex, value: /...}]\n"
|
|
||||||
" methods: [GET, POST, ...]\n"
|
|
||||||
" headers: [{name: X-Hdr, value: val, type: exact|regex}]\n"
|
|
||||||
" git: (optional; omit to block git clone/fetch)\n"
|
|
||||||
" fetch: true\n"
|
|
||||||
" dlp: (optional DLP scanner overrides)\n"
|
|
||||||
" outbound_detectors: [token_patterns, known_secrets]\n"
|
|
||||||
" inbound_detectors: [naive_injection_detection]\n"
|
|
||||||
"Omit any key that should use its default. "
|
|
||||||
"`list-egress-routes` returns routes in this same format."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"justification": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Why this egress route is needed.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["routes_yaml", "justification"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
||||||
"description": (
|
"description": (
|
||||||
@@ -271,12 +182,11 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
# Map each proposal tool to the input field that carries the agent's
|
# Map each non-egress tool to the input field that carries the agent's
|
||||||
# payload (stored in Proposal.proposed_file).
|
# payload (stored in Proposal.proposed_file). egress-block builds its
|
||||||
|
# payload from structured input fields in `handle_egress_block`.
|
||||||
PROPOSED_FILE_FIELD: dict[str, str] = {
|
PROPOSED_FILE_FIELD: dict[str, str] = {
|
||||||
_sv.TOOL_ALLOW: "routes_yaml",
|
|
||||||
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
|
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
|
||||||
_sv.TOOL_EGRESS_BLOCK: "routes_yaml",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -293,14 +203,6 @@ def validate_proposed_file(tool: str, content: str) -> None:
|
|||||||
# Dockerfiles are too varied to validate syntactically beyond
|
# Dockerfiles are too varied to validate syntactically beyond
|
||||||
# non-empty. The operator reads the diff in the TUI.
|
# non-empty. The operator reads the diff in the TUI.
|
||||||
pass
|
pass
|
||||||
elif tool in (_sv.TOOL_ALLOW, _sv.TOOL_EGRESS_BLOCK):
|
|
||||||
try:
|
|
||||||
load_routes(content)
|
|
||||||
except ValueError as e:
|
|
||||||
raise _RpcError(
|
|
||||||
ERR_INVALID_PARAMS,
|
|
||||||
f"{tool}: proposed routes.yaml is not valid: {e}",
|
|
||||||
) from e
|
|
||||||
else:
|
else:
|
||||||
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}")
|
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
"""Unit: Freezer class hierarchy."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle import supervise, bottle_state
|
|
||||||
from bot_bottle.backend import ActiveAgent
|
|
||||||
from bot_bottle.backend.freeze import get_freezer
|
|
||||||
from bot_bottle.backend.docker.freezer import DockerFreezer
|
|
||||||
from bot_bottle.backend.macos_container.freezer import MacosContainerFreezer
|
|
||||||
from bot_bottle.backend.smolmachines.freezer import SmolmachinesFreezer
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeHomeMixin:
|
|
||||||
def _setup_fake_home(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="freezer-test.")
|
|
||||||
original = supervise.bot_bottle_root
|
|
||||||
|
|
||||||
def fake_root() -> Path:
|
|
||||||
return Path(self._tmp.name) / ".bot-bottle"
|
|
||||||
|
|
||||||
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
|
|
||||||
self._restore = lambda: setattr(supervise, "bot_bottle_root", original)
|
|
||||||
|
|
||||||
def _teardown_fake_home(self):
|
|
||||||
self._restore()
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
def _make_agent(slug: str, backend: str = "docker") -> ActiveAgent:
|
|
||||||
return ActiveAgent(
|
|
||||||
backend_name=backend,
|
|
||||||
slug=slug,
|
|
||||||
agent_name="dev",
|
|
||||||
started_at="t",
|
|
||||||
services=(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetFreezer(unittest.TestCase):
|
|
||||||
def test_docker(self):
|
|
||||||
self.assertIsInstance(get_freezer("docker"), DockerFreezer)
|
|
||||||
|
|
||||||
def test_empty_backend_gives_docker(self):
|
|
||||||
self.assertIsInstance(get_freezer(""), DockerFreezer)
|
|
||||||
|
|
||||||
def test_macos_container(self):
|
|
||||||
self.assertIsInstance(get_freezer("macos-container"), MacosContainerFreezer)
|
|
||||||
|
|
||||||
def test_smolmachines(self):
|
|
||||||
self.assertIsInstance(get_freezer("smolmachines"), SmolmachinesFreezer)
|
|
||||||
|
|
||||||
def test_unknown_backend_dies(self):
|
|
||||||
with patch("bot_bottle.backend.freeze.die", side_effect=SystemExit("die")):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
get_freezer("unknown-backend")
|
|
||||||
|
|
||||||
|
|
||||||
class TestFreezerBaseCommit(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
"""The base Freezer.commit() owns the shared post-freeze steps."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def test_writes_committed_image_and_marks_preserved(self):
|
|
||||||
slug = "dev-abc12"
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend="docker",
|
|
||||||
))
|
|
||||||
freezer = get_freezer("docker")
|
|
||||||
agent = _make_agent(slug)
|
|
||||||
|
|
||||||
with patch.object(freezer, "_freeze", return_value="bot-bottle-committed-dev-abc12:latest"), \
|
|
||||||
patch("bot_bottle.backend.freeze.info"):
|
|
||||||
freezer.commit(agent)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
"bot-bottle-committed-dev-abc12:latest",
|
|
||||||
bottle_state.read_committed_image(slug),
|
|
||||||
)
|
|
||||||
self.assertTrue(bottle_state.is_preserved(slug))
|
|
||||||
|
|
||||||
def test_commit_slug_passes_correct_slug_to_freeze(self):
|
|
||||||
slug = "dev-abc12"
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend="docker",
|
|
||||||
))
|
|
||||||
freezer = get_freezer("docker")
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def capture_freeze(agent: ActiveAgent) -> str:
|
|
||||||
captured["slug"] = agent.slug
|
|
||||||
return "some-ref"
|
|
||||||
|
|
||||||
with patch.object(freezer, "_freeze", side_effect=capture_freeze), \
|
|
||||||
patch("bot_bottle.backend.freeze.info"):
|
|
||||||
freezer.commit_slug(slug)
|
|
||||||
|
|
||||||
self.assertEqual(slug, captured["slug"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestDockerFreezer(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def test_commits_container_and_records_image(self):
|
|
||||||
slug = "dev-abc12"
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend="docker",
|
|
||||||
))
|
|
||||||
freezer = DockerFreezer()
|
|
||||||
agent = _make_agent(slug)
|
|
||||||
|
|
||||||
with patch("bot_bottle.backend.docker.freezer.commit_container") as mock_commit, \
|
|
||||||
patch("bot_bottle.backend.freeze.info"), \
|
|
||||||
patch("bot_bottle.backend.docker.freezer.info"):
|
|
||||||
freezer.commit(agent)
|
|
||||||
|
|
||||||
mock_commit.assert_called_once_with(
|
|
||||||
f"bot-bottle-{slug}",
|
|
||||||
f"bot-bottle-committed-{slug}:latest",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
f"bot-bottle-committed-{slug}:latest",
|
|
||||||
bottle_state.read_committed_image(slug),
|
|
||||||
)
|
|
||||||
self.assertTrue(bottle_state.is_preserved(slug))
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerFreezer(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def _write_meta(self, slug: str) -> None:
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend="macos-container",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_commits_running_container_without_stopping(self):
|
|
||||||
"""Commit should exec-tar the running container, not stop it."""
|
|
||||||
slug = "dev-abc12"
|
|
||||||
self._write_meta(slug)
|
|
||||||
freezer = MacosContainerFreezer()
|
|
||||||
agent = _make_agent(slug, "macos-container")
|
|
||||||
|
|
||||||
with patch("bot_bottle.backend.macos_container.freezer.commit_container") as mock_commit, \
|
|
||||||
patch("bot_bottle.backend.freeze.info"), \
|
|
||||||
patch("bot_bottle.backend.macos_container.freezer.info"):
|
|
||||||
freezer.commit(agent)
|
|
||||||
|
|
||||||
mock_commit.assert_called_once_with(
|
|
||||||
f"bot-bottle-{slug}",
|
|
||||||
f"bot-bottle-committed-{slug}:latest",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
f"bot-bottle-committed-{slug}:latest",
|
|
||||||
bottle_state.read_committed_image(slug),
|
|
||||||
)
|
|
||||||
self.assertTrue(bottle_state.is_preserved(slug))
|
|
||||||
|
|
||||||
|
|
||||||
class TestSmolmachinesFreezer(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def _write_meta(self, slug: str) -> None:
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend="smolmachines",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_snapshots_running_vm_without_stopping(self):
|
|
||||||
"""Commit should exec-tar the running VM, not stop it."""
|
|
||||||
slug = "dev-abc12"
|
|
||||||
self._write_meta(slug)
|
|
||||||
freezer = SmolmachinesFreezer()
|
|
||||||
agent = _make_agent(slug, "smolmachines")
|
|
||||||
|
|
||||||
with patch("bot_bottle.backend.smolmachines.freezer._snapshot_running_vm") as mock_snap, \
|
|
||||||
patch("bot_bottle.backend.freeze.info"), \
|
|
||||||
patch("bot_bottle.backend.smolmachines.freezer.info"):
|
|
||||||
freezer.commit(agent)
|
|
||||||
|
|
||||||
expected_binary = bottle_state.bottle_state_dir(slug) / "committed-smolmachine"
|
|
||||||
mock_snap.assert_called_once_with(
|
|
||||||
f"bot-bottle-{slug}",
|
|
||||||
f"bot-bottle-committed-{slug}:latest",
|
|
||||||
expected_binary,
|
|
||||||
)
|
|
||||||
expected_sidecar = str(expected_binary.with_suffix(".smolmachine"))
|
|
||||||
self.assertEqual(expected_sidecar, bottle_state.read_committed_image(slug))
|
|
||||||
self.assertTrue(bottle_state.is_preserved(slug))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
+127
-45
@@ -7,10 +7,15 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from bot_bottle.cli.commit import cmd_commit
|
from bot_bottle.cli.commit import (
|
||||||
|
cmd_commit,
|
||||||
|
_agent_container_name,
|
||||||
|
_committed_image_tag,
|
||||||
|
_committed_smolmachine_artifact,
|
||||||
|
_committed_smolmachine_output,
|
||||||
|
)
|
||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
from bot_bottle import bottle_state
|
from bot_bottle import bottle_state
|
||||||
from bot_bottle.backend.freeze import CommitCancelled
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeHomeMixin:
|
class _FakeHomeMixin:
|
||||||
@@ -29,8 +34,32 @@ class _FakeHomeMixin:
|
|||||||
self._tmp.cleanup()
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommitHelpers(unittest.TestCase):
|
||||||
|
def test_committed_image_tag(self):
|
||||||
|
self.assertEqual(
|
||||||
|
"bot-bottle-committed-dev-abc12:latest",
|
||||||
|
_committed_image_tag("dev-abc12"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_agent_container_name(self):
|
||||||
|
self.assertEqual(
|
||||||
|
"bot-bottle-dev-abc12",
|
||||||
|
_agent_container_name("dev-abc12"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_committed_smolmachine_paths(self):
|
||||||
|
output = _committed_smolmachine_output("dev-abc12")
|
||||||
|
artifact = _committed_smolmachine_artifact("dev-abc12")
|
||||||
|
self.assertTrue(str(output).endswith(
|
||||||
|
"/.bot-bottle/state/dev-abc12/committed-smolmachine"
|
||||||
|
))
|
||||||
|
self.assertTrue(str(artifact).endswith(
|
||||||
|
"/.bot-bottle/state/dev-abc12/committed-smolmachine.smolmachine"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
||||||
"""cmd_commit with an explicit slug delegates to get_freezer."""
|
"""cmd_commit with an explicit slug bypasses the TUI picker."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._setup_fake_home()
|
self._setup_fake_home()
|
||||||
@@ -38,74 +67,127 @@ class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self._teardown_fake_home()
|
self._teardown_fake_home()
|
||||||
|
|
||||||
def _write_meta(self, slug: str, backend: str) -> None:
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend=backend,
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_commits_docker_bottle(self):
|
def test_commits_docker_bottle(self):
|
||||||
slug = "dev-abc12"
|
slug = "dev-abc12"
|
||||||
self._write_meta(slug, "docker")
|
# Write metadata saying this is a docker bottle.
|
||||||
|
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||||
|
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||||
|
started_at="t", backend="docker",
|
||||||
|
))
|
||||||
|
|
||||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
with patch(
|
||||||
mock_freezer = MagicMock()
|
"bot_bottle.cli.commit.commit_container",
|
||||||
mock_gf.return_value = mock_freezer
|
) as mock_commit, patch(
|
||||||
|
"bot_bottle.cli.commit.info",
|
||||||
|
):
|
||||||
rc = cmd_commit([slug])
|
rc = cmd_commit([slug])
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
self.assertEqual(0, rc)
|
||||||
mock_gf.assert_called_once_with("docker")
|
mock_commit.assert_called_once_with(
|
||||||
mock_freezer.commit_slug.assert_called_once_with(slug)
|
f"bot-bottle-{slug}",
|
||||||
|
f"bot-bottle-committed-{slug}:latest",
|
||||||
|
)
|
||||||
|
|
||||||
def test_empty_backend_passed_to_get_freezer(self):
|
def test_writes_committed_image_to_state(self):
|
||||||
"""Old state dirs without a backend field pass '' to get_freezer."""
|
|
||||||
slug = "dev-abc12"
|
slug = "dev-abc12"
|
||||||
self._write_meta(slug, "")
|
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||||
|
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||||
|
started_at="t", backend="docker",
|
||||||
|
))
|
||||||
|
|
||||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
with patch("bot_bottle.cli.commit.commit_container"), \
|
||||||
mock_freezer = MagicMock()
|
patch("bot_bottle.cli.commit.info"):
|
||||||
mock_gf.return_value = mock_freezer
|
cmd_commit([slug])
|
||||||
rc = cmd_commit([slug])
|
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
self.assertEqual(
|
||||||
mock_gf.assert_called_once_with("")
|
f"bot-bottle-committed-{slug}:latest",
|
||||||
|
bottle_state.read_committed_image(slug),
|
||||||
|
)
|
||||||
|
|
||||||
def test_commits_macos_container_bottle(self):
|
def test_marks_bottle_preserved(self):
|
||||||
slug = "dev-abc12"
|
slug = "dev-abc12"
|
||||||
self._write_meta(slug, "macos-container")
|
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||||
|
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||||
|
started_at="t", backend="docker",
|
||||||
|
))
|
||||||
|
|
||||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
with patch("bot_bottle.cli.commit.commit_container"), \
|
||||||
mock_freezer = MagicMock()
|
patch("bot_bottle.cli.commit.info"):
|
||||||
mock_gf.return_value = mock_freezer
|
cmd_commit([slug])
|
||||||
rc = cmd_commit([slug])
|
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
self.assertTrue(bottle_state.is_preserved(slug))
|
||||||
mock_gf.assert_called_once_with("macos-container")
|
|
||||||
mock_freezer.commit_slug.assert_called_once_with(slug)
|
|
||||||
|
|
||||||
def test_commits_smolmachines_bottle(self):
|
def test_empty_backend_treated_as_docker(self):
|
||||||
|
"""Old state dirs without a backend field should be treated as docker."""
|
||||||
slug = "dev-abc12"
|
slug = "dev-abc12"
|
||||||
self._write_meta(slug, "smolmachines")
|
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||||
|
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||||
|
started_at="t", backend="",
|
||||||
|
))
|
||||||
|
|
||||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
with patch("bot_bottle.cli.commit.commit_container") as mock_commit, \
|
||||||
mock_freezer = MagicMock()
|
patch("bot_bottle.cli.commit.info"):
|
||||||
mock_gf.return_value = mock_freezer
|
|
||||||
rc = cmd_commit([slug])
|
rc = cmd_commit([slug])
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
self.assertEqual(0, rc)
|
||||||
mock_gf.assert_called_once_with("smolmachines")
|
mock_commit.assert_called_once()
|
||||||
|
|
||||||
def test_returns_zero_on_commit_cancelled(self):
|
|
||||||
|
class TestCmdCommitSmolmachinesBackend(_FakeHomeMixin, unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._setup_fake_home()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._teardown_fake_home()
|
||||||
|
|
||||||
|
def test_packs_smolmachines_bottle(self):
|
||||||
slug = "dev-abc12"
|
slug = "dev-abc12"
|
||||||
self._write_meta(slug, "macos-container")
|
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||||
|
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||||
|
started_at="t", backend="smolmachines",
|
||||||
|
))
|
||||||
|
|
||||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
with patch(
|
||||||
mock_freezer = MagicMock()
|
"bot_bottle.cli.commit.pack_create_from_vm",
|
||||||
mock_freezer.commit_slug.side_effect = CommitCancelled
|
) as mock_pack, patch(
|
||||||
mock_gf.return_value = mock_freezer
|
"bot_bottle.cli.commit.info",
|
||||||
|
):
|
||||||
rc = cmd_commit([slug])
|
rc = cmd_commit([slug])
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
self.assertEqual(0, rc)
|
||||||
|
mock_pack.assert_called_once_with(
|
||||||
|
f"bot-bottle-{slug}",
|
||||||
|
_committed_smolmachine_output(slug),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
str(_committed_smolmachine_artifact(slug)),
|
||||||
|
bottle_state.read_committed_image(slug),
|
||||||
|
)
|
||||||
|
self.assertTrue(bottle_state.is_preserved(slug))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCmdCommitUnsupportedBackend(_FakeHomeMixin, unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self._setup_fake_home()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._teardown_fake_home()
|
||||||
|
|
||||||
|
def test_dies_for_macos_container_backend(self):
|
||||||
|
slug = "dev-abc12"
|
||||||
|
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||||
|
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||||
|
started_at="t", backend="macos-container",
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"),
|
||||||
|
) as mock_die:
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
cmd_commit([slug])
|
||||||
|
|
||||||
|
mock_die.assert_called_once()
|
||||||
|
self.assertIn("macos-container", mock_die.call_args.args[0])
|
||||||
|
|
||||||
|
|
||||||
class TestCmdCommitNoActiveBottles(_FakeHomeMixin, unittest.TestCase):
|
class TestCmdCommitNoActiveBottles(_FakeHomeMixin, unittest.TestCase):
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
"services"]["sidecars"]
|
"services"]["sidecars"]
|
||||||
targets = {v["target"] for v in sc["volumes"]}
|
targets = {v["target"] for v in sc["volumes"]}
|
||||||
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
||||||
self.assertIn("/etc/egress", targets)
|
self.assertIn("/etc/egress/routes.yaml", targets)
|
||||||
self.assertIn("/git-gate-entrypoint.sh", targets)
|
self.assertIn("/git-gate-entrypoint.sh", targets)
|
||||||
self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
|
self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
|
||||||
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
|
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
|
||||||
|
|||||||
@@ -292,10 +292,10 @@ class TestCodexSuperviseMcp(unittest.TestCase):
|
|||||||
bottle.exec.assert_called_once()
|
bottle.exec.assert_called_once()
|
||||||
script = bottle.exec.call_args.args[0]
|
script = bottle.exec.call_args.args[0]
|
||||||
self.assertEqual("node", bottle.exec.call_args.kwargs.get("user"))
|
self.assertEqual("node", bottle.exec.call_args.kwargs.get("user"))
|
||||||
self.assertEqual(
|
self.assertIn("codex mcp add", script)
|
||||||
f"codex mcp add supervise --url {_URL}",
|
self.assertIn("--transport http", script)
|
||||||
script,
|
self.assertIn("supervise", script)
|
||||||
)
|
self.assertIn(_URL, script)
|
||||||
|
|
||||||
def test_logs_warning_on_failure_but_does_not_raise(self):
|
def test_logs_warning_on_failure_but_does_not_raise(self):
|
||||||
bottle = _make_bottle(
|
bottle = _make_bottle(
|
||||||
|
|||||||
@@ -16,14 +16,16 @@ from bot_bottle.backend.docker import launch as launch_mod
|
|||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import Manifest
|
||||||
|
|
||||||
|
|
||||||
_SLUG = "dev-abc12"
|
_SLUG = "dev-abc12"
|
||||||
_COMMITTED_TAG = f"bot-bottle-committed-{_SLUG}:latest"
|
_COMMITTED_TAG = f"bot-bottle-committed-{_SLUG}:latest"
|
||||||
_DEFAULT_IMAGE = "bot-bottle-claude:latest"
|
_DEFAULT_IMAGE = "bot-bottle-claude:latest"
|
||||||
|
|
||||||
_IDX = ManifestIndex.from_json_obj({
|
|
||||||
|
def _manifest() -> Manifest:
|
||||||
|
return Manifest.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
@@ -32,7 +34,7 @@ _IDX = ManifestIndex.from_json_obj({
|
|||||||
def _plan(tmp: str) -> DockerBottlePlan:
|
def _plan(tmp: str) -> DockerBottlePlan:
|
||||||
stage = Path(tmp)
|
stage = Path(tmp)
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=_IDX,
|
manifest=_manifest(),
|
||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=False,
|
copy_cwd=False,
|
||||||
user_cwd=tmp,
|
user_cwd=tmp,
|
||||||
@@ -40,7 +42,6 @@ def _plan(tmp: str) -> DockerBottlePlan:
|
|||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
manifest=_IDX.load_for_agent("demo"),
|
|
||||||
stage_dir=stage,
|
stage_dir=stage,
|
||||||
git_gate_plan=GitGatePlan(
|
git_gate_plan=GitGatePlan(
|
||||||
slug=_SLUG,
|
slug=_SLUG,
|
||||||
|
|||||||
@@ -2,15 +2,12 @@
|
|||||||
add_route removed; docker exec / cp / kill paths are covered by the
|
add_route removed; docker exec / cp / kill paths are covered by the
|
||||||
integration test)."""
|
integration test)."""
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle import supervise
|
from bot_bottle.backend.docker.egress_apply import (
|
||||||
from bot_bottle.backend.egress_apply import EgressApplyError
|
EgressApplyError,
|
||||||
from bot_bottle.backend.docker.egress_apply import applicator
|
validate_routes_content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_ROUTES_EMPTY = "routes: []\n"
|
_ROUTES_EMPTY = "routes: []\n"
|
||||||
@@ -19,11 +16,11 @@ _ROUTES_ONE = 'routes:\n - host: "api.anthropic.com"\n'
|
|||||||
|
|
||||||
class TestValidateRoutesContent(unittest.TestCase):
|
class TestValidateRoutesContent(unittest.TestCase):
|
||||||
def test_accepts_minimal_route_table(self):
|
def test_accepts_minimal_route_table(self):
|
||||||
applicator.validate_routes_content(_ROUTES_EMPTY)
|
validate_routes_content(_ROUTES_EMPTY)
|
||||||
applicator.validate_routes_content(_ROUTES_ONE)
|
validate_routes_content(_ROUTES_ONE)
|
||||||
|
|
||||||
def test_accepts_full_route_with_matches(self):
|
def test_accepts_full_route_with_matches(self):
|
||||||
applicator.validate_routes_content(
|
validate_routes_content(
|
||||||
'routes:\n'
|
'routes:\n'
|
||||||
' - host: "api.github.com"\n'
|
' - host: "api.github.com"\n'
|
||||||
' auth_scheme: "Bearer"\n'
|
' auth_scheme: "Bearer"\n'
|
||||||
@@ -35,65 +32,25 @@ class TestValidateRoutesContent(unittest.TestCase):
|
|||||||
|
|
||||||
def test_rejects_bad_yaml(self):
|
def test_rejects_bad_yaml(self):
|
||||||
with self.assertRaises(EgressApplyError) as cm:
|
with self.assertRaises(EgressApplyError) as cm:
|
||||||
applicator.validate_routes_content("routes:\n\t- host: x\n")
|
validate_routes_content("routes:\n\t- host: x\n")
|
||||||
self.assertIn("not valid", str(cm.exception))
|
self.assertIn("not valid", str(cm.exception))
|
||||||
|
|
||||||
def test_rejects_missing_routes_key(self):
|
def test_rejects_missing_routes_key(self):
|
||||||
with self.assertRaises(EgressApplyError):
|
with self.assertRaises(EgressApplyError):
|
||||||
applicator.validate_routes_content("other: []\n")
|
validate_routes_content("other: []\n")
|
||||||
|
|
||||||
def test_rejects_non_list_routes(self):
|
def test_rejects_non_list_routes(self):
|
||||||
with self.assertRaises(EgressApplyError):
|
with self.assertRaises(EgressApplyError):
|
||||||
applicator.validate_routes_content('routes: "not a list"\n')
|
validate_routes_content('routes: "not a list"\n')
|
||||||
|
|
||||||
def test_rejects_partial_auth_pair(self):
|
def test_rejects_partial_auth_pair(self):
|
||||||
with self.assertRaises(EgressApplyError):
|
with self.assertRaises(EgressApplyError):
|
||||||
applicator.validate_routes_content(
|
validate_routes_content(
|
||||||
'routes:\n'
|
'routes:\n'
|
||||||
' - host: "x.example"\n'
|
' - host: "x.example"\n'
|
||||||
' auth_scheme: "Bearer"\n'
|
' auth_scheme: "Bearer"\n'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestApplyRoutesChange(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="egress-apply-test.")
|
|
||||||
original = supervise.bot_bottle_root
|
|
||||||
|
|
||||||
def fake_root() -> Path:
|
|
||||||
return Path(self._tmp.name) / ".bot-bottle"
|
|
||||||
|
|
||||||
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
|
|
||||||
self.addCleanup(lambda: setattr(supervise, "bot_bottle_root", original))
|
|
||||||
self.addCleanup(self._tmp.cleanup)
|
|
||||||
|
|
||||||
def test_writes_live_routes_and_signals_reload(self):
|
|
||||||
calls: list[list[str]] = []
|
|
||||||
|
|
||||||
def fake_run(argv: list[str], **kwargs: object) -> SimpleNamespace:
|
|
||||||
calls.append(list(argv))
|
|
||||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.docker.egress_apply.subprocess.run",
|
|
||||||
side_effect=fake_run,
|
|
||||||
):
|
|
||||||
before, after = applicator.apply_routes_change(
|
|
||||||
"dev",
|
|
||||||
"routes:\n - host: google.com\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual("", before)
|
|
||||||
self.assertEqual("routes:\n - host: google.com\n", after)
|
|
||||||
self.assertEqual(
|
|
||||||
"routes:\n - host: google.com\n",
|
|
||||||
(Path(self._tmp.name) / ".bot-bottle/state/dev/egress/routes.yaml").read_text(encoding="utf-8"),
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
["docker", "kill", "--signal", "HUP", "bot-bottle-sidecars-dev"],
|
|
||||||
calls[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -9,12 +9,8 @@ from types import SimpleNamespace
|
|||||||
from typing import cast
|
from typing import cast
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
|
||||||
from bot_bottle.backend import BottleSpec
|
|
||||||
from bot_bottle.backend.macos_container import launch
|
from bot_bottle.backend.macos_container import launch
|
||||||
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan
|
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
_MANIFEST = ManifestIndex.from_json_obj({
|
_MANIFEST = ManifestIndex.from_json_obj({
|
||||||
@@ -31,7 +27,7 @@ def _plan(
|
|||||||
agent_git_gate_url: str = "",
|
agent_git_gate_url: str = "",
|
||||||
agent_supervise_url: str = "",
|
agent_supervise_url: str = "",
|
||||||
) -> MacosContainerBottlePlan:
|
) -> MacosContainerBottlePlan:
|
||||||
routes_path = stage_dir / "routes.yaml"
|
routes_path = stage_dir / "source-routes.yaml"
|
||||||
routes_path.write_text("routes: []\n", encoding="utf-8")
|
routes_path.write_text("routes: []\n", encoding="utf-8")
|
||||||
ca_dir = stage_dir / "egress-ca"
|
ca_dir = stage_dir / "egress-ca"
|
||||||
ca_dir.mkdir(exist_ok=True)
|
ca_dir.mkdir(exist_ok=True)
|
||||||
@@ -129,10 +125,15 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|||||||
f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy",
|
f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy",
|
||||||
argv,
|
argv,
|
||||||
)
|
)
|
||||||
|
routes_dir = self.stage_dir / "macos-container-egress"
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
f"type=bind,source={self.stage_dir},target=/etc/egress,readonly",
|
f"type=bind,source={routes_dir},target=/etc/egress,readonly",
|
||||||
argv,
|
argv,
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"routes: []\n",
|
||||||
|
(routes_dir / "routes.yaml").read_text(encoding="utf-8"),
|
||||||
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
|
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
|
||||||
argv,
|
argv,
|
||||||
@@ -265,80 +266,5 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_plan(stage_dir: Path) -> MacosContainerBottlePlan:
|
|
||||||
return MacosContainerBottlePlan(
|
|
||||||
spec=cast(BottleSpec, SimpleNamespace()),
|
|
||||||
manifest=_MANIFEST,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())),
|
|
||||||
egress_plan=cast(EgressPlan, SimpleNamespace()),
|
|
||||||
supervise_plan=None,
|
|
||||||
agent_provision=AgentProvisionPlan(
|
|
||||||
template="claude",
|
|
||||||
command="claude",
|
|
||||||
prompt_mode="append_file",
|
|
||||||
image="bot-bottle-agent:latest",
|
|
||||||
dockerfile="/repo/Dockerfile",
|
|
||||||
guest_home="/home/node",
|
|
||||||
instance_name="bot-bottle-dev-abc",
|
|
||||||
prompt_file=stage_dir / "prompt.txt",
|
|
||||||
guest_env={},
|
|
||||||
),
|
|
||||||
slug="dev-abc",
|
|
||||||
forwarded_env={},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.stage_dir = Path(self._tmp.name)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def test_build_images_uses_committed_image_when_present(self):
|
|
||||||
plan = _build_plan(self.stage_dir)
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_build(image: str, context: str, *, dockerfile: str = "") -> None:
|
|
||||||
calls.append((image, context, dockerfile))
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
launch, "read_committed_image",
|
|
||||||
return_value="bot-bottle-committed-dev-abc:latest",
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "image_exists", return_value=True,
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "build_image", side_effect=fake_build,
|
|
||||||
), patch.object(launch, "info"):
|
|
||||||
updated = launch._build_images(plan)
|
|
||||||
|
|
||||||
self.assertEqual("bot-bottle-committed-dev-abc:latest", updated.image)
|
|
||||||
self.assertEqual(1, len(calls))
|
|
||||||
self.assertEqual(launch.SIDECAR_BUNDLE_IMAGE, calls[0][0])
|
|
||||||
|
|
||||||
def test_build_images_builds_agent_when_committed_image_missing(self):
|
|
||||||
plan = _build_plan(self.stage_dir)
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_build(image: str, context: str, *, dockerfile: str = "") -> None:
|
|
||||||
calls.append((image, context, dockerfile))
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
launch, "read_committed_image",
|
|
||||||
return_value="bot-bottle-committed-dev-abc:latest",
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "image_exists", return_value=False,
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "build_image", side_effect=fake_build,
|
|
||||||
):
|
|
||||||
updated = launch._build_images(plan)
|
|
||||||
|
|
||||||
self.assertEqual("bot-bottle-agent:latest", updated.image)
|
|
||||||
self.assertEqual(2, len(calls))
|
|
||||||
self.assertEqual("bot-bottle-agent:latest", calls[1][0])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -73,53 +73,6 @@ resolver #2
|
|||||||
)
|
)
|
||||||
self.assertTrue(run.call_args_list[-1].kwargs["check"])
|
self.assertTrue(run.call_args_list[-1].kwargs["check"])
|
||||||
|
|
||||||
def test_commit_container_execs_tar_and_builds_image(self):
|
|
||||||
# stderr is bytes because subprocess.run uses stderr=PIPE without text=True
|
|
||||||
completed = util.subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=0, stdout=b"", stderr=b"",
|
|
||||||
)
|
|
||||||
dockerfile_text = ""
|
|
||||||
|
|
||||||
def fake_build_image(image_tag: str, context: str, *, dockerfile: str = "") -> None:
|
|
||||||
nonlocal dockerfile_text
|
|
||||||
with open(dockerfile, encoding="utf-8") as f:
|
|
||||||
dockerfile_text = f.read()
|
|
||||||
|
|
||||||
with patch.object(util.subprocess, "run", return_value=completed) as run, \
|
|
||||||
patch.object(util, "build_image", side_effect=fake_build_image) as build_image, \
|
|
||||||
patch.object(util, "info"):
|
|
||||||
util.commit_container(
|
|
||||||
"bot-bottle-dev-abc12",
|
|
||||||
"bot-bottle-committed-dev-abc12:latest",
|
|
||||||
)
|
|
||||||
|
|
||||||
argv = run.call_args.args[0]
|
|
||||||
self.assertEqual("container", argv[0])
|
|
||||||
self.assertEqual("exec", argv[1])
|
|
||||||
self.assertIn("bot-bottle-dev-abc12", argv)
|
|
||||||
self.assertIn("tar", argv)
|
|
||||||
self.assertIn("--directory=/", argv)
|
|
||||||
build_image.assert_called_once()
|
|
||||||
self.assertEqual(
|
|
||||||
"bot-bottle-committed-dev-abc12:latest",
|
|
||||||
build_image.call_args.args[0],
|
|
||||||
)
|
|
||||||
self.assertIn("ADD rootfs.tar /\n", dockerfile_text)
|
|
||||||
self.assertIn("USER node\n", dockerfile_text)
|
|
||||||
self.assertIn("WORKDIR /home/node\n", dockerfile_text)
|
|
||||||
|
|
||||||
def test_commit_container_dies_on_exec_tar_failure(self):
|
|
||||||
failed = util.subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=1, stdout=b"", stderr=b"No such container",
|
|
||||||
)
|
|
||||||
with patch.object(util.subprocess, "run", return_value=failed), \
|
|
||||||
patch.object(util, "die", side_effect=SystemExit("die")) as die:
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
util.commit_container("missing-container", "some:tag")
|
|
||||||
|
|
||||||
die.assert_called_once()
|
|
||||||
self.assertIn("missing-container", die.call_args.args[0])
|
|
||||||
|
|
||||||
def test_build_image_restarts_builder_when_dns_mismatches(self):
|
def test_build_image_restarts_builder_when_dns_mismatches(self):
|
||||||
status = util.subprocess.CompletedProcess(
|
status = util.subprocess.CompletedProcess(
|
||||||
args=[],
|
args=[],
|
||||||
|
|||||||
@@ -317,22 +317,15 @@ class TestToolConstants(unittest.TestCase):
|
|||||||
def test_tools_tuple_matches_individual_constants(self):
|
def test_tools_tuple_matches_individual_constants(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
(
|
(
|
||||||
supervise.TOOL_ALLOW,
|
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
supervise.TOOL_EGRESS_BLOCK,
|
|
||||||
supervise.TOOL_LIST_EGRESS_ROUTES,
|
supervise.TOOL_LIST_EGRESS_ROUTES,
|
||||||
),
|
),
|
||||||
supervise.TOOLS,
|
supervise.TOOLS,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_component_map_has_egress_entries(self):
|
def test_component_map_has_no_entries(self):
|
||||||
self.assertEqual(
|
# egress-block removed in issue #198; capability-block never had one.
|
||||||
{
|
self.assertEqual({}, supervise.COMPONENT_FOR_TOOL)
|
||||||
supervise.TOOL_ALLOW: "egress",
|
|
||||||
supervise.TOOL_EGRESS_BLOCK: "egress",
|
|
||||||
},
|
|
||||||
supervise.COMPONENT_FOR_TOOL,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class _StubSupervise(supervise.Supervise):
|
class _StubSupervise(supervise.Supervise):
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
The curses TUI itself isn't exercised here — these tests cover the
|
The curses TUI itself isn't exercised here — these tests cover the
|
||||||
discovery + approve/reject paths that the TUI's key handlers call into.
|
discovery + approve/reject paths that the TUI's key handlers call into.
|
||||||
|
|
||||||
|
egress-block (add_route) was removed in issue #198; the TestEgressApplyWiring
|
||||||
|
class and all stubs for add_route have been dropped accordingly.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -9,7 +12,6 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
from bot_bottle.cli import supervise as supervise_cli
|
from bot_bottle.cli import supervise as supervise_cli
|
||||||
@@ -31,8 +33,6 @@ FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
|||||||
def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal:
|
def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal:
|
||||||
payloads = {
|
payloads = {
|
||||||
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
|
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
|
||||||
supervise.TOOL_ALLOW: "routes:\n - host: example.com\n",
|
|
||||||
supervise.TOOL_EGRESS_BLOCK: "routes:\n - host: example.com\n",
|
|
||||||
}
|
}
|
||||||
payload = payloads.get(tool, "")
|
payload = payloads.get(tool, "")
|
||||||
return Proposal.new(
|
return Proposal.new(
|
||||||
@@ -154,22 +154,6 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
supervise_cli.approve(qp)
|
supervise_cli.approve(qp)
|
||||||
self.assertEqual([], read_audit_entries("egress", "dev"))
|
self.assertEqual([], read_audit_entries("egress", "dev"))
|
||||||
|
|
||||||
def test_approve_egress_block_writes_audit_log(self):
|
|
||||||
qp = self._enqueue(tool=supervise.TOOL_EGRESS_BLOCK)
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.cli.supervise.apply_routes_change",
|
|
||||||
return_value=("routes: []\n", "routes:\n - host: example.com\n"),
|
|
||||||
) as apply_routes_change:
|
|
||||||
supervise_cli.approve(qp)
|
|
||||||
apply_routes_change.assert_called_once_with(
|
|
||||||
"dev",
|
|
||||||
"routes:\n - host: example.com\n",
|
|
||||||
)
|
|
||||||
entries = read_audit_entries("egress", "dev")
|
|
||||||
self.assertEqual(1, len(entries))
|
|
||||||
self.assertEqual(STATUS_APPROVED, entries[0].operator_action)
|
|
||||||
self.assertEqual("needed for dev", entries[0].justification)
|
|
||||||
|
|
||||||
|
|
||||||
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
||||||
# # DISABLED — capability_apply functionality is currently commented out.
|
# # DISABLED — capability_apply functionality is currently commented out.
|
||||||
|
|||||||
@@ -54,19 +54,13 @@ class TestValidation(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
|
def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
|
||||||
|
# egress-block has structured input (validated in
|
||||||
|
# _validate_and_bundle_egress_route, not here) and
|
||||||
|
# list-egress-routes takes no input. Only capability-block
|
||||||
|
# goes through `validate_proposed_file`.
|
||||||
with self.assertRaises(_RpcError):
|
with self.assertRaises(_RpcError):
|
||||||
validate_proposed_file(_sv.TOOL_CAPABILITY_BLOCK, " \n\t")
|
validate_proposed_file(_sv.TOOL_CAPABILITY_BLOCK, " \n\t")
|
||||||
|
|
||||||
def test_egress_routes_yaml_is_validated(self):
|
|
||||||
validate_proposed_file(
|
|
||||||
_sv.TOOL_ALLOW,
|
|
||||||
"routes:\n - host: example.com\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_invalid_egress_routes_yaml_rejected(self):
|
|
||||||
with self.assertRaises(_RpcError):
|
|
||||||
validate_proposed_file(_sv.TOOL_EGRESS_BLOCK, "routes: nope\n")
|
|
||||||
|
|
||||||
|
|
||||||
# --- JSON-RPC parsing ------------------------------------------------------
|
# --- JSON-RPC parsing ------------------------------------------------------
|
||||||
|
|
||||||
@@ -147,9 +141,7 @@ class TestHandleToolsList(unittest.TestCase):
|
|||||||
names = [t["name"] for t in result["tools"]] # type: ignore[index]
|
names = [t["name"] for t in result["tools"]] # type: ignore[index]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sorted([
|
sorted([
|
||||||
_sv.TOOL_ALLOW,
|
|
||||||
_sv.TOOL_CAPABILITY_BLOCK,
|
_sv.TOOL_CAPABILITY_BLOCK,
|
||||||
_sv.TOOL_EGRESS_BLOCK,
|
|
||||||
_sv.TOOL_LIST_EGRESS_ROUTES,
|
_sv.TOOL_LIST_EGRESS_ROUTES,
|
||||||
]),
|
]),
|
||||||
sorted(names),
|
sorted(names),
|
||||||
@@ -180,17 +172,6 @@ class TestHandleToolsList(unittest.TestCase):
|
|||||||
# No `required` array because no inputs are required.
|
# No `required` array because no inputs are required.
|
||||||
self.assertNotIn("required", schema) # type: ignore[operator]
|
self.assertNotIn("required", schema) # type: ignore[operator]
|
||||||
|
|
||||||
def test_egress_tools_take_routes_yaml_and_justification(self):
|
|
||||||
for tool_name in (_sv.TOOL_ALLOW, _sv.TOOL_EGRESS_BLOCK):
|
|
||||||
with self.subTest(tool_name=tool_name):
|
|
||||||
tool = next(t for t in TOOL_DEFINITIONS if t["name"] == tool_name)
|
|
||||||
schema = tool["inputSchema"]
|
|
||||||
self.assertEqual("object", schema["type"]) # type: ignore[index]
|
|
||||||
self.assertEqual(
|
|
||||||
["routes_yaml", "justification"],
|
|
||||||
schema["required"], # type: ignore[index]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestHandleToolsCall(unittest.TestCase):
|
class TestHandleToolsCall(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -239,26 +220,6 @@ class TestHandleToolsCall(unittest.TestCase):
|
|||||||
self.assertIn("status: approved", text)
|
self.assertIn("status: approved", text)
|
||||||
self.assertIn("notes: lgtm", text)
|
self.assertIn("notes: lgtm", text)
|
||||||
|
|
||||||
def test_allow_round_trips_through_queue(self):
|
|
||||||
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="ok")
|
|
||||||
try:
|
|
||||||
result = handle_tools_call(
|
|
||||||
{
|
|
||||||
"name": _sv.TOOL_ALLOW,
|
|
||||||
"arguments": {
|
|
||||||
"routes_yaml": "routes:\n - host: example.com\n",
|
|
||||||
"justification": "need example.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
self.config,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
responder.join()
|
|
||||||
self.assertFalse(result["isError"]) # type: ignore[index]
|
|
||||||
text = result["content"][0]["text"] # type: ignore[index]
|
|
||||||
self.assertIn("status: approved", text)
|
|
||||||
self.assertIn("notes: ok", text)
|
|
||||||
|
|
||||||
def test_rejected_response_sets_isError(self):
|
def test_rejected_response_sets_isError(self):
|
||||||
responder = self._respond_when_proposal_appears(_sv.STATUS_REJECTED, notes="nope")
|
responder = self._respond_when_proposal_appears(_sv.STATUS_REJECTED, notes="nope")
|
||||||
try:
|
try:
|
||||||
@@ -451,8 +412,7 @@ class TestHttpEndToEnd(unittest.TestCase):
|
|||||||
self.assertEqual(1, result["id"])
|
self.assertEqual(1, result["id"])
|
||||||
names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index]
|
names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index]
|
||||||
self.assertIn(_sv.TOOL_CAPABILITY_BLOCK, names)
|
self.assertIn(_sv.TOOL_CAPABILITY_BLOCK, names)
|
||||||
self.assertIn(_sv.TOOL_ALLOW, names)
|
self.assertNotIn("egress-block", names)
|
||||||
self.assertIn(_sv.TOOL_EGRESS_BLOCK, names)
|
|
||||||
|
|
||||||
def test_unknown_method_returns_jsonrpc_error(self):
|
def test_unknown_method_returns_jsonrpc_error(self):
|
||||||
result = self._post_jsonrpc(
|
result = self._post_jsonrpc(
|
||||||
|
|||||||
Reference in New Issue
Block a user