Cleanup backend and agent provider abstractions #216

Merged
didericis merged 21 commits from issue-215-dockerfile-colocation into main 2026-06-08 23:01:36 -04:00
66 changed files with 1235 additions and 1773 deletions
+31 -21
View File
@@ -20,6 +20,7 @@ Per PRD 0050 the per-provider implementations live under
from __future__ import annotations
import importlib.util
import inspect
import os
import shlex
import tempfile
@@ -51,7 +52,6 @@ class AgentProviderRuntime:
template: str
command: str
image: str
dockerfile: str
prompt_mode: PromptMode
bypass_args: tuple[str, ...]
resume_args: tuple[str, ...]
@@ -103,6 +103,9 @@ class AgentProvisionPlan:
prompt_mode: PromptMode
image: str
dockerfile: str
guest_home: str
instance_name: str
prompt_file: Path
guest_env: dict[str, str]
env_vars: dict[str, str] = field(default_factory=dict)
dirs: tuple[AgentProvisionDir, ...] = ()
@@ -127,13 +130,31 @@ class AgentProvider(ABC):
"""The static command / image / prompt-mode table for this
template."""
didericis marked this conversation as resolved Outdated
Outdated
Review

This should always return a docker/should never return None

This should always return a docker/should never return `None`
@property
def guest_home(self) -> str:
"""In-guest home directory for the agent user. Defaults to
`/home/node` to match the Debian-based bot-bottle-* images
(USER node). Override for plugins whose image runs as a
different user."""
return "/home/node"
@property
def dockerfile(self) -> Path:
"""Path to the provider's Dockerfile.
Default: the `Dockerfile` file next to this provider's
`agent_provider.py` module. Override to point at a non-standard
path."""
return Path(inspect.getfile(type(self))).parent / "Dockerfile"
@abstractmethod
def provision_plan(
self,
*,
dockerfile: str,
state_dir: Path,
guest_home: str,
instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
@@ -205,23 +226,10 @@ class AgentProvider(ABC):
def provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None:
"""Configure git inside the agent container.
Default: Debian/node — copies .git when --cwd is set, writes the
git-gate insteadOf gitconfig, sets user.name/email as node.
Override for images that run as a different user or use a
non-standard home directory."""
Default: Debian/node — writes the git-gate insteadOf gitconfig
and sets user.name/email as node. Workspace copy runs through
BottleBackend.provision_workspace against the running bottle."""
from .log import info
workspace = plan.workspace_plan
if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir:
guest_workspace_git = f"{workspace.guest_path}/.git"
host_git = str(workspace.host_path / ".git")
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
bottle.cp_in(host_git, guest_workspace_git)
bottle.exec(
f"chown -R {shlex.quote(workspace.owner)} "
f"{shlex.quote(guest_workspace_git)}",
user="root",
)
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if manifest_bottle.git:
@@ -317,12 +325,13 @@ def runtime_for(template: str) -> AgentProviderRuntime:
return get_provider(template).runtime
def agent_provision_plan(
def build_agent_provision_plan(
*,
template: str,
dockerfile: str,
state_dir: Path,
guest_home: str,
instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
@@ -336,7 +345,8 @@ def agent_provision_plan(
return get_provider(template).provision_plan(
dockerfile=dockerfile,
state_dir=state_dir,
guest_home=guest_home,
instance_name=instance_name,
prompt_file=prompt_file,
guest_env=guest_env,
auth_token=auth_token,
forward_host_credentials=forward_host_credentials,
+126 -13
View File
@@ -32,6 +32,7 @@ manifest does not carry a backend field; the host picks.
from __future__ import annotations
import os
import shlex
import sys
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
@@ -39,14 +40,15 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..log import die, info
from ..manifest import GitEntry, Manifest
from ..manifest import ManifestGitEntry, Manifest
from ..supervise import SupervisePlan
from ..util import expand_tilde
from ..workspace import WorkspacePlan
from ..env import resolve_env, ResolvedEnv
from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir
@@ -78,9 +80,12 @@ class BottlePlan(ABC):
spec: BottleSpec
stage_dir: Path
guest_home: str
git_gate_plan: GitGatePlan
@property
def guest_home(self) -> str:
return self.agent_provision.guest_home
@property
def git_gate_insteadof_host(self) -> str:
"""Host (and optional port) used in git-gate insteadOf URLs.
@@ -97,7 +102,10 @@ class BottlePlan(ABC):
egress_plan: EgressPlan
supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan
workspace_plan: WorkspacePlan
@property
def workspace_plan(self) -> WorkspacePlan:
return workspace_plan(self.spec, guest_home=self.guest_home)
def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr."""
@@ -263,14 +271,87 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
name: str
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
"""Template method: run cross-backend host-side validation, then
delegate to the subclass's `_resolve_plan` for the
backend-specific resolution (names, scratch files, etc.). The
validation step is enforced here so a future backend cannot
accidentally skip it. No remote/runtime resources are created."""
from .resolve_common import (
merge_provision_env_vars,
mint_slug,
prepare_agent_state_dir,
prepare_egress,
prepare_git_gate,
prepare_supervise,
resolve_manifest_dockerfile,
write_launch_metadata,
)
self._validate(spec)
return self._resolve_plan(spec, stage_dir=stage_dir)
self._preflight()
manifest = spec.manifest
manifest_bottle = manifest.bottle_for(spec.agent_name)
manifest_agent_provider = manifest_bottle.agent_provider
agent_provider = get_provider(manifest_agent_provider.template)
resolved_env = resolve_env(manifest, spec.agent_name)
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
# Manifest may override the Dockerfile per-bottle; otherwise fall
# back to the provider plugin's bundled Dockerfile (next to its
# agent_provider.py module).
if manifest_agent_provider.dockerfile:
agent_dockerfile_path = resolve_manifest_dockerfile(
manifest_agent_provider.dockerfile, spec,
)
else:
agent_dockerfile_path = str(agent_provider.dockerfile)
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
agent_provision_plan = build_agent_provision_plan(
template=manifest_agent_provider.template,
dockerfile=agent_dockerfile_path,
state_dir=agent_dir,
instance_name=f"bot-bottle-{slug}",
prompt_file=prompt_file,
guest_env=self._build_guest_env(resolved_env),
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
auth_token=manifest_agent_provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace.workdir,
label=spec.label,
color=spec.color,
)
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
supervise_plan = prepare_supervise(manifest_bottle, slug)
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
return self._resolve_plan(
spec,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir,
)
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
return {}
def _preflight(self) -> None:
"""
tasks to do before resolving a plan
"""
pass
def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists,
@@ -297,7 +378,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
f"Create it under ~/.claude/skills/, then re-run."
)
def _validate_git_entries(self, entries: Sequence[GitEntry]) -> None:
def _validate_git_entries(self, entries: Sequence[ManifestGitEntry]) -> None:
"""Each entry's IdentityFile must exist on the host (after
expanding leading ~) — the git-gate copies it in at start time
to authenticate the upstream push (PRD 0008). Shape is already
@@ -322,10 +403,21 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
)
@abstractmethod
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
def _resolve_plan(self,
spec: BottleSpec,
*,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names,
env-file, prompt-file, proxy plan, runtime detection. Called by
`prepare` after `_validate` succeeds."""
`prepare` after `_validate` succeeds. Instance name, image,
prompt file, Dockerfile path, and guest home all live on
`agent_provision_plan` — the source of truth."""
@abstractmethod
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
@@ -369,9 +461,30 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
return prompt_path
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the operator workspace into the running bottle when
the backend cannot bake it into the agent image. Default is
no-op for backends like Docker that handle this before launch."""
"""Copy the operator workspace into the running bottle.
This is the only supported workspace-provisioning path: Docker
does not build a derived image containing the current
workspace."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_contents):
return
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
guest_path = shlex.quote(workspace.guest_path)
guest_parent = shlex.quote(guest_parent)
owner = shlex.quote(workspace.owner)
mode = shlex.quote(workspace.mode)
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
bottle.exec(
f"rm -rf {guest_path} && mkdir -p {guest_parent}",
user="root",
)
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
bottle.exec(
f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}",
user="root",
)
def supervise_mcp_url(self, plan: PlanT) -> str:
"""Return the agent-side URL of the per-bottle supervise
+38 -7
View File
@@ -2,10 +2,10 @@
This module is a thin façade. The real work lives in four siblings:
- prepare.py — host-side resolution into a DockerBottlePlan
- launch.py — bring-up + teardown context manager
- cleanup.py — orphan enumeration + removal
- enumerate.py — active-agent listing
- resolve_plan.py — Docker-specific resolution into a DockerBottlePlan
- launch.py — bring-up + teardown context manager
- cleanup.py — orphan enumeration + removal
- enumerate.py — active-agent listing
The base class's `prepare` template runs cross-backend host-side
validation before calling `_resolve_plan` here.
@@ -25,11 +25,16 @@ from pathlib import Path
from typing import Generator, Sequence
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
from . import launch as _launch
from . import prepare as _prepare
from . import resolve_plan as _resolve_plan
from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan
@@ -48,8 +53,34 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
launch."""
return shutil.which("docker") is not None
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
return _prepare.resolve_plan(spec, stage_dir=stage_dir)
def _preflight(self) -> None:
_resolve_plan.preflight()
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
return _resolve_plan.build_guest_env(resolved_env)
def _resolve_plan(
self,
spec: BottleSpec,
*,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path,
) -> DockerBottlePlan:
return _resolve_plan.resolve_plan(
spec,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir,
)
@contextmanager
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
+19 -12
View File
@@ -22,25 +22,32 @@ class DockerBottlePlan(BottlePlan):
`agent_provision` from BottlePlan."""
slug: str
container_name: str
container_name_pinned: bool
image: str
derived_image: str # "" -> no derived image
runtime_image: str # image actually launched (derived or base)
# Absolute path to the Dockerfile that builds `image`. Empty means
# use the repo's default Dockerfile. Populated to a per-bottle
# state file (~/.bot-bottle/state/<slug>/Dockerfile) after a
# capability-block remediation (PRD 0016).
dockerfile_path: str
env_file: Path # docker --env-file: NAME=VALUE literals
# name -> value for vars forwarded into the docker-run child process
# via subprocess env (so values never land on argv or in a file).
# repr=False keeps secret/interpolated/OAuth values out of any
# accidental log of the plan dataclass.
forwarded_env: dict[str, str] = field(repr=False)
prompt_file: Path
use_runsc: bool
@property
def container_name(self) -> str:
return self.agent_provision.instance_name
@property
def image(self) -> str:
return self.agent_provision.image
@property
def dockerfile_path(self) -> str:
"""Absolute path to the Dockerfile that builds `image`. Sourced
from the agent provision plan — the manifest may override per
bottle; otherwise the provider plugin's bundled Dockerfile."""
return self.agent_provision.dockerfile
@property
def prompt_file(self) -> Path:
return self.agent_provision.prompt_file
@property
def agent_command(self) -> str:
return self.agent_provision.command
+4 -11
View File
@@ -32,10 +32,10 @@ from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from ...agent_provider import get_provider
from ...log import info, warn
from .bottle_state import (
from ...bottle_state import (
mark_preserved,
per_bottle_dockerfile,
transcript_snapshot_dir,
@@ -93,11 +93,11 @@ def fetch_current_dockerfile(slug: str) -> str:
override = per_bottle_dockerfile(slug)
if override is not None:
return override
repo_dockerfile = _repo_dockerfile_path()
repo_dockerfile = get_provider("claude").dockerfile
if repo_dockerfile.is_file():
return repo_dockerfile.read_text()
raise CapabilityApplyError(
f"no per-bottle Dockerfile for {slug} and no repo Dockerfile at "
f"no per-bottle Dockerfile for {slug} and no provider Dockerfile at "
f"{repo_dockerfile}"
)
@@ -125,13 +125,6 @@ def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
# --- Internals -------------------------------------------------------------
def _repo_dockerfile_path() -> Path:
"""Path to the repo's Claude Dockerfile (one dir above this module's
package root). Resolved at call time so the path is correct
regardless of where this module is imported from."""
# bot_bottle/backend/docker/capability_apply.py -> repo root
return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
def snapshot_transcript(slug: str) -> None:
"""`docker cp` /home/node/.claude out of the agent container into
1
+1 -1
View File
@@ -31,7 +31,7 @@ from ... import supervise as _supervise
from ...log import info, warn
from . import util as docker_mod
from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_state import bottle_state_dir, is_preserved
from ...bottle_state import bottle_state_dir, is_preserved
from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects
+1 -3
View File
@@ -222,7 +222,7 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
env.append(name)
service: dict[str, Any] = {
"image": plan.runtime_image,
"image": plan.image,
"container_name": plan.container_name,
"command": ["sleep", "infinity"],
"networks": {"internal": None},
@@ -230,8 +230,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
}
if plan.use_runsc:
service["runtime"] = "runsc"
if plan.env_file and plan.env_file.exists() and plan.env_file.stat().st_size > 0:
service["env_file"] = [str(plan.env_file)]
volumes: list[dict[str, Any]] = []
if plan.supervise_plan is not None:
+1 -1
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import subprocess
from .. import ActiveAgent
from .bottle_state import read_metadata
from ...bottle_state import read_metadata
from .compose import compose_project_name, list_active_slugs
+5 -9
View File
@@ -4,8 +4,8 @@ PRD 0018 chunk 3: each instance is one `docker compose` project.
The flow is:
1. Build the agent's base + derived image (compose builds the
sidecar images via the `build:` directive on first up).
1. Build the agent image from the provider Dockerfile (compose
builds the sidecar images via the `build:` directive on first up).
2. Mint the per-bottle egress CA (chunk 2 writes it under
state/<slug>/egress/).
3. Populate the inner plans with launch-time fields so the
@@ -15,8 +15,8 @@ The flow is:
7. `docker compose up -d` (token + OAuth values flow into the
compose subprocess env so `environment: [NAME]` bare-name
entries inherit without rendering values into the file).
8. Provision (CA install, prompt copy, skills, git, supervise
config) unchanged, uses `docker exec`.
8. Provision (CA install, prompt copy, skills, workspace, git,
supervise config) unchanged, uses `docker exec` / `docker cp`.
9. Yield a DockerBottle handle. `exec_agent` runs claude via
`docker exec -it` exactly like the pre-compose world.
@@ -43,7 +43,7 @@ from . import network as network_mod
from . import util as docker_mod
from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan
from .bottle_state import (
from ...bottle_state import (
bottle_state_dir,
egress_state_dir,
git_gate_state_dir,
@@ -97,10 +97,6 @@ def launch(
plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path,
)
if plan.derived_image:
docker_mod.build_image_with_cwd(
plan.derived_image, plan.image, plan.workspace_plan
)
internal_network = network_mod.network_name_for_slug(plan.slug)
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
-280
View File
@@ -1,280 +0,0 @@
"""Prepare step for the Docker bottle backend.
`resolve_plan` does all host-side resolution (image and container
names, env-file, prompt-file, proxy plan, runtime detection) and
returns a frozen DockerBottlePlan. No Docker resources are created;
the only side effects are scratch files under `stage_dir` and a probe
of `docker info`. Cross-backend host-side validation has already run
via the base class's `prepare` template before this is called.
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
from dataclasses import replace
from pathlib import Path
from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, runtime_for
from ...egress import Egress
from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate
from ...log import die
from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .. import BottleSpec
from . import util as docker_mod
from .bottle_plan import DockerBottlePlan
from .bottle_state import (
BottleMetadata,
agent_state_dir,
bottle_identity,
clear_preserve_marker,
egress_state_dir,
git_gate_state_dir,
per_bottle_dockerfile,
per_bottle_dockerfile_path,
per_bottle_image_tag,
supervise_state_dir,
write_metadata,
)
from .sidecar_bundle import sidecar_bundle_container_name
def resolve_plan(
spec: BottleSpec,
*,
stage_dir: Path,
) -> DockerBottlePlan:
"""Resolve Docker-specific names and write scratch files. Trusts
that the agent and its skills/git-gate keys are present
validation already ran in the base class."""
docker_mod.require_docker()
git_gate = GitGate()
egress = Egress()
supervise = Supervise()
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
guest_home = "/home/node"
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
# mints a random-suffixed identity (so parallel runs of the same
# agent in the same cwd don't collide on container/network
# names); a `resume` passes the recorded identity in via
# spec.identity to continue an existing bottle's state.
slug = spec.identity or bottle_identity(spec.agent_name)
# Record the launch metadata so `cli.py resume <identity>` can
# reconstruct the spec. Idempotent — re-writes on resume with a
# refreshed started_at.
write_metadata(BottleMetadata(
identity=slug,
agent_name=spec.agent_name,
cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(),
compose_project=f"bot-bottle-{slug}",
backend="docker",
label=spec.label,
color=spec.color,
))
# Clear any leftover preserve marker from a prior capability-block
# so this fresh launch can be cleaned up at session-end unless
# the agent triggers another capability-block.
clear_preserve_marker(slug)
# PRD 0016 capability-block: if a per-bottle Dockerfile has been
# written (via apply_capability_change), the base image becomes
# per_bottle_image_tag(slug) built from that file. --cwd still
# layers a derived image on top.
dockerfile_path = ""
if per_bottle_dockerfile(slug) is not None:
image_default = per_bottle_image_tag(slug)
dockerfile_path = str(per_bottle_dockerfile_path(slug))
elif provider.dockerfile:
image_default = f"bot-bottle-{provider.template}:{slug}"
dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
elif provider_runtime.dockerfile:
image_default = provider_runtime.image
dockerfile_path = provider_runtime.dockerfile
elif provider.template not in PROVIDER_TEMPLATES:
user_dockerfile = (
Path.home() / ".bot-bottle" / "contrib" / provider.template / "Dockerfile"
)
if user_dockerfile.is_file():
image_default = f"bot-bottle-{provider.template}:{slug}"
dockerfile_path = str(user_dockerfile)
else:
image_default = provider_runtime.image
else:
image_default = provider_runtime.image
image = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
derived_image = ""
runtime_image = image
if spec.copy_cwd:
derived_image = os.environ.get(
"BOT_BOTTLE_DERIVED_IMAGE", f"bot-bottle-cwd:{slug}"
)
runtime_image = derived_image
default_container = f"bot-bottle-{slug}"
pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "")
container_name_pinned = bool(pinned_container)
if container_name_pinned:
container_name = pinned_container
if docker_mod.container_exists(container_name):
die(
f"container '{container_name}' already exists "
f"(pinned via BOT_BOTTLE_CONTAINER). "
f"Remove it with 'docker rm -f {container_name}' or unset the override."
)
else:
container_name = ""
for candidate in docker_mod.container_name_candidates(default_container):
if not docker_mod.container_exists(candidate):
container_name = candidate
break
if not container_name:
die(
f"could not find a free container name after "
f"{default_container}-{docker_mod.MAX_CONTAINER_SUFFIX}; "
f"clean up old containers with 'docker rm -f <name>'"
)
# Probe the sidecar-bundle container name for an orphan from a
# previous run. Otherwise a stale bundle surfaces as a
# docker-create conflict deep inside launch() with no actionable
# hint; failing fast here points at the cleanup command.
bundle_name = sidecar_bundle_container_name(slug)
if docker_mod.container_exists(bundle_name):
die(
f"sidecar bundle container '{bundle_name}' already exists. "
f"This is an orphan from a previous run; clean it up with "
f"'./cli.py cleanup' (or 'docker rm -f {bundle_name}') and "
f"retry."
)
# PRD 0018 chunk 2: prepare-time scratch files live under
# ~/.bot-bottle/state/<slug>/<service>/ so chunk 3's compose
# bind-mounts can point at stable paths. The state subdirs are
# cleaned up by start.py's session-end teardown unless something
# explicitly preserves the state dir (capability-block, crash).
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
env_file = agent_dir / "agent.env"
prompt_file = agent_dir / "prompt.txt"
prompt_file.write_text("")
prompt_file.chmod(0o600)
git_gate_dir = git_gate_state_dir(slug)
git_gate_dir.mkdir(parents=True, exist_ok=True)
git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir)
resolved = resolve_env(manifest, spec.agent_name)
# Everything that should reach the bottle by-name (so its value
# never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded)
_write_env_file(resolved, env_file)
prompt_file.write_text(agent.prompt)
use_runsc = docker_mod.runsc_available()
agent_provision = agent_provision_plan(
template=provider.template,
dockerfile=dockerfile_path,
state_dir=agent_dir,
guest_home=guest_home,
forward_host_credentials=provider.forward_host_credentials,
auth_token=provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace_plan.workdir,
label=spec.label,
color=spec.color,
)
guest_env = dict(agent_provision.guest_env)
for key, val in agent_provision.env_vars.items():
guest_env.setdefault(key, val)
agent_provision = replace(agent_provision, guest_env=guest_env)
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = egress.prepare(
bottle, slug, egress_dir, agent_provision.egress_routes,
)
supervise_plan = None
if bottle.supervise:
# Current Dockerfile for the agent image. Read from the repo
# root; for `--cwd` derived images the base Dockerfile is what
# the agent should propose changes against (the derived layer
# is just a workspace copy).
# (routes.yaml used to land here too but PRD 0017 chunk 3
# moved it behind the `list-egress-routes` MCP tool so the
# agent gets live state rather than a launch-time snapshot.)
supervise_dockerfile_path = (
Path(dockerfile_path)
if dockerfile_path
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
)
dockerfile_content = (
supervise_dockerfile_path.read_text(encoding="utf-8")
if supervise_dockerfile_path.is_file()
else ""
)
supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True)
supervise_plan = supervise.prepare(
slug, supervise_dir,
dockerfile_content=dockerfile_content,
)
return DockerBottlePlan(
spec=spec,
stage_dir=stage_dir,
guest_home=guest_home,
slug=slug,
container_name=container_name,
container_name_pinned=container_name_pinned,
image=image,
derived_image=derived_image,
runtime_image=runtime_image,
dockerfile_path=dockerfile_path,
env_file=env_file,
forwarded_env=forwarded_env,
prompt_file=prompt_file,
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
use_runsc=use_runsc,
agent_provision=agent_provision,
workspace_plan=workspace_plan,
)
def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None:
"""Serialize the literal portion of a ResolvedEnv into docker's
`--env-file` syntax (NAME=VALUE per line, mode 600 since the file
may carry verbatim values from the manifest). Forwarded names ride
on the plan as a structured tuple instead."""
env_lines: list[str] = []
for name, value in resolved.literals.items():
if "\n" in value:
die(
f"env entry {name} (literal) contains a newline; "
f"docker --env-file cannot represent multi-line values."
)
env_lines.append(f"{name}={value}")
env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
env_file.chmod(0o600)
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
path = Path(os.path.expanduser(path_value))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
return str(path)
+59
View File
@@ -0,0 +1,59 @@
"""Prepare step for the Docker bottle backend.
`resolve_plan` does all host-side resolution (image and container
names, prompt-file, proxy plan, runtime detection) and returns a
frozen DockerBottlePlan. No Docker resources are created; the only
side effects are scratch files under `stage_dir` and a probe of
`docker info`. Cross-backend host-side validation has already run
via the base class's `prepare` template before this is called.
"""
from __future__ import annotations
from pathlib import Path
from . import util as docker_mod
from .bottle_plan import DockerBottlePlan
from .. import BottleSpec
from ...env import ResolvedEnv
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...supervise import SupervisePlan
from ...git_gate import GitGatePlan
def preflight() -> None:
docker_mod.require_docker()
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
return dict(resolved_env.literals)
def resolve_plan(
spec: BottleSpec,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
supervise_plan: SupervisePlan | None,
git_gate_plan: GitGatePlan,
stage_dir: Path,
) -> DockerBottlePlan:
"""Resolve Docker-specific names and write scratch files. Trusts
that the agent and its skills/git-gate keys are present
validation already ran in the base class."""
# ==== docker specific setup ====
use_runsc = docker_mod.runsc_available()
return DockerBottlePlan(
spec=spec,
stage_dir=stage_dir,
slug=slug,
forwarded_env=dict(resolved_env.forwarded),
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
use_runsc=use_runsc,
agent_provision=agent_provision_plan,
)
+34 -35
View File
@@ -7,11 +7,10 @@ from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
from typing import Iterable, Iterator
from ...log import die, info
from ...workspace import WorkspacePlan
# from ...workspace import WorkspacePlan
# Cap on the suffix the container-name conflict logic will try before
@@ -118,39 +117,39 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
subprocess.run(args, check=True)
def build_image_with_cwd(
derived: str,
base: str,
workspace: WorkspacePlan,
) -> None:
"""Build a thin derived image that copies the workspace into
the plan's guest path and sets the plan's workdir."""
import os
cwd = str(workspace.host_path)
if not os.path.isdir(cwd):
die(f"cwd not found at {cwd}")
info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}")
with tempfile.TemporaryDirectory(prefix="bot-bottle-cwd.") as tmp:
context_dir = os.path.join(tmp, "context")
staged_workspace = os.path.join(context_dir, "workspace")
shutil.copytree(
cwd,
staged_workspace,
symlinks=True,
ignore=shutil.ignore_patterns(".git"),
)
dockerfile = (
f"FROM {base}\n"
f"COPY --chown=node:node workspace/. {workspace.guest_path}\n"
f"WORKDIR {workspace.workdir}\n"
)
subprocess.run(
["docker", "build", "-t", derived, "-f", "-", context_dir],
input=dockerfile,
text=True,
check=True,
)
# def build_image_with_cwd(
# derived: str,
# base: str,
# workspace: "WorkspacePlan",
# ) -> None:
# """Build a thin derived image that copies the workspace into
# the plan's guest path and sets the plan's workdir."""
# import os
#
# cwd = str(workspace.host_path)
# if not os.path.isdir(cwd):
# die(f"cwd not found at {cwd}")
# info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}")
# with tempfile.TemporaryDirectory(prefix="bot-bottle-cwd.") as tmp:
# context_dir = os.path.join(tmp, "context")
# staged_workspace = os.path.join(context_dir, "workspace")
# shutil.copytree(
# cwd,
# staged_workspace,
# symlinks=True,
# ignore=shutil.ignore_patterns(".git"),
# )
# dockerfile = (
# f"FROM {base}\n"
# f"COPY --chown=node:node workspace/. {workspace.guest_path}\n"
# f"WORKDIR {workspace.workdir}\n"
# )
# subprocess.run(
# ["docker", "build", "-t", derived, "-f", "-", context_dir],
# input=dockerfile,
# text=True,
# check=True,
# )
def image_id(ref: str) -> str:
+122
View File
@@ -0,0 +1,122 @@
"""Shared helpers used by both backends' resolve_plan steps.
Each helper owns one well-defined step of the per-bottle plan
resolution so docker and smolmachines don't repeat the same logic.
Backend-specific steps (container names, env-file, per-bottle
Dockerfile overrides, subnet allocation) stay in the backend's own
resolve_plan.py.
"""
from __future__ import annotations
import os
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from ..agent_provider import AgentProvisionPlan
from ..bottle_state import (
BottleMetadata,
agent_state_dir,
bottle_identity,
egress_state_dir,
git_gate_state_dir,
supervise_state_dir,
write_metadata,
)
from ..egress import Egress, EgressPlan
from ..git_gate import GitGate, GitGatePlan
from ..manifest import ManifestBottle
from ..supervise import Supervise, SupervisePlan
from . import BottleSpec
def mint_slug(spec: BottleSpec) -> str:
"""Return the bottle identity: the recorded identity for a resume,
or a freshly minted one for a new start."""
return spec.identity or bottle_identity(spec.agent_name)
def write_launch_metadata(
slug: str, spec: BottleSpec, *, compose_project: str, backend: str,
) -> None:
"""Persist launch metadata so `cli.py resume <identity>` can
reconstruct the spec. Idempotent re-writes on resume with a
refreshed started_at."""
write_metadata(BottleMetadata(
identity=slug,
agent_name=spec.agent_name,
cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(),
compose_project=compose_project,
backend=backend,
label=spec.label,
color=spec.color,
))
def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]:
"""Create the agent state subdir, write the prompt file.
Returns (agent_dir, prompt_file)."""
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt"
prompt_file.write_text(agent.prompt or "")
prompt_file.chmod(0o600)
return agent_dir, prompt_file
def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan:
git_gate_dir = git_gate_state_dir(slug)
git_gate_dir.mkdir(parents=True, exist_ok=True)
return GitGate().prepare(bottle, slug, git_gate_dir)
def prepare_egress(
bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan,
) -> EgressPlan:
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
"""Prepare the supervise sidecar state dir. Returns None when
bottle.supervise is falsy."""
if not bottle.supervise:
return None
supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True)
return Supervise().prepare(slug, supervise_dir)
def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan:
"""Fold provision.env_vars into guest_env (setdefault semantics)
and return a new plan with the merged guest_env."""
merged = dict(provision.guest_env)
for key, val in provision.env_vars.items():
merged.setdefault(key, val)
return replace(provision, guest_env=merged)
def resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
"""Resolve a manifest-supplied dockerfile path relative to user_cwd."""
path = Path(os.path.expanduser(path_value))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
return str(path)
__all__ = [
"merge_provision_env_vars",
"mint_slug",
"prepare_agent_state_dir",
"prepare_egress",
"prepare_git_gate",
"prepare_supervise",
"resolve_manifest_dockerfile",
"write_launch_metadata",
]
+33 -10
View File
@@ -13,16 +13,20 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Generator, Sequence
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
from . import launch as _launch
from . import prepare as _prepare
from . import resolve_plan as _resolve_plan
from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
from .bottle_plan import SmolmachinesBottlePlan
from .provision import workspace as _workspace
class SmolmachinesBottleBackend(
@@ -41,10 +45,34 @@ class SmolmachinesBottleBackend(
runtime check happens at `prepare`."""
return _smolvm.is_available()
def _preflight(self) -> None:
_resolve_plan.preflight()
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
return _resolve_plan.build_guest_env(resolved_env)
def _resolve_plan(
self, spec: BottleSpec, *, stage_dir: Path
self,
spec: BottleSpec,
*,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path,
) -> SmolmachinesBottlePlan:
return _prepare.resolve_plan(spec, stage_dir=stage_dir)
return _resolve_plan.resolve_plan(
spec,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir,
)
@contextmanager
def launch(
@@ -53,11 +81,6 @@ class SmolmachinesBottleBackend(
with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle
def provision_workspace(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
_workspace.provision_workspace(plan, bottle)
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
"""The smolmachines guest reaches the supervise sidecar via a
host-published random port the launch step pinned earlier
+28 -26
View File
@@ -29,27 +29,6 @@ class SmolmachinesBottlePlan(BottlePlan):
bundle_subnet: str
bundle_gateway: str
bundle_ip: str
# smolvm machine name + agent image source. machine_create
# boots from a packed `.smolmachine` artifact (pre-baked at
# prepare time via `smolvm pack create`); using `--from`
# instead of `--image` avoids the registry-pull race we hit
# when machine_start tried to fetch on-demand and the libkrun
# agent's network attempt got refused by macOS.
#
# Chunk 2d ships with a public placeholder image (alpine)
# since bot-bottle-claude:latest lives in the operator's local
# docker daemon and smolvm's crane backend can't read from
# there; chunk 4 resolves the agent-image-conversion gap
# (push to a registry first, or smolvm grows a docker-daemon
# transport).
machine_name: str
# Agent image ref (docker tag). `launch` runs the
# build → save → registry push → smolvm pack pipeline against
# this and feeds the resulting `.smolmachine` artifact to
# `machine_create --from`. The pipeline runs at launch time
# (not prepare time) so the docker build output doesn't garble
# the dashboard's preflight modal.
agent_image_ref: str
# In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
# the guest has no DNS resolver inside the TSI allowlist.
# Passed to `smolvm machine create` as `-e K=V` flags.
@@ -57,11 +36,6 @@ class SmolmachinesBottlePlan(BottlePlan):
# `--smolfile` is mutually exclusive with `--from`, and
# `--from` is the path that avoids the registry-pull race).
guest_env: dict[str, str]
# Path to the agent's prompt file on the host. Always written
# (mode 0o600) so the in-VM path always exists; the file is
# empty when the agent has no prompt — claude-code reads it
# via --append-system-prompt-file only when non-empty.
prompt_file: Path
# Inner Plans for the sidecar bundle daemons. The same shape the
# docker backend uses — same `.prepare()` calls produced
# them — but our launch step doesn't populate the
@@ -82,6 +56,34 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_git_gate_host: str = ""
agent_supervise_url: str = ""
@property
def machine_name(self) -> str:
"""smolvm machine name. `machine_create` boots from a packed
`.smolmachine` artifact (pre-baked at prepare time via
`smolvm pack create`); using `--from` instead of `--image`
avoids the registry-pull race we hit when machine_start tried
to fetch on-demand and the libkrun agent's network attempt
got refused by macOS."""
return self.agent_provision.instance_name
@property
def agent_image(self) -> str:
"""Agent image ref (docker tag). `launch` runs the
build save registry push smolvm pack pipeline against
this and feeds the resulting `.smolmachine` artifact to
`machine_create --from`. The pipeline runs at launch time
(not prepare time) so the docker build output doesn't garble
the dashboard's preflight modal."""
return self.agent_provision.image
@property
def prompt_file(self) -> Path:
"""Path to the agent's prompt file on the host. Always written
(mode 0o600) so the in-VM path always exists; the file is
empty when the agent has no prompt claude-code reads it
via --append-system-prompt-file only when non-empty."""
return self.agent_provision.prompt_file
@property
def git_gate_insteadof_host(self) -> str:
return self.agent_git_gate_host
+1 -1
View File
@@ -23,7 +23,7 @@ import json
import subprocess
from .. import ActiveAgent
from ..docker.bottle_state import read_metadata
from ...bottle_state import read_metadata
from . import sidecar_bundle as _bundle
+2 -2
View File
@@ -41,7 +41,7 @@ from ..docker.git_gate import (
)
from ...git_gate import revoke_git_gate_provisioned_keys
from ...log import warn
from ..docker.bottle_state import egress_state_dir, git_gate_state_dir
from ...bottle_state import egress_state_dir, git_gate_state_dir
from . import loopback_alias as _loopback
from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm
@@ -90,7 +90,7 @@ def launch(
# 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_ref,
plan.agent_image,
dockerfile=plan.agent_dockerfile_path,
)
-185
View File
@@ -1,185 +0,0 @@
"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c).
Resolves the per-bottle docker subnet + bundle IP and assembles
the guest env. The agent's docker image build → smolmachine
pack pipeline runs in `launch.launch`, not here, so the
dashboard's preflight modal isn't garbled by docker-build output
before the operator has confirmed.
No VM bringup that's `launch.launch`'s job."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from dataclasses import replace
from pathlib import Path
from ...agent_provider import agent_provision_plan, runtime_for
from ...backend import BottleSpec
from ...backend.docker.bottle_state import (
BottleMetadata,
agent_state_dir,
bottle_identity,
egress_state_dir,
git_gate_state_dir,
supervise_state_dir,
write_metadata,
)
from ...egress import Egress
from ...env import resolve_env
from ...git_gate import GitGate
from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight
# Gateway ports the bundle exposes inside its container — git-gate's
# git-daemon, supervise's MCP. The agent inside the smolvm guest
# dials these on the bundle's pinned IP.
_BUNDLE_GIT_GATE_PORT = 9418
_BUNDLE_SUPERVISE_PORT = 9100
def resolve_plan(
spec: BottleSpec, *, stage_dir: Path
) -> SmolmachinesBottlePlan:
"""Materialize the smolmachines plan. The bundle's docker
subnet + pinned IP are derived from the slug; the agent's
`.smolmachine` artifact is built (or cache-hit) here so
launch's `machine create --from` boots without a registry
pull. Per-bottle guest env + the TSI allow_cidrs land on the
plan for launch to pass straight through to
`machine create` flags."""
smolmachines_preflight()
manifest = spec.manifest
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
guest_home = "/home/node"
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
slug = spec.identity or bottle_identity(spec.agent_name)
# Record minimal metadata so `cli.py resume` can recover the
# slug. Same schema as the docker backend.
write_metadata(BottleMetadata(
identity=slug,
agent_name=spec.agent_name,
cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(),
compose_project="",
backend="smolmachines",
label=spec.label,
color=spec.color,
))
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
# Agent's env: resolve through resolve_env() so ?prompt entries
# are prompted and ${HOST_VAR} entries are interpolated — matching
# the Docker backend's contract. Forwarded (secret/interpolated)
# values still reach the guest as -e K=V smolvm flags because
# smolvm 0.8.0 has no env-file or stdin injection path; this is
# the known argv-exposure gap documented in PRD 0038.
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated
# in launch.py after bundle bringup.
resolved = resolve_env(manifest, spec.agent_name)
guest_env: dict[str, str] = {
**resolved.literals,
**resolved.forwarded,
"NO_PROXY": "localhost,127.0.0.1",
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
}
git_gate_dir = git_gate_state_dir(slug)
git_gate_dir.mkdir(parents=True, exist_ok=True)
git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir)
# Prompt file is always written (mode 0o600) so the in-VM
# path always exists. Content is the agent's `prompt`
# field (markdown body) — empty for agents with no prompt.
# claude-code reads it via --append-system-prompt-file only
# when non-empty, but the file must exist either way to
# match the docker backend's contract.
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt"
agent = manifest.agents[spec.agent_name]
prompt_file.write_text(agent.prompt or "")
prompt_file.chmod(0o600)
machine_name = f"bot-bottle-{slug}"
# Stash the agent image ref — `launch.launch` runs the
# build → pack pipeline at bringup. Honors BOT_BOTTLE_IMAGE
# to match the docker backend's `resolve_plan` default.
agent_dockerfile_path = ""
if provider.dockerfile:
agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
image_default = f"bot-bottle-{provider.template}:{slug}"
elif provider_runtime.dockerfile:
agent_dockerfile_path = provider_runtime.dockerfile
image_default = provider_runtime.image
else:
image_default = provider_runtime.image
agent_image_ref = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
agent_provision = agent_provision_plan(
template=provider.template,
dockerfile=agent_dockerfile_path,
state_dir=agent_dir,
guest_home=guest_home,
guest_env=guest_env,
forward_host_credentials=provider.forward_host_credentials,
auth_token=provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace_plan.workdir,
label=spec.label,
color=spec.color,
)
merged_guest_env = dict(agent_provision.guest_env)
for key, val in agent_provision.env_vars.items():
merged_guest_env.setdefault(key, val)
agent_provision = replace(agent_provision, guest_env=merged_guest_env)
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = Egress().prepare(
bottle, slug, egress_dir, agent_provision.egress_routes,
)
supervise_plan = None
if bottle.supervise:
supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True)
supervise_plan = Supervise().prepare(slug, supervise_dir)
return SmolmachinesBottlePlan(
spec=spec,
stage_dir=stage_dir,
guest_home=guest_home,
slug=slug,
bundle_subnet=subnet,
bundle_gateway=gateway,
bundle_ip=bundle_ip,
machine_name=machine_name,
agent_image_ref=agent_image_ref,
guest_env=agent_provision.guest_env,
prompt_file=prompt_file,
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
agent_provision=agent_provision,
workspace_plan=workspace_plan,
)
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
path = Path(os.path.expanduser(path_value))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
return str(path)
@@ -6,8 +6,7 @@ the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
provisioning also moved to the AgentProvider ABC (with Debian/node
defaults); user plugins override them for non-standard images.
The module left in this subpackage handles the remaining backend-
specific step:
- workspace.py copy the operator workspace into the guest
No modules remain in this subpackage. Workspace copying now runs
through `BottleBackend.provision_workspace` against the running
bottle for every backend.
"""
@@ -1,32 +0,0 @@
"""Copy the operator workspace into a smolmachines guest."""
from __future__ import annotations
import shlex
from ....log import info
from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan
def provision_workspace(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Copy host cwd contents to the planned guest workspace."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_contents):
return
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
guest_path_q = shlex.quote(workspace.guest_path)
guest_parent_q = shlex.quote(guest_parent)
owner_q = shlex.quote(workspace.owner)
mode_q = shlex.quote(workspace.mode)
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
bottle.exec(
f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}",
user="root",
)
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
bottle.exec(
f"chown -R {owner_q} {guest_path_q} && chmod {mode_q} {guest_path_q}",
user="root",
)
@@ -0,0 +1,80 @@
"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c).
Resolves the per-bottle docker subnet + bundle IP and assembles
the guest env. The agent's docker image build → smolmachine
pack pipeline runs in `launch.launch`, not here, so the
dashboard's preflight modal isn't garbled by docker-build output
before the operator has confirmed.
No VM bringup that's `launch.launch`'s job."""
from __future__ import annotations
from pathlib import Path
from .. import BottleSpec
from ...env import ResolvedEnv
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...supervise import SupervisePlan
from ...git_gate import GitGatePlan
from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight
def preflight() -> None:
smolmachines_preflight()
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
# Agent's env: resolve through resolve_env() so ?prompt entries
# are prompted and ${HOST_VAR} entries are interpolated — matching
# the Docker backend's contract. Forwarded (secret/interpolated)
# values still reach the guest as -e K=V smolvm flags because
# smolvm 0.8.0 has no env-file or stdin injection path; this is
# the known argv-exposure gap documented in PRD 0038.
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated
# in launch.py after bundle bringup.
return {
**resolved_env.literals,
**resolved_env.forwarded,
"NO_PROXY": "localhost,127.0.0.1",
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
}
def resolve_plan(
spec: BottleSpec,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
supervise_plan: SupervisePlan | None,
git_gate_plan: GitGatePlan,
stage_dir: Path,
) -> SmolmachinesBottlePlan:
"""Materialize the smolmachines plan. The bundle's docker
subnet + pinned IP are derived from the slug; the agent's
`.smolmachine` artifact is built (or cache-hit) here so
launch's `machine create --from` boots without a registry
pull. Per-bottle guest env + the TSI allow_cidrs land on the
plan for launch to pass straight through to
`machine create` flags."""
# ==== smolmachines specific setup ====
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
return SmolmachinesBottlePlan(
spec=spec,
stage_dir=stage_dir,
slug=slug,
bundle_subnet=subnet,
bundle_gateway=gateway,
bundle_ip=bundle_ip,
guest_env=agent_provision_plan.guest_env,
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
agent_provision=agent_provision_plan,
)
@@ -37,8 +37,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import cast
from ... import supervise as _supervise
from . import util as docker_mod
from . import supervise as _supervise
# Directory layout: ~/.bot-bottle/state/<identity>/...
@@ -82,6 +81,7 @@ def bottle_identity(agent_name: str) -> str:
To continue an existing bottle's state, use the recorded
identity from BottleMetadata via `cli.py resume <identity>`,
not this function."""
from .backend.docker import util as docker_mod
slug = docker_mod.slugify(agent_name)
suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN))
return f"{slug}-{suffix}"
+1 -1
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
import argparse
from ..backend import BottleSpec
from ..backend.docker.bottle_state import read_metadata
from ..bottle_state import read_metadata
from ..log import die
from ..manifest import Manifest
from ._common import PROG, USER_CWD
+4 -4
View File
@@ -24,12 +24,12 @@ from ..backend import (
known_backend_names,
)
from ..backend.docker.bottle_plan import DockerBottlePlan
from ..backend.docker.bottle_state import (
from ..bottle_state import (
cleanup_state,
is_preserved,
mark_preserved,
)
from ..backend.docker.capability_apply import snapshot_transcript
# from ..backend.docker.capability_apply import snapshot_transcript
from ..log import info
from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line
@@ -39,7 +39,7 @@ from . import tui
def cmd_start(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
parser.add_argument("--remote-control", action="store_true")
parser.add_argument(
"--backend",
@@ -172,7 +172,7 @@ def capture_claude_session_state(identity: str, exit_code: int) -> None:
# instead of relying on each agent's transcript layout.
if not identity:
return
snapshot_transcript(identity)
# snapshot_transcript(identity)
if exit_code != 0:
mark_preserved(identity)
+21 -17
View File
@@ -20,12 +20,17 @@ from datetime import datetime, timezone
from pathlib import Path
from .. import supervise as _supervise
from ..backend.docker.bottle_state import read_metadata
from ..backend.docker.capability_apply import (
CapabilityApplyError,
apply_capability_change,
)
# from ..bottle_state import read_metadata
# from ..backend.docker.capability_apply import (
# CapabilityApplyError,
# apply_capability_change,
# )
from ..log import Die, error, info
class CapabilityApplyError(RuntimeError):
"""Placeholder while capability_apply is disabled."""
from ..supervise import (
COMPONENT_FOR_TOOL,
AuditEntry,
@@ -124,20 +129,19 @@ def approve(
) -> None:
"""Apply the proposal, write the waiting response, and audit it."""
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 = "", ""
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
_meta = read_metadata(qp.proposal.bottle_slug)
if _meta is not None and not _meta.compose_project:
raise CapabilityApplyError(
"capability-block remediation is not supported for smolmachines "
"bottles. Reject this proposal or handle the capability change "
"manually, then restart the bottle."
)
diff_before, diff_after = apply_capability_change(
qp.proposal.bottle_slug, file_to_apply,
)
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
# _meta = read_metadata(qp.proposal.bottle_slug)
# if _meta is not None and not _meta.compose_project:
# raise CapabilityApplyError(
# "capability-block remediation is not supported for smolmachines "
# "bottles. Reject this proposal or handle the capability change "
# "manually, then restart the bottle."
# )
# diff_before, diff_after = apply_capability_change(
# qp.proposal.bottle_slug, file_to_apply,
# )
response = Response(
proposal_id=qp.proposal.id,
+6 -4
View File
@@ -28,8 +28,6 @@ if TYPE_CHECKING:
from ...backend import Bottle, BottlePlan
_REPO_ROOT = Path(__file__).resolve().parents[3]
_SUPERVISE_MCP_NAME = "supervise"
@@ -44,7 +42,6 @@ _RUNTIME = AgentProviderRuntime(
template="claude",
command="claude",
image="bot-bottle-claude:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",),
@@ -62,7 +59,8 @@ class ClaudeAgentProvider(AgentProvider):
*,
dockerfile: str,
state_dir: Path,
guest_home: str,
instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
@@ -73,6 +71,7 @@ class ClaudeAgentProvider(AgentProvider):
) -> AgentProvisionPlan:
del forward_host_credentials, host_env # Codex-only knobs
resolved_guest_env = dict(guest_env or {})
guest_home = self.guest_home
trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = {
@@ -113,6 +112,9 @@ class ClaudeAgentProvider(AgentProvider):
prompt_mode=_RUNTIME.prompt_mode,
image=_RUNTIME.image,
dockerfile=dockerfile,
guest_home=guest_home,
instance_name=instance_name,
prompt_file=prompt_file,
env_vars=env_vars,
guest_env=resolved_guest_env,
files=files,
+6 -4
View File
@@ -32,8 +32,6 @@ if TYPE_CHECKING:
from ...backend import Bottle, BottlePlan
_REPO_ROOT = Path(__file__).resolve().parents[3]
_SUPERVISE_MCP_NAME = "supervise"
@@ -52,7 +50,6 @@ _RUNTIME = AgentProviderRuntime(
template="codex",
command="codex",
image="bot-bottle-codex:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"),
@@ -70,7 +67,8 @@ class CodexAgentProvider(AgentProvider):
*,
dockerfile: str,
state_dir: Path,
guest_home: str,
instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
@@ -81,6 +79,7 @@ class CodexAgentProvider(AgentProvider):
) -> AgentProvisionPlan:
del auth_token, label, color # Claude-only knobs
resolved_guest_env = dict(guest_env or {})
guest_home = self.guest_home
trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = {
@@ -150,6 +149,9 @@ class CodexAgentProvider(AgentProvider):
prompt_mode=_RUNTIME.prompt_mode,
image=_RUNTIME.image,
dockerfile=dockerfile,
guest_home=guest_home,
instance_name=instance_name,
prompt_file=prompt_file,
env_vars=env_vars,
guest_env=resolved_guest_env,
dirs=tuple(dirs),
+4 -4
View File
@@ -24,7 +24,7 @@ from .egress_addon_core import (
from .log import die
if TYPE_CHECKING:
from .manifest import Bottle
from .manifest import ManifestBottle
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
@@ -66,7 +66,7 @@ class EgressPlan:
def egress_manifest_routes(
bottle: Bottle,
bottle: ManifestBottle,
) -> tuple[EgressRoute, ...]:
out: list[EgressRoute] = []
for r in bottle.egress.routes:
@@ -98,7 +98,7 @@ def egress_manifest_routes(
def egress_routes_for_bottle(
bottle: Bottle,
bottle: ManifestBottle,
provider_routes: tuple[EgressRoute, ...] = (),
) -> tuple[EgressRoute, ...]:
manifest = egress_manifest_routes(bottle)
@@ -280,7 +280,7 @@ def egress_resolve_token_values(
class Egress(ABC):
def prepare(
self,
bottle: Bottle,
bottle: ManifestBottle,
slug: str,
stage_dir: Path,
provider_routes: tuple[EgressRoute, ...] = (),
+2 -1
View File
@@ -24,6 +24,7 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
is_git_push_request,
load_config,
match_route,
outbound_scan_headers,
scan_inbound,
scan_outbound,
)
@@ -159,7 +160,7 @@ class EgressAddon:
flow.request.pretty_host,
request_path,
query,
dict(flow.request.headers),
outbound_scan_headers(route, dict(flow.request.headers)),
body,
)
dlp_result = scan_outbound(route, scan_text, os.environ)
+22
View File
@@ -538,6 +538,27 @@ def build_outbound_scan_text(
return "\n".join(parts)
def outbound_scan_headers(
route: Route,
headers: typing.Mapping[str, str],
) -> dict[str, str]:
"""Return request headers that should be included in outbound DLP.
Routes that inject sidecar-owned auth always strip the agent's
Authorization header before forwarding. Scanning that header first
creates false positives for provider clients that insist on sending
their own bearer-shaped placeholder, while still not changing what
reaches the upstream.
"""
out: dict[str, str] = {}
skip_auth = bool(route.auth_scheme and route.token_env)
for name, value in headers.items():
if skip_auth and name.lower() == "authorization":
continue
out[name] = value
return out
def build_inbound_scan_text(
headers: typing.Mapping[str, str],
body: str,
@@ -644,6 +665,7 @@ __all__ = [
"load_config",
"load_routes",
"match_route",
"outbound_scan_headers",
"parse_config",
"parse_routes",
"scan_inbound",
+7 -7
View File
@@ -37,7 +37,7 @@ from dataclasses import dataclass
from pathlib import Path
from .log import info
from .manifest import Bottle, GitEntry
from .manifest import ManifestBottle, ManifestGitEntry
# Short network alias for git-gate inside the sidecar bundle. The
@@ -96,9 +96,9 @@ class GitGatePlan:
egress_network: str = ""
def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]:
def git_gate_upstreams_for_bottle(bottle: ManifestBottle) -> tuple[GitGateUpstream, ...]:
"""Lift each `bottle.git` entry into a GitGateUpstream. Unique-Name
validation already ran in `manifest.Bottle.from_dict`."""
validation already ran in `manifest.ManifestBottle.from_dict`."""
return tuple(
GitGateUpstream(
name=e.Name,
@@ -113,7 +113,7 @@ def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]
def git_gate_render_gitconfig(
entries: tuple[GitEntry, ...], gate_host: str, *, scheme: str = "git",
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
) -> str:
"""Render the agent's ~/.gitconfig content for git-gate
`insteadOf` rewrites. Pure host-side, no docker / smolvm;
@@ -361,7 +361,7 @@ exit 0
def _provision_dynamic_key(
entry: GitEntry,
entry: ManifestGitEntry,
slug: str,
stage_dir: Path,
) -> str:
@@ -402,7 +402,7 @@ def _provision_dynamic_key(
return str(key_file)
def revoke_git_gate_provisioned_keys(bottle: Bottle, stage_dir: Path) -> None:
def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) -> None:
"""Revoke all deploy keys provisioned for `bottle` during prepare.
Called at teardown after containers stop. Raises if any revocation
@@ -440,7 +440,7 @@ class GitGate(ABC):
start/stop lifecycle is backend-specific and lives on concrete
subclasses."""
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> GitGatePlan:
def prepare(self, bottle: ManifestBottle, slug: str, stage_dir: Path) -> GitGatePlan:
"""Compute the upstream table from `bottle.git` and write the
entrypoint, pre-receive hook, and access-hook scripts (mode
600) under `stage_dir`. Pure host-side, no docker subprocess.
+30 -30
View File
@@ -50,26 +50,26 @@ from pathlib import Path
from typing import Mapping
from .manifest_util import ManifestError, as_json_object
from .manifest_agent import Agent, AgentProvider
from .manifest_agent import ManifestAgent, ManifestAgentProvider
from .manifest_egress import (
EGRESS_AUTH_SCHEMES,
EgressConfig,
EgressRoute,
ManifestEgressConfig,
ManifestEgressRoute,
)
from .manifest_git import GitEntry, GitUser, parse_git_gate_config
from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config
from .manifest_schema import BOTTLE_KEYS
# Re-export everything that callers currently import from this module.
__all__ = [
"ManifestError",
"GitEntry",
"GitUser",
"AgentProvider",
"ManifestGitEntry",
"ManifestGitUser",
"ManifestAgentProvider",
"EGRESS_AUTH_SCHEMES",
"EgressRoute",
"EgressConfig",
"Agent",
"Bottle",
"ManifestEgressRoute",
"ManifestEgressConfig",
"ManifestAgent",
"ManifestBottle",
"Manifest",
]
@@ -86,16 +86,16 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
@dataclass(frozen=True)
class Bottle:
class ManifestBottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict)
agent_provider: AgentProvider = field(default_factory=AgentProvider)
git: tuple[GitEntry, ...] = ()
agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider)
git: tuple[ManifestGitEntry, ...] = ()
# Per-bottle git identity (issue #86). Empty default — bottles
# that don't set `git-gate.user:` in the manifest skip the
# `git config --global` step entirely. A bottle can declare a user
# identity without any git-gate.repos upstreams, and vice versa.
git_user: GitUser = field(default_factory=GitUser)
egress: EgressConfig = field(default_factory=EgressConfig)
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes MCP
# tools to the agent (egress-block, capability-block) plus mounts
@@ -105,7 +105,7 @@ class Bottle:
supervise: bool = False
@classmethod
def from_dict(cls, name: str, raw: object) -> "Bottle":
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
d = as_json_object(raw, f"bottle '{name}'")
if "runtime" in d:
@@ -157,22 +157,22 @@ class Bottle:
)
env[var] = value
git: tuple[GitEntry, ...] = ()
git_user = GitUser()
git: tuple[ManifestGitEntry, ...] = ()
git_user = ManifestGitUser()
git_raw = d.get("git-gate")
if git_raw is not None:
git, git_user = parse_git_gate_config(name, git_raw)
agent_provider = (
AgentProvider.from_dict(name, d["agent_provider"])
ManifestAgentProvider.from_dict(name, d["agent_provider"])
if "agent_provider" in d
else AgentProvider()
else ManifestAgentProvider()
)
egress = (
EgressConfig.from_dict(name, d["egress"])
ManifestEgressConfig.from_dict(name, d["egress"])
if "egress" in d
else EgressConfig()
else ManifestEgressConfig()
)
supervise_raw = d.get("supervise", False)
@@ -190,8 +190,8 @@ class Bottle:
@dataclass(frozen=True)
class Manifest:
bottles: Mapping[str, Bottle]
agents: Mapping[str, Agent]
bottles: Mapping[str, ManifestBottle]
agents: Mapping[str, ManifestAgent]
@classmethod
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
@@ -305,8 +305,8 @@ class Manifest:
bottles = resolve_bottles(raw_bottles)
bottle_names = set(bottles.keys())
agents: dict[str, Agent] = {
n: Agent.from_dict(n, a, bottle_names) for n, a in raw_agents.items()
agents: dict[str, ManifestAgent] = {
n: ManifestAgent.from_dict(n, a, bottle_names) for n, a in raw_agents.items()
}
return cls(bottles=bottles, agents=agents)
@@ -338,7 +338,7 @@ class Manifest:
)
raise ManifestError(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
def _effective_git_user(self, agent_name: str) -> GitUser:
def _effective_git_user(self, agent_name: str) -> ManifestGitUser:
"""Merge the agent's git.user over the referenced bottle's,
per-field, agent-wins-on-non-empty (issue #94). Same overlay
the `extends:` resolver applies between bottles
@@ -348,12 +348,12 @@ class Manifest:
over = agent.git_user
if over.is_empty():
return base
return GitUser(
return ManifestGitUser(
name=over.name or base.name,
email=over.email or base.email,
)
def bottle_for(self, agent_name: str) -> Bottle:
def bottle_for(self, agent_name: str) -> ManifestBottle:
"""Resolve the Bottle the named agent references, with the
agent's git.user overlaid on top. The validator guarantees both
lookups succeed for a manifest built via from_json_obj.
+8 -8
View File
@@ -7,12 +7,12 @@ from typing import cast
from .agent_provider import PROVIDER_TEMPLATES
from .manifest_util import ManifestError, as_json_object
from .manifest_git import GitUser
from .manifest_git import ManifestGitUser
from .manifest_schema import AGENT_MODEL_KEYS
@dataclass(frozen=True)
class AgentProvider:
class ManifestAgentProvider:
"""Provider/template for the agent process inside a bottle.
`template` selects a built-in launch/runtime contract. `dockerfile`
@@ -35,7 +35,7 @@ class AgentProvider:
forward_host_credentials: bool = False
@classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider":
def from_dict(cls, bottle_name: str, raw: object) -> "ManifestAgentProvider":
d = as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
for k in d:
if k not in {"template", "dockerfile", "auth_token", "forward_host_credentials"}:
@@ -98,7 +98,7 @@ class AgentProvider:
@dataclass(frozen=True)
class Agent:
class ManifestAgent:
bottle: str
skills: tuple[str, ...] = ()
prompt: str = ""
@@ -106,10 +106,10 @@ class Agent:
# bottle's git-gate.user per-field at `Manifest.bottle_for`. Only
# `user` is allowed at the agent level; `repos` stays bottle-only
# because it carries credentials and host trust.
git_user: GitUser = GitUser()
git_user: ManifestGitUser = ManifestGitUser()
@classmethod
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent":
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent":
d = as_json_object(raw, f"agent '{name}'")
unknown = set(d.keys()) - AGENT_MODEL_KEYS
if unknown:
@@ -164,7 +164,7 @@ class Agent:
# git-gate: agents may declare only `git-gate.user` (name/email).
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
git_user = GitUser()
git_user = ManifestGitUser()
git_raw = d.get("git-gate")
if git_raw is not None:
gd = as_json_object(git_raw, f"agent '{name}' git-gate")
@@ -177,6 +177,6 @@ class Agent:
f"(it carries credentials and host trust)."
)
if "user" in gd:
git_user = GitUser.from_dict(name, gd["user"])
git_user = ManifestGitUser.from_dict(name, gd["user"])
return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user)
+26 -26
View File
@@ -24,7 +24,7 @@ INBOUND_DETECTOR_NAMES = frozenset({"naive_injection_detection"})
def validate_egress_routes(
bottle_name: str,
routes: tuple[EgressRoute, ...],
routes: tuple[ManifestEgressRoute, ...],
) -> None:
seen_hosts: dict[str, None] = {}
for r in routes:
@@ -38,29 +38,29 @@ def validate_egress_routes(
@dataclass(frozen=True)
class PathMatch:
class ManifestPathMatch:
Type: str = "prefix"
Value: str = ""
@dataclass(frozen=True)
class HeaderMatch:
class ManifestHeaderMatch:
Name: str = ""
Value: str = ""
Type: str = "exact"
@dataclass(frozen=True)
class MatchEntry:
Paths: tuple[PathMatch, ...] = ()
class ManifestMatchEntry:
Paths: tuple[ManifestPathMatch, ...] = ()
Methods: tuple[str, ...] = ()
Headers: tuple[HeaderMatch, ...] = ()
Headers: tuple[ManifestHeaderMatch, ...] = ()
@dataclass(frozen=True)
class EgressRoute:
class ManifestEgressRoute:
Host: str
Matches: tuple[MatchEntry, ...] = ()
Matches: tuple[ManifestMatchEntry, ...] = ()
AuthScheme: str = ""
TokenRef: str = ""
Role: tuple[str, ...] = ()
@@ -68,7 +68,7 @@ class EgressRoute:
InboundDetectors: tuple[str, ...] | None = None
@classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute":
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "ManifestEgressRoute":
label = f"bottle '{bottle_name}' egress.routes[{idx}]"
d = as_json_object(raw, label)
host = d.get("host")
@@ -76,7 +76,7 @@ class EgressRoute:
raise ManifestError(f"{label} missing required string field 'host'")
# --- matches ---
matches: tuple[MatchEntry, ...] = ()
matches: tuple[ManifestMatchEntry, ...] = ()
matches_raw = d.get("matches")
if matches_raw is not None:
if not isinstance(matches_raw, list):
@@ -85,7 +85,7 @@ class EgressRoute:
f"(was {type(matches_raw).__name__})"
)
matches_list = cast(list[object], matches_raw)
entries: list[MatchEntry] = []
entries: list[ManifestMatchEntry] = []
for k, entry_raw in enumerate(matches_list):
entries.append(
_parse_match_entry(label, k, entry_raw)
@@ -185,17 +185,17 @@ class EgressRoute:
def _parse_match_entry(
route_label: str, k: int, raw: object,
) -> MatchEntry:
) -> ManifestMatchEntry:
label = f"{route_label} matches[{k}]"
d = as_json_object(raw, label)
paths: tuple[PathMatch, ...] = ()
paths: tuple[ManifestPathMatch, ...] = ()
paths_raw = d.get("paths")
if paths_raw is not None:
if not isinstance(paths_raw, list):
raise ManifestError(f"{label} paths must be an array")
paths_list = cast(list[object], paths_raw)
parsed_paths: list[PathMatch] = []
parsed_paths: list[ManifestPathMatch] = []
for j, p_raw in enumerate(paths_list):
parsed_paths.append(_parse_path_match(label, j, p_raw))
paths = tuple(parsed_paths)
@@ -220,13 +220,13 @@ def _parse_match_entry(
normalised.append(upper)
methods = tuple(normalised)
headers: tuple[HeaderMatch, ...] = ()
headers: tuple[ManifestHeaderMatch, ...] = ()
headers_raw = d.get("headers")
if headers_raw is not None:
if not isinstance(headers_raw, list):
raise ManifestError(f"{label} headers must be an array")
headers_list = cast(list[object], headers_raw)
parsed_headers: list[HeaderMatch] = []
parsed_headers: list[ManifestHeaderMatch] = []
for j, h_raw in enumerate(headers_list):
parsed_headers.append(_parse_header_match(label, j, h_raw))
headers = tuple(parsed_headers)
@@ -235,12 +235,12 @@ def _parse_match_entry(
if key not in ("paths", "methods", "headers"):
raise ManifestError(f"{label} has unknown key {key!r}")
return MatchEntry(Paths=paths, Methods=methods, Headers=headers)
return ManifestMatchEntry(Paths=paths, Methods=methods, Headers=headers)
def _parse_path_match(
entry_label: str, j: int, raw: object,
) -> PathMatch:
) -> ManifestPathMatch:
label = f"{entry_label} paths[{j}]"
d = as_json_object(raw, label)
ptype = d.get("type", "prefix")
@@ -266,12 +266,12 @@ def _parse_path_match(
for k in d:
if k not in ("type", "value"):
raise ManifestError(f"{label} has unknown key {k!r}")
return PathMatch(Type=ptype, Value=value)
return ManifestPathMatch(Type=ptype, Value=value)
def _parse_header_match(
entry_label: str, j: int, raw: object,
) -> HeaderMatch:
) -> ManifestHeaderMatch:
label = f"{entry_label} headers[{j}]"
d = as_json_object(raw, label)
name = d.get("name")
@@ -296,7 +296,7 @@ def _parse_header_match(
for k in d:
if k not in ("name", "value", "type"):
raise ManifestError(f"{label} has unknown key {k!r}")
return HeaderMatch(Name=name, Value=value, Type=htype)
return ManifestHeaderMatch(Name=name, Value=value, Type=htype)
def _parse_dlp_block(
@@ -350,15 +350,15 @@ LOG_LEVELS = frozenset({0, 1, 2})
@dataclass(frozen=True)
class EgressConfig:
routes: tuple[EgressRoute, ...] = ()
class ManifestEgressConfig:
routes: tuple[ManifestEgressRoute, ...] = ()
Log: int = 0
@classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig":
def from_dict(cls, bottle_name: str, raw: object) -> "ManifestEgressConfig":
d = as_json_object(raw, f"bottle '{bottle_name}' egress")
routes_raw = d.get("routes")
routes: tuple[EgressRoute, ...] = ()
routes: tuple[ManifestEgressRoute, ...] = ()
if routes_raw is not None:
if not isinstance(routes_raw, list):
raise ManifestError(
@@ -367,7 +367,7 @@ class EgressConfig:
)
routes_list = cast(list[object], routes_raw)
routes = tuple(
EgressRoute.from_dict(bottle_name, i, entry)
ManifestEgressRoute.from_dict(bottle_name, i, entry)
for i, entry in enumerate(routes_list)
)
validate_egress_routes(bottle_name, routes)
+21 -21
View File
@@ -5,12 +5,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .manifest import Bottle, GitEntry
from .manifest import ManifestBottle, ManifestGitEntry
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, Bottle]:
"""Apply `extends:` chains and return resolved Bottle objects."""
cache: dict[str, Bottle] = {}
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
"""Apply `extends:` chains and return resolved ManifestBottle objects."""
cache: dict[str, ManifestBottle] = {}
for name in raws:
if name not in cache:
_resolve_one_bottle(name, raws, cache, ())
@@ -20,10 +20,10 @@ def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, Bottle]:
def _resolve_one_bottle(
name: str,
raws: dict[str, dict[str, object]],
cache: dict[str, Bottle],
cache: dict[str, ManifestBottle],
seen: tuple[str, ...],
) -> Bottle:
from .manifest import Bottle, ManifestError
) -> ManifestBottle:
from .manifest import ManifestBottle, ManifestError
if name in cache:
return cache[name]
@@ -32,13 +32,13 @@ def _resolve_one_bottle(
raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}")
raw = raws[name]
parent_name_raw = raw.get("extends")
# Strip `extends:` before passing to Bottle.from_dict so it
# is not accidentally treated as a real Bottle field by future
# Strip `extends:` before passing to ManifestBottle.from_dict so it
# is not accidentally treated as a real ManifestBottle field by future
# schema additions. It is only meaningful here.
child_raw = {k: v for k, v in raw.items() if k != "extends"}
if parent_name_raw is None:
bottle = Bottle.from_dict(name, child_raw)
bottle = ManifestBottle.from_dict(name, child_raw)
cache[name] = bottle
return bottle
@@ -66,27 +66,27 @@ def _resolve_one_bottle(
def _merge_bottles(
parent: Bottle,
parent: ManifestBottle,
child_raw: dict[str, object],
name: str,
) -> Bottle:
) -> ManifestBottle:
"""Apply PRD 0025 merge rules."""
from .manifest import Bottle, GitUser
from .manifest import ManifestBottle, ManifestGitUser
from .manifest_egress import validate_egress_routes
# Parse the child's declared fields into a Bottle (with the
# Parse the child's declared fields into a ManifestBottle (with the
# usual defaults for anything missing). Validation runs the same
# way it would for a leaf bottle: typos / wrong types die here.
child = Bottle.from_dict(name, child_raw)
child = ManifestBottle.from_dict(name, child_raw)
# env: dict merge, child wins on collision.
merged_env = {**parent.env, **child.env}
# git-gate.user: per-field overlay. Each non-empty field on child
# wins; empties fall through to parent. The default GitUser()
# wins; empties fall through to parent. The default ManifestGitUser()
# is two empty strings, so a child that omits git-gate.user
# inherits the parent's user verbatim.
merged_git_user = GitUser(
merged_git_user = ManifestGitUser(
name=child.git_user.name or parent.git_user.name,
email=child.git_user.email or parent.git_user.email,
)
@@ -112,7 +112,7 @@ def _merge_bottles(
)
validate_egress_routes(name, merged_egress.routes)
return Bottle(
return ManifestBottle(
env=merged_env,
agent_provider=merged_agent_provider,
git=merged_git,
@@ -133,9 +133,9 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
def _merge_git_remotes(
parent: tuple[GitEntry, ...],
child: tuple[GitEntry, ...],
) -> tuple[GitEntry, ...]:
parent: tuple[ManifestGitEntry, ...],
child: tuple[ManifestGitEntry, ...],
) -> tuple[ManifestGitEntry, ...]:
by_host = {entry.UpstreamHost: entry for entry in parent}
for entry in child:
by_host[entry.UpstreamHost] = entry
+15 -15
View File
@@ -57,7 +57,7 @@ def parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
return (user, host, port, path)
def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None:
def validate_unique_git_names(bottle_name: str, git: tuple[ManifestGitEntry, ...]) -> None:
seen: dict[str, None] = {}
for g in git:
if g.Name in seen:
@@ -69,7 +69,7 @@ def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> No
@dataclass(frozen=True)
class ProvisionedKeyConfig:
class ManifestProvisionedKeyConfig:
"""Configuration for automatic deploy-key lifecycle management
(PRD 0048). Used when a git-gate.repos entry opts out of a
static identity file and instead wants a fresh SSH keypair
@@ -87,7 +87,7 @@ class ProvisionedKeyConfig:
@dataclass(frozen=True)
class GitEntry:
class ManifestGitEntry:
"""One upstream the per-agent git-gate (PRD 0008) is allowed to
talk to. `Upstream` is the real remote URL the agent would push to
if there were no gate; the gate hosts a bare repo at /git/<Name>.git
@@ -107,7 +107,7 @@ class GitEntry:
Upstream: str
IdentityFile: str = ""
KnownHostKey: str = ""
ProvisionedKey: Optional[ProvisionedKeyConfig] = None
ProvisionedKey: Optional[ManifestProvisionedKeyConfig] = None
RemoteKey: str = ""
UpstreamUser: str = ""
UpstreamHost: str = ""
@@ -117,7 +117,7 @@ class GitEntry:
@classmethod
def from_repos_entry(
cls, bottle_name: str, repo_name: str, raw: object
) -> "GitEntry":
) -> "ManifestGitEntry":
"""Parse one entry from `git-gate.repos.<repo_name>`.
YAML keys: `url` (required), exactly one of `identity` or
@@ -160,7 +160,7 @@ class GitEntry:
)
ident = ""
provisioned_key: Optional[ProvisionedKeyConfig] = None
provisioned_key: Optional[ManifestProvisionedKeyConfig] = None
if has_identity:
raw_ident = d.get("identity")
if not isinstance(raw_ident, str) or not raw_ident:
@@ -196,7 +196,7 @@ class GitEntry:
def _parse_provisioned_key_config(
bottle_name: str, label: str, raw: object
) -> ProvisionedKeyConfig:
) -> ManifestProvisionedKeyConfig:
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key")
for k in d:
if k not in {"provider", "token_env", "api_url"}:
@@ -221,7 +221,7 @@ def _parse_provisioned_key_config(
raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
)
return ProvisionedKeyConfig(
return ManifestProvisionedKeyConfig(
provider=provider,
token_env=token_env,
api_url=api_url_raw,
@@ -229,7 +229,7 @@ def _parse_provisioned_key_config(
@dataclass(frozen=True)
class GitUser:
class ManifestGitUser:
"""Per-bottle `git config --global user.name` / `user.email`
pair (issue #86). The agent's commits inside the bottle are
attributed to this identity rather than the agent image's
@@ -244,7 +244,7 @@ class GitUser:
email: str = ""
@classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "GitUser":
def from_dict(cls, bottle_name: str, raw: object) -> "ManifestGitUser":
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user")
for k in d:
if k not in {"name", "email"}:
@@ -279,7 +279,7 @@ class GitUser:
def parse_git_gate_config(
bottle_name: str,
raw: object,
) -> tuple[tuple[GitEntry, ...], GitUser]:
) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
for k in d:
if k not in {"user", "repos"}:
@@ -289,17 +289,17 @@ def parse_git_gate_config(
)
git_user = (
GitUser.from_dict(bottle_name, d["user"])
ManifestGitUser.from_dict(bottle_name, d["user"])
if "user" in d
else GitUser()
else ManifestGitUser()
)
git: tuple[GitEntry, ...] = ()
git: tuple[ManifestGitEntry, ...] = ()
repos_raw = d.get("repos")
if repos_raw is not None:
repos = as_json_object(repos_raw, f"bottle '{bottle_name}' git-gate.repos")
git = tuple(
GitEntry.from_repos_entry(bottle_name, name, entry)
ManifestGitEntry.from_repos_entry(bottle_name, name, entry)
for name, entry in repos.items()
)
validate_unique_git_names(bottle_name, git)
+6 -6
View File
@@ -14,7 +14,7 @@ from .manifest_schema import (
from .yaml_subset import YamlSubsetError, parse_frontmatter
if TYPE_CHECKING:
from .manifest import Agent, Bottle
from .manifest import ManifestAgent, ManifestBottle
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
@@ -34,7 +34,7 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
)
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, Bottle]:
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError
@@ -67,13 +67,13 @@ def load_agents_from_dir(
bottle_names: set[str],
*,
source: str, # noqa: F841 — unused, but required by interface
) -> dict[str, Agent]:
) -> dict[str, ManifestAgent]:
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict."""
from .manifest import Agent, ManifestError
from .manifest import ManifestAgent, ManifestError
out: dict[str, Agent] = {}
out: dict[str, ManifestAgent] = {}
if not agents_dir.is_dir():
return out
for path in sorted(agents_dir.glob("*.md")):
@@ -101,5 +101,5 @@ def load_agents_from_dir(
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
out[name] = Agent.from_dict(name, agent_dict, bottle_names)
out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names)
return out
-5
View File
@@ -465,8 +465,6 @@ class Supervise(ABC):
self,
slug: str,
stage_dir: Path,
*,
dockerfile_content: str = "",
) -> SupervisePlan:
"""Stage the per-bottle queue dir on the host and the
current-config dir under `stage_dir`. Returns the plan;
@@ -476,9 +474,6 @@ class Supervise(ABC):
queue_dir.mkdir(parents=True, exist_ok=True)
current_config_dir = stage_dir / "current-config"
current_config_dir.mkdir(parents=True, exist_ok=True)
dockerfile_path = current_config_dir / CURRENT_CONFIG_DOCKERFILE
dockerfile_path.write_text(dockerfile_content)
dockerfile_path.chmod(0o644)
return SupervisePlan(
slug=slug,
queue_dir=queue_dir,
+1 -1
View File
@@ -35,5 +35,5 @@ chmod 600 "$fake_key_dir/fake-key"
# Build the image graph quietly so the recorded run shows only the
# bottle launch and the four `!` probes, not BuildKit progress.
docker build -q -f Dockerfile.claude -t bot-bottle-claude:latest . >/dev/null 2>&1 || true
docker build -q -f bot_bottle/contrib/claude/Dockerfile -t bot-bottle-claude:latest . >/dev/null 2>&1 || true
docker build -q -f Dockerfile.git-gate -t bot-bottle-git-gate:latest . >/dev/null 2>&1 || true
-219
View File
@@ -1,219 +0,0 @@
"""Integration: drive `apply_capability_change` against a real
container that mimics the agent's name + filesystem layout (PRD 0016).
The real `cli.py start <agent>` flow is too heavy for an integration
test (it builds the agent image, brings up all the sidecars, attaches
an interactive agent session). Instead, this test stages the
minimum the orchestrator interacts with:
- A lightweight `alpine:latest sleep infinity` container named
`bot-bottle-<slug>` (matches the agent container name pattern)
on the per-bottle internal network.
- A marker file under `/home/node/.claude/` so we can assert the
transcript snapshot path actually transferred bytes.
Then `apply_capability_change` runs and we verify:
- Per-bottle Dockerfile written.
- Containers + networks removed.
- Transcript snapshot dir on the host has the marker file.
docker exec / cp / rm work across the docker socket boundary, so
this test runs in DinD too no act_runner skip needed.
"""
from __future__ import annotations
import os
import subprocess
import tempfile
import time
import unittest
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.backend.docker import bottle_state
from bot_bottle.backend.docker.capability_apply import apply_capability_change
from bot_bottle.backend.docker.network import (
network_create_egress,
network_create_internal,
network_remove,
)
from bot_bottle.backend.docker.sidecar_bundle import (
sidecar_bundle_container_name,
)
from tests._docker import skip_unless_docker
ALPINE_IMAGE = "alpine:latest"
@skip_unless_docker()
class TestCapabilityApply(unittest.TestCase):
@classmethod
def setUpClass(cls):
r = subprocess.run(
["docker", "pull", ALPINE_IMAGE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
if r.returncode != 0:
raise unittest.SkipTest(f"could not pull {ALPINE_IMAGE}")
def setUp(self):
self.slug = f"cb-test-cap-{os.getpid()}-{int(time.time())}"
self.agent_name = f"bot-bottle-{self.slug}"
self.sidecar_names: list[str] = []
self.internal_net = ""
self.egress_net = ""
# Fake home so tests don't touch ~/.bot-bottle/.
self._tmp = tempfile.TemporaryDirectory(prefix="cap-apply-int.")
self._original_root = supervise.bot_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
def tearDown(self):
supervise.bot_bottle_root = self._original_root # type: ignore[assignment]
for name in [self.agent_name, *self.sidecar_names]:
subprocess.run(
["docker", "rm", "-f", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
)
for n in (self.internal_net, self.egress_net):
if n:
network_remove(n)
self._tmp.cleanup()
def _bring_up_fake_bottle(self) -> None:
self.internal_net = network_create_internal(self.slug)
self.egress_net = network_create_egress(self.slug)
# Agent container with the canonical name.
r = subprocess.run(
[
"docker", "run", "-d",
"--name", self.agent_name,
"--network", self.internal_net,
ALPINE_IMAGE,
"sh", "-c",
"mkdir -p /home/node/.claude && "
"echo 'transcript-marker' > /home/node/.claude/sessions.json && "
"sleep 3600",
],
capture_output=True, text=True, check=False,
)
self.assertEqual(0, r.returncode, r.stderr)
# Also start a fake sidecar bundle so teardown has something
# extra to clean up (mirrors a real bottle's container set).
sidecar = sidecar_bundle_container_name(self.slug)
subprocess.run(
[
"docker", "run", "-d",
"--name", sidecar,
"--network", self.internal_net,
ALPINE_IMAGE, "sleep", "3600",
],
capture_output=True, text=True, check=False,
)
self.sidecar_names.append(sidecar)
def _containers_named_like(self) -> list[str]:
"""All running/stopped containers whose names start with
the bottle's slug — both agent + sidecars."""
r = subprocess.run(
[
"docker", "ps", "-a",
"--filter", f"name={self.agent_name}",
"--format", "{{.Names}}",
],
capture_output=True, text=True, check=False,
)
return [line for line in (r.stdout or "").splitlines() if line]
def _networks_named_like(self) -> list[str]:
r = subprocess.run(
[
"docker", "network", "ls",
"--filter", f"name={self.slug}",
"--format", "{{.Name}}",
],
capture_output=True, text=True, check=False,
)
return [line for line in (r.stdout or "").splitlines() if line]
def test_apply_writes_dockerfile_and_tears_down(self):
self._bring_up_fake_bottle()
self.assertIn(self.agent_name, self._containers_named_like())
new_dockerfile = "FROM python:3.13\nRUN apk add ripgrep\n"
before, after = apply_capability_change(self.slug, new_dockerfile)
# Before is the repo Dockerfile (no prior per-bottle override);
# after is what we passed in.
self.assertIn("FROM ", before)
self.assertEqual(new_dockerfile, after)
# Per-bottle Dockerfile written on the host.
self.assertEqual(
new_dockerfile,
bottle_state.per_bottle_dockerfile(self.slug),
)
# Agent + sidecars gone.
self.assertEqual([], self._containers_named_like())
# Networks removed (matching the slug substring).
nets = self._networks_named_like()
self.assertEqual([], nets)
# Mark them as already cleaned so tearDown is idempotent.
self.internal_net = ""
self.egress_net = ""
self.sidecar_names = []
def test_transcript_snapshot_captured(self):
self._bring_up_fake_bottle()
apply_capability_change(self.slug, "FROM x\n")
snap = bottle_state.transcript_snapshot_dir(self.slug)
self.assertTrue(snap.is_dir(), f"transcript snapshot dir {snap} missing")
# docker cp <container>:/home/node/.claude <dst> produces
# <dst>/.claude/sessions.json (it preserves the source dir name
# inside the destination if the destination already exists).
# Walk the snapshot looking for the marker contents.
marker_found = False
for path in snap.rglob("sessions.json"):
if "transcript-marker" in path.read_text():
marker_found = True
break
self.assertTrue(marker_found, f"marker not found under {snap}")
# Cleaned up by apply already.
self.internal_net = ""
self.egress_net = ""
self.sidecar_names = []
def test_subsequent_apply_uses_per_bottle_dockerfile_for_before(self):
# First change: before is repo's Dockerfile.
self._bring_up_fake_bottle()
first_before, _ = apply_capability_change(self.slug, "FROM v1\n")
self.assertIn("FROM ", first_before)
# Second change: before is "FROM v1\n" (the per-bottle override
# from the first change), proving the state persists across
# rebuilds.
self._bring_up_fake_bottle()
second_before, second_after = apply_capability_change(self.slug, "FROM v2\n")
self.assertEqual("FROM v1\n", second_before)
self.assertEqual("FROM v2\n", second_after)
self.internal_net = ""
self.egress_net = ""
self.sidecar_names = []
def test_teardown_idempotent_when_nothing_running(self):
# No bottle ever brought up — teardown still doesn't raise.
apply_capability_change(self.slug, "FROM x\n")
self.assertEqual(
"FROM x\n",
bottle_state.per_bottle_dockerfile(self.slug),
)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -29,7 +29,7 @@ import unittest
from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.backend.docker.bottle_state import cleanup_state
from bot_bottle.bottle_state import cleanup_state
from bot_bottle.manifest import Manifest
from tests._docker import skip_unless_docker
+31 -21
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from bot_bottle.agent_provider import (
CODEX_HOST_CREDENTIAL_HOSTS,
agent_provision_plan,
build_agent_provision_plan,
)
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
@@ -25,11 +25,12 @@ def _jwt(exp: int) -> str:
class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_plan_declares_home_state(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="/tmp/Dockerfile.codex",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
)
config = Path(tmp, "codex-config.toml").read_text()
self.assertEqual("codex", plan.template)
@@ -50,11 +51,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan(
guest_home="/home/node",
build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
trusted_project_path="/home/node/workspace",
)
config = Path(tmp, "codex-config.toml").read_text()
@@ -68,11 +70,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(2000000000)},
}))
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
guest_env={"CODEX_HOME": "/run/codex-home"},
forward_host_credentials=True,
host_env={"CODEX_HOME": str(home)},
@@ -88,11 +91,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="claude",
dockerfile="/tmp/Dockerfile.claude",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN",
)
claude_config = json.loads(Path(tmp, "claude.json").read_text())
@@ -110,11 +114,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan(
guest_home="/home/node",
build_agent_provision_plan(
template="claude",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
trusted_project_path="/home/node/workspace",
)
config = json.loads(Path(tmp, "claude.json").read_text())
@@ -129,11 +134,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(2000000000)},
}))
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=True,
host_env={"CODEX_HOME": str(home)},
)
@@ -145,11 +151,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=False,
)
self.assertEqual(
@@ -162,11 +169,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_without_auth_token_has_passthrough_egress_route(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="claude",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
)
self.assertEqual(1, len(plan.egress_routes))
route = plan.egress_routes[0]
@@ -185,11 +193,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt",
"tokens": {"access_token": access},
}))
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=True,
host_env={"CODEX_HOME": str(home)},
)
@@ -200,11 +209,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=False,
)
self.assertEqual({}, plan.provisioned_env)
+119
View File
@@ -0,0 +1,119 @@
"""Unit: shared backend prepare wiring.
These tests keep the base `BottleBackend.prepare` template honest:
backend-specific preflight/env hooks must be wired through, and launch
metadata must record the backend that actually prepared the plan.
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from bot_bottle import bottle_state
from bot_bottle import supervise
from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import Manifest
def _manifest() -> Manifest:
return Manifest.from_json_obj({
"bottles": {
"dev": {
"env": {
"LITERAL_ENV": "literal-value",
"FORWARDED_ENV": "${HOST_SECRET_ENV}",
},
},
},
"agents": {
"demo": {
"bottle": "dev",
"skills": [],
"prompt": "hello",
},
},
})
def _spec(tmp: Path, *, identity: str) -> BottleSpec:
return BottleSpec(
manifest=_manifest(),
agent_name="demo",
copy_cwd=False,
user_cwd=str(tmp),
identity=identity,
)
class _FakeStateMixin:
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.")
self.root = Path(self.tmp.name) / ".bot-bottle"
self.original_root = supervise.bot_bottle_root
supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment]
def tearDown(self) -> None:
supervise.bot_bottle_root = self.original_root # type: ignore[assignment]
self.tmp.cleanup()
class TestDockerPrepare(_FakeStateMixin, unittest.TestCase):
def test_records_backend_and_preserves_env_split(self) -> None:
backend = DockerBottleBackend()
spec = _spec(Path(self.tmp.name), identity="demo-docker")
with (
patch.dict("os.environ", {"HOST_SECRET_ENV": "secret-value"}),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker",
) as require_docker,
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(spec, Path(self.tmp.name) / "stage")
require_docker.assert_called_once_with()
metadata = bottle_state.read_metadata("demo-docker")
self.assertIsNotNone(metadata)
assert metadata is not None
self.assertEqual("docker", metadata.backend)
self.assertEqual({"FORWARDED_ENV": "secret-value"}, plan.forwarded_env)
self.assertEqual("literal-value", plan.agent_provision.guest_env["LITERAL_ENV"])
self.assertNotIn("FORWARDED_ENV", plan.agent_provision.guest_env)
class TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase):
def test_records_backend_and_builds_guest_env(self) -> None:
backend = SmolmachinesBottleBackend()
spec = _spec(Path(self.tmp.name), identity="demo-smol")
with (
patch.dict("os.environ", {"HOST_SECRET_ENV": "secret-value"}),
patch(
"bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight",
) as preflight,
):
plan = backend.prepare(spec, Path(self.tmp.name) / "stage")
preflight.assert_called_once_with()
metadata = bottle_state.read_metadata("demo-smol")
self.assertIsNotNone(metadata)
assert metadata is not None
self.assertEqual("smolmachines", metadata.backend)
self.assertEqual("literal-value", plan.guest_env["LITERAL_ENV"])
self.assertEqual("secret-value", plan.guest_env["FORWARDED_ENV"])
self.assertEqual(
"/etc/ssl/certs/ca-certificates.crt",
plan.guest_env["SSL_CERT_FILE"],
)
if __name__ == "__main__":
unittest.main()
+157
View File
@@ -0,0 +1,157 @@
"""Unit: runtime workspace provisioning.
Workspace copy is intentionally handled through
`BottleBackend.provision_workspace` against a running bottle. The
Docker derived-image workspace path stays disabled.
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
from bot_bottle import bottle_state
from bot_bottle import supervise
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import Manifest
def _manifest() -> Manifest:
return Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {
"demo": {
"bottle": "dev",
"skills": [],
"prompt": "",
},
},
})
def _spec(tmp: Path, *, copy_cwd: bool = True, identity: str = "demo-work") -> BottleSpec:
return BottleSpec(
manifest=_manifest(),
agent_name="demo",
copy_cwd=copy_cwd,
user_cwd=str(tmp),
identity=identity,
)
def _bottle() -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = "bot-bottle-demo-work"
bottle.exec.return_value = ExecResult(returncode=0, stdout="", stderr="")
return bottle
class _FakeStateMixin:
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.")
self.tmp = Path(self.tmp_dir.name)
self.root = self.tmp / ".bot-bottle"
self.original_root = supervise.bot_bottle_root
supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment]
def tearDown(self) -> None:
supervise.bot_bottle_root = self.original_root # type: ignore[assignment]
self.tmp_dir.cleanup()
class TestRuntimeWorkspaceProvisioning(_FakeStateMixin, unittest.TestCase):
def test_default_backend_method_copies_workspace_to_running_bottle(self) -> None:
(self.tmp / "src.txt").write_text("hello\n")
(self.tmp / ".git").mkdir()
backend = DockerBottleBackend()
with (
patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(_spec(self.tmp), self.tmp / "stage")
bottle = _bottle()
backend.provision_workspace(plan, bottle)
self.assertEqual(
[
call(
"rm -rf /home/node/workspace && mkdir -p /home/node",
user="root",
),
call(
"chown -R node:node /home/node/workspace && "
"chmod 755 /home/node/workspace",
user="root",
),
],
bottle.exec.call_args_list,
)
bottle.cp_in.assert_called_once_with(str(self.tmp), "/home/node/workspace")
def test_default_backend_method_noops_without_copy_cwd(self) -> None:
backend = DockerBottleBackend()
with (
patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(_spec(self.tmp, copy_cwd=False), self.tmp / "stage")
bottle = _bottle()
backend.provision_workspace(plan, bottle)
bottle.exec.assert_not_called()
bottle.cp_in.assert_not_called()
def test_smolmachines_uses_same_running_bottle_method(self) -> None:
backend = SmolmachinesBottleBackend()
with patch(
"bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight",
):
plan = backend.prepare(
_spec(self.tmp, identity="demo-smol-work"),
self.tmp / "stage",
)
bottle = _bottle()
backend.provision_workspace(plan, bottle)
bottle.cp_in.assert_called_once_with(str(self.tmp), "/home/node/workspace")
metadata = bottle_state.read_metadata("demo-smol-work")
self.assertIsNotNone(metadata)
assert metadata is not None
self.assertEqual("smolmachines", metadata.backend)
class TestWorkspaceTrustPath(_FakeStateMixin, unittest.TestCase):
def test_prepare_trusts_workspace_path_when_copying_cwd(self) -> None:
backend = DockerBottleBackend()
with (
patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(_spec(self.tmp), self.tmp / "stage")
claude_config = self.root / "state" / "demo-work" / "agent" / "claude.json"
config = claude_config.read_text()
self.assertIn('"/home/node/workspace"', config)
self.assertEqual("/home/node/workspace", plan.workspace_plan.workdir)
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -7,8 +7,8 @@ import unittest
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.backend.docker import bottle_state
from bot_bottle.backend.docker.bottle_state import (
from bot_bottle import bottle_state
from bot_bottle.bottle_state import (
BottleMetadata,
read_metadata,
write_metadata,
@@ -260,7 +260,7 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
def test_missing_backend_field_defaults_to_empty(self):
# Old state dirs written before PRD 0040 have no backend key.
import json
from bot_bottle.backend.docker import bottle_state as bs
from bot_bottle import bottle_state as bs
path = bs.metadata_path("dev-b3")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({
-131
View File
@@ -1,131 +0,0 @@
"""Unit: capability_apply helpers (PRD 0016 Phase 2).
docker cp / exec / rm / network rm paths are covered by the
integration test in Phase 4. Here we cover:
- fetch_current_dockerfile fallback chain (per-bottle repo)
- apply_capability_change writes the per-bottle Dockerfile and
returns the correct (before, after).
- apply_capability_change rejects empty input.
"""
import tempfile
import unittest
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.backend.docker import bottle_state, capability_apply
from bot_bottle.backend.docker.capability_apply import (
CapabilityApplyError,
apply_capability_change,
fetch_current_dockerfile,
)
class _FakeHomeMixin:
def _setup_fake_home(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cap-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._restore = lambda: setattr(supervise, "bot_bottle_root", original)
def _teardown_fake_home(self):
self._restore()
self._tmp.cleanup()
class TestFetchCurrentDockerfile(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
def tearDown(self):
self._teardown_fake_home()
def test_returns_per_bottle_dockerfile_when_present(self):
bottle_state.write_per_bottle_dockerfile("dev", "FROM rebuilt\n")
self.assertEqual("FROM rebuilt\n", fetch_current_dockerfile("dev"))
def test_falls_back_to_repo_dockerfile_when_no_override(self):
# The repo's Dockerfile actually exists; the test just checks
# we get its content (non-empty) when no per-bottle override
# is set.
content = fetch_current_dockerfile("dev-no-override")
self.assertIn("FROM ", content)
class TestApplyCapabilityChange(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
# Stub out the docker-dependent helpers. The orchestrator's
# job is to sequence write + snapshot + push + teardown; we
# validate that sequence here, not the docker primitives.
self._calls: list[str] = []
self._orig_snapshot = capability_apply.snapshot_transcript
self._orig_push = capability_apply._push_working_tree
self._orig_teardown = capability_apply._teardown_bottle
def stub_snapshot(slug: object) -> None: # type: ignore
self._calls.append(f"snapshot:{slug}")
def stub_push(slug: object) -> None: # type: ignore
self._calls.append(f"push:{slug}")
def stub_teardown(slug: object) -> None: # type: ignore
self._calls.append(f"teardown:{slug}")
capability_apply.snapshot_transcript = stub_snapshot # type: ignore[assignment]
capability_apply._push_working_tree = stub_push # type: ignore[assignment]
capability_apply._teardown_bottle = stub_teardown # type: ignore[assignment]
def tearDown(self):
capability_apply.snapshot_transcript = self._orig_snapshot # type: ignore[assignment]
capability_apply._push_working_tree = self._orig_push # type: ignore[assignment]
capability_apply._teardown_bottle = self._orig_teardown # type: ignore[assignment]
self._teardown_fake_home()
def test_writes_per_bottle_dockerfile_and_returns_before_after(self):
bottle_state.write_per_bottle_dockerfile("dev", "FROM old\n")
before, after = apply_capability_change("dev", "FROM new\nRUN apk add ripgrep\n")
self.assertEqual("FROM old\n", before)
self.assertEqual("FROM new\nRUN apk add ripgrep\n", after)
self.assertEqual(
"FROM new\nRUN apk add ripgrep\n",
bottle_state.per_bottle_dockerfile("dev"),
)
def test_calls_snapshot_push_teardown_in_order(self):
apply_capability_change("dev", "FROM new\n")
# Snapshot + push must happen BEFORE write_per_bottle_dockerfile
# (so they capture pre-rebuild state) and BEFORE teardown (so
# the agent container still exists to docker exec / cp from).
# Teardown must be last.
self.assertEqual(
["snapshot:dev", "push:dev", "teardown:dev"],
self._calls,
)
def test_marks_preserved_before_teardown(self):
# cli.py's session-end cleanup reads the marker after the
# bottle is torn down. The marker must therefore be written
# before teardown — otherwise the cleanup would see no
# marker and rm the state dir we just populated.
apply_capability_change("dev", "FROM new\n")
self.assertTrue(bottle_state.is_preserved("dev"))
def test_first_change_falls_back_to_repo_dockerfile_for_before(self):
# No per-bottle override yet — before-diff comes from the
# repo's Dockerfile.
before, after = apply_capability_change("dev-fresh", "FROM new\n")
self.assertIn("FROM ", before)
self.assertEqual("FROM new\n", after)
def test_empty_dockerfile_rejected(self):
with self.assertRaises(CapabilityApplyError):
apply_capability_change("dev", " \n\t\n")
if __name__ == "__main__":
unittest.main()
+6 -15
View File
@@ -9,7 +9,7 @@ import unittest
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.backend.docker import bottle_state
from bot_bottle import bottle_state
from bot_bottle.cli import start as start_mod
@@ -29,29 +29,20 @@ class _FakeHomeMixin:
class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
# snapshot_transcript is commented out (capability_apply is disabled);
# capture_claude_session_state now only handles the preserve marker.
def setUp(self):
self._setup_fake_home()
# Stub the docker-dependent snapshot call so this stays a
# unit test. apply_capability_change's integration test
# covers the real docker cp path.
self._snap_calls: list[str] = []
self._orig_snap = start_mod.snapshot_transcript
start_mod.snapshot_transcript = lambda identity: ( # type: ignore
self._snap_calls.append(identity)
)
def tearDown(self):
start_mod.snapshot_transcript = self._orig_snap
self._teardown_fake_home()
def test_clean_exit_snapshots_but_does_not_mark(self):
def test_clean_exit_does_not_mark(self):
start_mod.capture_claude_session_state("dev-abc", exit_code=0)
self.assertEqual(["dev-abc"], self._snap_calls)
self.assertFalse(bottle_state.is_preserved("dev-abc"))
def test_crash_snapshots_and_marks(self):
def test_crash_marks_preserved(self):
start_mod.capture_claude_session_state("dev-abc", exit_code=137)
self.assertEqual(["dev-abc"], self._snap_calls)
self.assertTrue(bottle_state.is_preserved("dev-abc"))
def test_ctrl_c_treated_as_crash(self):
@@ -64,7 +55,7 @@ class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
# Backends without an identity field shouldn't crash this
# path (the _identity_from_plan helper falls back to "").
start_mod.capture_claude_session_state("", exit_code=137)
self.assertEqual([], self._snap_calls)
self.assertFalse(bottle_state.is_preserved(""))
class TestSettleState(_FakeHomeMixin, unittest.TestCase):
+7 -12
View File
@@ -33,7 +33,6 @@ from bot_bottle.egress import (
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
SLUG = "demo-abc12"
@@ -149,19 +148,10 @@ def _plan(
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=STAGE,
slug=SLUG,
container_name=f"bot-bottle-{SLUG}",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
prompt_file=STAGE / "prompt",
git_gate_plan=_git_gate_plan(upstreams),
egress_plan=_egress_plan(routes),
supervise_plan=_supervise_plan() if supervise else None,
@@ -172,9 +162,11 @@ def _plan(
prompt_mode="append_file",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name=f"bot-bottle-{SLUG}",
prompt_file=STAGE / "prompt",
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
@@ -210,7 +202,7 @@ class TestAgentAlwaysPresent(unittest.TestCase):
def test_agent_image_uses_runtime_image(self):
plan = _plan()
s = bottle_plan_to_compose(plan)["services"]["agent"]
self.assertEqual(plan.runtime_image, s["image"])
self.assertEqual(plan.image, s["image"])
def test_agent_only_on_internal_network(self):
s = bottle_plan_to_compose(_plan())["services"]["agent"]
@@ -252,6 +244,9 @@ class TestAgentAlwaysPresent(unittest.TestCase):
prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest",
dockerfile="",
guest_home="/home/node",
instance_name=f"bot-bottle-{SLUG}",
prompt_file=STAGE / "prompt",
guest_env={"CODEX_HOME": "/home/node/.codex"},
)
plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore
+17 -15
View File
@@ -24,7 +24,6 @@ from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
_URL = "http://supervise:9100/"
@@ -76,19 +75,10 @@ def _plan(
current_config_dir=Path("/tmp/current-config"),
)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
container_name="bot-bottle-demo-abc12",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=Path("/tmp/agent.env"),
forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -106,9 +96,12 @@ def _plan(
use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={},
image="bot-bottle-claude:latest", dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
@@ -211,7 +204,10 @@ class TestClaudeProvision(unittest.TestCase):
def test_copies_files_and_chowns(self):
provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json",
),),
@@ -234,7 +230,10 @@ class TestClaudeProvision(unittest.TestCase):
def test_dies_when_file_chown_fails(self):
provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json",
),),
@@ -250,7 +249,10 @@ class TestClaudeProvision(unittest.TestCase):
def test_runs_verify_commands(self):
provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
verify=(AgentProvisionCommand(
("/usr/bin/true",), "verify failed",
),),
+17 -15
View File
@@ -25,7 +25,6 @@ from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
_URL = "http://supervise:9100/"
@@ -77,19 +76,10 @@ def _plan(
current_config_dir=Path("/tmp/current-config"),
)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
container_name="bot-bottle-demo-abc12",
container_name_pinned=False,
image="bot-bottle-codex:latest",
derived_image="",
runtime_image="bot-bottle-codex:latest",
dockerfile_path="",
env_file=Path("/tmp/agent.env"),
forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -107,9 +97,12 @@ def _plan(
use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan(
template="codex", command="codex", prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={},
image="bot-bottle-codex:latest", dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
@@ -177,7 +170,10 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan(
template="codex", command="codex",
prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),),
files=(AgentProvisionFile(
Path("/tmp/codex-config.toml"),
@@ -201,7 +197,10 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan(
template="codex", command="codex",
prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
pre_copy=(AgentProvisionCommand(
("find", "/home/node/.codex", "-name", "*.sqlite", "-delete"),
"could not reset runtime db files",
@@ -223,7 +222,10 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan(
template="codex", command="codex",
prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),),
)
bottle = _make_bottle(exec_result=ExecResult(1, "", "mkdir: nope\n"))
+1 -1
View File
@@ -16,7 +16,7 @@ import unittest
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.backend.docker import bottle_state
from bot_bottle import bottle_state
from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs
+2 -1
View File
@@ -24,7 +24,8 @@ import unittest
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.backend.docker import bottle_state, enumerate as _enumerate
from bot_bottle import bottle_state
from bot_bottle.backend.docker import enumerate as _enumerate
class TestParseServicesByProject(unittest.TestCase):
+4 -12
View File
@@ -22,7 +22,6 @@ 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 Manifest
from bot_bottle.workspace import workspace_plan
def _manifest() -> Manifest:
@@ -43,7 +42,6 @@ def _plan(tmp: str) -> DockerBottlePlan:
identity="test-teardown-00001",
)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=stage,
git_gate_plan=GitGatePlan(
@@ -64,21 +62,15 @@ def _plan(tmp: str) -> DockerBottlePlan:
template="claude",
command="claude",
prompt_mode="append_file",
image="",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-test-teardown-abc",
prompt_file=stage / "prompt.txt",
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
slug="test-teardown-00001",
container_name="bot-bottle-test-teardown-abc",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=stage / "env",
forwarded_env={},
prompt_file=stage / "prompt.txt",
use_runsc=False,
)
+4 -31
View File
@@ -22,7 +22,6 @@ 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 Manifest
from bot_bottle.workspace import workspace_plan
class _Provider(AgentProvider):
@@ -30,7 +29,7 @@ class _Provider(AgentProvider):
@property
def runtime(self) -> AgentProviderRuntime:
return AgentProviderRuntime(
template="test", command="test", image="", dockerfile="",
template="test", command="test", image="",
prompt_mode="append_file", bypass_args=(), resume_args=(),
remote_control_args=(),
)
@@ -61,19 +60,10 @@ def _plan(*, git_user: dict | None = None, # type: ignore
copy_cwd=copy_cwd, user_cwd=user_cwd,
)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
container_name="bot-bottle-demo-abc12",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=Path("/tmp/agent.env"),
forwarded_env={},
prompt_file=Path("/tmp/prompt.txt"),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -95,9 +85,11 @@ def _plan(*, git_user: dict | None = None, # type: ignore
prompt_mode="append_file",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
@@ -131,25 +123,6 @@ class TestProvisionGitUser(unittest.TestCase):
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
self.assertEqual([], _git_config_exec_calls(bottle))
def test_copies_cwd_git_to_workspace_plan_path(self):
cwd = self.stage / "cwd"
(cwd / ".git").mkdir(parents=True)
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_called_once_with(
f"{cwd}/.git",
"/home/node/workspace/.git",
)
chown_calls = [
c for c in bottle.exec.call_args_list
if "chown" in (c.args[0] if c.args else "")
and "/home/node/workspace/.git" in (c.args[0] if c.args else "")
]
self.assertEqual(1, len(chown_calls))
self.assertIn("node:node", chown_calls[0].args[0])
def test_sets_name_and_email(self):
plan = _plan(
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"},
-58
View File
@@ -8,13 +8,10 @@ integration smoke."""
from __future__ import annotations
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from bot_bottle.backend.docker import util as docker_mod
from bot_bottle.workspace import WorkspacePlan
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
@@ -70,60 +67,5 @@ class TestSave(unittest.TestCase):
)
class TestBuildImageWithCwd(unittest.TestCase):
def test_uses_workspace_plan_paths(self):
with tempfile.TemporaryDirectory(prefix="bb-docker-cwd.") as tmp:
workspace = WorkspacePlan(
enabled=True,
host_path=Path(tmp),
guest_home="/guest/home",
guest_path="/guest/home/workspace",
workdir="/guest/home/workspace",
)
with patch.object(docker_mod.subprocess, "run") as run:
docker_mod.build_image_with_cwd("derived:tag", "base:tag", workspace)
argv = run.call_args.args[0]
dockerfile = run.call_args.kwargs["input"]
self.assertEqual(["docker", "build", "-t", "derived:tag", "-f", "-"], argv[:6])
self.assertTrue(argv[6].endswith("/context"))
self.assertIn("FROM base:tag\n", dockerfile)
self.assertIn(
"COPY --chown=node:node workspace/. /guest/home/workspace\n",
dockerfile,
)
self.assertIn("WORKDIR /guest/home/workspace\n", dockerfile)
def test_staged_context_includes_hidden_files_but_not_git_dir(self):
with tempfile.TemporaryDirectory(prefix="bb-docker-cwd.") as tmp:
root = Path(tmp)
(root / ".gitignore").write_text("*.pyc\n")
(root / ".dockerignore").write_text(".gitignore\n")
(root / ".env.example").write_text("SAFE=1\n")
(root / ".git").mkdir()
(root / ".git" / "config").write_text("[core]\n")
workspace = WorkspacePlan(
enabled=True,
host_path=root,
guest_home="/guest/home",
guest_path="/guest/home/workspace",
workdir="/guest/home/workspace",
)
def inspect_context(*args, **kwargs): # type: ignore
context = Path(args[0][-1])
staged = context / "workspace"
self.assertTrue((staged / ".gitignore").is_file())
self.assertTrue((staged / ".dockerignore").is_file())
self.assertTrue((staged / ".env.example").is_file())
self.assertFalse((staged / ".git").exists())
return _ok()
with patch.object(
docker_mod.subprocess, "run", side_effect=inspect_context,
):
docker_mod.build_image_with_cwd("derived:tag", "base:tag", workspace)
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -30,6 +30,7 @@ from bot_bottle.egress_addon_core import (
load_config,
load_routes,
match_route,
outbound_scan_headers,
parse_config,
parse_routes,
scan_inbound,
@@ -798,6 +799,41 @@ class TestBuildOutboundScanText(unittest.TestCase):
self.assertIn(fragment, text)
class TestOutboundScanHeaders(unittest.TestCase):
def test_authed_route_omits_authorization_header_from_scan(self):
route = Route(
host="chatgpt.com",
auth_scheme="Bearer",
token_env="EGRESS_TOKEN_0",
)
headers = outbound_scan_headers(route, {
"Authorization": "Bearer " + "A" * 60,
"x-api-key": "still-scanned",
})
self.assertNotIn("Authorization", headers)
self.assertEqual({"x-api-key": "still-scanned"}, headers)
def test_authed_route_omits_lowercase_authorization_header_from_scan(self):
route = Route(
host="chatgpt.com",
auth_scheme="Bearer",
token_env="EGRESS_TOKEN_0",
)
headers = outbound_scan_headers(route, {
"authorization": "Bearer " + "A" * 60,
"accept": "application/json",
})
self.assertEqual({"accept": "application/json"}, headers)
def test_unauthenticated_route_keeps_authorization_header_in_scan(self):
route = Route(host="api.example.com")
auth = "Bearer " + "A" * 60
headers = outbound_scan_headers(route, {
"Authorization": auth,
})
self.assertEqual({"Authorization": auth}, headers)
# --- scan_outbound -------------------------------------------------------
_AWS_KEY = "AKIAIOSFODNN7EXAMPLE"
@@ -815,6 +851,27 @@ class TestScanOutbound(unittest.TestCase):
)
self.assertIsNone(scan_outbound(_ROUTE, text, {}))
def test_authed_route_authorization_placeholder_not_scanned(self):
route = Route(
host="chatgpt.com",
auth_scheme="Bearer",
token_env="EGRESS_TOKEN_0",
)
headers = outbound_scan_headers(route, {
"Authorization": "Bearer " + "A" * 60,
"content-type": "application/json",
})
text = build_outbound_scan_text(
host="chatgpt.com",
path="/backend-api/codex/responses",
query="",
headers=headers,
body='{"jsonrpc":"2.0","method":"initialize"}',
)
self.assertIsNone(scan_outbound(route, text, {
"EGRESS_TOKEN_0": "sidecar-owned-secret",
}))
def test_token_in_body_blocked(self):
text = build_outbound_scan_text(
host="api.example.com",
+4 -4
View File
@@ -2,7 +2,7 @@
import unittest
from bot_bottle.manifest import ManifestError, GitUser, Manifest
from bot_bottle.manifest import ManifestError, ManifestGitUser, Manifest
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -99,13 +99,13 @@ class TestGitUserDirect(unittest.TestCase):
"""Direct GitUser dataclass exercises (no manifest wrapper)."""
def test_is_empty_default(self):
self.assertTrue(GitUser().is_empty())
self.assertTrue(ManifestGitUser().is_empty())
def test_is_empty_false_when_name_set(self):
self.assertFalse(GitUser(name="x").is_empty())
self.assertFalse(ManifestGitUser(name="x").is_empty())
def test_is_empty_false_when_email_set(self):
self.assertFalse(GitUser(email="x@y").is_empty())
self.assertFalse(ManifestGitUser(email="x@y").is_empty())
if __name__ == "__main__":
+2 -2
View File
@@ -7,7 +7,7 @@ silently ignoring."""
import unittest
from typing import Any
from bot_bottle.manifest import ManifestError, Bottle, Manifest
from bot_bottle.manifest import ManifestError, ManifestBottle, Manifest
def _manifest_with_runtime(value: object) -> dict[str, Any]:
@@ -26,7 +26,7 @@ class TestManifestRuntimeRemoved(unittest.TestCase):
self.assertIn("dev", m.bottles)
def test_bottle_dataclass_has_no_runtime_attribute(self):
self.assertFalse(hasattr(Bottle(), "runtime"))
self.assertFalse(hasattr(ManifestBottle(), "runtime"))
def test_any_runtime_value_is_rejected(self):
for value in ("runsc", "runc", "kata-runtime", "", 42, None):
+7 -20
View File
@@ -20,7 +20,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan
def _manifest() -> Manifest:
@@ -79,13 +78,16 @@ def _egress_plan(tmp: str) -> EgressPlan:
)
def _agent_provision() -> AgentProvisionPlan:
def _agent_provision(tmp: str) -> AgentProvisionPlan:
return AgentProvisionPlan(
template="claude",
command="claude",
prompt_mode="append_file",
image="",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-test-00001",
prompt_file=Path(tmp) / "prompt.txt",
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
)
@@ -93,24 +95,14 @@ def _agent_provision() -> AgentProvisionPlan:
def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
stage = Path(tmp)
return DockerBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp),
supervise_plan=None,
agent_provision=_agent_provision(),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
agent_provision=_agent_provision(tmp),
slug="test-00001",
container_name="bot-bottle-test-00001",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=stage / "env",
forwarded_env={},
prompt_file=stage / "prompt.txt",
use_runsc=False,
)
@@ -118,22 +110,17 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
stage = Path(tmp)
return SmolmachinesBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp),
supervise_plan=None,
agent_provision=_agent_provision(),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
agent_provision=_agent_provision(tmp),
slug="test-00001",
bundle_subnet="10.99.0.0/24",
bundle_gateway="10.99.0.1",
bundle_ip="10.99.0.2",
machine_name="bot-bottle-test-00001",
agent_image_ref="bot-bottle-claude:latest",
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
prompt_file=stage / "prompt.txt",
)
-133
View File
@@ -1,133 +0,0 @@
"""Unit: smolmachines prepare.py env resolution (PRD 0038)."""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.env import ResolvedEnv
class TestSmolmachinesResolveEnv(unittest.TestCase):
"""resolve_plan() must call resolve_env() and build guest_env
from the resolved values rather than from raw bottle.env."""
def _run_resolve_plan(
self,
resolved: ResolvedEnv,
*,
extra_host_env: dict[str, str] | None = None,
) -> dict[str, str]:
from bot_bottle.backend import BottleSpec
from bot_bottle.manifest import Manifest
with tempfile.TemporaryDirectory() as tmp:
stage = Path(tmp) / "stage"
stage.mkdir()
# Minimal manifest with one env literal so the spec is valid.
manifest = Manifest.from_json_obj({
"agents": {"myagent": {"bottle": "mybottle"}},
"bottles": {"mybottle": {"env": {"PLAIN": "literal-value"}}},
})
spec = BottleSpec(
manifest=manifest,
agent_name="myagent",
copy_cwd=False,
user_cwd=tmp,
identity="test-slug-00001",
)
from bot_bottle import supervise as _sup
orig_root = _sup.bot_bottle_root
_sup.bot_bottle_root = lambda: Path(tmp) / ".bot-bottle" # type: ignore[assignment]
host_env = {**os.environ, **(extra_host_env or {})} # type: ignore
try:
with (
patch("bot_bottle.backend.smolmachines.prepare.resolve_env",
return_value=resolved) as mock_resolve,
patch("bot_bottle.backend.smolmachines.prepare.smolmachines_preflight"),
patch("bot_bottle.backend.smolmachines.prepare.smolmachines_bundle_subnet",
return_value=("10.99.0.0/24", "10.99.0.1", "10.99.0.2")),
patch("bot_bottle.backend.smolmachines.prepare.GitGate") as mock_gg,
patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg,
patch("bot_bottle.backend.smolmachines.prepare.Supervise"),
patch(
"bot_bottle.backend.smolmachines.prepare.agent_provision_plan"
) as mock_app,
patch("bot_bottle.backend.smolmachines.prepare.runtime_for"),
):
mock_gg.return_value.prepare.return_value = MagicMock()
mock_eg.return_value.prepare.return_value = MagicMock()
def _make_provision(**kwargs): # type: ignore
return AgentProvisionPlan(
template="claude",
command="claude",
prompt_mode="append_file",
dockerfile="",
image="bot-bottle-claude:latest",
guest_env=dict(kwargs.get("guest_env") or {}),
)
mock_app.side_effect = lambda **kw: _make_provision(**kw) # type: ignore
from bot_bottle.backend.smolmachines.prepare import resolve_plan
plan = resolve_plan(spec, stage_dir=stage)
mock_resolve.assert_called_once_with(manifest, "myagent")
return dict(plan.guest_env)
finally:
_sup.bot_bottle_root = orig_root # type: ignore[assignment]
def test_literal_env_reaches_guest_env(self):
resolved = ResolvedEnv(
literals={"PLAIN": "hello"},
forwarded={},
)
guest_env = self._run_resolve_plan(resolved)
self.assertEqual("hello", guest_env["PLAIN"])
def test_forwarded_env_reaches_guest_env(self):
# Secrets / interpolated values land in forwarded; they must
# still reach the guest (argv exposure is the known gap).
resolved = ResolvedEnv(
literals={},
forwarded={"SECRET": "s3cr3t", "INTERP": "resolved-val"},
)
guest_env = self._run_resolve_plan(resolved)
self.assertEqual("s3cr3t", guest_env["SECRET"])
self.assertEqual("resolved-val", guest_env["INTERP"])
def test_raw_manifest_sentinel_not_in_guest_env(self):
# Before the fix, ?prompt and ${HOST} would appear verbatim.
# After the fix, resolve_env() is called so the caller sees
# the mocked resolved values (no raw sentinel survives).
resolved = ResolvedEnv(
literals={},
forwarded={"MY_SECRET": "actual-value"},
)
guest_env = self._run_resolve_plan(resolved)
for v in guest_env.values():
self.assertFalse(
v.startswith("?"),
f"raw secret sentinel survived in guest_env: {v!r}",
)
self.assertFalse(
v.startswith("${"),
f"raw interpolation sentinel survived in guest_env: {v!r}",
)
def test_tls_trust_env_always_present(self):
resolved = ResolvedEnv(literals={}, forwarded={})
guest_env = self._run_resolve_plan(resolved)
for key in ("NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"):
self.assertIn(key, guest_env, f"{key} missing from guest_env")
if __name__ == "__main__":
unittest.main()
+15 -88
View File
@@ -26,16 +26,15 @@ from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
from bot_bottle.backend.smolmachines.bottle_plan import (
SmolmachinesBottlePlan,
)
from bot_bottle.backend.smolmachines.provision import (
workspace as _workspace,
)
# from bot_bottle.backend.smolmachines.provision import (
# workspace as _workspace,
# )
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import GitEntry, Manifest
from bot_bottle.manifest import ManifestGitEntry, Manifest
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
class _Provider(AgentProvider):
@@ -43,7 +42,7 @@ class _Provider(AgentProvider):
@property
def runtime(self) -> AgentProviderRuntime:
return AgentProviderRuntime(
template="test", command="test", image="", dockerfile="",
template="test", command="test", image="",
prompt_mode="append_file", bypass_args=(), resume_args=(),
remote_control_args=(),
)
@@ -71,11 +70,6 @@ def _make_bottle(
return bottle
def _exec_scripts(bottle: MagicMock) -> list[str]:
"""All script strings passed to bottle.exec, in call order."""
return [c.args[0] for c in bottle.exec.call_args_list]
def _exec_users(bottle: MagicMock) -> list[str]: # type: ignore
"""user= kwarg from each bottle.exec call, in order."""
return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list]
@@ -85,7 +79,7 @@ def _plan(
*,
agent_prompt: str = "",
skills: list[str] | None = None,
git: list[GitEntry] = (), # type: ignore
git: list[ManifestGitEntry] = (), # type: ignore
git_user: dict | None = None, # type: ignore
copy_cwd: bool = False,
user_cwd: str = "/tmp/x",
@@ -140,17 +134,13 @@ def _plan(
current_config_dir=Path("/tmp/current-config"),
)
return SmolmachinesBottlePlan(
guest_home="/home/node",
spec=spec,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
bundle_subnet="192.168.50.0/24",
bundle_gateway="192.168.50.1",
bundle_ip=bundle_ip,
machine_name="bot-bottle-demo-abc12",
agent_image_ref="bot-bottle-claude:latest",
guest_env=dict(guest_env or {}),
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -173,7 +163,6 @@ def _plan(
codex_auth_file=codex_auth_file,
guest_env=dict(guest_env or {}),
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
@@ -188,8 +177,11 @@ def _agent_provision(
template=template,
command=template,
prompt_mode="append_file",
image="",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
guest_env=dict(guest_env or {}),
)
auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex")
@@ -227,6 +219,9 @@ def _agent_provision(
prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
guest_env=dict(guest_env or {}),
dirs=(AgentProvisionDir(auth_dir),),
files=tuple(files),
@@ -341,9 +336,7 @@ class TestSmolmachinesBottleExec(unittest.TestCase):
class TestProvisionGit(unittest.TestCase):
"""provision_git dispatches two independent passes (cwd .git
copy + gitconfig insteadOf write); each no-ops on its own
when its condition doesn't hold."""
"""provision_git writes gitconfig insteadOf rules when configured."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with
@@ -358,41 +351,13 @@ class TestProvisionGit(unittest.TestCase):
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_copies_cwd_git_when_copy_cwd_and_git_present(self):
# Stage a fake host .git dir under user_cwd so the path-
# check in provision_git fires.
cwd = self.stage / "cwd"
(cwd / ".git").mkdir(parents=True)
plan = _plan(
copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage,
)
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_called_once_with(
f"{cwd}/.git",
"/home/node/workspace/.git",
)
scripts = _exec_scripts(bottle)
self.assertTrue(any("mkdir -p" in s and "/home/node/workspace" in s for s in scripts))
# chown the workspace tree so the agent (node) owns it.
self.assertTrue(
any("chown -R" in s and "node:node" in s and "/home/node/workspace/.git" in s
for s in scripts)
)
def test_skips_cwd_when_copy_cwd_false(self):
plan = _plan(copy_cwd=False, stage_dir=self.stage)
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_not_called()
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
# Smolmachines's TSI-allowlisted guest dials git-gate via
# smart HTTP at `127.0.0.1:<host port>` — the bundle's
# git HTTP port is published on host loopback at launch
# time, and the plan carries the discovered host port.
plan = _plan(
git=[GitEntry(
git=[ManifestGitEntry(
Name="bot-bottle",
Upstream="ssh://git@host/repo.git",
IdentityFile="~/.ssh/id_ed25519",
@@ -505,43 +470,5 @@ class TestProvisionGitUser(unittest.TestCase):
self.assertIn("bot@example.com", calls[0][0])
class TestProvisionWorkspace(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-workspace.") # pylint: disable=consider-using-with
self.stage = Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def test_noop_when_copy_cwd_false(self):
plan = _plan(copy_cwd=False, stage_dir=self.stage)
bottle = _make_bottle()
_workspace.provision_workspace(plan, bottle)
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_copies_workspace_to_plan_path_and_chowns(self):
cwd = self.stage / "cwd"
cwd.mkdir()
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
bottle = _make_bottle()
_workspace.provision_workspace(plan, bottle)
bottle.cp_in.assert_called_once_with(
str(cwd),
"/home/node/workspace",
)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("rm -rf /home/node/workspace" in s and "mkdir -p /home/node" in s
for s in scripts)
)
self.assertTrue(
any("chown -R node:node /home/node/workspace" in s
and "chmod 755 /home/node/workspace" in s
for s in scripts)
)
if __name__ == "__main__":
unittest.main()
+5 -11
View File
@@ -359,25 +359,19 @@ class TestSupervisePrepare(unittest.TestCase):
return lambda: setattr(supervise, "bot_bottle_root", original)
def test_prepare_creates_queue_and_current_config(self):
plan = _StubSupervise().prepare(
"dev", self.stage_dir,
dockerfile_content="FROM python:3.13\n",
)
plan = _StubSupervise().prepare("dev", self.stage_dir)
self.assertTrue(plan.queue_dir.is_dir())
self.assertTrue(plan.current_config_dir.is_dir())
self.assertEqual(
"FROM python:3.13\n",
(plan.current_config_dir / "Dockerfile").read_text(),
)
self.assertEqual("dev", plan.slug)
self.assertEqual("", plan.internal_network)
def test_prepare_only_writes_dockerfile_to_current_config(self):
def test_prepare_writes_no_files_to_current_config(self):
# dockerfile_content is no longer accepted by prepare.
# routes.yaml + allowlist live behind the
# `list-egress-routes` MCP tool now (PRD 0017 chunk 3).
# `list-egress-routes` MCP tool (PRD 0017 chunk 3).
plan = _StubSupervise().prepare("dev", self.stage_dir)
files = sorted(p.name for p in plan.current_config_dir.iterdir())
self.assertEqual(["Dockerfile"], files)
self.assertEqual([], files)
if __name__ == "__main__":
+6 -113
View File
@@ -14,7 +14,6 @@ from datetime import datetime, timezone
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.backend.docker.capability_apply import CapabilityApplyError
from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.supervise import (
Proposal,
@@ -115,13 +114,8 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
self._original_apply_capability = supervise_cli.apply_capability_change
supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore
"FROM old\n", content,
)
def tearDown(self):
supervise_cli.apply_capability_change = self._original_apply_capability
self._teardown_fake_home()
def _enqueue(self, tool: str = TOOL_CAPABILITY_BLOCK):
@@ -161,67 +155,9 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
self.assertEqual([], read_audit_entries("egress", "dev"))
class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
"""PRD 0016 Phase 3: approve() on a capability-block proposal
calls apply_capability_change, archives the proposal afterward
(sidecar is gone so it can't archive itself), and writes no
audit entry (capability-block has none per PRD 0013)."""
def setUp(self):
self._setup_fake_home()
self._original = supervise_cli.apply_capability_change
def tearDown(self):
supervise_cli.apply_capability_change = self._original
self._teardown_fake_home()
def _enqueue_capability(self, proposed: str = "FROM python:3.13\nRUN apk add ripgrep\n"):
p = Proposal.new(
bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK,
proposed_file=proposed,
justification="need ripgrep",
current_file_hash=sha256_hex(proposed),
now=FIXED,
)
qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p)
return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def test_capability_block_calls_apply_with_proposed_file(self):
calls = []
supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore
calls.append((slug, content)) or ("FROM old\n", content)
)
qp = self._enqueue_capability("FROM bookworm\n")
supervise_cli.approve(qp)
self.assertEqual([("dev", "FROM bookworm\n")], calls)
def test_apply_failure_blocks_response_and_keeps_pending(self):
supervise_cli.apply_capability_change = lambda slug, content: (_ for _ in ()).throw( # type: ignore
CapabilityApplyError("teardown failed")
)
qp = self._enqueue_capability()
with self.assertRaises(CapabilityApplyError):
supervise_cli.approve(qp)
self.assertEqual(
[qp.proposal.id],
[p.id for p in supervise.list_pending_proposals(qp.queue_dir)],
)
def test_no_audit_log_for_capability(self):
supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore
qp = self._enqueue_capability()
supervise_cli.approve(qp)
self.assertEqual([], read_audit_entries("egress", "dev"))
def test_proposal_archived_after_apply(self):
supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore
qp = self._enqueue_capability()
supervise_cli.approve(qp)
self.assertEqual([], supervise.list_pending_proposals(qp.queue_dir))
processed = list((qp.queue_dir / "processed").glob("*.json"))
self.assertEqual(2, len(processed))
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
# # DISABLED — capability_apply functionality is currently commented out.
# pass
class TestEditInEditor(unittest.TestCase):
@@ -268,52 +204,9 @@ class TestEditInEditor(unittest.TestCase):
os.environ["EDITOR"] = original_editor
class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
"""approve() must refuse capability-block for smolmachines bottles and
pass it through for Docker bottles (PRD 0039)."""
def setUp(self):
self._setup_fake_home()
self._original_apply_capability = supervise_cli.apply_capability_change
supervise_cli.apply_capability_change = lambda slug, content: ("", content) # type: ignore
def tearDown(self):
supervise_cli.apply_capability_change = self._original_apply_capability
self._teardown_fake_home()
def _enqueue_capability(self, slug: str = "dev") -> "supervise_cli.QueuedProposal":
p = _proposal(slug=slug, tool=TOOL_CAPABILITY_BLOCK)
qdir = supervise.queue_dir_for_slug(slug)
qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p)
return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def _write_metadata(self, slug: str, compose_project: str) -> None:
from bot_bottle.backend.docker.bottle_state import BottleMetadata, write_metadata
write_metadata(BottleMetadata(
identity=slug,
agent_name="myagent",
cwd="",
copy_cwd=False,
started_at="2026-06-02T00:00:00+00:00",
compose_project=compose_project,
))
def test_smolmachines_bottle_raises_capability_apply_error(self):
self._write_metadata("dev", compose_project="")
qp = self._enqueue_capability("dev")
with self.assertRaises(CapabilityApplyError) as ctx:
supervise_cli.approve(qp)
self.assertIn("smolmachines", str(ctx.exception))
def test_docker_bottle_calls_apply_capability_change(self):
self._write_metadata("dev", compose_project="bot-bottle-dev")
qp = self._enqueue_capability("dev")
supervise_cli.approve(qp) # must not raise
def test_no_metadata_falls_through_to_docker_path(self):
qp = self._enqueue_capability("dev")
supervise_cli.approve(qp) # must not raise
# class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
# # DISABLED — capability_apply functionality is currently commented out.
# pass
if __name__ == "__main__":
-58
View File
@@ -1,58 +0,0 @@
"""Unit: backend-neutral workspace planning."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from bot_bottle.backend import BottleSpec
from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan
def _spec(*, copy_cwd: bool, user_cwd: str) -> BottleSpec:
manifest = Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
return BottleSpec(
manifest=manifest,
agent_name="demo",
copy_cwd=copy_cwd,
user_cwd=user_cwd,
)
class TestWorkspacePlan(unittest.TestCase):
def test_disabled_uses_guest_home_as_workdir(self):
plan = workspace_plan(
_spec(copy_cwd=False, user_cwd="/tmp/project"),
guest_home="/home/node",
)
self.assertFalse(plan.enabled)
self.assertEqual("/home/node", plan.guest_path)
self.assertEqual("/home/node", plan.workdir)
def test_enabled_uses_workspace_under_guest_home(self):
plan = workspace_plan(
_spec(copy_cwd=True, user_cwd="/tmp/project"),
guest_home="/guest/home",
)
self.assertTrue(plan.enabled)
self.assertEqual(Path("/tmp/project"), plan.host_path)
self.assertEqual("/guest/home/workspace", plan.guest_path)
self.assertEqual("/guest/home/workspace", plan.workdir)
def test_detects_host_git_dir(self):
with tempfile.TemporaryDirectory(prefix="bb-workspace.") as tmp:
Path(tmp, ".git").mkdir()
plan = workspace_plan(
_spec(copy_cwd=True, user_cwd=tmp),
guest_home="/home/node",
)
self.assertTrue(plan.has_host_git_dir)
if __name__ == "__main__":
unittest.main()