Compare commits

..

3 Commits

Author SHA1 Message Date
didericis-codex 16020a6a09 feat: support smolmachines bottle commit
lint / lint (push) Failing after 1m55s
test / unit (pull_request) Failing after 40s
test / integration (pull_request) Successful in 29s
2026-06-23 00:35:13 -04:00
didericis-claude 6e67b9c771 docs(prd): mark commit-bottle-state PRD as Active 2026-06-23 00:35:13 -04:00
didericis-claude 3484c3aed0 feat(cli): add commit command to snapshot running bottle state
Adds `./cli.py commit [<slug>]` which runs `docker commit` on the
active agent container and stores the resulting image tag in per-bottle
state. The next `./cli.py resume <slug>` automatically boots from the
committed snapshot instead of rebuilding from the Dockerfile, preserving
all in-container state across restarts and migrations.

- bottle_state: add write_committed_image / read_committed_image helpers
- docker/util: add commit_container wrapper around `docker commit`
- docker/launch: check for a committed image before the Dockerfile build
  step; fall back to normal build if the image is absent from the daemon
- cli/commit: new command with interactive slug picker; errors clearly on
  non-Docker backends
- 50 new unit tests covering all paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 00:35:13 -04:00
35 changed files with 305 additions and 1341 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# bot-bottle # bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![pylint](https://img.shields.io/badge/pylint-9.93%2F10-brightgreen)](https://github.com/PyCQA/pylint) [![pylint](https://img.shields.io/badge/pylint-9.92%2F10-brightgreen)](https://github.com/PyCQA/pylint)
[![pyright](https://img.shields.io/badge/pyright-0%20errors-brightgreen)](https://github.com/microsoft/pyright) [![pyright](https://img.shields.io/badge/pyright-0%20errors-brightgreen)](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.
-8
View File
@@ -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",
] ]
+1 -1
View File
@@ -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)
+18 -29
View File
@@ -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], raise EgressApplyError(
capture_output=True, text=True, check=False, env=os.environ, f"proposed routes.yaml is not valid: {e}"
) ) from e
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(
f"could not reload egress bundle {container}: "
f"{last_error or 'docker kill failed'}"
)
applicator = DockerEgressApplicator()
__all__ = [ __all__ = [
"DockerEgressApplicator",
"EgressApplyError", "EgressApplyError",
"applicator",
"fetch_current_routes", "fetch_current_routes",
"validate_routes_content",
] ]
-23
View File
@@ -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")
-50
View File
@@ -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"]
-100
View File
@@ -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"
)
+17 -19
View File
@@ -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(
+1 -6
View File
@@ -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",
]
-145
View File
@@ -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 imageregistrypack_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])
+2 -12
View File
@@ -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.
-16
View File
@@ -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)
+65 -13
View File
@@ -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
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
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 -34
View File
@@ -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,
+3 -3
View File
@@ -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}"
) )
+1 -3
View File
@@ -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",
+2 -2
View File
@@ -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(
-51
View File
@@ -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
View File
@@ -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"
+10 -108
View File
@@ -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. import supervise as _sv
from egress_addon_core import load_routes
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}")
-216
View File
@@ -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
View File
@@ -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):
+1 -1
View File
@@ -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")
+4 -4
View File
@@ -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,23 +16,25 @@ 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({
"bottles": {"dev": {}}, def _manifest() -> Manifest:
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, return Manifest.from_json_obj({
}) "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
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,
+11 -54
View File
@@ -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()
+7 -81
View File
@@ -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()
-47
View File
@@ -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=[],
+3 -10
View File
@@ -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):
+3 -19
View File
@@ -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.
+5 -45
View File
@@ -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(