Compare commits

...

31 Commits

Author SHA1 Message Date
didericis-claude c48c3688b8 fix(smolmachines): exclude /tmp+/var/tmp from snapshot; mkdir -p on boot
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 23s
lint / lint (push) Successful in 1m59s
prd-number / assign-numbers (push) Successful in 1m8s
test / unit (push) Successful in 35s
test / integration (push) Successful in 21s
Update Quality Badges / update-badges (push) Successful in 1m22s
On resume from a committed snapshot, smolvm's pack process remaps all
file uids to the host uid (501 on macOS). Files in /tmp that were
created during the session (e.g. /tmp/claude-1000 owned by node=uid
1000) get remapped to 501. Claude Code then refuses to use the temp
directory because it's owned by a different uid.

Two-part fix:
- Exclude ./tmp and ./var/tmp from the tar in _exec_tar_to_file.
  Both directories are ephemeral; a resumed VM should start with clean
  temp directories identical to a fresh VM.
- Add mkdir -p /tmp /var/tmp to _init_vm before chown/chmod, so the
  directories are created if the committed snapshot omitted them.
2026-06-23 16:53:41 -04:00
didericis-claude 6040b20e6e fix(smolmachines): write tar to VM file then machine_cp to host
Replace the Popen/stdout=PIPE approach with a write-then-copy
strategy that avoids binary-stdout piping through the smolvm exec
channel entirely:

1. Probe connectivity with `machine_exec(machine, ["true"])` first.
   If this fails while an interactive session is running, the error
   now says "concurrent exec not available" instead of the opaque
   "<no stderr>".

2. Run `tar --create --gzip --file=/var/tmp/.bot-bottle-commit.tar.gz`
   inside the VM via machine_exec (same mechanism used during
   provisioning). tar writes to a file in the VM, not stdout, so
   smolvm never has to transmit binary data over the exec channel.

3. Copy the compressed archive to the host with machine_cp.

4. Dockerfile switches to ADD rootfs.tar.gz / — Docker decompresses
   gzip tarballs automatically.
2026-06-23 16:53:41 -04:00
didericis-claude f2775101a0 fix(smolmachines): pipe tar stdout via PIPE not file fd
smolvm machine exec requires stdout to be a pipe, not a regular
file descriptor. Passing stdout=file caused smolvm to return
non-zero with no stderr (the error was silently swallowed or went
to the regular-file fd instead of reaching us).

Switch _snapshot_running_vm to a new _exec_tar_to_file helper that
uses Popen with stdout=PIPE and streams the tar to disk via
shutil.copyfileobj. A background thread drains stderr concurrently
to prevent deadlock when the stderr pipe buffer fills while we are
writing stdout data.
2026-06-23 16:53:41 -04:00
didericis-claude dd99c495f4 fix(smolmachines): use sh -c not sh -lc in exec_agent
The terminal-decoration wrapper script is invoked with sh -lc, which
sources login-shell init files (/etc/profile, ~/.profile) rather than
interactive-shell files (~/.zshrc). smolvm is typically installed via
homebrew whose PATH setup lands in ~/.zprofile or ~/.zshrc — not picked
up by sh -l — so pty_resize.py's Popen(["smolvm", ...]) raises
FileNotFoundError, pty_resize exits non-zero, and the trailing reset-
printf makes sh exit 0. The caller sees "session ended (exit 0)"
immediately with no agent output.

Use sh -c instead. The calling process (./cli.py) inherits the user's
interactive shell PATH where smolvm is present, confirmed by the
provision steps (machine_exec) succeeding before exec_agent is reached.
2026-06-23 16:53:41 -04:00
didericis-claude eb64a52ffa fix(smolmachines): commit via exec-tar instead of stop→pack
smolvm pack create --from-vm requires the VM to be stopped, and stopping
a smolmachines VM terminates any running interactive session.

Instead, mirror the macos-container approach: exec into the running VM as
root and stream the root filesystem via tar (smolvm machine exec -- tar),
build a Docker image from the archive, push to an ephemeral local registry,
and run smolvm pack create --image to produce the .smolmachine artifact.
The VM stays running throughout the commit.

Remove the stop-confirm prompt and machine_is_running check that were
added in the previous commit — neither is needed when we no longer stop.
2026-06-23 16:53:41 -04:00
didericis-claude d11e3940fa fix(smolmachines): stop VM before pack commit, with confirm prompt
smolvm pack create --from-vm requires the VM to be stopped. Add
machine_is_running() to smolvm.py (via machine ls --json state field),
and add the same confirm-stop flow to SmolmachinesFreezer that was
originally designed for macos-container: if running, prompt the user,
stop the VM, then pack. Already-stopped VMs are packed directly.
2026-06-23 16:53:41 -04:00
didericis-claude a32c0c7865 test: update macos-container tests for exec-tar commit approach
- Rename export test to reflect new exec-tar mechanism; update argv
  assertions to match the new `container exec ... tar` command shape
- Change mock stderr from str to bytes (subprocess.PIPE without text=True)
- Add type annotation to capture_freeze closure to satisfy pyright
2026-06-23 16:53:41 -04:00
didericis-claude ccb2956562 fix(macos-container): commit via exec-tar instead of stop→export
Apple Container removes containers when they stop, making the
stop-then-export flow impossible regardless of the --rm flag.

Replace `container export` (requires stopped container) with
`container exec --user root <name> tar --create ... --file=- --directory=/ .`
streamed to a temp file, then build the committed image from that archive
as before. The bottle stays running after commit, which is better UX.

Drop the stop-confirm prompt from MacosContainerFreezer since we no longer
need to stop the container at all.
2026-06-23 16:53:41 -04:00
didericis-claude c6362fda7b fix(macos-container): remove --rm from agent run so commit can export
container stop was removing the container immediately (due to --rm)
before container export could run. The force_remove_container teardown
callback on the ExitStack already handles cleanup on normal exit, so
--rm was redundant. Without it, the stopped container stays available
for container export to snapshot.
2026-06-23 16:53:41 -04:00
didericis-claude cb321f7ad4 refactor(freezer): drop Bottle from commit signature
Freezer._freeze only ever used bottle.name, which is always
f"bot-bottle-{agent.slug}". Remove the Bottle parameter from
commit() and _freeze(), derive the container name from agent.slug
directly in each subclass, and delete the _NamedBottle stub that
existed solely to paper over this.
2026-06-23 16:53:41 -04:00
didericis-claude 311cd46185 refactor(commit): introduce Freezer class hierarchy across backends
Adds a Freezer ABC (backend/freeze.py) that encapsulates the
stop-commit-mark-preserved flow for all backends, following the same
pattern as BottleBackend. Each backend gets its own Freezer subclass:

  DockerFreezer           — docker commit
  MacosContainerFreezer   — container export + image rebuild; prompts
                            to stop if the container is running
  SmolmachinesFreezer     — smolvm pack create --from-vm

