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 from __future__ import annotations
import importlib.util import importlib.util
import inspect
import os import os
import shlex import shlex
import tempfile import tempfile
@@ -51,7 +52,6 @@ class AgentProviderRuntime:
template: str template: str
command: str command: str
image: str image: str
dockerfile: str
prompt_mode: PromptMode prompt_mode: PromptMode
bypass_args: tuple[str, ...] bypass_args: tuple[str, ...]
resume_args: tuple[str, ...] resume_args: tuple[str, ...]
@@ -103,6 +103,9 @@ class AgentProvisionPlan:
prompt_mode: PromptMode prompt_mode: PromptMode
image: str image: str
dockerfile: str dockerfile: str
guest_home: str
instance_name: str
prompt_file: Path
guest_env: dict[str, str] guest_env: dict[str, str]
env_vars: dict[str, str] = field(default_factory=dict) env_vars: dict[str, str] = field(default_factory=dict)
dirs: tuple[AgentProvisionDir, ...] = () dirs: tuple[AgentProvisionDir, ...] = ()
@@ -127,13 +130,31 @@ class AgentProvider(ABC):
"""The static command / image / prompt-mode table for this """The static command / image / prompt-mode table for this
template.""" 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 @abstractmethod
def provision_plan( def provision_plan(
self, self,
*, *,
dockerfile: str, dockerfile: str,
state_dir: Path, state_dir: Path,
guest_home: str, instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None, guest_env: dict[str, str] | None = None,
auth_token: str = "", auth_token: str = "",
forward_host_credentials: bool = False, forward_host_credentials: bool = False,
@@ -205,23 +226,10 @@ class AgentProvider(ABC):
def provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None: def provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None:
"""Configure git inside the agent container. """Configure git inside the agent container.
Default: Debian/node — copies .git when --cwd is set, writes the Default: Debian/node — writes the git-gate insteadOf gitconfig
git-gate insteadOf gitconfig, sets user.name/email as node. and sets user.name/email as node. Workspace copy runs through
Override for images that run as a different user or use a BottleBackend.provision_workspace against the running bottle."""
non-standard home directory."""
from .log import info 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) manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if manifest_bottle.git: if manifest_bottle.git:
@@ -317,12 +325,13 @@ def runtime_for(template: str) -> AgentProviderRuntime:
return get_provider(template).runtime return get_provider(template).runtime
def agent_provision_plan( def build_agent_provision_plan(
*, *,
template: str, template: str,
dockerfile: str, dockerfile: str,
state_dir: Path, state_dir: Path,
guest_home: str, instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None, guest_env: dict[str, str] | None = None,
auth_token: str = "", auth_token: str = "",
forward_host_credentials: bool = False, forward_host_credentials: bool = False,
@@ -336,7 +345,8 @@ def agent_provision_plan(
return get_provider(template).provision_plan( return get_provider(template).provision_plan(
dockerfile=dockerfile, dockerfile=dockerfile,
state_dir=state_dir, state_dir=state_dir,
guest_home=guest_home, instance_name=instance_name,
prompt_file=prompt_file,
guest_env=guest_env, guest_env=guest_env,
auth_token=auth_token, auth_token=auth_token,
forward_host_credentials=forward_host_credentials, 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 from __future__ import annotations
import os import os
import shlex
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
@@ -39,14 +40,15 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar 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 ..egress import EgressPlan
from ..git_gate import GitGatePlan from ..git_gate import GitGatePlan
from ..log import die, info from ..log import die, info
from ..manifest import GitEntry, Manifest from ..manifest import ManifestGitEntry, Manifest
from ..supervise import SupervisePlan from ..supervise import SupervisePlan
from ..util import expand_tilde 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 .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
@@ -78,9 +80,12 @@ class BottlePlan(ABC):
spec: BottleSpec spec: BottleSpec
stage_dir: Path stage_dir: Path
guest_home: str
git_gate_plan: GitGatePlan git_gate_plan: GitGatePlan
@property
def guest_home(self) -> str:
return self.agent_provision.guest_home
@property @property
def git_gate_insteadof_host(self) -> str: def git_gate_insteadof_host(self) -> str:
"""Host (and optional port) used in git-gate insteadOf URLs. """Host (and optional port) used in git-gate insteadOf URLs.
@@ -97,7 +102,10 @@ class BottlePlan(ABC):
egress_plan: EgressPlan egress_plan: EgressPlan
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan 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: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr.""" """Render the y/N preflight summary to stderr."""
@@ -263,14 +271,87 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
name: str 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 """Template method: run cross-backend host-side validation, then
delegate to the subclass's `_resolve_plan` for the delegate to the subclass's `_resolve_plan` for the
backend-specific resolution (names, scratch files, etc.). The backend-specific resolution (names, scratch files, etc.). The
validation step is enforced here so a future backend cannot validation step is enforced here so a future backend cannot
accidentally skip it. No remote/runtime resources are created.""" 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) 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: def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists, """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." 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 """Each entry's IdentityFile must exist on the host (after
expanding leading ~) — the git-gate copies it in at start time expanding leading ~) — the git-gate copies it in at start time
to authenticate the upstream push (PRD 0008). Shape is already to authenticate the upstream push (PRD 0008). Shape is already
@@ -322,10 +403,21 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
) )
@abstractmethod @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, """Backend-specific plan resolution: image/container names,
env-file, prompt-file, proxy plan, runtime detection. Called by 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 @abstractmethod
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]: def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
@@ -369,9 +461,30 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
return prompt_path return prompt_path
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None: def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the operator workspace into the running bottle when """Copy the operator workspace into the running bottle.
the backend cannot bake it into the agent image. Default is
no-op for backends like Docker that handle this before launch.""" 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: def supervise_mcp_url(self, plan: PlanT) -> str:
"""Return the agent-side URL of the per-bottle supervise """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: This module is a thin façade. The real work lives in four siblings:
- prepare.py — host-side resolution into a DockerBottlePlan - resolve_plan.py — Docker-specific resolution into a DockerBottlePlan
- launch.py — bring-up + teardown context manager - launch.py — bring-up + teardown context manager
- cleanup.py — orphan enumeration + removal - cleanup.py — orphan enumeration + removal
- enumerate.py — active-agent listing - enumerate.py — active-agent listing
The base class's `prepare` template runs cross-backend host-side The base class's `prepare` template runs cross-backend host-side
validation before calling `_resolve_plan` here. validation before calling `_resolve_plan` here.
@@ -25,11 +25,16 @@ from pathlib import Path
from typing import Generator, Sequence from typing import Generator, Sequence
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT 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 ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
from . import prepare as _prepare from . import resolve_plan as _resolve_plan
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
@@ -48,8 +53,34 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
launch.""" launch."""
return shutil.which("docker") is not None return shutil.which("docker") is not None
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: def _preflight(self) -> None:
return _prepare.resolve_plan(spec, stage_dir=stage_dir) _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 @contextmanager
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]: def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
+19 -12
View File
@@ -22,25 +22,32 @@ class DockerBottlePlan(BottlePlan):
`agent_provision` from BottlePlan.""" `agent_provision` from BottlePlan."""
slug: str 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 # name -> value for vars forwarded into the docker-run child process
# via subprocess env (so values never land on argv or in a file). # via subprocess env (so values never land on argv or in a file).
# repr=False keeps secret/interpolated/OAuth values out of any # repr=False keeps secret/interpolated/OAuth values out of any
# accidental log of the plan dataclass. # accidental log of the plan dataclass.
forwarded_env: dict[str, str] = field(repr=False) forwarded_env: dict[str, str] = field(repr=False)
prompt_file: Path
use_runsc: bool 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 @property
def agent_command(self) -> str: def agent_command(self) -> str:
return self.agent_provision.command return self.agent_provision.command
+4 -11
View File
@@ -32,10 +32,10 @@ from __future__ import annotations
import shutil import shutil
import subprocess import subprocess
from pathlib import Path
from ...agent_provider import get_provider
from ...log import info, warn from ...log import info, warn
from .bottle_state import ( from ...bottle_state import (
mark_preserved, mark_preserved,
per_bottle_dockerfile, per_bottle_dockerfile,
transcript_snapshot_dir, transcript_snapshot_dir,
@@ -93,11 +93,11 @@ def fetch_current_dockerfile(slug: str) -> str:
override = per_bottle_dockerfile(slug) override = per_bottle_dockerfile(slug)
if override is not None: if override is not None:
return override return override
repo_dockerfile = _repo_dockerfile_path() repo_dockerfile = get_provider("claude").dockerfile
if repo_dockerfile.is_file(): if repo_dockerfile.is_file():
return repo_dockerfile.read_text() return repo_dockerfile.read_text()
raise CapabilityApplyError( 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}" f"{repo_dockerfile}"
) )
@@ -125,13 +125,6 @@ def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
# --- Internals ------------------------------------------------------------- # --- 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: def snapshot_transcript(slug: str) -> None:
"""`docker cp` /home/node/.claude out of the agent container into """`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 ...log import info, warn
from . import util as docker_mod from . import util as docker_mod
from .bottle_cleanup_plan import DockerBottleCleanupPlan 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 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) env.append(name)
service: dict[str, Any] = { service: dict[str, Any] = {
"image": plan.runtime_image, "image": plan.image,
"container_name": plan.container_name, "container_name": plan.container_name,
"command": ["sleep", "infinity"], "command": ["sleep", "infinity"],
"networks": {"internal": None}, "networks": {"internal": None},
@@ -230,8 +230,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
} }
if plan.use_runsc: if plan.use_runsc:
service["runtime"] = "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]] = [] volumes: list[dict[str, Any]] = []
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
+1 -1
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import subprocess import subprocess
from .. import ActiveAgent from .. import ActiveAgent
from .bottle_state import read_metadata from ...bottle_state import read_metadata
from .compose import compose_project_name, list_active_slugs 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: The flow is:
1. Build the agent's base + derived image (compose builds the 1. Build the agent image from the provider Dockerfile (compose
sidecar images via the `build:` directive on first up). builds the sidecar images via the `build:` directive on first up).
2. Mint the per-bottle egress CA (chunk 2 writes it under 2. Mint the per-bottle egress CA (chunk 2 writes it under
state/<slug>/egress/). state/<slug>/egress/).
3. Populate the inner plans with launch-time fields so the 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 7. `docker compose up -d` (token + OAuth values flow into the
compose subprocess env so `environment: [NAME]` bare-name compose subprocess env so `environment: [NAME]` bare-name
entries inherit without rendering values into the file). entries inherit without rendering values into the file).
8. Provision (CA install, prompt copy, skills, git, supervise 8. Provision (CA install, prompt copy, skills, workspace, git,
config) — unchanged, uses `docker exec`. supervise config) — unchanged, uses `docker exec` / `docker cp`.
9. Yield a DockerBottle handle. `exec_agent` runs claude via 9. Yield a DockerBottle handle. `exec_agent` runs claude via
`docker exec -it` exactly like the pre-compose world. `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 . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .bottle_state import ( from ...bottle_state import (
bottle_state_dir, bottle_state_dir,
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
@@ -97,10 +97,6 @@ def launch(
plan.image, _REPO_DIR, plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path, 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) internal_network = network_mod.network_name_for_slug(plan.slug)
egress_network = network_mod.network_egress_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 re
import shutil import shutil
import subprocess import subprocess
import tempfile
from typing import Iterable, Iterator from typing import Iterable, Iterator
from ...log import die, info 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 # 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) subprocess.run(args, check=True)
def build_image_with_cwd( # def build_image_with_cwd(
derived: str, # derived: str,
base: str, # base: str,
workspace: WorkspacePlan, # workspace: "WorkspacePlan",
) -> None: # ) -> None:
"""Build a thin derived image that copies the workspace into # """Build a thin derived image that copies the workspace into
the plan's guest path and sets the plan's workdir.""" # the plan's guest path and sets the plan's workdir."""
import os # import os
#
cwd = str(workspace.host_path) # cwd = str(workspace.host_path)
if not os.path.isdir(cwd): # if not os.path.isdir(cwd):
die(f"cwd not found at {cwd}") # die(f"cwd not found at {cwd}")
info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}") # info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}")
with tempfile.TemporaryDirectory(prefix="bot-bottle-cwd.") as tmp: # with tempfile.TemporaryDirectory(prefix="bot-bottle-cwd.") as tmp:
context_dir = os.path.join(tmp, "context") # context_dir = os.path.join(tmp, "context")
staged_workspace = os.path.join(context_dir, "workspace") # staged_workspace = os.path.join(context_dir, "workspace")
shutil.copytree( # shutil.copytree(
cwd, # cwd,
staged_workspace, # staged_workspace,
symlinks=True, # symlinks=True,
ignore=shutil.ignore_patterns(".git"), # ignore=shutil.ignore_patterns(".git"),
) # )
dockerfile = ( # dockerfile = (
f"FROM {base}\n" # f"FROM {base}\n"
f"COPY --chown=node:node workspace/. {workspace.guest_path}\n" # f"COPY --chown=node:node workspace/. {workspace.guest_path}\n"
f"WORKDIR {workspace.workdir}\n" # f"WORKDIR {workspace.workdir}\n"
) # )
subprocess.run( # subprocess.run(
["docker", "build", "-t", derived, "-f", "-", context_dir], # ["docker", "build", "-t", derived, "-f", "-", context_dir],
input=dockerfile, # input=dockerfile,
text=True, # text=True,
check=True, # check=True,
) # )
def image_id(ref: str) -> str: 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 pathlib import Path
from typing import Generator, Sequence 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 cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
from . import prepare as _prepare from . import resolve_plan as _resolve_plan
from . import smolvm as _smolvm from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle from .bottle import SmolmachinesBottle
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
from .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .provision import workspace as _workspace
class SmolmachinesBottleBackend( class SmolmachinesBottleBackend(
@@ -41,10 +45,34 @@ class SmolmachinesBottleBackend(
runtime check happens at `prepare`.""" runtime check happens at `prepare`."""
return _smolvm.is_available() 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( 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: ) -> 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 @contextmanager
def launch( def launch(
@@ -53,11 +81,6 @@ class SmolmachinesBottleBackend(
with _launch.launch(plan, provision=self.provision) as bottle: with _launch.launch(plan, provision=self.provision) as bottle:
yield 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: def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
"""The smolmachines guest reaches the supervise sidecar via a """The smolmachines guest reaches the supervise sidecar via a
host-published random port the launch step pinned earlier host-published random port the launch step pinned earlier
+28 -26
View File
@@ -29,27 +29,6 @@ class SmolmachinesBottlePlan(BottlePlan):
bundle_subnet: str bundle_subnet: str
bundle_gateway: str bundle_gateway: str
bundle_ip: 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 # In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
# the guest has no DNS resolver inside the TSI allowlist. # the guest has no DNS resolver inside the TSI allowlist.
# Passed to `smolvm machine create` as `-e K=V` flags. # Passed to `smolvm machine create` as `-e K=V` flags.
@@ -57,11 +36,6 @@ class SmolmachinesBottlePlan(BottlePlan):
# `--smolfile` is mutually exclusive with `--from`, and # `--smolfile` is mutually exclusive with `--from`, and
# `--from` is the path that avoids the registry-pull race). # `--from` is the path that avoids the registry-pull race).
guest_env: dict[str, str] 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 # Inner Plans for the sidecar bundle daemons. The same shape the
# docker backend uses — same `.prepare()` calls produced # docker backend uses — same `.prepare()` calls produced
# them — but our launch step doesn't populate the # them — but our launch step doesn't populate the
@@ -82,6 +56,34 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_git_gate_host: str = "" agent_git_gate_host: str = ""
agent_supervise_url: 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 @property
def git_gate_insteadof_host(self) -> str: def git_gate_insteadof_host(self) -> str:
return self.agent_git_gate_host return self.agent_git_gate_host
+1 -1
View File
@@ -23,7 +23,7 @@ import json
import subprocess import subprocess
from .. import ActiveAgent from .. import ActiveAgent
from ..docker.bottle_state import read_metadata from ...bottle_state import read_metadata
from . import sidecar_bundle as _bundle 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 ...git_gate import revoke_git_gate_provisioned_keys
from ...log import warn 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 loopback_alias as _loopback
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm from . import smolvm as _smolvm
@@ -90,7 +90,7 @@ def launch(
# here, not in prepare, so the docker-build output doesn't # here, not in prepare, so the docker-build output doesn't
# garble the dashboard's preflight modal. # garble the dashboard's preflight modal.
agent_from_path = _ensure_smolmachine( agent_from_path = _ensure_smolmachine(
plan.agent_image_ref, plan.agent_image,
dockerfile=plan.agent_dockerfile_path, 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 provisioning also moved to the AgentProvider ABC (with Debian/node
defaults); user plugins override them for non-standard images. defaults); user plugins override them for non-standard images.
The module left in this subpackage handles the remaining backend- No modules remain in this subpackage. Workspace copying now runs
specific step: through `BottleBackend.provision_workspace` against the running
bottle for every backend.
- workspace.py copy the operator workspace into the guest
""" """
@@ -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 pathlib import Path
from typing import cast from typing import cast
from ... import supervise as _supervise from . import supervise as _supervise
from . import util as docker_mod
# Directory layout: ~/.bot-bottle/state/<identity>/... # 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 To continue an existing bottle's state, use the recorded
identity from BottleMetadata via `cli.py resume <identity>`, identity from BottleMetadata via `cli.py resume <identity>`,
not this function.""" not this function."""
from .backend.docker import util as docker_mod
slug = docker_mod.slugify(agent_name) slug = docker_mod.slugify(agent_name)
suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN)) suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN))
return f"{slug}-{suffix}" return f"{slug}-{suffix}"
+1 -1
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
import argparse import argparse
from ..backend import BottleSpec from ..backend import BottleSpec
from ..backend.docker.bottle_state import read_metadata from ..bottle_state import read_metadata
from ..log import die from ..log import die
from ..manifest import Manifest from ..manifest import Manifest
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
+4 -4
View File
@@ -24,12 +24,12 @@ from ..backend import (
known_backend_names, known_backend_names,
) )
from ..backend.docker.bottle_plan import DockerBottlePlan from ..backend.docker.bottle_plan import DockerBottlePlan
from ..backend.docker.bottle_state import ( from ..bottle_state import (
cleanup_state, cleanup_state,
is_preserved, is_preserved,
mark_preserved, mark_preserved,
) )
from ..backend.docker.capability_apply import snapshot_transcript # from ..backend.docker.capability_apply import snapshot_transcript
from ..log import info from ..log import info
from ..manifest import Manifest from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line from ._common import PROG, USER_CWD, read_tty_line
@@ -39,7 +39,7 @@ from . import tui
def cmd_start(argv: list[str]) -> int: def cmd_start(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True) parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
parser.add_argument("--dry-run", action="store_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("--remote-control", action="store_true")
parser.add_argument( parser.add_argument(
"--backend", "--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. # instead of relying on each agent's transcript layout.
if not identity: if not identity:
return return
snapshot_transcript(identity) # snapshot_transcript(identity)
if exit_code != 0: if exit_code != 0:
mark_preserved(identity) mark_preserved(identity)
+21 -17
View File
@@ -20,12 +20,17 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .. import supervise as _supervise from .. import supervise as _supervise
from ..backend.docker.bottle_state import read_metadata # from ..bottle_state import read_metadata
from ..backend.docker.capability_apply import ( # from ..backend.docker.capability_apply import (
CapabilityApplyError, # CapabilityApplyError,
apply_capability_change, # apply_capability_change,
) # )
from ..log import Die, error, info from ..log import Die, error, info
class CapabilityApplyError(RuntimeError):
"""Placeholder while capability_apply is disabled."""
from ..supervise import ( from ..supervise import (
COMPONENT_FOR_TOOL, COMPONENT_FOR_TOOL,
AuditEntry, AuditEntry,
@@ -124,20 +129,19 @@ def approve(
) -> None: ) -> None:
"""Apply the proposal, write the waiting response, and audit it.""" """Apply the proposal, write the waiting response, and audit it."""
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
diff_before, diff_after = "", "" diff_before, diff_after = "", ""
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: # if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
_meta = read_metadata(qp.proposal.bottle_slug) # _meta = read_metadata(qp.proposal.bottle_slug)
if _meta is not None and not _meta.compose_project: # if _meta is not None and not _meta.compose_project:
raise CapabilityApplyError( # raise CapabilityApplyError(
"capability-block remediation is not supported for smolmachines " # "capability-block remediation is not supported for smolmachines "
"bottles. Reject this proposal or handle the capability change " # "bottles. Reject this proposal or handle the capability change "
"manually, then restart the bottle." # "manually, then restart the bottle."
) # )
diff_before, diff_after = apply_capability_change( # diff_before, diff_after = apply_capability_change(
qp.proposal.bottle_slug, file_to_apply, # qp.proposal.bottle_slug, file_to_apply,
) # )
response = Response( response = Response(
proposal_id=qp.proposal.id, proposal_id=qp.proposal.id,
+6 -4
View File
@@ -28,8 +28,6 @@ if TYPE_CHECKING:
from ...backend import Bottle, BottlePlan from ...backend import Bottle, BottlePlan
_REPO_ROOT = Path(__file__).resolve().parents[3]
_SUPERVISE_MCP_NAME = "supervise" _SUPERVISE_MCP_NAME = "supervise"
@@ -44,7 +42,6 @@ _RUNTIME = AgentProviderRuntime(
template="claude", template="claude",
command="claude", command="claude",
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
prompt_mode="append_file", prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",), bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",), resume_args=("--continue",),
@@ -62,7 +59,8 @@ class ClaudeAgentProvider(AgentProvider):
*, *,
dockerfile: str, dockerfile: str,
state_dir: Path, state_dir: Path,
guest_home: str, instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None, guest_env: dict[str, str] | None = None,
auth_token: str = "", auth_token: str = "",
forward_host_credentials: bool = False, forward_host_credentials: bool = False,
@@ -73,6 +71,7 @@ class ClaudeAgentProvider(AgentProvider):
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
del forward_host_credentials, host_env # Codex-only knobs del forward_host_credentials, host_env # Codex-only knobs
resolved_guest_env = dict(guest_env or {}) resolved_guest_env = dict(guest_env or {})
guest_home = self.guest_home
trusted_path = trusted_project_path or guest_home trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = { env_vars: dict[str, str] = {
@@ -113,6 +112,9 @@ class ClaudeAgentProvider(AgentProvider):
prompt_mode=_RUNTIME.prompt_mode, prompt_mode=_RUNTIME.prompt_mode,
image=_RUNTIME.image, image=_RUNTIME.image,
dockerfile=dockerfile, dockerfile=dockerfile,
guest_home=guest_home,
instance_name=instance_name,
prompt_file=prompt_file,
env_vars=env_vars, env_vars=env_vars,
guest_env=resolved_guest_env, guest_env=resolved_guest_env,
files=files, files=files,
+6 -4
View File
@@ -32,8 +32,6 @@ if TYPE_CHECKING:
from ...backend import Bottle, BottlePlan from ...backend import Bottle, BottlePlan
_REPO_ROOT = Path(__file__).resolve().parents[3]
_SUPERVISE_MCP_NAME = "supervise" _SUPERVISE_MCP_NAME = "supervise"
@@ -52,7 +50,6 @@ _RUNTIME = AgentProviderRuntime(
template="codex", template="codex",
command="codex", command="codex",
image="bot-bottle-codex:latest", image="bot-bottle-codex:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",), bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"), resume_args=("resume", "--last"),
@@ -70,7 +67,8 @@ class CodexAgentProvider(AgentProvider):
*, *,
dockerfile: str, dockerfile: str,
state_dir: Path, state_dir: Path,
guest_home: str, instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None, guest_env: dict[str, str] | None = None,
auth_token: str = "", auth_token: str = "",
forward_host_credentials: bool = False, forward_host_credentials: bool = False,
@@ -81,6 +79,7 @@ class CodexAgentProvider(AgentProvider):
) -> AgentProvisionPlan: ) -> AgentProvisionPlan:
del auth_token, label, color # Claude-only knobs del auth_token, label, color # Claude-only knobs
resolved_guest_env = dict(guest_env or {}) resolved_guest_env = dict(guest_env or {})
guest_home = self.guest_home
trusted_path = trusted_project_path or guest_home trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = { env_vars: dict[str, str] = {
@@ -150,6 +149,9 @@ class CodexAgentProvider(AgentProvider):
prompt_mode=_RUNTIME.prompt_mode, prompt_mode=_RUNTIME.prompt_mode,
image=_RUNTIME.image, image=_RUNTIME.image,
dockerfile=dockerfile, dockerfile=dockerfile,
guest_home=guest_home,
instance_name=instance_name,
prompt_file=prompt_file,
env_vars=env_vars, env_vars=env_vars,
guest_env=resolved_guest_env, guest_env=resolved_guest_env,
dirs=tuple(dirs), dirs=tuple(dirs),
+4 -4
View File
@@ -24,7 +24,7 @@ from .egress_addon_core import (
from .log import die from .log import die
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import Bottle from .manifest import ManifestBottle
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
@@ -66,7 +66,7 @@ class EgressPlan:
def egress_manifest_routes( def egress_manifest_routes(
bottle: Bottle, bottle: ManifestBottle,
) -> tuple[EgressRoute, ...]: ) -> tuple[EgressRoute, ...]:
out: list[EgressRoute] = [] out: list[EgressRoute] = []
for r in bottle.egress.routes: for r in bottle.egress.routes:
@@ -98,7 +98,7 @@ def egress_manifest_routes(
def egress_routes_for_bottle( def egress_routes_for_bottle(
bottle: Bottle, bottle: ManifestBottle,
provider_routes: tuple[EgressRoute, ...] = (), provider_routes: tuple[EgressRoute, ...] = (),
) -> tuple[EgressRoute, ...]: ) -> tuple[EgressRoute, ...]:
manifest = egress_manifest_routes(bottle) manifest = egress_manifest_routes(bottle)
@@ -280,7 +280,7 @@ def egress_resolve_token_values(
class Egress(ABC): class Egress(ABC):
def prepare( def prepare(
self, self,
bottle: Bottle, bottle: ManifestBottle,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
provider_routes: tuple[EgressRoute, ...] = (), 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, is_git_push_request,
load_config, load_config,
match_route, match_route,
outbound_scan_headers,
scan_inbound, scan_inbound,
scan_outbound, scan_outbound,
) )
@@ -159,7 +160,7 @@ class EgressAddon:
flow.request.pretty_host, flow.request.pretty_host,
request_path, request_path,
query, query,
dict(flow.request.headers), outbound_scan_headers(route, dict(flow.request.headers)),
body, body,
) )
dlp_result = scan_outbound(route, scan_text, os.environ) 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) 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( def build_inbound_scan_text(
headers: typing.Mapping[str, str], headers: typing.Mapping[str, str],
body: str, body: str,
@@ -644,6 +665,7 @@ __all__ = [
"load_config", "load_config",
"load_routes", "load_routes",
"match_route", "match_route",
"outbound_scan_headers",
"parse_config", "parse_config",
"parse_routes", "parse_routes",
"scan_inbound", "scan_inbound",
+7 -7
View File
@@ -37,7 +37,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .log import info 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 # Short network alias for git-gate inside the sidecar bundle. The
@@ -96,9 +96,9 @@ class GitGatePlan:
egress_network: str = "" 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 """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( return tuple(
GitGateUpstream( GitGateUpstream(
name=e.Name, name=e.Name,
@@ -113,7 +113,7 @@ def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]
def git_gate_render_gitconfig( def git_gate_render_gitconfig(
entries: tuple[GitEntry, ...], gate_host: str, *, scheme: str = "git", entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
) -> str: ) -> str:
"""Render the agent's ~/.gitconfig content for git-gate """Render the agent's ~/.gitconfig content for git-gate
`insteadOf` rewrites. Pure host-side, no docker / smolvm; `insteadOf` rewrites. Pure host-side, no docker / smolvm;
@@ -361,7 +361,7 @@ exit 0
def _provision_dynamic_key( def _provision_dynamic_key(
entry: GitEntry, entry: ManifestGitEntry,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
) -> str: ) -> str:
@@ -402,7 +402,7 @@ def _provision_dynamic_key(
return str(key_file) 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. """Revoke all deploy keys provisioned for `bottle` during prepare.
Called at teardown after containers stop. Raises if any revocation 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 start/stop lifecycle is backend-specific and lives on concrete
subclasses.""" 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 """Compute the upstream table from `bottle.git` and write the
entrypoint, pre-receive hook, and access-hook scripts (mode entrypoint, pre-receive hook, and access-hook scripts (mode
600) under `stage_dir`. Pure host-side, no docker subprocess. 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 typing import Mapping
from .manifest_util import ManifestError, as_json_object from .manifest_util import ManifestError, as_json_object
from .manifest_agent import Agent, AgentProvider from .manifest_agent import ManifestAgent, ManifestAgentProvider
from .manifest_egress import ( from .manifest_egress import (
EGRESS_AUTH_SCHEMES, EGRESS_AUTH_SCHEMES,
EgressConfig, ManifestEgressConfig,
EgressRoute, 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 from .manifest_schema import BOTTLE_KEYS
# Re-export everything that callers currently import from this module. # Re-export everything that callers currently import from this module.
__all__ = [ __all__ = [
"ManifestError", "ManifestError",
"GitEntry", "ManifestGitEntry",
"GitUser", "ManifestGitUser",
"AgentProvider", "ManifestAgentProvider",
"EGRESS_AUTH_SCHEMES", "EGRESS_AUTH_SCHEMES",
"EgressRoute", "ManifestEgressRoute",
"EgressConfig", "ManifestEgressConfig",
"Agent", "ManifestAgent",
"Bottle", "ManifestBottle",
"Manifest", "Manifest",
] ]
@@ -86,16 +86,16 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
@dataclass(frozen=True) @dataclass(frozen=True)
class Bottle: class ManifestBottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict) env: Mapping[str, str] = field(default_factory=_empty_str_dict)
agent_provider: AgentProvider = field(default_factory=AgentProvider) agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider)
git: tuple[GitEntry, ...] = () git: tuple[ManifestGitEntry, ...] = ()
# Per-bottle git identity (issue #86). Empty default — bottles # Per-bottle git identity (issue #86). Empty default — bottles
# that don't set `git-gate.user:` in the manifest skip the # that don't set `git-gate.user:` in the manifest skip the
# `git config --global` step entirely. A bottle can declare a user # `git config --global` step entirely. A bottle can declare a user
# identity without any git-gate.repos upstreams, and vice versa. # identity without any git-gate.repos upstreams, and vice versa.
git_user: GitUser = field(default_factory=GitUser) git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
egress: EgressConfig = field(default_factory=EgressConfig) egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes MCP # the launch step brings up a supervise sidecar that exposes MCP
# tools to the agent (egress-block, capability-block) plus mounts # tools to the agent (egress-block, capability-block) plus mounts
@@ -105,7 +105,7 @@ class Bottle:
supervise: bool = False supervise: bool = False
@classmethod @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}'") d = as_json_object(raw, f"bottle '{name}'")
if "runtime" in d: if "runtime" in d:
@@ -157,22 +157,22 @@ class Bottle:
) )
env[var] = value env[var] = value
git: tuple[GitEntry, ...] = () git: tuple[ManifestGitEntry, ...] = ()
git_user = GitUser() git_user = ManifestGitUser()
git_raw = d.get("git-gate") git_raw = d.get("git-gate")
if git_raw is not None: if git_raw is not None:
git, git_user = parse_git_gate_config(name, git_raw) git, git_user = parse_git_gate_config(name, git_raw)
agent_provider = ( agent_provider = (
AgentProvider.from_dict(name, d["agent_provider"]) ManifestAgentProvider.from_dict(name, d["agent_provider"])
if "agent_provider" in d if "agent_provider" in d
else AgentProvider() else ManifestAgentProvider()
) )
egress = ( egress = (
EgressConfig.from_dict(name, d["egress"]) ManifestEgressConfig.from_dict(name, d["egress"])
if "egress" in d if "egress" in d
else EgressConfig() else ManifestEgressConfig()
) )
supervise_raw = d.get("supervise", False) supervise_raw = d.get("supervise", False)
@@ -190,8 +190,8 @@ class Bottle:
@dataclass(frozen=True) @dataclass(frozen=True)
class Manifest: class Manifest:
bottles: Mapping[str, Bottle] bottles: Mapping[str, ManifestBottle]
agents: Mapping[str, Agent] agents: Mapping[str, ManifestAgent]
@classmethod @classmethod
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest": def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
@@ -305,8 +305,8 @@ class Manifest:
bottles = resolve_bottles(raw_bottles) bottles = resolve_bottles(raw_bottles)
bottle_names = set(bottles.keys()) bottle_names = set(bottles.keys())
agents: dict[str, Agent] = { agents: dict[str, ManifestAgent] = {
n: Agent.from_dict(n, a, bottle_names) for n, a in raw_agents.items() n: ManifestAgent.from_dict(n, a, bottle_names) for n, a in raw_agents.items()
} }
return cls(bottles=bottles, agents=agents) 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).") 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, """Merge the agent's git.user over the referenced bottle's,
per-field, agent-wins-on-non-empty (issue #94). Same overlay per-field, agent-wins-on-non-empty (issue #94). Same overlay
the `extends:` resolver applies between bottles the `extends:` resolver applies between bottles
@@ -348,12 +348,12 @@ class Manifest:
over = agent.git_user over = agent.git_user
if over.is_empty(): if over.is_empty():
return base return base
return GitUser( return ManifestGitUser(
name=over.name or base.name, name=over.name or base.name,
email=over.email or base.email, 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 """Resolve the Bottle the named agent references, with the
agent's git.user overlaid on top. The validator guarantees both agent's git.user overlaid on top. The validator guarantees both
lookups succeed for a manifest built via from_json_obj. 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 .agent_provider import PROVIDER_TEMPLATES
from .manifest_util import ManifestError, as_json_object 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 from .manifest_schema import AGENT_MODEL_KEYS
@dataclass(frozen=True) @dataclass(frozen=True)
class AgentProvider: class ManifestAgentProvider:
"""Provider/template for the agent process inside a bottle. """Provider/template for the agent process inside a bottle.
`template` selects a built-in launch/runtime contract. `dockerfile` `template` selects a built-in launch/runtime contract. `dockerfile`
@@ -35,7 +35,7 @@ class AgentProvider:
forward_host_credentials: bool = False forward_host_credentials: bool = False
@classmethod @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") d = as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
for k in d: for k in d:
if k not in {"template", "dockerfile", "auth_token", "forward_host_credentials"}: if k not in {"template", "dockerfile", "auth_token", "forward_host_credentials"}:
@@ -98,7 +98,7 @@ class AgentProvider:
@dataclass(frozen=True) @dataclass(frozen=True)
class Agent: class ManifestAgent:
bottle: str bottle: str
skills: tuple[str, ...] = () skills: tuple[str, ...] = ()
prompt: str = "" prompt: str = ""
@@ -106,10 +106,10 @@ class Agent:
# bottle's git-gate.user per-field at `Manifest.bottle_for`. Only # bottle's git-gate.user per-field at `Manifest.bottle_for`. Only
# `user` is allowed at the agent level; `repos` stays bottle-only # `user` is allowed at the agent level; `repos` stays bottle-only
# because it carries credentials and host trust. # because it carries credentials and host trust.
git_user: GitUser = GitUser() git_user: ManifestGitUser = ManifestGitUser()
@classmethod @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}'") d = as_json_object(raw, f"agent '{name}'")
unknown = set(d.keys()) - AGENT_MODEL_KEYS unknown = set(d.keys()) - AGENT_MODEL_KEYS
if unknown: if unknown:
@@ -164,7 +164,7 @@ class Agent:
# git-gate: agents may declare only `git-gate.user` (name/email). # git-gate: agents may declare only `git-gate.user` (name/email).
# `git-gate.repos` is bottle-only — it carries credentials and host trust. # `git-gate.repos` is bottle-only — it carries credentials and host trust.
git_user = GitUser() git_user = ManifestGitUser()
git_raw = d.get("git-gate") git_raw = d.get("git-gate")
if git_raw is not None: if git_raw is not None:
gd = as_json_object(git_raw, f"agent '{name}' git-gate") gd = as_json_object(git_raw, f"agent '{name}' git-gate")
@@ -177,6 +177,6 @@ class Agent:
f"(it carries credentials and host trust)." f"(it carries credentials and host trust)."
) )
if "user" in gd: 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) 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( def validate_egress_routes(
bottle_name: str, bottle_name: str,
routes: tuple[EgressRoute, ...], routes: tuple[ManifestEgressRoute, ...],
) -> None: ) -> None:
seen_hosts: dict[str, None] = {} seen_hosts: dict[str, None] = {}
for r in routes: for r in routes:
@@ -38,29 +38,29 @@ def validate_egress_routes(
@dataclass(frozen=True) @dataclass(frozen=True)
class PathMatch: class ManifestPathMatch:
Type: str = "prefix" Type: str = "prefix"
Value: str = "" Value: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
class HeaderMatch: class ManifestHeaderMatch:
Name: str = "" Name: str = ""
Value: str = "" Value: str = ""
Type: str = "exact" Type: str = "exact"
@dataclass(frozen=True) @dataclass(frozen=True)
class MatchEntry: class ManifestMatchEntry:
Paths: tuple[PathMatch, ...] = () Paths: tuple[ManifestPathMatch, ...] = ()
Methods: tuple[str, ...] = () Methods: tuple[str, ...] = ()
Headers: tuple[HeaderMatch, ...] = () Headers: tuple[ManifestHeaderMatch, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressRoute: class ManifestEgressRoute:
Host: str Host: str
Matches: tuple[MatchEntry, ...] = () Matches: tuple[ManifestMatchEntry, ...] = ()
AuthScheme: str = "" AuthScheme: str = ""
TokenRef: str = "" TokenRef: str = ""
Role: tuple[str, ...] = () Role: tuple[str, ...] = ()
@@ -68,7 +68,7 @@ class EgressRoute:
InboundDetectors: tuple[str, ...] | None = None InboundDetectors: tuple[str, ...] | None = None
@classmethod @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}]" label = f"bottle '{bottle_name}' egress.routes[{idx}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
host = d.get("host") host = d.get("host")
@@ -76,7 +76,7 @@ class EgressRoute:
raise ManifestError(f"{label} missing required string field 'host'") raise ManifestError(f"{label} missing required string field 'host'")
# --- matches --- # --- matches ---
matches: tuple[MatchEntry, ...] = () matches: tuple[ManifestMatchEntry, ...] = ()
matches_raw = d.get("matches") matches_raw = d.get("matches")
if matches_raw is not None: if matches_raw is not None:
if not isinstance(matches_raw, list): if not isinstance(matches_raw, list):
@@ -85,7 +85,7 @@ class EgressRoute:
f"(was {type(matches_raw).__name__})" f"(was {type(matches_raw).__name__})"
) )
matches_list = cast(list[object], matches_raw) matches_list = cast(list[object], matches_raw)
entries: list[MatchEntry] = [] entries: list[ManifestMatchEntry] = []
for k, entry_raw in enumerate(matches_list): for k, entry_raw in enumerate(matches_list):
entries.append( entries.append(
_parse_match_entry(label, k, entry_raw) _parse_match_entry(label, k, entry_raw)
@@ -185,17 +185,17 @@ class EgressRoute:
def _parse_match_entry( def _parse_match_entry(
route_label: str, k: int, raw: object, route_label: str, k: int, raw: object,
) -> MatchEntry: ) -> ManifestMatchEntry:
label = f"{route_label} matches[{k}]" label = f"{route_label} matches[{k}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
paths: tuple[PathMatch, ...] = () paths: tuple[ManifestPathMatch, ...] = ()
paths_raw = d.get("paths") paths_raw = d.get("paths")
if paths_raw is not None: if paths_raw is not None:
if not isinstance(paths_raw, list): if not isinstance(paths_raw, list):
raise ManifestError(f"{label} paths must be an array") raise ManifestError(f"{label} paths must be an array")
paths_list = cast(list[object], paths_raw) paths_list = cast(list[object], paths_raw)
parsed_paths: list[PathMatch] = [] parsed_paths: list[ManifestPathMatch] = []
for j, p_raw in enumerate(paths_list): for j, p_raw in enumerate(paths_list):
parsed_paths.append(_parse_path_match(label, j, p_raw)) parsed_paths.append(_parse_path_match(label, j, p_raw))
paths = tuple(parsed_paths) paths = tuple(parsed_paths)
@@ -220,13 +220,13 @@ def _parse_match_entry(
normalised.append(upper) normalised.append(upper)
methods = tuple(normalised) methods = tuple(normalised)
headers: tuple[HeaderMatch, ...] = () headers: tuple[ManifestHeaderMatch, ...] = ()
headers_raw = d.get("headers") headers_raw = d.get("headers")
if headers_raw is not None: if headers_raw is not None:
if not isinstance(headers_raw, list): if not isinstance(headers_raw, list):
raise ManifestError(f"{label} headers must be an array") raise ManifestError(f"{label} headers must be an array")
headers_list = cast(list[object], headers_raw) headers_list = cast(list[object], headers_raw)
parsed_headers: list[HeaderMatch] = [] parsed_headers: list[ManifestHeaderMatch] = []
for j, h_raw in enumerate(headers_list): for j, h_raw in enumerate(headers_list):
parsed_headers.append(_parse_header_match(label, j, h_raw)) parsed_headers.append(_parse_header_match(label, j, h_raw))
headers = tuple(parsed_headers) headers = tuple(parsed_headers)
@@ -235,12 +235,12 @@ def _parse_match_entry(
if key not in ("paths", "methods", "headers"): if key not in ("paths", "methods", "headers"):
raise ManifestError(f"{label} has unknown key {key!r}") 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( def _parse_path_match(
entry_label: str, j: int, raw: object, entry_label: str, j: int, raw: object,
) -> PathMatch: ) -> ManifestPathMatch:
label = f"{entry_label} paths[{j}]" label = f"{entry_label} paths[{j}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
ptype = d.get("type", "prefix") ptype = d.get("type", "prefix")
@@ -266,12 +266,12 @@ def _parse_path_match(
for k in d: for k in d:
if k not in ("type", "value"): if k not in ("type", "value"):
raise ManifestError(f"{label} has unknown key {k!r}") 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( def _parse_header_match(
entry_label: str, j: int, raw: object, entry_label: str, j: int, raw: object,
) -> HeaderMatch: ) -> ManifestHeaderMatch:
label = f"{entry_label} headers[{j}]" label = f"{entry_label} headers[{j}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
name = d.get("name") name = d.get("name")
@@ -296,7 +296,7 @@ def _parse_header_match(
for k in d: for k in d:
if k not in ("name", "value", "type"): if k not in ("name", "value", "type"):
raise ManifestError(f"{label} has unknown key {k!r}") 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( def _parse_dlp_block(
@@ -350,15 +350,15 @@ LOG_LEVELS = frozenset({0, 1, 2})
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressConfig: class ManifestEgressConfig:
routes: tuple[EgressRoute, ...] = () routes: tuple[ManifestEgressRoute, ...] = ()
Log: int = 0 Log: int = 0
@classmethod @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") d = as_json_object(raw, f"bottle '{bottle_name}' egress")
routes_raw = d.get("routes") routes_raw = d.get("routes")
routes: tuple[EgressRoute, ...] = () routes: tuple[ManifestEgressRoute, ...] = ()
if routes_raw is not None: if routes_raw is not None:
if not isinstance(routes_raw, list): if not isinstance(routes_raw, list):
raise ManifestError( raise ManifestError(
@@ -367,7 +367,7 @@ class EgressConfig:
) )
routes_list = cast(list[object], routes_raw) routes_list = cast(list[object], routes_raw)
routes = tuple( routes = tuple(
EgressRoute.from_dict(bottle_name, i, entry) ManifestEgressRoute.from_dict(bottle_name, i, entry)
for i, entry in enumerate(routes_list) for i, entry in enumerate(routes_list)
) )
validate_egress_routes(bottle_name, routes) validate_egress_routes(bottle_name, routes)
+21 -21
View File
@@ -5,12 +5,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if 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]: def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
"""Apply `extends:` chains and return resolved Bottle objects.""" """Apply `extends:` chains and return resolved ManifestBottle objects."""
cache: dict[str, Bottle] = {} cache: dict[str, ManifestBottle] = {}
for name in raws: for name in raws:
if name not in cache: if name not in cache:
_resolve_one_bottle(name, raws, 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( def _resolve_one_bottle(
name: str, name: str,
raws: dict[str, dict[str, object]], raws: dict[str, dict[str, object]],
cache: dict[str, Bottle], cache: dict[str, ManifestBottle],
seen: tuple[str, ...], seen: tuple[str, ...],
) -> Bottle: ) -> ManifestBottle:
from .manifest import Bottle, ManifestError from .manifest import ManifestBottle, ManifestError
if name in cache: if name in cache:
return cache[name] return cache[name]
@@ -32,13 +32,13 @@ def _resolve_one_bottle(
raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}") raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}")
raw = raws[name] raw = raws[name]
parent_name_raw = raw.get("extends") parent_name_raw = raw.get("extends")
# Strip `extends:` before passing to Bottle.from_dict so it # Strip `extends:` before passing to ManifestBottle.from_dict so it
# is not accidentally treated as a real Bottle field by future # is not accidentally treated as a real ManifestBottle field by future
# schema additions. It is only meaningful here. # schema additions. It is only meaningful here.
child_raw = {k: v for k, v in raw.items() if k != "extends"} child_raw = {k: v for k, v in raw.items() if k != "extends"}
if parent_name_raw is None: if parent_name_raw is None:
bottle = Bottle.from_dict(name, child_raw) bottle = ManifestBottle.from_dict(name, child_raw)
cache[name] = bottle cache[name] = bottle
return bottle return bottle
@@ -66,27 +66,27 @@ def _resolve_one_bottle(
def _merge_bottles( def _merge_bottles(
parent: Bottle, parent: ManifestBottle,
child_raw: dict[str, object], child_raw: dict[str, object],
name: str, name: str,
) -> Bottle: ) -> ManifestBottle:
"""Apply PRD 0025 merge rules.""" """Apply PRD 0025 merge rules."""
from .manifest import Bottle, GitUser from .manifest import ManifestBottle, ManifestGitUser
from .manifest_egress import validate_egress_routes 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 # usual defaults for anything missing). Validation runs the same
# way it would for a leaf bottle: typos / wrong types die here. # 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. # env: dict merge, child wins on collision.
merged_env = {**parent.env, **child.env} merged_env = {**parent.env, **child.env}
# git-gate.user: per-field overlay. Each non-empty field on child # 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 # is two empty strings, so a child that omits git-gate.user
# inherits the parent's user verbatim. # inherits the parent's user verbatim.
merged_git_user = GitUser( merged_git_user = ManifestGitUser(
name=child.git_user.name or parent.git_user.name, name=child.git_user.name or parent.git_user.name,
email=child.git_user.email or parent.git_user.email, email=child.git_user.email or parent.git_user.email,
) )
@@ -112,7 +112,7 @@ def _merge_bottles(
) )
validate_egress_routes(name, merged_egress.routes) validate_egress_routes(name, merged_egress.routes)
return Bottle( return ManifestBottle(
env=merged_env, env=merged_env,
agent_provider=merged_agent_provider, agent_provider=merged_agent_provider,
git=merged_git, git=merged_git,
@@ -133,9 +133,9 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
def _merge_git_remotes( def _merge_git_remotes(
parent: tuple[GitEntry, ...], parent: tuple[ManifestGitEntry, ...],
child: tuple[GitEntry, ...], child: tuple[ManifestGitEntry, ...],
) -> tuple[GitEntry, ...]: ) -> tuple[ManifestGitEntry, ...]:
by_host = {entry.UpstreamHost: entry for entry in parent} by_host = {entry.UpstreamHost: entry for entry in parent}
for entry in child: for entry in child:
by_host[entry.UpstreamHost] = entry 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) 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] = {} seen: dict[str, None] = {}
for g in git: for g in git:
if g.Name in seen: if g.Name in seen:
@@ -69,7 +69,7 @@ def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> No
@dataclass(frozen=True) @dataclass(frozen=True)
class ProvisionedKeyConfig: class ManifestProvisionedKeyConfig:
"""Configuration for automatic deploy-key lifecycle management """Configuration for automatic deploy-key lifecycle management
(PRD 0048). Used when a git-gate.repos entry opts out of a (PRD 0048). Used when a git-gate.repos entry opts out of a
static identity file and instead wants a fresh SSH keypair static identity file and instead wants a fresh SSH keypair
@@ -87,7 +87,7 @@ class ProvisionedKeyConfig:
@dataclass(frozen=True) @dataclass(frozen=True)
class GitEntry: class ManifestGitEntry:
"""One upstream the per-agent git-gate (PRD 0008) is allowed to """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 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 if there were no gate; the gate hosts a bare repo at /git/<Name>.git
@@ -107,7 +107,7 @@ class GitEntry:
Upstream: str Upstream: str
IdentityFile: str = "" IdentityFile: str = ""
KnownHostKey: str = "" KnownHostKey: str = ""
ProvisionedKey: Optional[ProvisionedKeyConfig] = None ProvisionedKey: Optional[ManifestProvisionedKeyConfig] = None
RemoteKey: str = "" RemoteKey: str = ""
UpstreamUser: str = "" UpstreamUser: str = ""
UpstreamHost: str = "" UpstreamHost: str = ""
@@ -117,7 +117,7 @@ class GitEntry:
@classmethod @classmethod
def from_repos_entry( def from_repos_entry(
cls, bottle_name: str, repo_name: str, raw: object cls, bottle_name: str, repo_name: str, raw: object
) -> "GitEntry": ) -> "ManifestGitEntry":
"""Parse one entry from `git-gate.repos.<repo_name>`. """Parse one entry from `git-gate.repos.<repo_name>`.
YAML keys: `url` (required), exactly one of `identity` or YAML keys: `url` (required), exactly one of `identity` or
@@ -160,7 +160,7 @@ class GitEntry:
) )
ident = "" ident = ""
provisioned_key: Optional[ProvisionedKeyConfig] = None provisioned_key: Optional[ManifestProvisionedKeyConfig] = None
if has_identity: if has_identity:
raw_ident = d.get("identity") raw_ident = d.get("identity")
if not isinstance(raw_ident, str) or not raw_ident: if not isinstance(raw_ident, str) or not raw_ident:
@@ -196,7 +196,7 @@ class GitEntry:
def _parse_provisioned_key_config( def _parse_provisioned_key_config(
bottle_name: str, label: str, raw: object bottle_name: str, label: str, raw: object
) -> ProvisionedKeyConfig: ) -> ManifestProvisionedKeyConfig:
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key") d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key")
for k in d: for k in d:
if k not in {"provider", "token_env", "api_url"}: if k not in {"provider", "token_env", "api_url"}:
@@ -221,7 +221,7 @@ def _parse_provisioned_key_config(
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string" f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
) )
return ProvisionedKeyConfig( return ManifestProvisionedKeyConfig(
provider=provider, provider=provider,
token_env=token_env, token_env=token_env,
api_url=api_url_raw, api_url=api_url_raw,
@@ -229,7 +229,7 @@ def _parse_provisioned_key_config(
@dataclass(frozen=True) @dataclass(frozen=True)
class GitUser: class ManifestGitUser:
"""Per-bottle `git config --global user.name` / `user.email` """Per-bottle `git config --global user.name` / `user.email`
pair (issue #86). The agent's commits inside the bottle are pair (issue #86). The agent's commits inside the bottle are
attributed to this identity rather than the agent image's attributed to this identity rather than the agent image's
@@ -244,7 +244,7 @@ class GitUser:
email: str = "" email: str = ""
@classmethod @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") d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user")
for k in d: for k in d:
if k not in {"name", "email"}: if k not in {"name", "email"}:
@@ -279,7 +279,7 @@ class GitUser:
def parse_git_gate_config( def parse_git_gate_config(
bottle_name: str, bottle_name: str,
raw: object, raw: object,
) -> tuple[tuple[GitEntry, ...], GitUser]: ) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate") d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
for k in d: for k in d:
if k not in {"user", "repos"}: if k not in {"user", "repos"}:
@@ -289,17 +289,17 @@ def parse_git_gate_config(
) )
git_user = ( git_user = (
GitUser.from_dict(bottle_name, d["user"]) ManifestGitUser.from_dict(bottle_name, d["user"])
if "user" in d if "user" in d
else GitUser() else ManifestGitUser()
) )
git: tuple[GitEntry, ...] = () git: tuple[ManifestGitEntry, ...] = ()
repos_raw = d.get("repos") repos_raw = d.get("repos")
if repos_raw is not None: if repos_raw is not None:
repos = as_json_object(repos_raw, f"bottle '{bottle_name}' git-gate.repos") repos = as_json_object(repos_raw, f"bottle '{bottle_name}' git-gate.repos")
git = tuple( git = tuple(
GitEntry.from_repos_entry(bottle_name, name, entry) ManifestGitEntry.from_repos_entry(bottle_name, name, entry)
for name, entry in repos.items() for name, entry in repos.items()
) )
validate_unique_git_names(bottle_name, git) 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 from .yaml_subset import YamlSubsetError, parse_frontmatter
if TYPE_CHECKING: 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: 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 """Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict.""" `{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError from .manifest import ManifestError
@@ -67,13 +67,13 @@ def load_agents_from_dir(
bottle_names: set[str], bottle_names: set[str],
*, *,
source: str, # noqa: F841 — unused, but required by interface 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 """Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt. `{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict.""" 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(): if not agents_dir.is_dir():
return out return out
for path in sorted(agents_dir.glob("*.md")): for path in sorted(agents_dir.glob("*.md")):
@@ -101,5 +101,5 @@ def load_agents_from_dir(
} }
if "git-gate" in fm: if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"] 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 return out
-5
View File
@@ -465,8 +465,6 @@ class Supervise(ABC):
self, self,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
*,
dockerfile_content: str = "",
) -> SupervisePlan: ) -> SupervisePlan:
"""Stage the per-bottle queue dir on the host and the """Stage the per-bottle queue dir on the host and the
current-config dir under `stage_dir`. Returns the plan; current-config dir under `stage_dir`. Returns the plan;
@@ -476,9 +474,6 @@ class Supervise(ABC):
queue_dir.mkdir(parents=True, exist_ok=True) queue_dir.mkdir(parents=True, exist_ok=True)
current_config_dir = stage_dir / "current-config" current_config_dir = stage_dir / "current-config"
current_config_dir.mkdir(parents=True, exist_ok=True) 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( return SupervisePlan(
slug=slug, slug=slug,
queue_dir=queue_dir, 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 # Build the image graph quietly so the recorded run shows only the
# bottle launch and the four `!` probes, not BuildKit progress. # 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 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 pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend 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 bot_bottle.manifest import Manifest
from tests._docker import skip_unless_docker 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 ( from bot_bottle.agent_provider import (
CODEX_HOST_CREDENTIAL_HOSTS, CODEX_HOST_CREDENTIAL_HOSTS,
agent_provision_plan, build_agent_provision_plan,
) )
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
@@ -25,11 +25,12 @@ def _jwt(exp: int) -> str:
class TestAgentProviderRuntime(unittest.TestCase): class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_plan_declares_home_state(self): def test_codex_plan_declares_home_state(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node",
template="codex", template="codex",
dockerfile="/tmp/Dockerfile.codex", dockerfile="/tmp/Dockerfile.codex",
state_dir=Path(tmp), state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
) )
config = Path(tmp, "codex-config.toml").read_text() config = Path(tmp, "codex-config.toml").read_text()
self.assertEqual("codex", plan.template) self.assertEqual("codex", plan.template)
@@ -50,11 +51,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_trusts_requested_project_path(self): def test_codex_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan( build_agent_provision_plan(
guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
state_dir=Path(tmp), state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
trusted_project_path="/home/node/workspace", trusted_project_path="/home/node/workspace",
) )
config = Path(tmp, "codex-config.toml").read_text() config = Path(tmp, "codex-config.toml").read_text()
@@ -68,11 +70,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt", "auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(2000000000)}, "tokens": {"access_token": _jwt(2000000000)},
})) }))
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
state_dir=Path(tmp), state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
guest_env={"CODEX_HOME": "/run/codex-home"}, guest_env={"CODEX_HOME": "/run/codex-home"},
forward_host_credentials=True, forward_host_credentials=True,
host_env={"CODEX_HOME": str(home)}, 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): def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node",
template="claude", template="claude",
dockerfile="/tmp/Dockerfile.claude", dockerfile="/tmp/Dockerfile.claude",
state_dir=Path(tmp), state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN", auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN",
) )
claude_config = json.loads(Path(tmp, "claude.json").read_text()) 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): def test_claude_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan( build_agent_provision_plan(
guest_home="/home/node",
template="claude", template="claude",
dockerfile="", dockerfile="",
state_dir=Path(tmp), state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
trusted_project_path="/home/node/workspace", trusted_project_path="/home/node/workspace",
) )
config = json.loads(Path(tmp, "claude.json").read_text()) config = json.loads(Path(tmp, "claude.json").read_text())
@@ -129,11 +134,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt", "auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(2000000000)}, "tokens": {"access_token": _jwt(2000000000)},
})) }))
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
state_dir=Path(tmp), state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=True, forward_host_credentials=True,
host_env={"CODEX_HOME": str(home)}, 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): def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
state_dir=Path(tmp), state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=False, forward_host_credentials=False,
) )
self.assertEqual( self.assertEqual(
@@ -162,11 +169,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_without_auth_token_has_passthrough_egress_route(self): def test_claude_without_auth_token_has_passthrough_egress_route(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node",
template="claude", template="claude",
dockerfile="", dockerfile="",
state_dir=Path(tmp), state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
) )
self.assertEqual(1, len(plan.egress_routes)) self.assertEqual(1, len(plan.egress_routes))
route = plan.egress_routes[0] route = plan.egress_routes[0]
@@ -185,11 +193,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt", "auth_mode": "chatgpt",
"tokens": {"access_token": access}, "tokens": {"access_token": access},
})) }))
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
state_dir=Path(tmp), state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=True, forward_host_credentials=True,
host_env={"CODEX_HOME": str(home)}, 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): def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
state_dir=Path(tmp), state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=False, forward_host_credentials=False,
) )
self.assertEqual({}, plan.provisioned_env) 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 pathlib import Path
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.backend.docker import bottle_state from bot_bottle import bottle_state
from bot_bottle.backend.docker.bottle_state import ( from bot_bottle.bottle_state import (
BottleMetadata, BottleMetadata,
read_metadata, read_metadata,
write_metadata, write_metadata,
@@ -260,7 +260,7 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
def test_missing_backend_field_defaults_to_empty(self): def test_missing_backend_field_defaults_to_empty(self):
# Old state dirs written before PRD 0040 have no backend key. # Old state dirs written before PRD 0040 have no backend key.
import json 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 = bs.metadata_path("dev-b3")
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({ 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 pathlib import Path
from bot_bottle import supervise 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 from bot_bottle.cli import start as start_mod
@@ -29,29 +29,20 @@ class _FakeHomeMixin:
class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase): 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): def setUp(self):
self._setup_fake_home() 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): def tearDown(self):
start_mod.snapshot_transcript = self._orig_snap
self._teardown_fake_home() 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) 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")) 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) 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")) self.assertTrue(bottle_state.is_preserved("dev-abc"))
def test_ctrl_c_treated_as_crash(self): 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 # Backends without an identity field shouldn't crash this
# path (the _identity_from_plan helper falls back to ""). # path (the _identity_from_plan helper falls back to "").
start_mod.capture_claude_session_state("", exit_code=137) 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): 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.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
SLUG = "demo-abc12" SLUG = "demo-abc12"
@@ -149,19 +148,10 @@ def _plan(
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress) spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=STAGE, stage_dir=STAGE,
slug=SLUG, 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"}, forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
prompt_file=STAGE / "prompt",
git_gate_plan=_git_gate_plan(upstreams), git_gate_plan=_git_gate_plan(upstreams),
egress_plan=_egress_plan(routes), egress_plan=_egress_plan(routes),
supervise_plan=_supervise_plan() if supervise else None, supervise_plan=_supervise_plan() if supervise else None,
@@ -172,9 +162,11 @@ def _plan(
prompt_mode="append_file", prompt_mode="append_file",
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
dockerfile="", dockerfile="",
guest_home="/home/node",
instance_name=f"bot-bottle-{SLUG}",
prompt_file=STAGE / "prompt",
guest_env={}, 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): def test_agent_image_uses_runtime_image(self):
plan = _plan() plan = _plan()
s = bottle_plan_to_compose(plan)["services"]["agent"] 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): def test_agent_only_on_internal_network(self):
s = bottle_plan_to_compose(_plan())["services"]["agent"] s = bottle_plan_to_compose(_plan())["services"]["agent"]
@@ -252,6 +244,9 @@ class TestAgentAlwaysPresent(unittest.TestCase):
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest", image="bot-bottle-codex:latest",
dockerfile="", dockerfile="",
guest_home="/home/node",
instance_name=f"bot-bottle-{SLUG}",
prompt_file=STAGE / "prompt",
guest_env={"CODEX_HOME": "/home/node/.codex"}, guest_env={"CODEX_HOME": "/home/node/.codex"},
) )
plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore 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.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
_URL = "http://supervise:9100/" _URL = "http://supervise:9100/"
@@ -76,19 +75,10 @@ def _plan(
current_config_dir=Path("/tmp/current-config"), current_config_dir=Path("/tmp/current-config"),
) )
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=Path("/tmp/stage"), stage_dir=Path("/tmp/stage"),
slug="demo-abc12", 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={}, forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -106,9 +96,12 @@ def _plan(
use_runsc=False, use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan( agent_provision=agent_provision or AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file", 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): def test_copies_files_and_chowns(self):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file", 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( files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json", Path("/tmp/claude.json"), "/home/node/.claude.json",
),), ),),
@@ -234,7 +230,10 @@ class TestClaudeProvision(unittest.TestCase):
def test_dies_when_file_chown_fails(self): def test_dies_when_file_chown_fails(self):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file", 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( files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json", Path("/tmp/claude.json"), "/home/node/.claude.json",
),), ),),
@@ -250,7 +249,10 @@ class TestClaudeProvision(unittest.TestCase):
def test_runs_verify_commands(self): def test_runs_verify_commands(self):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file", 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( verify=(AgentProvisionCommand(
("/usr/bin/true",), "verify failed", ("/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.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
_URL = "http://supervise:9100/" _URL = "http://supervise:9100/"
@@ -77,19 +76,10 @@ def _plan(
current_config_dir=Path("/tmp/current-config"), current_config_dir=Path("/tmp/current-config"),
) )
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=Path("/tmp/stage"), stage_dir=Path("/tmp/stage"),
slug="demo-abc12", 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={}, forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -107,9 +97,12 @@ def _plan(
use_runsc=False, use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan( agent_provision=agent_provision or AgentProvisionPlan(
template="codex", command="codex", prompt_mode="read_prompt_file", 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( provision = AgentProvisionPlan(
template="codex", command="codex", template="codex", command="codex",
prompt_mode="read_prompt_file", 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"),), dirs=(AgentProvisionDir("/home/node/.codex"),),
files=(AgentProvisionFile( files=(AgentProvisionFile(
Path("/tmp/codex-config.toml"), Path("/tmp/codex-config.toml"),
@@ -201,7 +197,10 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="codex", command="codex", template="codex", command="codex",
prompt_mode="read_prompt_file", 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( pre_copy=(AgentProvisionCommand(
("find", "/home/node/.codex", "-name", "*.sqlite", "-delete"), ("find", "/home/node/.codex", "-name", "*.sqlite", "-delete"),
"could not reset runtime db files", "could not reset runtime db files",
@@ -223,7 +222,10 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="codex", command="codex", template="codex", command="codex",
prompt_mode="read_prompt_file", 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"),), dirs=(AgentProvisionDir("/home/node/.codex"),),
) )
bottle = _make_bottle(exec_result=ExecResult(1, "", "mkdir: nope\n")) 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 pathlib import Path
from bot_bottle import supervise 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 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 pathlib import Path
from bot_bottle import supervise 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): 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.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan
def _manifest() -> Manifest: def _manifest() -> Manifest:
@@ -43,7 +42,6 @@ def _plan(tmp: str) -> DockerBottlePlan:
identity="test-teardown-00001", identity="test-teardown-00001",
) )
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=stage, stage_dir=stage,
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
@@ -64,21 +62,15 @@ def _plan(tmp: str) -> DockerBottlePlan:
template="claude", template="claude",
command="claude", command="claude",
prompt_mode="append_file", prompt_mode="append_file",
image="", image="bot-bottle-claude:latest",
dockerfile="", dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-test-teardown-abc",
prompt_file=stage / "prompt.txt",
guest_env={}, guest_env={},
), ),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
slug="test-teardown-00001", 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={}, forwarded_env={},
prompt_file=stage / "prompt.txt",
use_runsc=False, 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.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan
class _Provider(AgentProvider): class _Provider(AgentProvider):
@@ -30,7 +29,7 @@ class _Provider(AgentProvider):
@property @property
def runtime(self) -> AgentProviderRuntime: def runtime(self) -> AgentProviderRuntime:
return AgentProviderRuntime( return AgentProviderRuntime(
template="test", command="test", image="", dockerfile="", template="test", command="test", image="",
prompt_mode="append_file", bypass_args=(), resume_args=(), prompt_mode="append_file", bypass_args=(), resume_args=(),
remote_control_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, copy_cwd=copy_cwd, user_cwd=user_cwd,
) )
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=stage_dir or Path("/tmp/stage"), stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12", 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={}, forwarded_env={},
prompt_file=Path("/tmp/prompt.txt"),
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), 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", prompt_mode="append_file",
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
dockerfile="", dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={}, 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)) _PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
self.assertEqual([], _git_config_exec_calls(bottle)) 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): def test_sets_name_and_email(self):
plan = _plan( plan = _plan(
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"}, git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"},
-58
View File
@@ -8,13 +8,10 @@ integration smoke."""
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
import tempfile
import unittest import unittest
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle.backend.docker import util as docker_mod 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 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+57
View File
@@ -30,6 +30,7 @@ from bot_bottle.egress_addon_core import (
load_config, load_config,
load_routes, load_routes,
match_route, match_route,
outbound_scan_headers,
parse_config, parse_config,
parse_routes, parse_routes,
scan_inbound, scan_inbound,
@@ -798,6 +799,41 @@ class TestBuildOutboundScanText(unittest.TestCase):
self.assertIn(fragment, text) 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 ------------------------------------------------------- # --- scan_outbound -------------------------------------------------------
_AWS_KEY = "AKIAIOSFODNN7EXAMPLE" _AWS_KEY = "AKIAIOSFODNN7EXAMPLE"
@@ -815,6 +851,27 @@ class TestScanOutbound(unittest.TestCase):
) )
self.assertIsNone(scan_outbound(_ROUTE, text, {})) 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): def test_token_in_body_blocked(self):
text = build_outbound_scan_text( text = build_outbound_scan_text(
host="api.example.com", host="api.example.com",
+4 -4
View File
@@ -2,7 +2,7 @@
import unittest 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 def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -99,13 +99,13 @@ class TestGitUserDirect(unittest.TestCase):
"""Direct GitUser dataclass exercises (no manifest wrapper).""" """Direct GitUser dataclass exercises (no manifest wrapper)."""
def test_is_empty_default(self): 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): 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): 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__": if __name__ == "__main__":
+2 -2
View File
@@ -7,7 +7,7 @@ silently ignoring."""
import unittest import unittest
from typing import Any 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]: def _manifest_with_runtime(value: object) -> dict[str, Any]:
@@ -26,7 +26,7 @@ class TestManifestRuntimeRemoved(unittest.TestCase):
self.assertIn("dev", m.bottles) self.assertIn("dev", m.bottles)
def test_bottle_dataclass_has_no_runtime_attribute(self): 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): def test_any_runtime_value_is_rejected(self):
for value in ("runsc", "runc", "kata-runtime", "", 42, None): 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.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan
def _manifest() -> Manifest: 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( return AgentProvisionPlan(
template="claude", template="claude",
command="claude", command="claude",
prompt_mode="append_file", prompt_mode="append_file",
image="", image="bot-bottle-claude:latest",
dockerfile="", 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"}, 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: def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
stage = Path(tmp) stage = Path(tmp)
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=stage, stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp), git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp), egress_plan=_egress_plan(tmp),
supervise_plan=None, supervise_plan=None,
agent_provision=_agent_provision(), agent_provision=_agent_provision(tmp),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
slug="test-00001", 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={}, forwarded_env={},
prompt_file=stage / "prompt.txt",
use_runsc=False, use_runsc=False,
) )
@@ -118,22 +110,17 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan: def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
stage = Path(tmp) stage = Path(tmp)
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=stage, stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp), git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp), egress_plan=_egress_plan(tmp),
supervise_plan=None, supervise_plan=None,
agent_provision=_agent_provision(), agent_provision=_agent_provision(tmp),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
slug="test-00001", slug="test-00001",
bundle_subnet="10.99.0.0/24", bundle_subnet="10.99.0.0/24",
bundle_gateway="10.99.0.1", bundle_gateway="10.99.0.1",
bundle_ip="10.99.0.2", 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"}, 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 ( from bot_bottle.backend.smolmachines.bottle_plan import (
SmolmachinesBottlePlan, SmolmachinesBottlePlan,
) )
from bot_bottle.backend.smolmachines.provision import ( # from bot_bottle.backend.smolmachines.provision import (
workspace as _workspace, # workspace as _workspace,
) # )
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream 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.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
class _Provider(AgentProvider): class _Provider(AgentProvider):
@@ -43,7 +42,7 @@ class _Provider(AgentProvider):
@property @property
def runtime(self) -> AgentProviderRuntime: def runtime(self) -> AgentProviderRuntime:
return AgentProviderRuntime( return AgentProviderRuntime(
template="test", command="test", image="", dockerfile="", template="test", command="test", image="",
prompt_mode="append_file", bypass_args=(), resume_args=(), prompt_mode="append_file", bypass_args=(), resume_args=(),
remote_control_args=(), remote_control_args=(),
) )
@@ -71,11 +70,6 @@ def _make_bottle(
return 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 def _exec_users(bottle: MagicMock) -> list[str]: # type: ignore
"""user= kwarg from each bottle.exec call, in order.""" """user= kwarg from each bottle.exec call, in order."""
return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list] return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list]
@@ -85,7 +79,7 @@ def _plan(
*, *,
agent_prompt: str = "", agent_prompt: str = "",
skills: list[str] | None = None, skills: list[str] | None = None,
git: list[GitEntry] = (), # type: ignore git: list[ManifestGitEntry] = (), # type: ignore
git_user: dict | None = None, # type: ignore git_user: dict | None = None, # type: ignore
copy_cwd: bool = False, copy_cwd: bool = False,
user_cwd: str = "/tmp/x", user_cwd: str = "/tmp/x",
@@ -140,17 +134,13 @@ def _plan(
current_config_dir=Path("/tmp/current-config"), current_config_dir=Path("/tmp/current-config"),
) )
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=stage_dir or Path("/tmp/stage"), stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
bundle_subnet="192.168.50.0/24", bundle_subnet="192.168.50.0/24",
bundle_gateway="192.168.50.1", bundle_gateway="192.168.50.1",
bundle_ip=bundle_ip, bundle_ip=bundle_ip,
machine_name="bot-bottle-demo-abc12",
agent_image_ref="bot-bottle-claude:latest",
guest_env=dict(guest_env or {}), guest_env=dict(guest_env or {}),
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
slug="demo-abc12", slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -173,7 +163,6 @@ def _plan(
codex_auth_file=codex_auth_file, codex_auth_file=codex_auth_file,
guest_env=dict(guest_env or {}), guest_env=dict(guest_env or {}),
), ),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
) )
@@ -188,8 +177,11 @@ def _agent_provision(
template=template, template=template,
command=template, command=template,
prompt_mode="append_file", prompt_mode="append_file",
image="", image="bot-bottle-claude:latest",
dockerfile="", 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 {}), guest_env=dict(guest_env or {}),
) )
auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex") auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex")
@@ -227,6 +219,9 @@ def _agent_provision(
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest", image="bot-bottle-codex:latest",
dockerfile="", 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 {}), guest_env=dict(guest_env or {}),
dirs=(AgentProvisionDir(auth_dir),), dirs=(AgentProvisionDir(auth_dir),),
files=tuple(files), files=tuple(files),
@@ -341,9 +336,7 @@ class TestSmolmachinesBottleExec(unittest.TestCase):
class TestProvisionGit(unittest.TestCase): class TestProvisionGit(unittest.TestCase):
"""provision_git dispatches two independent passes (cwd .git """provision_git writes gitconfig insteadOf rules when configured."""
copy + gitconfig insteadOf write); each no-ops on its own
when its condition doesn't hold."""
def setUp(self): def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with 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.cp_in.assert_not_called()
bottle.exec.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): def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
# Smolmachines's TSI-allowlisted guest dials git-gate via # Smolmachines's TSI-allowlisted guest dials git-gate via
# smart HTTP at `127.0.0.1:<host port>` — the bundle's # smart HTTP at `127.0.0.1:<host port>` — the bundle's
# git HTTP port is published on host loopback at launch # git HTTP port is published on host loopback at launch
# time, and the plan carries the discovered host port. # time, and the plan carries the discovered host port.
plan = _plan( plan = _plan(
git=[GitEntry( git=[ManifestGitEntry(
Name="bot-bottle", Name="bot-bottle",
Upstream="ssh://git@host/repo.git", Upstream="ssh://git@host/repo.git",
IdentityFile="~/.ssh/id_ed25519", IdentityFile="~/.ssh/id_ed25519",
@@ -505,43 +470,5 @@ class TestProvisionGitUser(unittest.TestCase):
self.assertIn("bot@example.com", calls[0][0]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+5 -11
View File
@@ -359,25 +359,19 @@ class TestSupervisePrepare(unittest.TestCase):
return lambda: setattr(supervise, "bot_bottle_root", original) return lambda: setattr(supervise, "bot_bottle_root", original)
def test_prepare_creates_queue_and_current_config(self): def test_prepare_creates_queue_and_current_config(self):
plan = _StubSupervise().prepare( plan = _StubSupervise().prepare("dev", self.stage_dir)
"dev", self.stage_dir,
dockerfile_content="FROM python:3.13\n",
)
self.assertTrue(plan.queue_dir.is_dir()) self.assertTrue(plan.queue_dir.is_dir())
self.assertTrue(plan.current_config_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("dev", plan.slug)
self.assertEqual("", plan.internal_network) 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 # 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) plan = _StubSupervise().prepare("dev", self.stage_dir)
files = sorted(p.name for p in plan.current_config_dir.iterdir()) files = sorted(p.name for p in plan.current_config_dir.iterdir())
self.assertEqual(["Dockerfile"], files) self.assertEqual([], files)
if __name__ == "__main__": if __name__ == "__main__":
+6 -113
View File
@@ -14,7 +14,6 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from bot_bottle import supervise 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.cli import supervise as supervise_cli
from bot_bottle.supervise import ( from bot_bottle.supervise import (
Proposal, Proposal,
@@ -115,13 +114,8 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
class TestApproveReject(_FakeHomeMixin, unittest.TestCase): class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def setUp(self): def setUp(self):
self._setup_fake_home() 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): def tearDown(self):
supervise_cli.apply_capability_change = self._original_apply_capability
self._teardown_fake_home() self._teardown_fake_home()
def _enqueue(self, tool: str = TOOL_CAPABILITY_BLOCK): def _enqueue(self, tool: str = TOOL_CAPABILITY_BLOCK):
@@ -161,67 +155,9 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
self.assertEqual([], read_audit_entries("egress", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): # class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
"""PRD 0016 Phase 3: approve() on a capability-block proposal # # DISABLED — capability_apply functionality is currently commented out.
calls apply_capability_change, archives the proposal afterward # pass
(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 TestEditInEditor(unittest.TestCase): class TestEditInEditor(unittest.TestCase):
@@ -268,52 +204,9 @@ class TestEditInEditor(unittest.TestCase):
os.environ["EDITOR"] = original_editor os.environ["EDITOR"] = original_editor
class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase): # class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
"""approve() must refuse capability-block for smolmachines bottles and # # DISABLED — capability_apply functionality is currently commented out.
pass it through for Docker bottles (PRD 0039).""" # pass
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
if __name__ == "__main__": 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()