The base class owns write_committed_image, mark_preserved, and the
resume hint. Subclasses implement _freeze() and optionally override
_export_hint() for migration instructions.

Freezer.commit(agent, bottle) is the primary entry point for use
within a live launch context. Freezer.commit_slug(slug) is a
convenience wrapper for cmd_commit, which no longer branches on
backend names itself.

get_freezer(backend_name) is the factory, analogous to
get_bottle_backend(). CommitCancelled is raised by MacosContainerFreezer
when the user declines the stop prompt; cmd_commit catches it and
returns 0.
2026-06-23 16:53:41 -04:00
didericis-claude 28335f453f fix(commit): stop running macos-container bottle before committing
`container export` requires the container to be stopped first. When a
running bottle is detected, prompt the user to confirm, stop the
container, then commit. Adds `container_is_running` and
`stop_container` helpers to the macos-container util.

Addresses #240 (comment)
2026-06-23 16:53:41 -04:00
didericis-claude a1aa8feb85 fix: correct Manifest/ManifestIndex usage and add missing type annotations in tests
- test_docker_launch_committed_image: replace Manifest.from_json_obj
  (nonexistent) with ManifestIndex.from_json_obj; pass manifest= arg
  to DockerBottlePlan constructor (required by BottlePlan base class)
- test_macos_container_launch: cast SimpleNamespace stubs to their
  expected types (BottleSpec, GitGatePlan, EgressPlan) in _build_plan;
  add str type annotations to fake_build parameter signatures
- test_macos_container_util: add str type annotations to fake_build_image
  parameter signatures
2026-06-23 16:53:41 -04:00
didericis cb3bb209d6 feat: support macos-container bottle commits 2026-06-23 16:53:41 -04:00
didericis-codex 6e73cc4d86 feat: support smolmachines bottle commit 2026-06-23 16:53:41 -04:00
didericis-claude 64fac71025 docs(prd): mark commit-bottle-state PRD as Active 2026-06-23 16:53:41 -04:00
didericis-claude f8ac22c316 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 16:53:41 -04:00
Quality Badge Bot 9465857a99 chore: update quality badges
- Pylint: 9.93/10
- Pyright: 0 errors

[skip ci]
2026-06-23 20:46:17 +00:00
didericis-claude 200306f1cf refactor: export applicator singletons from egress_apply backends
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 21s
lint / lint (push) Successful in 1m44s
test / unit (push) Successful in 32s
test / integration (push) Successful in 19s
Update Quality Badges / update-badges (push) Successful in 1m17s
Replace module-level apply_routes_change wrappers with a public
applicator singleton in each backend. Callers now work with the
EgressApplicator instance directly (applicator.apply_routes_change)
rather than through a function shim.
2026-06-23 20:39:05 +00:00
didericis-claude 77bdaf0a96 refactor: extract EgressApplicator base class shared between backends
lint / lint (push) Successful in 1m56s
test / unit (pull_request) Successful in 42s
test / integration (pull_request) Successful in 20s
Pulls the duplicated apply_routes_change / validate_routes_content /
_routes_path logic into EgressApplicator (ABC) in backend/egress_apply.py.
DockerEgressApplicator and MacOSContainerEgressApplicator override the
single abstract _signal_bundle_reload method with their respective kill
commands. Module-level shims preserve the existing public API.
2026-06-23 20:33:43 +00:00
didericis 7e344bbb53 fix: add lowercase proxy env vars, route_to_yaml_dict, and richer tool descriptions
lint / lint (push) Successful in 1m51s
test / unit (pull_request) Successful in 41s
test / integration (pull_request) Successful in 18s
- Set http_proxy/https_proxy (lowercase) alongside uppercase variants in smolmachines guest env for tools that only check lowercase
- Replace dataclasses.asdict with route_to_yaml_dict in /allowlist introspection so returned routes use YAML-schema-compatible keys
- Expand routes_yaml tool description in supervise_server to document all accepted route keys, making the round-trip from list-egress-routes to propose/apply explicit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 16:13:07 -04:00
didericis-claude 5eb27cd9a8 fix: mount egress dir (not file) for docker and smolmachines backends
lint / lint (push) Successful in 1m37s
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 16s
Mirrors the fix already applied to the macos-container backend in
eb3e64e: bind-mount the parent egress directory instead of the
routes file itself, so the live routes update is visible inside the
running sidecar bundle when the host overwrites the file.
2026-06-23 09:05:44 +00:00
didericis-claude 5808d0b828 feat: add smolmachines/egress_apply proxying docker backend
lint / lint (push) Successful in 1m40s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 16s
2026-06-23 06:53:56 +00:00
didericis-claude 7a991e1f5e refactor: split _signal_bundle_reload per backend, move macos egress to macos_container
lint / lint (push) Successful in 1m47s
test / unit (pull_request) Successful in 38s
test / integration (pull_request) Successful in 19s
2026-06-23 05:57:07 +00:00
didericis-claude 5606797ac2 refactor: drop legacy routes path fallback from _routes_path
lint / lint (push) Successful in 1m37s
test / unit (pull_request) Failing after 29s
test / integration (pull_request) Successful in 18s
2026-06-23 05:48:50 +00:00
didericis-claude ebbb4053cf fix: add type annotations to fake_run in test_egress_apply
lint / lint (push) Successful in 1m40s
test / unit (pull_request) Successful in 36s
test / integration (pull_request) Successful in 18s
2026-06-23 05:47:11 +00:00
didericis eb3e64ea8f fix(macos-container): mount live egress routes dir
lint / lint (push) Failing after 1m35s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 16s
2026-06-23 01:39:29 -04:00
didericis 0ec1085238 fix(supervise): apply egress approvals
lint / lint (push) Failing after 1m34s
test / unit (pull_request) Successful in 33s
test / integration (pull_request) Successful in 15s
2026-06-23 01:33:35 -04:00
didericis 4c39b45e34 fix(supervise): restore egress proposal tools
lint / lint (push) Successful in 1m35s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Successful in 16s
2026-06-23 01:24:28 -04:00
didericis-codex 3ea35ba5d2 fix: update codex supervise mcp registration
lint / lint (push) Successful in 1m54s
test / unit (pull_request) Successful in 38s
test / integration (pull_request) Successful in 22s
2026-06-23 04:06:21 +00:00
Quality Badge Bot 7c6ab62e26 chore: update quality badges
- Pylint: 9.92/10
- Pyright: 0 errors

[skip ci]
2026-06-23 04:05:16 +00:00
43 changed files with 2084 additions and 121 deletions
+8
View File
@@ -526,6 +526,11 @@ 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
@@ -613,9 +618,12 @@ __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, EGRESS_ROUTES_IN_CONTAINER)) volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent)))
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)
+28 -17
View File
@@ -1,24 +1,21 @@
"""Host-side helper for egress sidecar inspection (issue #198). """Host-side helper for egress sidecar inspection and live updates.
`_merge_single_route`, `add_route`, and `apply_routes_change` were The approve path uses this module to validate a proposed routes file,
removed when the egress-block MCP tool was dropped. The remaining write it to the bottle's live egress state dir, and signal the sidecar
helpers support runtime inspection and validation of the routes file bundle so the mitmproxy addon reloads it.
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 ...egress_addon_core import load_routes from ...log import warn
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(
@@ -33,17 +30,31 @@ def fetch_current_routes(slug: str) -> str:
return r.stdout return r.stdout
def validate_routes_content(content: str) -> None: class DockerEgressApplicator(EgressApplicator):
try: def _signal_bundle_reload(self, slug: str) -> None:
load_routes(content) container = sidecar_bundle_container_name(slug)
except ValueError as e: result = subprocess.run(
["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"proposed routes.yaml is not valid: {e}" f"could not reload egress bundle {container}: "
) from e 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
@@ -0,0 +1,23 @@
"""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")
+13 -2
View File
@@ -47,6 +47,7 @@ from ...bottle_state import (
bottle_state_dir, bottle_state_dir,
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
read_committed_image,
) )
from .compose import ( from .compose import (
bottle_plan_to_compose, bottle_plan_to_compose,
@@ -91,8 +92,18 @@ def launch(
) )
try: try:
# Step 1: agent image build. Sidecar images get built lazily by # Step 1: agent image. Use a committed snapshot when one exists
# `docker compose up` via the renderer's `build:` directives. # and is present in the local daemon; otherwise build from the
# Dockerfile. Sidecar images get built lazily by `docker compose
# up` via the renderer's `build:` directives.
committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed):
info(f"using committed image {committed!r}")
plan = dataclasses.replace(
plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
)
else:
docker_mod.build_image( docker_mod.build_image(
plan.image, _REPO_DIR, plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path, dockerfile=plan.dockerfile_path,
+15
View File
@@ -152,6 +152,21 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
# ) # )
def commit_container(container_name: str, image_tag: str) -> None:
"""Run `docker commit <container_name> <image_tag>` to snapshot the
running container's filesystem state as a local Docker image."""
result = subprocess.run(
["docker", "commit", container_name, image_tag],
capture_output=True, text=True, check=False,
)
if result.returncode != 0:
die(
f"docker commit {container_name!r}{image_tag!r} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
info(f"committed {container_name!r}{image_tag!r}")
def image_id(ref: str) -> str: def image_id(ref: str) -> str:
"""Return the content-addressed image ID (e.g. """Return the content-addressed image ID (e.g.
`sha256:abcd...`) for `ref`. The smolmachines backend keys its `sha256:abcd...`) for `ref`. The smolmachines backend keys its
+50
View File
@@ -0,0 +1,50 @@
"""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
@@ -0,0 +1,100 @@
"""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")
@@ -0,0 +1,39 @@
"""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"]
@@ -0,0 +1,31 @@
"""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"
)
+19 -17
View File
@@ -12,13 +12,16 @@ 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 egress_state_dir, git_gate_state_dir from ...bottle_state import (
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
@@ -84,7 +87,7 @@ def launch(
try: try:
plan = _mint_certs(plan) plan = _mint_certs(plan)
_build_images(plan) 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)
@@ -135,17 +138,28 @@ 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) -> None: def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
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(
@@ -314,7 +328,6 @@ 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):
@@ -364,7 +377,7 @@ def _sidecar_mounts(
)) ))
if ep.routes: if ep.routes:
mounts.append(( mounts.append((
str(_stage_routes_dir(plan)), str(ep.routes_path.parent),
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
True, True,
)) ))
@@ -375,17 +388,6 @@ 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,6 +8,7 @@ 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
@@ -72,6 +73,53 @@ 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()
@@ -218,6 +266,36 @@ 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(
+6 -1
View File
@@ -145,7 +145,12 @@ 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
return subprocess.run(["sh", "-lc", script], check=False).returncode # Use sh -c (not -lc) so the script inherits PATH from the calling
# 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
@@ -0,0 +1,21 @@
"""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
@@ -0,0 +1,145 @@
"""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])
+43 -12
View File
@@ -40,8 +40,12 @@ from ..docker.git_gate import (
GIT_GATE_HOOK_IN_CONTAINER, GIT_GATE_HOOK_IN_CONTAINER,
) )
from ...git_gate import revoke_git_gate_provisioned_keys from ...git_gate import revoke_git_gate_provisioned_keys
from ...log import warn from ...log import info, warn
from ...bottle_state import egress_state_dir, git_gate_state_dir from ...bottle_state import (
egress_state_dir,
git_gate_state_dir,
read_committed_image,
)
from . import loopback_alias as _loopback from . import loopback_alias as _loopback
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm from . import smolvm as _smolvm
@@ -85,14 +89,7 @@ def launch(
plan = _start_bundle(plan, network, loopback_ip, stack) plan = _start_bundle(plan, network, loopback_ip, stack)
plan = _discover_urls(plan, loopback_ip) plan = _discover_urls(plan, loopback_ip)
# Build the agent image and pack it into a `.smolmachine` agent_from_path = _agent_from_path(plan)
# artifact (or hit the per-Dockerfile-digest cache). Runs
# here, not in prepare, so the docker-build output doesn't
# garble the dashboard's preflight modal.
agent_from_path = _ensure_smolmachine(
plan.agent_image,
dockerfile=plan.agent_dockerfile_path,
)
_launch_vm(plan, agent_from_path, loopback_ip, stack) _launch_vm(plan, agent_from_path, loopback_ip, stack)
_init_vm(plan) _init_vm(plan)
@@ -217,11 +214,15 @@ 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,
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}", "https_proxy": agent_proxy_url,
"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}"
@@ -275,10 +276,16 @@ 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",
@@ -308,7 +315,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), EGRESS_ROUTES_IN_CONTAINER, True)) volumes.append((str(ep.routes_path.parent), str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), 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.
@@ -382,6 +389,30 @@ def _resolve_token_env(
return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env) return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env)
def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
"""Return the `.smolmachine` artifact used for `machine create --from`.
Prefer a committed VM artifact when one is recorded and still
present. If the file was removed, fall back to the normal image
build + pack cache path.
"""
committed = read_committed_image(plan.slug)
if committed:
committed_path = Path(committed)
if committed_path.is_file():
info(f"using committed smolmachine {str(committed_path)!r}")
return committed_path
# Build the agent image and pack it into a `.smolmachine`
# artifact (or hit the per-Dockerfile-digest cache). Runs here,
# not in prepare, so the docker-build output doesn't garble the
# dashboard's preflight modal.
return _ensure_smolmachine(
plan.agent_image,
dockerfile=plan.agent_dockerfile_path,
)
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path: def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
"""Build the agent docker image and convert it into a """Build the agent docker image and convert it into a
`.smolmachine` artifact, caching the result under `.smolmachine` artifact, caching the result under
+26
View File
@@ -25,6 +25,7 @@ smolvm binary."""
from __future__ import annotations from __future__ import annotations
import json
import shutil import shutil
import subprocess import subprocess
import time import time
@@ -94,6 +95,16 @@ def pack_create(image: str, output: Path) -> None:
_smolvm("pack", "create", "--image", image, "-o", str(output)) _smolvm("pack", "create", "--image", image, "-o", str(output))
def pack_create_from_vm(name: str, output: Path) -> None:
"""`smolvm pack create --from-vm <name> -o <output>`.
Snapshots an existing persistent VM into a pack artifact. As
with `pack_create`, smolvm writes a launcher at `output` and the
bootable sidecar at `output.smolmachine`.
"""
_smolvm("pack", "create", "--from-vm", name, "-o", str(output))
# --- Machine lifecycle --------------------------------------------------- # --- Machine lifecycle ---------------------------------------------------
@@ -143,6 +154,21 @@ 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)
+30
View File
@@ -43,6 +43,7 @@ from . import supervise as _supervise
# Directory layout: ~/.bot-bottle/state/<identity>/... # Directory layout: ~/.bot-bottle/state/<identity>/...
_STATE_SUBDIR = "state" _STATE_SUBDIR = "state"
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile" _PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
_COMMITTED_IMAGE_NAME = "committed-image"
_TRANSCRIPT_SUBDIR = "transcript" _TRANSCRIPT_SUBDIR = "transcript"
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources # Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
# live here so chunk 3's `docker compose up` can find them at stable # live here so chunk 3's `docker compose up` can find them at stable
@@ -179,6 +180,32 @@ def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
return p return p
def committed_image_path(identity: str) -> Path:
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
def write_committed_image(identity: str, image_tag: str) -> Path:
"""Persist the committed image tag for `identity`. The next
`cli.py resume <identity>` will boot from this image instead of
rebuilding from the Dockerfile."""
path = committed_image_path(identity)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(image_tag.strip() + "\n")
path.chmod(0o644)
return path
def read_committed_image(identity: str) -> str | None:
"""Return the committed image tag for `identity`, or None if no
commit has been recorded. Used by the Docker launch step to skip
the Dockerfile build when a committed snapshot exists."""
path = committed_image_path(identity)
if not path.is_file():
return None
tag = path.read_text().strip()
return tag or None
def per_bottle_image_tag(identity: str) -> str: def per_bottle_image_tag(identity: str) -> str:
"""Image tag for a rebuilt bottle. Distinct from the base """Image tag for a rebuilt bottle. Distinct from the base
bot-bottle-claude:latest so per-bottle rebuilds don't collide in bot-bottle-claude:latest so per-bottle rebuilds don't collide in
@@ -314,6 +341,7 @@ __all__ = [
"bottle_state_dir", "bottle_state_dir",
"cleanup_state", "cleanup_state",
"clear_preserve_marker", "clear_preserve_marker",
"committed_image_path",
"egress_state_dir", "egress_state_dir",
"git_gate_state_dir", "git_gate_state_dir",
"is_preserved", "is_preserved",
@@ -323,9 +351,11 @@ __all__ = [
"per_bottle_dockerfile_path", "per_bottle_dockerfile_path",
"per_bottle_image_tag", "per_bottle_image_tag",
"preserve_marker_path", "preserve_marker_path",
"read_committed_image",
"read_metadata", "read_metadata",
"supervise_state_dir", "supervise_state_dir",
"transcript_snapshot_dir", "transcript_snapshot_dir",
"write_committed_image",
"write_metadata", "write_metadata",
"write_per_bottle_dockerfile", "write_per_bottle_dockerfile",
] ]
+4 -1
View File
@@ -1,6 +1,6 @@
"""Main CLI dispatcher. """Main CLI dispatcher.
Commands: cleanup, edit, info, init, list, resume, start, supervise Commands: cleanup, commit, edit, info, init, list, resume, start, supervise
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,6 +12,7 @@ from ..manifest import ManifestError
from ._common import PROG from ._common import PROG
from . import list as _list_mod from . import list as _list_mod
from .cleanup import cmd_cleanup from .cleanup import cmd_cleanup
from .commit import cmd_commit
from .edit import cmd_edit from .edit import cmd_edit
from .info import cmd_info from .info import cmd_info
from .init import cmd_init from .init import cmd_init
@@ -23,6 +24,7 @@ cmd_list = _list_mod.cmd_list
COMMANDS = { COMMANDS = {
"cleanup": cmd_cleanup, "cleanup": cmd_cleanup,
"commit": cmd_commit,
"edit": cmd_edit, "edit": cmd_edit,
"info": cmd_info, "info": cmd_info,
"init": cmd_init, "init": cmd_init,
@@ -37,6 +39,7 @@ def usage() -> None:
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n") sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
sys.stderr.write("Commands:\n") sys.stderr.write("Commands:\n")
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n") sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
sys.stderr.write(" commit snapshot a running bottle's container state to a Docker image\n")
sys.stderr.write(" edit open an agent in vim for editing\n") sys.stderr.write(" edit open an agent in vim for editing\n")
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n") sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n") sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
+53
View File
@@ -0,0 +1,53 @@
"""commit: freeze a running bottle's state to a resumable artifact.
Docker bottles are committed to a local Docker image. Macos-container
bottles are exported and rebuilt as a local Apple Container image.
Smolmachines bottles are packed from the running VM into a
`.smolmachine` artifact. The resulting reference is stored in
per-bottle state so the next `./cli.py resume <slug>` boots from the
snapshot instead of rebuilding from the Dockerfile.
"""
from __future__ import annotations
import argparse
from ..backend import enumerate_active_agents
from ..backend.freeze import CommitCancelled, get_freezer
from ..bottle_state import read_metadata
from ..log import die
from ._common import PROG
from . import tui
def cmd_commit(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
parser.add_argument(
"slug",
nargs="?",
default=None,
help=(
"bottle slug from `cli.py list active` "
"(omit to pick interactively)"
),
)
args = parser.parse_args(argv)
slug = args.slug
if slug is None:
active = enumerate_active_agents()
if not active:
die("no active bottles; start one with `./cli.py start`")
choices = [a.slug for a in active]
slug = tui.filter_select(choices, title="Select bottle to commit")
if slug is None:
return 0
metadata = read_metadata(slug)
backend = metadata.backend if metadata else ""
try:
get_freezer(backend).commit_slug(slug)
except CommitCancelled:
return 0
return 0
+34 -3
View File
@@ -3,7 +3,8 @@ 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. The egress-block tool was removed in issue #198. the bottle Dockerfile. Egress proposals are queued for operator review
as full routes.yaml updates.
""" """
from __future__ import annotations from __future__ import annotations
@@ -20,11 +21,21 @@ 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
@@ -40,6 +51,8 @@ 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,
@@ -63,7 +76,17 @@ 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,) ApplyError = (CapabilityApplyError, EgressApplyError)
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]:
@@ -115,6 +138,8 @@ 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"
@@ -129,6 +154,7 @@ 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:
@@ -142,6 +168,11 @@ 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 --transport http " f"codex mcp add {_SUPERVISE_MCP_NAME} --url "
f"{_SUPERVISE_MCP_NAME} {supervise_url}", f"{shlex.quote(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 --transport http supervise {supervise_url}" f"codex mcp add supervise --url {shlex.quote(supervise_url)}"
) )
+3 -1
View File
@@ -31,6 +31,7 @@ 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)
@@ -295,7 +296,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.yaml" routes_path = stage_dir / EGRESS_ROUTES_FILENAME
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(
@@ -309,6 +310,7 @@ 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,7 +5,6 @@ egress container."""
from __future__ import annotations from __future__ import annotations
import dataclasses
import json import json
import os import os
import signal import signal
@@ -27,6 +26,7 @@ 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": [dataclasses.asdict(r) for r in self.config.routes]}, {"routes": [route_to_yaml_dict(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,6 +359,56 @@ 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:
@@ -698,6 +748,7 @@ def scan_inbound(
__all__ = [ __all__ = [
"LOG_BLOCKS", "LOG_BLOCKS",
"route_to_yaml_dict",
"LOG_FULL", "LOG_FULL",
"LOG_OFF", "LOG_OFF",
"Config", "Config",
+19 -12
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 agent proposes a new routes.yaml * egress-block / allow 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,27 +49,34 @@ 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
# alias + listen port (see bot_bottle.egress.EGRESS_HOSTNAME # listen port (see backend.docker.egress.EGRESS_PORT). The supervise
# and backend.docker.egress.EGRESS_PORT — the values # daemon runs inside the sidecar bundle alongside egress, so loopback
# are inlined here so the in-container supervise_server doesn't # is the stable address across docker, smolmachines, and Apple
# need to import the egress package). # Container backends.
EGRESS_FORWARD_PROXY = "http://egress:9099" EGRESS_FORWARD_PROXY = "http://127.0.0.1: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 # here — those changes are captured by git history + the rebuild record
# record laid down in PRD 0016. egress-block was removed in issue #198. # laid down in PRD 0016.
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"
@@ -431,9 +438,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 # moved them behind the `list-egress-routes` MCP tool (live state
# state from egress's introspection endpoint) so the agent # from egress's introspection endpoint) so the agent always sees
# always sees current data rather than a launch-time snapshot. # current data rather than a launch-time snapshot.
CURRENT_CONFIG_DOCKERFILE = "Dockerfile" CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
+108 -10
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 egress-block tool was removed in issue #198; changes when stuck. The tools are `allow`, `egress-block`,
the remaining tools are `capability-block` and `list-egress-routes`. `capability-block`, and `list-egress-routes`.
Each queued tool call: Each queued tool call:
@@ -44,9 +44,15 @@ import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
# Same-directory import inside the bundle container; `supervise.py` try:
# is COPYed alongside this file by Dockerfile.sidecars. # Same-directory imports inside the bundle container; these files are
import supervise as _sv # COPYed flat under /app by Dockerfile.sidecars.
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 ----------------------------------------------
@@ -142,8 +148,9 @@ 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 `egress-block` proposal so the new " "before composing an `allow` or `egress-block` proposal so "
"routes file extends the live one rather than replacing it." "the new routes file extends the live one rather than "
"replacing it."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -151,6 +158,88 @@ 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": (
@@ -182,11 +271,12 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
] ]
# Map each non-egress tool to the input field that carries the agent's # Map each proposal tool to the input field that carries the agent's
# payload (stored in Proposal.proposed_file). egress-block builds its # payload (stored in Proposal.proposed_file).
# 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",
} }
@@ -203,6 +293,14 @@ 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}")
+159
View File
@@ -0,0 +1,159 @@
# PRD prd-new: Commit bottle state to an image
- **Status:** Active
- **Author:** Claude
- **Created:** 2026-06-20
- **Issue:** #194
## Summary
Add a `commit` CLI command that freezes a running bottle's state to a
resumable local artifact. Docker bottles are stored as Docker images;
smolmachines bottles are stored as `.smolmachine` artifacts. Operators
can then resume the bottle from that exact filesystem snapshot, or
export the artifact to migrate work to a different host.
## Problem
When a long-running agent session is interrupted — by a host reboot, a
network failure, or a planned infrastructure migration — the in-progress
container state is lost. `cli.py resume` rebuilds the agent image from
the Dockerfile and reprovi-sions the bottle, but that returns the guest
to its initial state, not to wherever the agent was mid-task.
There is no mechanism today to capture "what's installed / configured
inside the running container right now" and make it reproducible. The
`capability-block` flow writes a new Dockerfile and marks the bottle for
resume, but that only applies when the agent itself has requested a
capability change; it doesn't help the operator who wants to take a
snapshot before a planned host reboot or hardware migration.
## Goals / Success Criteria
- `./cli.py commit [<slug>]` takes a snapshot of the running agent and
stores it as a local artifact.
- Without a slug argument the command shows the same interactive picker
as `start` (the list of active slugs).
- The committed artifact reference is stored in per-bottle state so
that the next `./cli.py resume <slug>` automatically uses the
snapshot instead of rebuilding from the Dockerfile.
- `mark_preserved` is called so the state dir survives the normal
session-end cleanup.
- A backend-specific export hint is printed so operators know how to
migrate the snapshot.
- The command errors clearly on unsupported backends.
## Non-goals
- macOS-container backend support.
- Automatic commit on agent exit.
- Image push to a remote registry.
- Storing the image tag in the manifest or sharing it between operators.
## Design
### Docker image tag
`bot-bottle-committed-<slug>:latest` — namespaced under `bot-bottle-`
to match existing image naming conventions; `committed` distinguishes it
from the build-time image (`bot-bottle-claude:latest`) and the
capability-block rebuild image (`bot-bottle-rebuilt-<identity>:latest`).
### State storage
A new plain-text file `committed-image` is added to the per-bottle state
directory:
```
~/.bot-bottle/state/<identity>/
metadata.json
Dockerfile (capability-block override; optional)
committed-image (committed artifact reference; optional)
transcript/
```
`bottle_state.committed_image_path(identity)` returns the path.
`write_committed_image` / `read_committed_image` are the read/write
helpers, matching the existing `per_bottle_dockerfile` pattern. Docker
stores a Docker tag in this file; smolmachines stores the absolute path
to the committed `.smolmachine` artifact.
### `commit` command
```
./cli.py commit [<slug>]
```
1. Resolve slug (arg or interactive picker from `enumerate_active_agents`).
2. Check metadata and branch by backend.
3. For Docker, derive container name `bot-bottle-<slug>` and run
`docker commit <container> bot-bottle-committed-<slug>:latest`.
4. For smolmachines, derive machine name `bot-bottle-<slug>` and run
`smolvm pack create --from-vm <machine> -o ~/.bot-bottle/state/<slug>/committed-smolmachine`.
5. Write the Docker image tag or smolmachine artifact path to
`~/.bot-bottle/state/<slug>/committed-image`.
6. Call `mark_preserved(<slug>)` so the state dir survives session-end.
7. Print the resume hint and a backend-specific export example.
### Resume from committed image
`bot_bottle/backend/docker/launch.py` already rebuilds the agent image
at the top of the `launch` context manager. The change is a check
immediately before that step:
```python
committed = read_committed_image(plan.slug)
if committed and docker_mod.image_exists(committed):
info(f"using committed image {committed!r}")
plan = dataclasses.replace(
plan,
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
)
else:
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
```
Replacing `agent_provision.image` propagates to `plan.image` (a
property) and from there to the Compose spec renderer's `_agent_service`
`image:` field, so the container boots from the committed snapshot.
The build step is skipped entirely when a committed image is found and
exists locally.
If the committed image has been deleted from the local daemon (e.g.
after `docker rmi` or a `docker system prune`), the launch falls back
to a normal Dockerfile build, matching the pre-commit behavior.
### Resume from committed smolmachine
`bot_bottle/backend/smolmachines/launch.py` checks the committed
reference before the normal Docker build -> pack cache path:
```python
committed = read_committed_image(plan.slug)
if committed and Path(committed).is_file():
return Path(committed)
return _ensure_smolmachine(plan.agent_image, dockerfile=plan.agent_dockerfile_path)
```
The returned path is passed to `smolvm machine create --from`, so the
resumed VM boots from the committed snapshot. If the artifact has been
deleted, launch falls back to the normal build and pack flow.
## Testing strategy
- Unit tests for `write_committed_image` / `read_committed_image` in
`tests/unit/test_bottle_state.py`, using the existing `_FakeHomeMixin`
pattern.
- Unit tests for `commit_container` in `tests/unit/test_docker_util_image.py`,
mocking `subprocess.run` and asserting on the `docker commit` argv.
- Unit tests for `cmd_commit` argument parsing, Docker commit,
smolmachines pack, and the unsupported backend error path, mocking
`enumerate_active_agents`, `commit_container`, and
`pack_create_from_vm`.
- Unit tests for the launch-step committed-image branch: patch
`read_committed_image` to return a tag, patch `image_exists` to return
True, and assert that `build_image` is not called and `plan.image` is
overridden.
- Unit tests for the smolmachines launch-step committed-artifact branch:
patch `read_committed_image` to return an existing path and assert the
normal `_ensure_smolmachine` path is skipped.
+216
View File
@@ -0,0 +1,216 @@
"""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()
+51
View File
@@ -277,5 +277,56 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
self.assertEqual("", loaded.backend) self.assertEqual("", loaded.backend)
class TestCommittedImage(_FakeHomeMixin, unittest.TestCase):
"""write_committed_image / read_committed_image round-trip."""
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def test_returns_none_when_absent(self):
self.assertIsNone(bottle_state.read_committed_image("dev"))
def test_write_then_read_roundtrip(self):
bottle_state.write_committed_image("dev", "bot-bottle-committed-dev:latest")
self.assertEqual(
"bot-bottle-committed-dev:latest",
bottle_state.read_committed_image("dev"),
)
def test_strips_trailing_newline_on_read(self):
path = bottle_state.committed_image_path("dev")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("bot-bottle-committed-dev:latest\n\n")
self.assertEqual(
"bot-bottle-committed-dev:latest",
bottle_state.read_committed_image("dev"),
)
def test_isolated_per_slug(self):
bottle_state.write_committed_image("dev", "bot-bottle-committed-dev:latest")
bottle_state.write_committed_image("api", "bot-bottle-committed-api:latest")
self.assertEqual(
"bot-bottle-committed-dev:latest",
bottle_state.read_committed_image("dev"),
)
self.assertEqual(
"bot-bottle-committed-api:latest",
bottle_state.read_committed_image("api"),
)
def test_path_under_state_dir(self):
path = bottle_state.committed_image_path("dev")
self.assertTrue(str(path).endswith("/.bot-bottle/state/dev/committed-image"))
def test_empty_content_returns_none(self):
path = bottle_state.committed_image_path("dev")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(" \n")
self.assertIsNone(bottle_state.read_committed_image("dev"))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+143
View File
@@ -0,0 +1,143 @@
"""Unit: cli.py commit command."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.cli.commit import cmd_commit
from bot_bottle import supervise
from bot_bottle import bottle_state
from bot_bottle.backend.freeze import CommitCancelled
class _FakeHomeMixin:
def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-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()
class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
"""cmd_commit with an explicit slug delegates to get_freezer."""
def setUp(self):
self._setup_fake_home()
def tearDown(self):
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):
slug = "dev-abc12"
self._write_meta(slug, "docker")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer
rc = cmd_commit([slug])
self.assertEqual(0, rc)
mock_gf.assert_called_once_with("docker")
mock_freezer.commit_slug.assert_called_once_with(slug)
def test_empty_backend_passed_to_get_freezer(self):
"""Old state dirs without a backend field pass '' to get_freezer."""
slug = "dev-abc12"
self._write_meta(slug, "")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer
rc = cmd_commit([slug])
self.assertEqual(0, rc)
mock_gf.assert_called_once_with("")
def test_commits_macos_container_bottle(self):
slug = "dev-abc12"
self._write_meta(slug, "macos-container")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer
rc = cmd_commit([slug])
self.assertEqual(0, rc)
mock_gf.assert_called_once_with("macos-container")
mock_freezer.commit_slug.assert_called_once_with(slug)
def test_commits_smolmachines_bottle(self):
slug = "dev-abc12"
self._write_meta(slug, "smolmachines")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
mock_gf.return_value = mock_freezer
rc = cmd_commit([slug])
self.assertEqual(0, rc)
mock_gf.assert_called_once_with("smolmachines")
def test_returns_zero_on_commit_cancelled(self):
slug = "dev-abc12"
self._write_meta(slug, "macos-container")
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
mock_freezer = MagicMock()
mock_freezer.commit_slug.side_effect = CommitCancelled
mock_gf.return_value = mock_freezer
rc = cmd_commit([slug])
self.assertEqual(0, rc)
class TestCmdCommitNoActiveBottles(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def test_dies_when_no_active_bottles_and_no_slug(self):
with patch(
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[],
), patch(
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"),
) as mock_die:
with self.assertRaises(SystemExit):
cmd_commit([])
mock_die.assert_called_once()
def test_returns_zero_when_picker_cancelled(self):
active = MagicMock()
active.slug = "dev-abc12"
with patch(
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[active],
), patch(
"bot_bottle.cli.commit.tui.filter_select", return_value=None,
):
rc = cmd_commit([])
self.assertEqual(0, rc)
if __name__ == "__main__":
unittest.main()
+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/routes.yaml", targets) self.assertIn("/etc/egress", 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.assertIn("codex mcp add", script) self.assertEqual(
self.assertIn("--transport http", script) f"codex mcp add supervise --url {_URL}",
self.assertIn("supervise", script) 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(
@@ -0,0 +1,192 @@
"""Unit: Docker launch step uses committed image when available."""
from __future__ import annotations
import contextlib
import io
import tempfile
import unittest
from pathlib import Path
from typing import Any
from unittest import mock
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex
_SLUG = "dev-abc12"
_COMMITTED_TAG = f"bot-bottle-committed-{_SLUG}:latest"
_DEFAULT_IMAGE = "bot-bottle-claude:latest"
_IDX = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def _plan(tmp: str) -> DockerBottlePlan:
stage = Path(tmp)
spec = BottleSpec(
manifest=_IDX,
agent_name="demo",
copy_cwd=False,
user_cwd=tmp,
identity=_SLUG,
)
return DockerBottlePlan(
spec=spec,
manifest=_IDX.load_for_agent("demo"),
stage_dir=stage,
git_gate_plan=GitGatePlan(
slug=_SLUG,
entrypoint_script=stage / "entrypoint.sh",
hook_script=stage / "hook.sh",
access_hook_script=stage / "access-hook.sh",
upstreams=(),
),
egress_plan=EgressPlan(
slug=_SLUG,
routes_path=stage / "egress.yaml",
routes=(),
token_env_map={},
),
supervise_plan=None,
agent_provision=AgentProvisionPlan(
template="claude",
command="claude",
prompt_mode="append_file",
image=_DEFAULT_IMAGE,
dockerfile="",
guest_home="/home/node",
instance_name=f"bot-bottle-{_SLUG}",
prompt_file=stage / "prompt.txt",
guest_env={},
),
slug=_SLUG,
forwarded_env={},
use_runsc=False,
)
class TestLaunchCommittedImage(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
def tearDown(self) -> None:
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
def _run_launch(
self,
plan: DockerBottlePlan,
*,
committed_tag: str | None = None,
image_present: bool = True,
) -> list[str]:
"""Drive launch() through its full sequence with the committed-image
behaviour controlled by the arguments. Returns the images that were
passed to `build_image` (empty list if it was never called)."""
built: list[str] = []
def fake_build(image: str, ctx: str, *, dockerfile: str = "") -> None:
del ctx, dockerfile
built.append(image)
with mock.patch.object(
launch_mod, "read_committed_image", return_value=committed_tag,
), mock.patch.object(
launch_mod.docker_mod, "image_exists", return_value=image_present,
), mock.patch.object(
launch_mod.docker_mod, "build_image", side_effect=fake_build,
), mock.patch.object(
launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")),
), mock.patch.object(
launch_mod.network_mod, "network_name_for_slug",
return_value="bb-internal",
), mock.patch.object(
launch_mod.network_mod, "network_egress_name_for_slug",
return_value="bb-egress",
), mock.patch.object(
launch_mod, "bottle_plan_to_compose",
return_value={"services": {"agent": {}}},
), mock.patch.object(
launch_mod, "write_compose_file",
return_value=Path("/tmp/compose.yml"),
), mock.patch.object(launch_mod, "compose_up"), \
mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object(launch_mod, "compose_down"), \
contextlib.redirect_stderr(io.StringIO()):
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
pass
return built
def test_skips_build_when_committed_image_present(self) -> None:
plan = _plan(self._tmp)
built = self._run_launch(plan, committed_tag=_COMMITTED_TAG, image_present=True)
self.assertEqual([], built, "build_image should not be called when committed image exists")
def test_uses_committed_image_in_compose_spec(self) -> None:
"""The compose spec renderer receives the committed image tag via
plan.image captured here by checking what bottle_plan_to_compose
was called with."""
plan = _plan(self._tmp)
captured_plans: list[DockerBottlePlan] = []
def fake_compose(p: DockerBottlePlan) -> dict[str, Any]:
captured_plans.append(p)
return {"services": {"agent": {}}}
with mock.patch.object(
launch_mod, "read_committed_image", return_value=_COMMITTED_TAG,
), mock.patch.object(
launch_mod.docker_mod, "image_exists", return_value=True,
), mock.patch.object(
launch_mod.docker_mod, "build_image",
), mock.patch.object(
launch_mod, "egress_tls_init",
return_value=(Path("/egress_ca"), Path("/egress_cert")),
), mock.patch.object(
launch_mod.network_mod, "network_name_for_slug",
return_value="bb-internal",
), mock.patch.object(
launch_mod.network_mod, "network_egress_name_for_slug",
return_value="bb-egress",
), mock.patch.object(
launch_mod, "bottle_plan_to_compose", side_effect=fake_compose,
), mock.patch.object(
launch_mod, "write_compose_file",
return_value=Path("/tmp/compose.yml"),
), mock.patch.object(launch_mod, "compose_up"), \
mock.patch.object(launch_mod, "compose_dump_logs"), \
mock.patch.object(launch_mod, "compose_down"), \
contextlib.redirect_stderr(io.StringIO()):
provision = mock.Mock(return_value=None)
with launch_mod.launch(plan, provision=provision):
pass
self.assertEqual(1, len(captured_plans))
self.assertEqual(_COMMITTED_TAG, captured_plans[0].image)
def test_falls_back_to_build_when_no_committed_image(self) -> None:
plan = _plan(self._tmp)
built = self._run_launch(plan, committed_tag=None)
self.assertEqual([_DEFAULT_IMAGE], built)
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None:
plan = _plan(self._tmp)
built = self._run_launch(
plan, committed_tag=_COMMITTED_TAG, image_present=False,
)
self.assertEqual([_DEFAULT_IMAGE], built)
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -67,5 +67,46 @@ class TestSave(unittest.TestCase):
) )
class TestCommitContainer(unittest.TestCase):
def test_runs_docker_commit(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_ok(),
) as run, patch.object(docker_mod, "info"):
docker_mod.commit_container(
"bot-bottle-dev-abc12",
"bot-bottle-committed-dev-abc12:latest",
)
argv = run.call_args.args[0]
self.assertEqual(
[
"docker", "commit",
"bot-bottle-dev-abc12",
"bot-bottle-committed-dev-abc12:latest",
],
argv,
)
def test_dies_on_docker_commit_failure(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_fail("No such container"),
), patch.object(
docker_mod, "die", side_effect=SystemExit("die"),
) as die:
with self.assertRaises(SystemExit):
docker_mod.commit_container("missing-container", "some:tag")
die.assert_called_once()
self.assertIn("missing-container", die.call_args.args[0])
def test_die_message_includes_image_tag(self):
with patch.object(
docker_mod.subprocess, "run", return_value=_fail("boom"),
), patch.object(
docker_mod, "die", side_effect=SystemExit("die"),
) as die:
with self.assertRaises(SystemExit):
docker_mod.commit_container("ctr", "my-tag:v1")
self.assertIn("my-tag:v1", die.call_args.args[0])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+54 -11
View File
@@ -2,12 +2,15 @@
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.backend.docker.egress_apply import ( from bot_bottle import supervise
EgressApplyError, from bot_bottle.backend.egress_apply import EgressApplyError
validate_routes_content, from bot_bottle.backend.docker.egress_apply import applicator
)
_ROUTES_EMPTY = "routes: []\n" _ROUTES_EMPTY = "routes: []\n"
@@ -16,11 +19,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):
validate_routes_content(_ROUTES_EMPTY) applicator.validate_routes_content(_ROUTES_EMPTY)
validate_routes_content(_ROUTES_ONE) applicator.validate_routes_content(_ROUTES_ONE)
def test_accepts_full_route_with_matches(self): def test_accepts_full_route_with_matches(self):
validate_routes_content( applicator.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'
@@ -32,25 +35,65 @@ 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:
validate_routes_content("routes:\n\t- host: x\n") applicator.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):
validate_routes_content("other: []\n") applicator.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):
validate_routes_content('routes: "not a list"\n') applicator.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):
validate_routes_content( applicator.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()
+81 -7
View File
@@ -9,8 +9,12 @@ 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({
@@ -27,7 +31,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 / "source-routes.yaml" routes_path = stage_dir / "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)
@@ -125,15 +129,10 @@ 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={routes_dir},target=/etc/egress,readonly", f"type=bind,source={self.stage_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,
@@ -266,5 +265,80 @@ 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,6 +73,53 @@ 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=[],
@@ -16,6 +16,8 @@ from __future__ import annotations
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import patch from unittest.mock import patch
from bot_bottle.backend.smolmachines import launch as _launch_mod from bot_bottle.backend.smolmachines import launch as _launch_mod
@@ -141,5 +143,46 @@ class TestEnsureSmolmachine(unittest.TestCase):
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine")) self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
class TestAgentFromPath(unittest.TestCase):
def _plan(self) -> Any:
return cast(Any, SimpleNamespace(
slug="dev-abc12",
agent_image="bot-bottle-claude:latest",
agent_dockerfile_path="/repo/Dockerfile",
))
def test_uses_committed_artifact_when_present(self):
with tempfile.TemporaryDirectory(prefix="committed-smolmachine.") as tmp:
artifact = Path(tmp) / "committed-smolmachine.smolmachine"
artifact.write_text("")
with patch.object(
_launch_mod, "read_committed_image", return_value=str(artifact),
), patch.object(
_launch_mod, "_ensure_smolmachine",
) as ensure, patch.object(
_launch_mod, "info",
):
result = _launch_mod._agent_from_path(self._plan())
self.assertEqual(artifact, result)
ensure.assert_not_called()
def test_falls_back_when_committed_artifact_missing(self):
packed = Path("/cache/agent.smolmachine")
with patch.object(
_launch_mod, "read_committed_image",
return_value="/missing/committed.smolmachine",
), patch.object(
_launch_mod, "_ensure_smolmachine", return_value=packed,
) as ensure:
result = _launch_mod._agent_from_path(self._plan())
self.assertEqual(packed, result)
ensure.assert_called_once_with(
"bot-bottle-claude:latest",
dockerfile="/repo/Dockerfile",
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+20
View File
@@ -24,6 +24,7 @@ from bot_bottle.backend.smolmachines.smolvm import (
machine_start, machine_start,
machine_stop, machine_stop,
pack_create, pack_create,
pack_create_from_vm,
wait_exec_ready, wait_exec_ready,
) )
@@ -63,6 +64,17 @@ class TestArgvShapes(unittest.TestCase):
argv, argv,
) )
def test_pack_create_from_vm_argv(self):
with self._patch_run() as m:
pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/committed"))
argv = m.call_args.args[0]
self.assertEqual(
["smolvm", "pack", "create",
"--from-vm", "bot-bottle-dev-abc12",
"-o", "/tmp/committed"],
argv,
)
def test_machine_create_minimal(self): def test_machine_create_minimal(self):
with self._patch_run() as m: with self._patch_run() as m:
machine_create("agent-xyz") machine_create("agent-xyz")
@@ -193,6 +205,14 @@ class TestErrorPath(unittest.TestCase):
with self.assertRaises(SmolvmError): with self.assertRaises(SmolvmError):
pack_create("missing:tag", Path("/tmp/out")) pack_create("missing:tag", Path("/tmp/out"))
def test_pack_create_from_vm_failure_raises(self):
with patch(
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
return_value=_fail("pack failed"),
):
with self.assertRaises(SmolvmError):
pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/out"))
def test_exec_failure_returns_result(self): def test_exec_failure_returns_result(self):
# The in-VM command's exit code is what Bottle.exec sees; # The in-VM command's exit code is what Bottle.exec sees;
# `false` exiting non-zero is not a smolvm failure. # `false` exiting non-zero is not a smolvm failure.
+10 -3
View File
@@ -317,15 +317,22 @@ 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_no_entries(self): def test_component_map_has_egress_entries(self):
# egress-block removed in issue #198; capability-block never had one. self.assertEqual(
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):
+19 -3
View File
@@ -2,9 +2,6 @@
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
@@ -12,6 +9,7 @@ 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
@@ -33,6 +31,8 @@ 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,6 +154,22 @@ 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.
+45 -5
View File
@@ -54,13 +54,19 @@ 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 ------------------------------------------------------
@@ -141,7 +147,9 @@ 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),
@@ -172,6 +180,17 @@ 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):
@@ -220,6 +239,26 @@ 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:
@@ -412,7 +451,8 @@ 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.assertNotIn("egress-block", names) self.assertIn(_sv.TOOL_ALLOW, 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(