Compare commits

...

10 Commits

Author SHA1 Message Date
didericis-codex 0a2152dfac test: narrow metadata assertions for pyright 2026-06-08 23:04:37 -04:00
didericis-codex 97eb8af4da fix: restore runtime workspace provisioning 2026-06-08 23:04:37 -04:00
didericis-codex dd1b6ef33f fix: restore backend prepare wiring 2026-06-08 23:04:37 -04:00
didericis-codex a67d856d45 fix: resolve pyright strict errors 2026-06-08 23:04:37 -04:00
didericis e42f095529 fix(egress): ignore stripped auth header in DLP scan 2026-06-08 23:04:37 -04:00
didericis-claude 265b464afa refactor: make AgentProvisionPlan the source of truth for instance_name, prompt_file, image, dockerfile, guest_home
Drop the parallel fields passed through prepare() → _resolve_plan and
read everything from agent_provision instead. The provider plugin now
declares its own guest_home (so the backend stops hardcoding
"/home/node") and the wrapper that builds the provision plan accepts
instance_name and prompt_file, which providers store on the plan.

DockerBottlePlan and SmolmachinesBottlePlan expose container_name /
machine_name, image / agent_image, dockerfile_path /
agent_dockerfile_path, and prompt_file as properties that delegate to
agent_provision so existing call sites keep working unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 23:04:37 -04:00
didericis-claude bbe7e70ea3 fix: fall back to provider's bundled Dockerfile when manifest doesn't override
BottleBackend.prepare was calling resolve_manifest_dockerfile("", spec)
for every bottle where the manifest did not set agent_provider.dockerfile.
That resolves an empty string against user_cwd, returning the cwd
itself — which docker then tried to read as a Dockerfile, giving
"is a directory" errors during image build.

When the manifest doesn't override, use the provider plugin's bundled
Dockerfile path (next to its agent_provider.py module) — mirroring
the pre-refactor behavior.
2026-06-08 23:04:37 -04:00
didericis-claude 19ab23993d fix: thread slug + resolved_env from prepare to each backend's _resolve_plan
BottleBackend.prepare computed slug and resolved_env but never passed
them to _resolve_plan. The concrete docker/smolmachines _resolve_plan
methods still had the old (spec, *, stage_dir) signature too, so
prepare's kwargs blew up with "unexpected keyword argument
'instance_name'" the moment cli.py start was invoked.

Update the abstract _resolve_plan signature and both backend
implementations to accept the full kwarg set prepare passes, and
forward to resolve_plan.resolve_plan() with everything.
2026-06-08 23:04:37 -04:00
didericis-claude 93dc193dc5 chore: comment out workspace + capability_apply, fix circular imports
The recent refactor partially removed workspace planning and
capability-apply logic. This commit finishes the cleanup so the
test suite imports cleanly:

- Comment out workspace_plan field/property on BottlePlan and the
  provision_workspace dispatch.
- Comment out workspace usages in docker.util (build_image_with_cwd),
  smolmachines.provision.workspace, agent_provider.provision_git,
  smolmachines.backend.
- Comment out capability_apply imports in cli.start and cli.supervise;
  add a local CapabilityApplyError placeholder so the supervise CLI
  module still imports.
- Break the bottle_state → backend.docker → backend circular import
  by lazy-loading docker_mod inside bottle_identity, and by moving the
  resolve_common import inside BottleBackend.prepare.
- Delete tests for workspace and capability_apply (unit + integration).
- Update test fixtures to drop removed kwargs (container_name_pinned,
  derived_image, env_file, workspace_plan, agent_image_ref) from
  DockerBottlePlan / SmolmachinesBottlePlan constructors.
- Delete the obsolete test_smolmachines_prepare.py (tested the old
  resolve_plan signature; the shared prepare flow now lives in
  BottleBackend.prepare).
- Adjust test_supervise.py for the new Supervise.prepare signature
  (dockerfile_content arg removed).

925 → 897 tests, all passing.
2026-06-08 23:04:37 -04:00
didericis 48e26bebec chore: sketch out desired refactor
Manual refactor into the rough shape we want/how we want the
resolve_plan logic to be consolidated. Needs subsequent fixes.
2026-06-08 23:03:08 -04:00
43 changed files with 857 additions and 1395 deletions
+20 -20
View File
@@ -104,6 +104,8 @@ class AgentProvisionPlan:
image: str
dockerfile: str
guest_home: str
instance_name: str
prompt_file: Path
guest_env: dict[str, str]
env_vars: dict[str, str] = field(default_factory=dict)
dirs: tuple[AgentProvisionDir, ...] = ()
@@ -128,6 +130,14 @@ class AgentProvider(ABC):
"""The static command / image / prompt-mode table for this
template."""
@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.
@@ -143,7 +153,8 @@ class AgentProvider(ABC):
*,
dockerfile: str,
state_dir: Path,
guest_home: str,
instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
@@ -215,23 +226,10 @@ class AgentProvider(ABC):
def provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None:
"""Configure git inside the agent container.
Default: Debian/node — copies .git when --cwd is set, writes the
git-gate insteadOf gitconfig, sets user.name/email as node.
Override for images that run as a different user or use a
non-standard home directory."""
Default: Debian/node — writes the git-gate insteadOf gitconfig
and sets user.name/email as node. Workspace copy runs through
BottleBackend.provision_workspace against the running bottle."""
from .log import info
workspace = plan.workspace_plan
if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir:
guest_workspace_git = f"{workspace.guest_path}/.git"
host_git = str(workspace.host_path / ".git")
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
bottle.cp_in(host_git, guest_workspace_git)
bottle.exec(
f"chown -R {shlex.quote(workspace.owner)} "
f"{shlex.quote(guest_workspace_git)}",
user="root",
)
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if manifest_bottle.git:
@@ -327,12 +325,13 @@ def runtime_for(template: str) -> AgentProviderRuntime:
return get_provider(template).runtime
def agent_provision_plan(
def build_agent_provision_plan(
*,
template: str,
dockerfile: str,
state_dir: Path,
guest_home: str,
instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
@@ -346,7 +345,8 @@ def agent_provision_plan(
return get_provider(template).provision_plan(
dockerfile=dockerfile,
state_dir=state_dir,
guest_home=guest_home,
instance_name=instance_name,
prompt_file=prompt_file,
guest_env=guest_env,
auth_token=auth_token,
forward_host_credentials=forward_host_credentials,
+120 -10
View File
@@ -32,6 +32,7 @@ manifest does not carry a backend field; the host picks.
from __future__ import annotations
import os
import shlex
import sys
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
@@ -39,14 +40,15 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider
from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan
from ..git_gate import GitGatePlan
from ..log import die, info
from ..manifest import ManifestGitEntry, Manifest
from ..supervise import SupervisePlan
from ..util import expand_tilde
from ..workspace import WorkspacePlan
from ..env import resolve_env, ResolvedEnv
from ..workspace import WorkspacePlan, workspace_plan
from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir
@@ -100,7 +102,10 @@ class BottlePlan(ABC):
egress_plan: EgressPlan
supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan
workspace_plan: WorkspacePlan
@property
def workspace_plan(self) -> WorkspacePlan:
return workspace_plan(self.spec, guest_home=self.guest_home)
def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr."""
@@ -266,14 +271,87 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
name: str
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
"""Template method: run cross-backend host-side validation, then
delegate to the subclass's `_resolve_plan` for the
backend-specific resolution (names, scratch files, etc.). The
validation step is enforced here so a future backend cannot
accidentally skip it. No remote/runtime resources are created."""
from .resolve_common import (
merge_provision_env_vars,
mint_slug,
prepare_agent_state_dir,
prepare_egress,
prepare_git_gate,
prepare_supervise,
resolve_manifest_dockerfile,
write_launch_metadata,
)
self._validate(spec)
return self._resolve_plan(spec, stage_dir=stage_dir)
self._preflight()
manifest = spec.manifest
manifest_bottle = manifest.bottle_for(spec.agent_name)
manifest_agent_provider = manifest_bottle.agent_provider
agent_provider = get_provider(manifest_agent_provider.template)
resolved_env = resolve_env(manifest, spec.agent_name)
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project="", backend=self.name)
# Manifest may override the Dockerfile per-bottle; otherwise fall
# back to the provider plugin's bundled Dockerfile (next to its
# agent_provider.py module).
if manifest_agent_provider.dockerfile:
agent_dockerfile_path = resolve_manifest_dockerfile(
manifest_agent_provider.dockerfile, spec,
)
else:
agent_dockerfile_path = str(agent_provider.dockerfile)
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
agent_provision_plan = build_agent_provision_plan(
template=manifest_agent_provider.template,
dockerfile=agent_dockerfile_path,
state_dir=agent_dir,
instance_name=f"bot-bottle-{slug}",
prompt_file=prompt_file,
guest_env=self._build_guest_env(resolved_env),
forward_host_credentials=manifest_agent_provider.forward_host_credentials,
auth_token=manifest_agent_provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace.workdir,
label=spec.label,
color=spec.color,
)
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
supervise_plan = prepare_supervise(manifest_bottle, slug)
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
return self._resolve_plan(
spec,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir,
)
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
return {}
def _preflight(self) -> None:
"""
tasks to do before resolving a plan
"""
pass
def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists,
@@ -325,10 +403,21 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
)
@abstractmethod
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
def _resolve_plan(self,
spec: BottleSpec,
*,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names,
env-file, prompt-file, proxy plan, runtime detection. Called by
`prepare` after `_validate` succeeds."""
`prepare` after `_validate` succeeds. Instance name, image,
prompt file, Dockerfile path, and guest home all live on
`agent_provision_plan` — the source of truth."""
@abstractmethod
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
@@ -372,9 +461,30 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
return prompt_path
def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None:
"""Copy the operator workspace into the running bottle when
the backend cannot bake it into the agent image. Default is
no-op for backends like Docker that handle this before launch."""
"""Copy the operator workspace into the running bottle.
This is the only supported workspace-provisioning path: Docker
does not build a derived image containing the current
workspace."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_contents):
return
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
guest_path = shlex.quote(workspace.guest_path)
guest_parent = shlex.quote(guest_parent)
owner = shlex.quote(workspace.owner)
mode = shlex.quote(workspace.mode)
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
bottle.exec(
f"rm -rf {guest_path} && mkdir -p {guest_parent}",
user="root",
)
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
bottle.exec(
f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}",
user="root",
)
def supervise_mcp_url(self, plan: PlanT) -> str:
"""Return the agent-side URL of the per-bottle supervise
+37 -6
View File
@@ -2,10 +2,10 @@
This module is a thin façade. The real work lives in four siblings:
- prepare.py — host-side resolution into a DockerBottlePlan
- launch.py — bring-up + teardown context manager
- cleanup.py — orphan enumeration + removal
- enumerate.py — active-agent listing
- resolve_plan.py — Docker-specific resolution into a DockerBottlePlan
- launch.py — bring-up + teardown context manager
- cleanup.py — orphan enumeration + removal
- enumerate.py — active-agent listing
The base class's `prepare` template runs cross-backend host-side
validation before calling `_resolve_plan` here.
@@ -25,6 +25,11 @@ from pathlib import Path
from typing import Generator, Sequence
from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
@@ -48,8 +53,34 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
launch."""
return shutil.which("docker") is not None
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
return _resolve_plan.resolve_plan(spec, stage_dir=stage_dir)
def _preflight(self) -> None:
_resolve_plan.preflight()
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
return _resolve_plan.build_guest_env(resolved_env)
def _resolve_plan(
self,
spec: BottleSpec,
*,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path,
) -> DockerBottlePlan:
return _resolve_plan.resolve_plan(
spec,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir,
)
@contextmanager
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
+19 -12
View File
@@ -22,25 +22,32 @@ class DockerBottlePlan(BottlePlan):
`agent_provision` from BottlePlan."""
slug: str
container_name: str
container_name_pinned: bool
image: str
derived_image: str # "" -> no derived image
runtime_image: str # image actually launched (derived or base)
# Absolute path to the Dockerfile that builds `image`. Empty means
# use the repo's default Dockerfile. Populated to a per-bottle
# state file (~/.bot-bottle/state/<slug>/Dockerfile) after a
# capability-block remediation (PRD 0016).
dockerfile_path: str
env_file: Path # docker --env-file: NAME=VALUE literals
# name -> value for vars forwarded into the docker-run child process
# via subprocess env (so values never land on argv or in a file).
# repr=False keeps secret/interpolated/OAuth values out of any
# accidental log of the plan dataclass.
forwarded_env: dict[str, str] = field(repr=False)
prompt_file: Path
use_runsc: bool
@property
def container_name(self) -> str:
return self.agent_provision.instance_name
@property
def image(self) -> str:
return self.agent_provision.image
@property
def dockerfile_path(self) -> str:
"""Absolute path to the Dockerfile that builds `image`. Sourced
from the agent provision plan — the manifest may override per
bottle; otherwise the provider plugin's bundled Dockerfile."""
return self.agent_provision.dockerfile
@property
def prompt_file(self) -> Path:
return self.agent_provision.prompt_file
@property
def agent_command(self) -> str:
return self.agent_provision.command
@@ -32,7 +32,6 @@ from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from ...agent_provider import get_provider
from ...log import info, warn
+1 -3
View File
@@ -222,7 +222,7 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
env.append(name)
service: dict[str, Any] = {
"image": plan.runtime_image,
"image": plan.image,
"container_name": plan.container_name,
"command": ["sleep", "infinity"],
"networks": {"internal": None},
@@ -230,8 +230,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
}
if plan.use_runsc:
service["runtime"] = "runsc"
if plan.env_file and plan.env_file.exists() and plan.env_file.stat().st_size > 0:
service["env_file"] = [str(plan.env_file)]
volumes: list[dict[str, Any]] = []
if plan.supervise_plan is not None:
+4 -8
View File
@@ -4,8 +4,8 @@ PRD 0018 chunk 3: each instance is one `docker compose` project.
The flow is:
1. Build the agent's base + derived image (compose builds the
sidecar images via the `build:` directive on first up).
1. Build the agent image from the provider Dockerfile (compose
builds the sidecar images via the `build:` directive on first up).
2. Mint the per-bottle egress CA (chunk 2 writes it under
state/<slug>/egress/).
3. Populate the inner plans with launch-time fields so the
@@ -15,8 +15,8 @@ The flow is:
7. `docker compose up -d` (token + OAuth values flow into the
compose subprocess env so `environment: [NAME]` bare-name
entries inherit without rendering values into the file).
8. Provision (CA install, prompt copy, skills, git, supervise
config) — unchanged, uses `docker exec`.
8. Provision (CA install, prompt copy, skills, workspace, git,
supervise config) — unchanged, uses `docker exec` / `docker cp`.
9. Yield a DockerBottle handle. `exec_agent` runs claude via
`docker exec -it` exactly like the pre-compose world.
@@ -97,10 +97,6 @@ def launch(
plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path,
)
if plan.derived_image:
docker_mod.build_image_with_cwd(
plan.derived_image, plan.image, plan.workspace_plan
)
internal_network = network_mod.network_name_for_slug(plan.slug)
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
+26 -174
View File
@@ -1,207 +1,59 @@
"""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
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
import os
from pathlib import Path
from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, get_provider
from ...env import ResolvedEnv, resolve_env
from ...log import die
from ...workspace import workspace_plan as resolve_workspace_plan
from .. import BottleSpec
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,
)
from . import util as docker_mod
from .bottle_plan import DockerBottlePlan
from ...bottle_state import (
clear_preserve_marker,
per_bottle_dockerfile,
per_bottle_dockerfile_path,
per_bottle_image_tag,
)
from .sidecar_bundle import sidecar_bundle_container_name
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_mod.require_docker()
manifest = spec.manifest
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_obj = get_provider(provider.template)
provider_runtime = provider_obj.runtime
guest_home = "/home/node"
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project=f"bot-bottle-{slug}", backend="docker")
# 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.
if provider.template in PROVIDER_TEMPLATES:
image = provider_runtime.image
else:
image = f"bot-bottle-{provider.template}:{slug}"
dockerfile_path = str(provider_obj.dockerfile)
if per_bottle_dockerfile(slug) is not None:
image = per_bottle_image_tag(slug)
dockerfile_path = str(per_bottle_dockerfile_path(slug))
elif provider.dockerfile:
image = f"bot-bottle-{provider.template}:{slug}"
dockerfile_path = resolve_manifest_dockerfile(provider.dockerfile, spec)
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."
)
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
env_file = agent_dir / "agent.env"
git_gate_plan = prepare_git_gate(bottle, slug)
resolved = resolve_env(manifest, spec.agent_name)
forwarded_env: dict[str, str] = dict(resolved.forwarded)
_write_env_file(resolved, env_file)
# ==== docker specific setup ====
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,
)
agent_provision = merge_provision_env_vars(agent_provision)
egress_plan = prepare_egress(bottle, slug, agent_provision)
# Current Dockerfile for the agent image. 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 provider_obj.dockerfile
)
dockerfile_content = (
supervise_dockerfile_path.read_text(encoding="utf-8")
if supervise_dockerfile_path.is_file()
else ""
)
supervise_plan = prepare_supervise(bottle, slug, dockerfile_content=dockerfile_content)
return DockerBottlePlan(
spec=spec,
stage_dir=stage_dir,
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,
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,
workspace_plan=workspace_plan,
agent_provision=agent_provision_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)
+34 -35
View File
@@ -7,11 +7,10 @@ from __future__ import annotations
import re
import shutil
import subprocess
import tempfile
from typing import Iterable, Iterator
from ...log import die, info
from ...workspace import WorkspacePlan
# from ...workspace import WorkspacePlan
# Cap on the suffix the container-name conflict logic will try before
@@ -118,39 +117,39 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
subprocess.run(args, check=True)
def build_image_with_cwd(
derived: str,
base: str,
workspace: WorkspacePlan,
) -> None:
"""Build a thin derived image that copies the workspace into
the plan's guest path and sets the plan's workdir."""
import os
cwd = str(workspace.host_path)
if not os.path.isdir(cwd):
die(f"cwd not found at {cwd}")
info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}")
with tempfile.TemporaryDirectory(prefix="bot-bottle-cwd.") as tmp:
context_dir = os.path.join(tmp, "context")
staged_workspace = os.path.join(context_dir, "workspace")
shutil.copytree(
cwd,
staged_workspace,
symlinks=True,
ignore=shutil.ignore_patterns(".git"),
)
dockerfile = (
f"FROM {base}\n"
f"COPY --chown=node:node workspace/. {workspace.guest_path}\n"
f"WORKDIR {workspace.workdir}\n"
)
subprocess.run(
["docker", "build", "-t", derived, "-f", "-", context_dir],
input=dockerfile,
text=True,
check=True,
)
# def build_image_with_cwd(
# derived: str,
# base: str,
# workspace: "WorkspacePlan",
# ) -> None:
# """Build a thin derived image that copies the workspace into
# the plan's guest path and sets the plan's workdir."""
# import os
#
# cwd = str(workspace.host_path)
# if not os.path.isdir(cwd):
# die(f"cwd not found at {cwd}")
# info(f"building image {derived} from {base} with {cwd} -> {workspace.guest_path}")
# with tempfile.TemporaryDirectory(prefix="bot-bottle-cwd.") as tmp:
# context_dir = os.path.join(tmp, "context")
# staged_workspace = os.path.join(context_dir, "workspace")
# shutil.copytree(
# cwd,
# staged_workspace,
# symlinks=True,
# ignore=shutil.ignore_patterns(".git"),
# )
# dockerfile = (
# f"FROM {base}\n"
# f"COPY --chown=node:node workspace/. {workspace.guest_path}\n"
# f"WORKDIR {workspace.workdir}\n"
# )
# subprocess.run(
# ["docker", "build", "-t", derived, "-f", "-", context_dir],
# input=dockerfile,
# text=True,
# check=True,
# )
def image_id(ref: str) -> str:
+2 -4
View File
@@ -83,16 +83,14 @@ def prepare_egress(
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
def prepare_supervise(
bottle: ManifestBottle, slug: str, *, dockerfile_content: str = "",
) -> SupervisePlan | None:
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, dockerfile_content=dockerfile_content)
return Supervise().prepare(slug, supervise_dir)
def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan:
+32 -9
View File
@@ -13,7 +13,12 @@ from contextlib import contextmanager
from pathlib import Path
from typing import Generator, Sequence
from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
from . import launch as _launch
@@ -22,7 +27,6 @@ from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
from .bottle_plan import SmolmachinesBottlePlan
from .provision import workspace as _workspace
class SmolmachinesBottleBackend(
@@ -41,10 +45,34 @@ class SmolmachinesBottleBackend(
runtime check happens at `prepare`."""
return _smolvm.is_available()
def _preflight(self) -> None:
_resolve_plan.preflight()
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
return _resolve_plan.build_guest_env(resolved_env)
def _resolve_plan(
self, spec: BottleSpec, *, stage_dir: Path
self,
spec: BottleSpec,
*,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path,
) -> SmolmachinesBottlePlan:
return _resolve_plan.resolve_plan(spec, stage_dir=stage_dir)
return _resolve_plan.resolve_plan(
spec,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir,
)
@contextmanager
def launch(
@@ -53,11 +81,6 @@ class SmolmachinesBottleBackend(
with _launch.launch(plan, provision=self.provision) as bottle:
yield bottle
def provision_workspace(
self, plan: SmolmachinesBottlePlan, bottle: Bottle
) -> None:
_workspace.provision_workspace(plan, bottle)
def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str:
"""The smolmachines guest reaches the supervise sidecar via a
host-published random port the launch step pinned earlier
+28 -26
View File
@@ -29,27 +29,6 @@ class SmolmachinesBottlePlan(BottlePlan):
bundle_subnet: str
bundle_gateway: str
bundle_ip: str
# smolvm machine name + agent image source. machine_create
# boots from a packed `.smolmachine` artifact (pre-baked at
# prepare time via `smolvm pack create`); using `--from`
# instead of `--image` avoids the registry-pull race we hit
# when machine_start tried to fetch on-demand and the libkrun
# agent's network attempt got refused by macOS.
#
# Chunk 2d ships with a public placeholder image (alpine)
# since bot-bottle-claude:latest lives in the operator's local
# docker daemon and smolvm's crane backend can't read from
# there; chunk 4 resolves the agent-image-conversion gap
# (push to a registry first, or smolvm grows a docker-daemon
# transport).
machine_name: str
# Agent image ref (docker tag). `launch` runs the
# build → save → registry push → smolvm pack pipeline against
# this and feeds the resulting `.smolmachine` artifact to
# `machine_create --from`. The pipeline runs at launch time
# (not prepare time) so the docker build output doesn't garble
# the dashboard's preflight modal.
agent_image_ref: str
# In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
# the guest has no DNS resolver inside the TSI allowlist.
# Passed to `smolvm machine create` as `-e K=V` flags.
@@ -57,11 +36,6 @@ class SmolmachinesBottlePlan(BottlePlan):
# `--smolfile` is mutually exclusive with `--from`, and
# `--from` is the path that avoids the registry-pull race).
guest_env: dict[str, str]
# Path to the agent's prompt file on the host. Always written
# (mode 0o600) so the in-VM path always exists; the file is
# empty when the agent has no prompt — claude-code reads it
# via --append-system-prompt-file only when non-empty.
prompt_file: Path
# Inner Plans for the sidecar bundle daemons. The same shape the
# docker backend uses — same `.prepare()` calls produced
# them — but our launch step doesn't populate the
@@ -82,6 +56,34 @@ class SmolmachinesBottlePlan(BottlePlan):
agent_git_gate_host: str = ""
agent_supervise_url: str = ""
@property
def machine_name(self) -> str:
"""smolvm machine name. `machine_create` boots from a packed
`.smolmachine` artifact (pre-baked at prepare time via
`smolvm pack create`); using `--from` instead of `--image`
avoids the registry-pull race we hit when machine_start tried
to fetch on-demand and the libkrun agent's network attempt
got refused by macOS."""
return self.agent_provision.instance_name
@property
def agent_image(self) -> str:
"""Agent image ref (docker tag). `launch` runs the
build save registry push smolvm pack pipeline against
this and feeds the resulting `.smolmachine` artifact to
`machine_create --from`. The pipeline runs at launch time
(not prepare time) so the docker build output doesn't garble
the dashboard's preflight modal."""
return self.agent_provision.image
@property
def prompt_file(self) -> Path:
"""Path to the agent's prompt file on the host. Always written
(mode 0o600) so the in-VM path always exists; the file is
empty when the agent has no prompt claude-code reads it
via --append-system-prompt-file only when non-empty."""
return self.agent_provision.prompt_file
@property
def git_gate_insteadof_host(self) -> str:
return self.agent_git_gate_host
+1 -1
View File
@@ -90,7 +90,7 @@ def launch(
# here, not in prepare, so the docker-build output doesn't
# garble the dashboard's preflight modal.
agent_from_path = _ensure_smolmachine(
plan.agent_image_ref,
plan.agent_image,
dockerfile=plan.agent_dockerfile_path,
)
@@ -6,8 +6,7 @@ the `AgentProvider` plugin under `bot_bottle/contrib/`. CA and git
provisioning also moved to the AgentProvider ABC (with Debian/node
defaults); user plugins override them for non-standard images.
The module left in this subpackage handles the remaining backend-
specific step:
- workspace.py copy the operator workspace into the guest
No modules remain in this subpackage. Workspace copying now runs
through `BottleBackend.provision_workspace` against the running
bottle for every backend.
"""
@@ -1,32 +0,0 @@
"""Copy the operator workspace into a smolmachines guest."""
from __future__ import annotations
import shlex
from ....log import info
from ... import Bottle
from ..bottle_plan import SmolmachinesBottlePlan
def provision_workspace(plan: SmolmachinesBottlePlan, bottle: Bottle) -> None:
"""Copy host cwd contents to the planned guest workspace."""
workspace = plan.workspace_plan
if not (workspace.enabled and workspace.copy_contents):
return
guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/"
guest_path_q = shlex.quote(workspace.guest_path)
guest_parent_q = shlex.quote(guest_parent)
owner_q = shlex.quote(workspace.owner)
mode_q = shlex.quote(workspace.mode)
info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}")
bottle.exec(
f"rm -rf {guest_path_q} && mkdir -p {guest_parent_q}",
user="root",
)
bottle.cp_in(str(workspace.host_path), workspace.guest_path)
bottle.exec(
f"chown -R {owner_q} {guest_path_q} && chmod {mode_q} {guest_path_q}",
user="root",
)
+32 -82
View File
@@ -10,59 +10,22 @@ No VM bringup — that's `launch.launch`'s job."""
from __future__ import annotations
import os
from pathlib import Path
from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, get_provider
from ...backend import BottleSpec
from ...env import resolve_env
from ...workspace import workspace_plan as resolve_workspace_plan
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,
)
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
# 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."""
def preflight() -> None:
smolmachines_preflight()
manifest = spec.manifest
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_obj = get_provider(provider.template)
provider_runtime = provider_obj.runtime
guest_home = "/home/node"
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project="", backend="smolmachines")
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
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)
@@ -71,45 +34,36 @@ def resolve_plan(
# 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,
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",
}
git_gate_plan = prepare_git_gate(bottle, slug)
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
machine_name = f"bot-bottle-{slug}"
if provider.template in PROVIDER_TEMPLATES:
agent_image_ref = provider_runtime.image
else:
agent_image_ref = f"bot-bottle-{provider.template}:{slug}"
agent_dockerfile_path = str(provider_obj.dockerfile)
if provider.dockerfile:
agent_image_ref = f"bot-bottle-{provider.template}:{slug}"
agent_dockerfile_path = resolve_manifest_dockerfile(provider.dockerfile, spec)
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,
)
agent_provision = merge_provision_env_vars(agent_provision)
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."""
egress_plan = prepare_egress(bottle, slug, agent_provision)
supervise_plan = prepare_supervise(bottle, slug)
# ==== smolmachines specific setup ====
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
return SmolmachinesBottlePlan(
spec=spec,
@@ -118,13 +72,9 @@ def resolve_plan(
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,
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,
workspace_plan=workspace_plan,
agent_provision=agent_provision_plan,
)
+1 -1
View File
@@ -38,7 +38,6 @@ from pathlib import Path
from typing import cast
from . import supervise as _supervise
from .backend.docker import util as docker_mod
# Directory layout: ~/.bot-bottle/state/<identity>/...
@@ -82,6 +81,7 @@ def bottle_identity(agent_name: str) -> str:
To continue an existing bottle's state, use the recorded
identity from BottleMetadata via `cli.py resume <identity>`,
not this function."""
from .backend.docker import util as docker_mod
slug = docker_mod.slugify(agent_name)
suffix = "".join(secrets.choice(_SUFFIX_ALPHABET) for _ in range(_RANDOM_SUFFIX_LEN))
return f"{slug}-{suffix}"
+3 -3
View File
@@ -29,7 +29,7 @@ from ..bottle_state import (
is_preserved,
mark_preserved,
)
from ..backend.docker.capability_apply import snapshot_transcript
# from ..backend.docker.capability_apply import snapshot_transcript
from ..log import info
from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line
@@ -39,7 +39,7 @@ from . import tui
def cmd_start(argv: list[str]) -> int:
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
parser.add_argument("--remote-control", action="store_true")
parser.add_argument(
"--backend",
@@ -172,7 +172,7 @@ def capture_claude_session_state(identity: str, exit_code: int) -> None:
# instead of relying on each agent's transcript layout.
if not identity:
return
snapshot_transcript(identity)
# snapshot_transcript(identity)
if exit_code != 0:
mark_preserved(identity)
+21 -17
View File
@@ -20,12 +20,17 @@ from datetime import datetime, timezone
from pathlib import Path
from .. import supervise as _supervise
from ..bottle_state import read_metadata
from ..backend.docker.capability_apply import (
CapabilityApplyError,
apply_capability_change,
)
# from ..bottle_state import read_metadata
# from ..backend.docker.capability_apply import (
# CapabilityApplyError,
# apply_capability_change,
# )
from ..log import Die, error, info
class CapabilityApplyError(RuntimeError):
"""Placeholder while capability_apply is disabled."""
from ..supervise import (
COMPONENT_FOR_TOOL,
AuditEntry,
@@ -124,20 +129,19 @@ def approve(
) -> None:
"""Apply the proposal, write the waiting response, and audit it."""
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
diff_before, diff_after = "", ""
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
_meta = read_metadata(qp.proposal.bottle_slug)
if _meta is not None and not _meta.compose_project:
raise CapabilityApplyError(
"capability-block remediation is not supported for smolmachines "
"bottles. Reject this proposal or handle the capability change "
"manually, then restart the bottle."
)
diff_before, diff_after = apply_capability_change(
qp.proposal.bottle_slug, file_to_apply,
)
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
# _meta = read_metadata(qp.proposal.bottle_slug)
# if _meta is not None and not _meta.compose_project:
# raise CapabilityApplyError(
# "capability-block remediation is not supported for smolmachines "
# "bottles. Reject this proposal or handle the capability change "
# "manually, then restart the bottle."
# )
# diff_before, diff_after = apply_capability_change(
# qp.proposal.bottle_slug, file_to_apply,
# )
response = Response(
proposal_id=qp.proposal.id,
+5 -1
View File
@@ -59,7 +59,8 @@ class ClaudeAgentProvider(AgentProvider):
*,
dockerfile: str,
state_dir: Path,
guest_home: str,
instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
@@ -70,6 +71,7 @@ class ClaudeAgentProvider(AgentProvider):
) -> AgentProvisionPlan:
del forward_host_credentials, host_env # Codex-only knobs
resolved_guest_env = dict(guest_env or {})
guest_home = self.guest_home
trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = {
@@ -111,6 +113,8 @@ class ClaudeAgentProvider(AgentProvider):
image=_RUNTIME.image,
dockerfile=dockerfile,
guest_home=guest_home,
instance_name=instance_name,
prompt_file=prompt_file,
env_vars=env_vars,
guest_env=resolved_guest_env,
files=files,
+5 -1
View File
@@ -67,7 +67,8 @@ class CodexAgentProvider(AgentProvider):
*,
dockerfile: str,
state_dir: Path,
guest_home: str,
instance_name: str,
prompt_file: Path,
guest_env: dict[str, str] | None = None,
auth_token: str = "",
forward_host_credentials: bool = False,
@@ -78,6 +79,7 @@ class CodexAgentProvider(AgentProvider):
) -> AgentProvisionPlan:
del auth_token, label, color # Claude-only knobs
resolved_guest_env = dict(guest_env or {})
guest_home = self.guest_home
trusted_path = trusted_project_path or guest_home
env_vars: dict[str, str] = {
@@ -148,6 +150,8 @@ class CodexAgentProvider(AgentProvider):
image=_RUNTIME.image,
dockerfile=dockerfile,
guest_home=guest_home,
instance_name=instance_name,
prompt_file=prompt_file,
env_vars=env_vars,
guest_env=resolved_guest_env,
dirs=tuple(dirs),
+2 -1
View File
@@ -24,6 +24,7 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
is_git_push_request,
load_config,
match_route,
outbound_scan_headers,
scan_inbound,
scan_outbound,
)
@@ -159,7 +160,7 @@ class EgressAddon:
flow.request.pretty_host,
request_path,
query,
dict(flow.request.headers),
outbound_scan_headers(route, dict(flow.request.headers)),
body,
)
dlp_result = scan_outbound(route, scan_text, os.environ)
+22
View File
@@ -538,6 +538,27 @@ def build_outbound_scan_text(
return "\n".join(parts)
def outbound_scan_headers(
route: Route,
headers: typing.Mapping[str, str],
) -> dict[str, str]:
"""Return request headers that should be included in outbound DLP.
Routes that inject sidecar-owned auth always strip the agent's
Authorization header before forwarding. Scanning that header first
creates false positives for provider clients that insist on sending
their own bearer-shaped placeholder, while still not changing what
reaches the upstream.
"""
out: dict[str, str] = {}
skip_auth = bool(route.auth_scheme and route.token_env)
for name, value in headers.items():
if skip_auth and name.lower() == "authorization":
continue
out[name] = value
return out
def build_inbound_scan_text(
headers: typing.Mapping[str, str],
body: str,
@@ -644,6 +665,7 @@ __all__ = [
"load_config",
"load_routes",
"match_route",
"outbound_scan_headers",
"parse_config",
"parse_routes",
"scan_inbound",
-5
View File
@@ -465,8 +465,6 @@ class Supervise(ABC):
self,
slug: str,
stage_dir: Path,
*,
dockerfile_content: str = "",
) -> SupervisePlan:
"""Stage the per-bottle queue dir on the host and the
current-config dir under `stage_dir`. Returns the plan;
@@ -476,9 +474,6 @@ class Supervise(ABC):
queue_dir.mkdir(parents=True, exist_ok=True)
current_config_dir = stage_dir / "current-config"
current_config_dir.mkdir(parents=True, exist_ok=True)
dockerfile_path = current_config_dir / CURRENT_CONFIG_DOCKERFILE
dockerfile_path.write_text(dockerfile_content)
dockerfile_path.chmod(0o644)
return SupervisePlan(
slug=slug,
queue_dir=queue_dir,
-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 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()
+31 -21
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from bot_bottle.agent_provider import (
CODEX_HOST_CREDENTIAL_HOSTS,
agent_provision_plan,
build_agent_provision_plan,
)
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
@@ -25,11 +25,12 @@ def _jwt(exp: int) -> str:
class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_plan_declares_home_state(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="/tmp/Dockerfile.codex",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
)
config = Path(tmp, "codex-config.toml").read_text()
self.assertEqual("codex", plan.template)
@@ -50,11 +51,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan(
guest_home="/home/node",
build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
trusted_project_path="/home/node/workspace",
)
config = Path(tmp, "codex-config.toml").read_text()
@@ -68,11 +70,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(2000000000)},
}))
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
guest_env={"CODEX_HOME": "/run/codex-home"},
forward_host_credentials=True,
host_env={"CODEX_HOME": str(home)},
@@ -88,11 +91,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="claude",
dockerfile="/tmp/Dockerfile.claude",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
auth_token="BOT_BOTTLE_CLAUDE_OAUTH_TOKEN",
)
claude_config = json.loads(Path(tmp, "claude.json").read_text())
@@ -110,11 +114,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan(
guest_home="/home/node",
build_agent_provision_plan(
template="claude",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
trusted_project_path="/home/node/workspace",
)
config = json.loads(Path(tmp, "claude.json").read_text())
@@ -129,11 +134,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(2000000000)},
}))
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=True,
host_env={"CODEX_HOME": str(home)},
)
@@ -145,11 +151,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=False,
)
self.assertEqual(
@@ -162,11 +169,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_without_auth_token_has_passthrough_egress_route(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="claude",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
)
self.assertEqual(1, len(plan.egress_routes))
route = plan.egress_routes[0]
@@ -185,11 +193,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt",
"tokens": {"access_token": access},
}))
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=True,
host_env={"CODEX_HOME": str(home)},
)
@@ -200,11 +209,12 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan(
guest_home="/home/node",
plan = build_agent_provision_plan(
template="codex",
dockerfile="",
state_dir=Path(tmp),
instance_name="bot-bottle-test",
prompt_file=Path(tmp) / "prompt.txt",
forward_host_credentials=False,
)
self.assertEqual({}, plan.provisioned_env)
+119
View File
@@ -0,0 +1,119 @@
"""Unit: shared backend prepare wiring.
These tests keep the base `BottleBackend.prepare` template honest:
backend-specific preflight/env hooks must be wired through, and launch
metadata must record the backend that actually prepared the plan.
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from bot_bottle import bottle_state
from bot_bottle import supervise
from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import Manifest
def _manifest() -> Manifest:
return Manifest.from_json_obj({
"bottles": {
"dev": {
"env": {
"LITERAL_ENV": "literal-value",
"FORWARDED_ENV": "${HOST_SECRET_ENV}",
},
},
},
"agents": {
"demo": {
"bottle": "dev",
"skills": [],
"prompt": "hello",
},
},
})
def _spec(tmp: Path, *, identity: str) -> BottleSpec:
return BottleSpec(
manifest=_manifest(),
agent_name="demo",
copy_cwd=False,
user_cwd=str(tmp),
identity=identity,
)
class _FakeStateMixin:
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory(prefix="backend-prepare.")
self.root = Path(self.tmp.name) / ".bot-bottle"
self.original_root = supervise.bot_bottle_root
supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment]
def tearDown(self) -> None:
supervise.bot_bottle_root = self.original_root # type: ignore[assignment]
self.tmp.cleanup()
class TestDockerPrepare(_FakeStateMixin, unittest.TestCase):
def test_records_backend_and_preserves_env_split(self) -> None:
backend = DockerBottleBackend()
spec = _spec(Path(self.tmp.name), identity="demo-docker")
with (
patch.dict("os.environ", {"HOST_SECRET_ENV": "secret-value"}),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker",
) as require_docker,
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(spec, Path(self.tmp.name) / "stage")
require_docker.assert_called_once_with()
metadata = bottle_state.read_metadata("demo-docker")
self.assertIsNotNone(metadata)
assert metadata is not None
self.assertEqual("docker", metadata.backend)
self.assertEqual({"FORWARDED_ENV": "secret-value"}, plan.forwarded_env)
self.assertEqual("literal-value", plan.agent_provision.guest_env["LITERAL_ENV"])
self.assertNotIn("FORWARDED_ENV", plan.agent_provision.guest_env)
class TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase):
def test_records_backend_and_builds_guest_env(self) -> None:
backend = SmolmachinesBottleBackend()
spec = _spec(Path(self.tmp.name), identity="demo-smol")
with (
patch.dict("os.environ", {"HOST_SECRET_ENV": "secret-value"}),
patch(
"bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight",
) as preflight,
):
plan = backend.prepare(spec, Path(self.tmp.name) / "stage")
preflight.assert_called_once_with()
metadata = bottle_state.read_metadata("demo-smol")
self.assertIsNotNone(metadata)
assert metadata is not None
self.assertEqual("smolmachines", metadata.backend)
self.assertEqual("literal-value", plan.guest_env["LITERAL_ENV"])
self.assertEqual("secret-value", plan.guest_env["FORWARDED_ENV"])
self.assertEqual(
"/etc/ssl/certs/ca-certificates.crt",
plan.guest_env["SSL_CERT_FILE"],
)
if __name__ == "__main__":
unittest.main()
+157
View File
@@ -0,0 +1,157 @@
"""Unit: runtime workspace provisioning.
Workspace copy is intentionally handled through
`BottleBackend.provision_workspace` against a running bottle. The
Docker derived-image workspace path stays disabled.
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch
from bot_bottle import bottle_state
from bot_bottle import supervise
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import Manifest
def _manifest() -> Manifest:
return Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {
"demo": {
"bottle": "dev",
"skills": [],
"prompt": "",
},
},
})
def _spec(tmp: Path, *, copy_cwd: bool = True, identity: str = "demo-work") -> BottleSpec:
return BottleSpec(
manifest=_manifest(),
agent_name="demo",
copy_cwd=copy_cwd,
user_cwd=str(tmp),
identity=identity,
)
def _bottle() -> MagicMock:
bottle = MagicMock(spec=Bottle)
bottle.name = "bot-bottle-demo-work"
bottle.exec.return_value = ExecResult(returncode=0, stdout="", stderr="")
return bottle
class _FakeStateMixin:
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory(prefix="backend-workspace.")
self.tmp = Path(self.tmp_dir.name)
self.root = self.tmp / ".bot-bottle"
self.original_root = supervise.bot_bottle_root
supervise.bot_bottle_root = lambda: self.root # type: ignore[assignment]
def tearDown(self) -> None:
supervise.bot_bottle_root = self.original_root # type: ignore[assignment]
self.tmp_dir.cleanup()
class TestRuntimeWorkspaceProvisioning(_FakeStateMixin, unittest.TestCase):
def test_default_backend_method_copies_workspace_to_running_bottle(self) -> None:
(self.tmp / "src.txt").write_text("hello\n")
(self.tmp / ".git").mkdir()
backend = DockerBottleBackend()
with (
patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(_spec(self.tmp), self.tmp / "stage")
bottle = _bottle()
backend.provision_workspace(plan, bottle)
self.assertEqual(
[
call(
"rm -rf /home/node/workspace && mkdir -p /home/node",
user="root",
),
call(
"chown -R node:node /home/node/workspace && "
"chmod 755 /home/node/workspace",
user="root",
),
],
bottle.exec.call_args_list,
)
bottle.cp_in.assert_called_once_with(str(self.tmp), "/home/node/workspace")
def test_default_backend_method_noops_without_copy_cwd(self) -> None:
backend = DockerBottleBackend()
with (
patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(_spec(self.tmp, copy_cwd=False), self.tmp / "stage")
bottle = _bottle()
backend.provision_workspace(plan, bottle)
bottle.exec.assert_not_called()
bottle.cp_in.assert_not_called()
def test_smolmachines_uses_same_running_bottle_method(self) -> None:
backend = SmolmachinesBottleBackend()
with patch(
"bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight",
):
plan = backend.prepare(
_spec(self.tmp, identity="demo-smol-work"),
self.tmp / "stage",
)
bottle = _bottle()
backend.provision_workspace(plan, bottle)
bottle.cp_in.assert_called_once_with(str(self.tmp), "/home/node/workspace")
metadata = bottle_state.read_metadata("demo-smol-work")
self.assertIsNotNone(metadata)
assert metadata is not None
self.assertEqual("smolmachines", metadata.backend)
class TestWorkspaceTrustPath(_FakeStateMixin, unittest.TestCase):
def test_prepare_trusts_workspace_path_when_copying_cwd(self) -> None:
backend = DockerBottleBackend()
with (
patch("bot_bottle.backend.docker.resolve_plan.docker_mod.require_docker"),
patch(
"bot_bottle.backend.docker.resolve_plan.docker_mod.runsc_available",
return_value=False,
),
):
plan = backend.prepare(_spec(self.tmp), self.tmp / "stage")
claude_config = self.root / "state" / "demo-work" / "agent" / "claude.json"
config = claude_config.read_text()
self.assertIn('"/home/node/workspace"', config)
self.assertEqual("/home/node/workspace", plan.workspace_plan.workdir)
if __name__ == "__main__":
unittest.main()
-132
View File
@@ -1,132 +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 import bottle_state
from bot_bottle.backend.docker import 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()
+5 -14
View File
@@ -29,29 +29,20 @@ class _FakeHomeMixin:
class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
# snapshot_transcript is commented out (capability_apply is disabled);
# capture_claude_session_state now only handles the preserve marker.
def setUp(self):
self._setup_fake_home()
# Stub the docker-dependent snapshot call so this stays a
# unit test. apply_capability_change's integration test
# covers the real docker cp path.
self._snap_calls: list[str] = []
self._orig_snap = start_mod.snapshot_transcript
start_mod.snapshot_transcript = lambda identity: ( # type: ignore
self._snap_calls.append(identity)
)
def tearDown(self):
start_mod.snapshot_transcript = self._orig_snap
self._teardown_fake_home()
def test_clean_exit_snapshots_but_does_not_mark(self):
def test_clean_exit_does_not_mark(self):
start_mod.capture_claude_session_state("dev-abc", exit_code=0)
self.assertEqual(["dev-abc"], self._snap_calls)
self.assertFalse(bottle_state.is_preserved("dev-abc"))
def test_crash_snapshots_and_marks(self):
def test_crash_marks_preserved(self):
start_mod.capture_claude_session_state("dev-abc", exit_code=137)
self.assertEqual(["dev-abc"], self._snap_calls)
self.assertTrue(bottle_state.is_preserved("dev-abc"))
def test_ctrl_c_treated_as_crash(self):
@@ -64,7 +55,7 @@ class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
# Backends without an identity field shouldn't crash this
# path (the _identity_from_plan helper falls back to "").
start_mod.capture_claude_session_state("", exit_code=137)
self.assertEqual([], self._snap_calls)
self.assertFalse(bottle_state.is_preserved(""))
class TestSettleState(_FakeHomeMixin, unittest.TestCase):
+5 -11
View File
@@ -33,7 +33,6 @@ from bot_bottle.egress import (
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
SLUG = "demo-abc12"
@@ -152,15 +151,7 @@ def _plan(
spec=spec,
stage_dir=STAGE,
slug=SLUG,
container_name=f"bot-bottle-{SLUG}",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
prompt_file=STAGE / "prompt",
git_gate_plan=_git_gate_plan(upstreams),
egress_plan=_egress_plan(routes),
supervise_plan=_supervise_plan() if supervise else None,
@@ -172,9 +163,10 @@ def _plan(
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name=f"bot-bottle-{SLUG}",
prompt_file=STAGE / "prompt",
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
@@ -210,7 +202,7 @@ class TestAgentAlwaysPresent(unittest.TestCase):
def test_agent_image_uses_runtime_image(self):
plan = _plan()
s = bottle_plan_to_compose(plan)["services"]["agent"]
self.assertEqual(plan.runtime_image, s["image"])
self.assertEqual(plan.image, s["image"])
def test_agent_only_on_internal_network(self):
s = bottle_plan_to_compose(_plan())["services"]["agent"]
@@ -253,6 +245,8 @@ class TestAgentAlwaysPresent(unittest.TestCase):
image="bot-bottle-codex:latest",
dockerfile="",
guest_home="/home/node",
instance_name=f"bot-bottle-{SLUG}",
prompt_file=STAGE / "prompt",
guest_env={"CODEX_HOME": "/home/node/.codex"},
)
plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore
+17 -14
View File
@@ -24,7 +24,6 @@ from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
_URL = "http://supervise:9100/"
@@ -79,15 +78,7 @@ def _plan(
spec=spec,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
container_name="bot-bottle-demo-abc12",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=Path("/tmp/agent.env"),
forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -105,9 +96,12 @@ def _plan(
use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_home="/home/node", 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"),
)
@@ -210,7 +204,10 @@ class TestClaudeProvision(unittest.TestCase):
def test_copies_files_and_chowns(self):
provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_home="/home/node", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json",
),),
@@ -233,7 +230,10 @@ class TestClaudeProvision(unittest.TestCase):
def test_dies_when_file_chown_fails(self):
provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_home="/home/node", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json",
),),
@@ -249,7 +249,10 @@ class TestClaudeProvision(unittest.TestCase):
def test_runs_verify_commands(self):
provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_home="/home/node", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
verify=(AgentProvisionCommand(
("/usr/bin/true",), "verify failed",
),),
+17 -14
View File
@@ -25,7 +25,6 @@ from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
_URL = "http://supervise:9100/"
@@ -80,15 +79,7 @@ def _plan(
spec=spec,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
container_name="bot-bottle-demo-abc12",
container_name_pinned=False,
image="bot-bottle-codex:latest",
derived_image="",
runtime_image="bot-bottle-codex:latest",
dockerfile_path="",
env_file=Path("/tmp/agent.env"),
forwarded_env={},
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -106,9 +97,12 @@ def _plan(
use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan(
template="codex", command="codex", prompt_mode="read_prompt_file",
image="", dockerfile="", guest_home="/home/node", 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"),
)
@@ -176,7 +170,10 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan(
template="codex", command="codex",
prompt_mode="read_prompt_file",
image="", dockerfile="", guest_home="/home/node", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),),
files=(AgentProvisionFile(
Path("/tmp/codex-config.toml"),
@@ -200,7 +197,10 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan(
template="codex", command="codex",
prompt_mode="read_prompt_file",
image="", dockerfile="", guest_home="/home/node", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
pre_copy=(AgentProvisionCommand(
("find", "/home/node/.codex", "-name", "*.sqlite", "-delete"),
"could not reset runtime db files",
@@ -222,7 +222,10 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan(
template="codex", command="codex",
prompt_mode="read_prompt_file",
image="", dockerfile="", guest_home="/home/node", guest_env={},
image="", dockerfile="", guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),),
)
bottle = _make_bottle(exec_result=ExecResult(1, "", "mkdir: nope\n"))
+3 -11
View File
@@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan
def _manifest() -> Manifest:
@@ -63,22 +62,15 @@ def _plan(tmp: str) -> DockerBottlePlan:
template="claude",
command="claude",
prompt_mode="append_file",
image="",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-test-teardown-abc",
prompt_file=stage / "prompt.txt",
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
slug="test-teardown-00001",
container_name="bot-bottle-test-teardown-abc",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=stage / "env",
forwarded_env={},
prompt_file=stage / "prompt.txt",
use_runsc=False,
)
+2 -29
View File
@@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan
class _Provider(AgentProvider):
@@ -64,15 +63,7 @@ def _plan(*, git_user: dict | None = None, # type: ignore
spec=spec,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
container_name="bot-bottle-demo-abc12",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=Path("/tmp/agent.env"),
forwarded_env={},
prompt_file=Path("/tmp/prompt.txt"),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -95,9 +86,10 @@ def _plan(*, git_user: dict | None = None, # type: ignore
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/prompt.txt"),
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
@@ -131,25 +123,6 @@ class TestProvisionGitUser(unittest.TestCase):
_PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage))
self.assertEqual([], _git_config_exec_calls(bottle))
def test_copies_cwd_git_to_workspace_plan_path(self):
cwd = self.stage / "cwd"
(cwd / ".git").mkdir(parents=True)
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_called_once_with(
f"{cwd}/.git",
"/home/node/workspace/.git",
)
chown_calls = [
c for c in bottle.exec.call_args_list
if "chown" in (c.args[0] if c.args else "")
and "/home/node/workspace/.git" in (c.args[0] if c.args else "")
]
self.assertEqual(1, len(chown_calls))
self.assertIn("node:node", chown_calls[0].args[0])
def test_sets_name_and_email(self):
plan = _plan(
git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"},
-58
View File
@@ -8,13 +8,10 @@ integration smoke."""
from __future__ import annotations
import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from bot_bottle.backend.docker import util as docker_mod
from bot_bottle.workspace import WorkspacePlan
def _ok(stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: # type: ignore
@@ -70,60 +67,5 @@ class TestSave(unittest.TestCase):
)
class TestBuildImageWithCwd(unittest.TestCase):
def test_uses_workspace_plan_paths(self):
with tempfile.TemporaryDirectory(prefix="bb-docker-cwd.") as tmp:
workspace = WorkspacePlan(
enabled=True,
host_path=Path(tmp),
guest_home="/guest/home",
guest_path="/guest/home/workspace",
workdir="/guest/home/workspace",
)
with patch.object(docker_mod.subprocess, "run") as run:
docker_mod.build_image_with_cwd("derived:tag", "base:tag", workspace)
argv = run.call_args.args[0]
dockerfile = run.call_args.kwargs["input"]
self.assertEqual(["docker", "build", "-t", "derived:tag", "-f", "-"], argv[:6])
self.assertTrue(argv[6].endswith("/context"))
self.assertIn("FROM base:tag\n", dockerfile)
self.assertIn(
"COPY --chown=node:node workspace/. /guest/home/workspace\n",
dockerfile,
)
self.assertIn("WORKDIR /guest/home/workspace\n", dockerfile)
def test_staged_context_includes_hidden_files_but_not_git_dir(self):
with tempfile.TemporaryDirectory(prefix="bb-docker-cwd.") as tmp:
root = Path(tmp)
(root / ".gitignore").write_text("*.pyc\n")
(root / ".dockerignore").write_text(".gitignore\n")
(root / ".env.example").write_text("SAFE=1\n")
(root / ".git").mkdir()
(root / ".git" / "config").write_text("[core]\n")
workspace = WorkspacePlan(
enabled=True,
host_path=root,
guest_home="/guest/home",
guest_path="/guest/home/workspace",
workdir="/guest/home/workspace",
)
def inspect_context(*args, **kwargs): # type: ignore
context = Path(args[0][-1])
staged = context / "workspace"
self.assertTrue((staged / ".gitignore").is_file())
self.assertTrue((staged / ".dockerignore").is_file())
self.assertTrue((staged / ".env.example").is_file())
self.assertFalse((staged / ".git").exists())
return _ok()
with patch.object(
docker_mod.subprocess, "run", side_effect=inspect_context,
):
docker_mod.build_image_with_cwd("derived:tag", "base:tag", workspace)
if __name__ == "__main__":
unittest.main()
+57
View File
@@ -30,6 +30,7 @@ from bot_bottle.egress_addon_core import (
load_config,
load_routes,
match_route,
outbound_scan_headers,
parse_config,
parse_routes,
scan_inbound,
@@ -798,6 +799,41 @@ class TestBuildOutboundScanText(unittest.TestCase):
self.assertIn(fragment, text)
class TestOutboundScanHeaders(unittest.TestCase):
def test_authed_route_omits_authorization_header_from_scan(self):
route = Route(
host="chatgpt.com",
auth_scheme="Bearer",
token_env="EGRESS_TOKEN_0",
)
headers = outbound_scan_headers(route, {
"Authorization": "Bearer " + "A" * 60,
"x-api-key": "still-scanned",
})
self.assertNotIn("Authorization", headers)
self.assertEqual({"x-api-key": "still-scanned"}, headers)
def test_authed_route_omits_lowercase_authorization_header_from_scan(self):
route = Route(
host="chatgpt.com",
auth_scheme="Bearer",
token_env="EGRESS_TOKEN_0",
)
headers = outbound_scan_headers(route, {
"authorization": "Bearer " + "A" * 60,
"accept": "application/json",
})
self.assertEqual({"accept": "application/json"}, headers)
def test_unauthenticated_route_keeps_authorization_header_in_scan(self):
route = Route(host="api.example.com")
auth = "Bearer " + "A" * 60
headers = outbound_scan_headers(route, {
"Authorization": auth,
})
self.assertEqual({"Authorization": auth}, headers)
# --- scan_outbound -------------------------------------------------------
_AWS_KEY = "AKIAIOSFODNN7EXAMPLE"
@@ -815,6 +851,27 @@ class TestScanOutbound(unittest.TestCase):
)
self.assertIsNone(scan_outbound(_ROUTE, text, {}))
def test_authed_route_authorization_placeholder_not_scanned(self):
route = Route(
host="chatgpt.com",
auth_scheme="Bearer",
token_env="EGRESS_TOKEN_0",
)
headers = outbound_scan_headers(route, {
"Authorization": "Bearer " + "A" * 60,
"content-type": "application/json",
})
text = build_outbound_scan_text(
host="chatgpt.com",
path="/backend-api/codex/responses",
query="",
headers=headers,
body='{"jsonrpc":"2.0","method":"initialize"}',
)
self.assertIsNone(scan_outbound(route, text, {
"EGRESS_TOKEN_0": "sidecar-owned-secret",
}))
def test_token_in_body_blocked(self):
text = build_outbound_scan_text(
host="api.example.com",
+6 -18
View File
@@ -20,7 +20,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan
def _manifest() -> Manifest:
@@ -79,14 +78,16 @@ def _egress_plan(tmp: str) -> EgressPlan:
)
def _agent_provision() -> AgentProvisionPlan:
def _agent_provision(tmp: str) -> AgentProvisionPlan:
return AgentProvisionPlan(
template="claude",
command="claude",
prompt_mode="append_file",
image="",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-test-00001",
prompt_file=Path(tmp) / "prompt.txt",
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
)
@@ -99,18 +100,9 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp),
supervise_plan=None,
agent_provision=_agent_provision(),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
agent_provision=_agent_provision(tmp),
slug="test-00001",
container_name="bot-bottle-test-00001",
container_name_pinned=False,
image="bot-bottle-claude:latest",
derived_image="",
runtime_image="bot-bottle-claude:latest",
dockerfile_path="",
env_file=stage / "env",
forwarded_env={},
prompt_file=stage / "prompt.txt",
use_runsc=False,
)
@@ -123,16 +115,12 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp),
supervise_plan=None,
agent_provision=_agent_provision(),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
agent_provision=_agent_provision(tmp),
slug="test-00001",
bundle_subnet="10.99.0.0/24",
bundle_gateway="10.99.0.1",
bundle_ip="10.99.0.2",
machine_name="bot-bottle-test-00001",
agent_image_ref="bot-bottle-claude:latest",
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
prompt_file=stage / "prompt.txt",
)
-133
View File
@@ -1,133 +0,0 @@
"""Unit: smolmachines prepare.py env resolution (PRD 0038)."""
from __future__ import annotations
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
from bot_bottle.agent_provider import AgentProvisionPlan
from bot_bottle.env import ResolvedEnv
class TestSmolmachinesResolveEnv(unittest.TestCase):
"""resolve_plan() must call resolve_env() and build guest_env
from the resolved values rather than from raw bottle.env."""
def _run_resolve_plan(
self,
resolved: ResolvedEnv,
*,
extra_host_env: dict[str, str] | None = None,
) -> dict[str, str]:
from bot_bottle.backend import BottleSpec
from bot_bottle.manifest import Manifest
with tempfile.TemporaryDirectory() as tmp:
stage = Path(tmp) / "stage"
stage.mkdir()
# Minimal manifest with one env literal so the spec is valid.
manifest = Manifest.from_json_obj({
"agents": {"myagent": {"bottle": "mybottle"}},
"bottles": {"mybottle": {"env": {"PLAIN": "literal-value"}}},
})
spec = BottleSpec(
manifest=manifest,
agent_name="myagent",
copy_cwd=False,
user_cwd=tmp,
identity="test-slug-00001",
)
from bot_bottle import supervise as _sup
orig_root = _sup.bot_bottle_root
_sup.bot_bottle_root = lambda: Path(tmp) / ".bot-bottle" # type: ignore[assignment]
host_env = {**os.environ, **(extra_host_env or {})} # type: ignore
try:
with (
patch("bot_bottle.backend.smolmachines.resolve_plan.resolve_env",
return_value=resolved) as mock_resolve,
patch("bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight"),
patch("bot_bottle.backend.smolmachines.resolve_plan.smolmachines_bundle_subnet",
return_value=("10.99.0.0/24", "10.99.0.1", "10.99.0.2")),
patch("bot_bottle.backend.resolve_common.GitGate") as mock_gg,
patch("bot_bottle.backend.resolve_common.Egress") as mock_eg,
patch("bot_bottle.backend.resolve_common.Supervise"),
patch(
"bot_bottle.backend.smolmachines.resolve_plan.agent_provision_plan"
) as mock_app,
):
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_home="/home/node",
guest_env=dict(kwargs.get("guest_env") or {}),
)
mock_app.side_effect = lambda **kw: _make_provision(**kw) # type: ignore
from bot_bottle.backend.smolmachines.resolve_plan 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()
+9 -83
View File
@@ -26,16 +26,15 @@ from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle
from bot_bottle.backend.smolmachines.bottle_plan import (
SmolmachinesBottlePlan,
)
from bot_bottle.backend.smolmachines.provision import (
workspace as _workspace,
)
# from bot_bottle.backend.smolmachines.provision import (
# workspace as _workspace,
# )
from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import ManifestGitEntry, Manifest
from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan
class _Provider(AgentProvider):
@@ -71,11 +70,6 @@ def _make_bottle(
return bottle
def _exec_scripts(bottle: MagicMock) -> list[str]:
"""All script strings passed to bottle.exec, in call order."""
return [c.args[0] for c in bottle.exec.call_args_list]
def _exec_users(bottle: MagicMock) -> list[str]: # type: ignore
"""user= kwarg from each bottle.exec call, in order."""
return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list]
@@ -146,10 +140,7 @@ def _plan(
bundle_subnet="192.168.50.0/24",
bundle_gateway="192.168.50.1",
bundle_ip=bundle_ip,
machine_name="bot-bottle-demo-abc12",
agent_image_ref="bot-bottle-claude:latest",
guest_env=dict(guest_env or {}),
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan(
slug="demo-abc12",
entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"),
@@ -172,7 +163,6 @@ def _plan(
codex_auth_file=codex_auth_file,
guest_env=dict(guest_env or {}),
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
@@ -187,9 +177,11 @@ def _agent_provision(
template=template,
command=template,
prompt_mode="append_file",
image="",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
guest_env=dict(guest_env or {}),
)
auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex")
@@ -228,6 +220,8 @@ def _agent_provision(
image="bot-bottle-codex:latest",
dockerfile="",
guest_home="/home/node",
instance_name="bot-bottle-demo-abc12",
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
guest_env=dict(guest_env or {}),
dirs=(AgentProvisionDir(auth_dir),),
files=tuple(files),
@@ -342,9 +336,7 @@ class TestSmolmachinesBottleExec(unittest.TestCase):
class TestProvisionGit(unittest.TestCase):
"""provision_git dispatches two independent passes (cwd .git
copy + gitconfig insteadOf write); each no-ops on its own
when its condition doesn't hold."""
"""provision_git writes gitconfig insteadOf rules when configured."""
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with
@@ -359,34 +351,6 @@ class TestProvisionGit(unittest.TestCase):
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_copies_cwd_git_when_copy_cwd_and_git_present(self):
# Stage a fake host .git dir under user_cwd so the path-
# check in provision_git fires.
cwd = self.stage / "cwd"
(cwd / ".git").mkdir(parents=True)
plan = _plan(
copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage,
)
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_called_once_with(
f"{cwd}/.git",
"/home/node/workspace/.git",
)
scripts = _exec_scripts(bottle)
self.assertTrue(any("mkdir -p" in s and "/home/node/workspace" in s for s in scripts))
# chown the workspace tree so the agent (node) owns it.
self.assertTrue(
any("chown -R" in s and "node:node" in s and "/home/node/workspace/.git" in s
for s in scripts)
)
def test_skips_cwd_when_copy_cwd_false(self):
plan = _plan(copy_cwd=False, stage_dir=self.stage)
bottle = _make_bottle()
_PROVIDER.provision_git(bottle, plan)
bottle.cp_in.assert_not_called()
def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self):
# Smolmachines's TSI-allowlisted guest dials git-gate via
# smart HTTP at `127.0.0.1:<host port>` — the bundle's
@@ -506,43 +470,5 @@ class TestProvisionGitUser(unittest.TestCase):
self.assertIn("bot@example.com", calls[0][0])
class TestProvisionWorkspace(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-workspace.") # pylint: disable=consider-using-with
self.stage = Path(self._tmp.name)
def tearDown(self):
self._tmp.cleanup()
def test_noop_when_copy_cwd_false(self):
plan = _plan(copy_cwd=False, stage_dir=self.stage)
bottle = _make_bottle()
_workspace.provision_workspace(plan, bottle)
bottle.cp_in.assert_not_called()
bottle.exec.assert_not_called()
def test_copies_workspace_to_plan_path_and_chowns(self):
cwd = self.stage / "cwd"
cwd.mkdir()
plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage)
bottle = _make_bottle()
_workspace.provision_workspace(plan, bottle)
bottle.cp_in.assert_called_once_with(
str(cwd),
"/home/node/workspace",
)
scripts = _exec_scripts(bottle)
self.assertTrue(
any("rm -rf /home/node/workspace" in s and "mkdir -p /home/node" in s
for s in scripts)
)
self.assertTrue(
any("chown -R node:node /home/node/workspace" in s
and "chmod 755 /home/node/workspace" in s
for s in scripts)
)
if __name__ == "__main__":
unittest.main()
+5 -11
View File
@@ -359,25 +359,19 @@ class TestSupervisePrepare(unittest.TestCase):
return lambda: setattr(supervise, "bot_bottle_root", original)
def test_prepare_creates_queue_and_current_config(self):
plan = _StubSupervise().prepare(
"dev", self.stage_dir,
dockerfile_content="FROM python:3.13\n",
)
plan = _StubSupervise().prepare("dev", self.stage_dir)
self.assertTrue(plan.queue_dir.is_dir())
self.assertTrue(plan.current_config_dir.is_dir())
self.assertEqual(
"FROM python:3.13\n",
(plan.current_config_dir / "Dockerfile").read_text(),
)
self.assertEqual("dev", plan.slug)
self.assertEqual("", plan.internal_network)
def test_prepare_only_writes_dockerfile_to_current_config(self):
def test_prepare_writes_no_files_to_current_config(self):
# dockerfile_content is no longer accepted by prepare.
# routes.yaml + allowlist live behind the
# `list-egress-routes` MCP tool now (PRD 0017 chunk 3).
# `list-egress-routes` MCP tool (PRD 0017 chunk 3).
plan = _StubSupervise().prepare("dev", self.stage_dir)
files = sorted(p.name for p in plan.current_config_dir.iterdir())
self.assertEqual(["Dockerfile"], files)
self.assertEqual([], files)
if __name__ == "__main__":
+6 -113
View File
@@ -14,7 +14,6 @@ from datetime import datetime, timezone
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.backend.docker.capability_apply import CapabilityApplyError
from bot_bottle.cli import supervise as supervise_cli
from bot_bottle.supervise import (
Proposal,
@@ -115,13 +114,8 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase):
class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
def setUp(self):
self._setup_fake_home()
self._original_apply_capability = supervise_cli.apply_capability_change
supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore
"FROM old\n", content,
)
def tearDown(self):
supervise_cli.apply_capability_change = self._original_apply_capability
self._teardown_fake_home()
def _enqueue(self, tool: str = TOOL_CAPABILITY_BLOCK):
@@ -161,67 +155,9 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
self.assertEqual([], read_audit_entries("egress", "dev"))
class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
"""PRD 0016 Phase 3: approve() on a capability-block proposal
calls apply_capability_change, archives the proposal afterward
(sidecar is gone so it can't archive itself), and writes no
audit entry (capability-block has none per PRD 0013)."""
def setUp(self):
self._setup_fake_home()
self._original = supervise_cli.apply_capability_change
def tearDown(self):
supervise_cli.apply_capability_change = self._original
self._teardown_fake_home()
def _enqueue_capability(self, proposed: str = "FROM python:3.13\nRUN apk add ripgrep\n"):
p = Proposal.new(
bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK,
proposed_file=proposed,
justification="need ripgrep",
current_file_hash=sha256_hex(proposed),
now=FIXED,
)
qdir = supervise.queue_dir_for_slug("dev")
qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p)
return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def test_capability_block_calls_apply_with_proposed_file(self):
calls = []
supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore
calls.append((slug, content)) or ("FROM old\n", content)
)
qp = self._enqueue_capability("FROM bookworm\n")
supervise_cli.approve(qp)
self.assertEqual([("dev", "FROM bookworm\n")], calls)
def test_apply_failure_blocks_response_and_keeps_pending(self):
supervise_cli.apply_capability_change = lambda slug, content: (_ for _ in ()).throw( # type: ignore
CapabilityApplyError("teardown failed")
)
qp = self._enqueue_capability()
with self.assertRaises(CapabilityApplyError):
supervise_cli.approve(qp)
self.assertEqual(
[qp.proposal.id],
[p.id for p in supervise.list_pending_proposals(qp.queue_dir)],
)
def test_no_audit_log_for_capability(self):
supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore
qp = self._enqueue_capability()
supervise_cli.approve(qp)
self.assertEqual([], read_audit_entries("egress", "dev"))
def test_proposal_archived_after_apply(self):
supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore
qp = self._enqueue_capability()
supervise_cli.approve(qp)
self.assertEqual([], supervise.list_pending_proposals(qp.queue_dir))
processed = list((qp.queue_dir / "processed").glob("*.json"))
self.assertEqual(2, len(processed))
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
# # DISABLED — capability_apply functionality is currently commented out.
# pass
class TestEditInEditor(unittest.TestCase):
@@ -268,52 +204,9 @@ class TestEditInEditor(unittest.TestCase):
os.environ["EDITOR"] = original_editor
class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
"""approve() must refuse capability-block for smolmachines bottles and
pass it through for Docker bottles (PRD 0039)."""
def setUp(self):
self._setup_fake_home()
self._original_apply_capability = supervise_cli.apply_capability_change
supervise_cli.apply_capability_change = lambda slug, content: ("", content) # type: ignore
def tearDown(self):
supervise_cli.apply_capability_change = self._original_apply_capability
self._teardown_fake_home()
def _enqueue_capability(self, slug: str = "dev") -> "supervise_cli.QueuedProposal":
p = _proposal(slug=slug, tool=TOOL_CAPABILITY_BLOCK)
qdir = supervise.queue_dir_for_slug(slug)
qdir.mkdir(parents=True, exist_ok=True)
supervise.write_proposal(qdir, p)
return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def _write_metadata(self, slug: str, compose_project: str) -> None:
from bot_bottle.bottle_state import BottleMetadata, write_metadata
write_metadata(BottleMetadata(
identity=slug,
agent_name="myagent",
cwd="",
copy_cwd=False,
started_at="2026-06-02T00:00:00+00:00",
compose_project=compose_project,
))
def test_smolmachines_bottle_raises_capability_apply_error(self):
self._write_metadata("dev", compose_project="")
qp = self._enqueue_capability("dev")
with self.assertRaises(CapabilityApplyError) as ctx:
supervise_cli.approve(qp)
self.assertIn("smolmachines", str(ctx.exception))
def test_docker_bottle_calls_apply_capability_change(self):
self._write_metadata("dev", compose_project="bot-bottle-dev")
qp = self._enqueue_capability("dev")
supervise_cli.approve(qp) # must not raise
def test_no_metadata_falls_through_to_docker_path(self):
qp = self._enqueue_capability("dev")
supervise_cli.approve(qp) # must not raise
# class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
# # DISABLED — capability_apply functionality is currently commented out.
# pass
if __name__ == "__main__":
-58
View File
@@ -1,58 +0,0 @@
"""Unit: backend-neutral workspace planning."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from bot_bottle.backend import BottleSpec
from bot_bottle.manifest import Manifest
from bot_bottle.workspace import workspace_plan
def _spec(*, copy_cwd: bool, user_cwd: str) -> BottleSpec:
manifest = Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
return BottleSpec(
manifest=manifest,
agent_name="demo",
copy_cwd=copy_cwd,
user_cwd=user_cwd,
)
class TestWorkspacePlan(unittest.TestCase):
def test_disabled_uses_guest_home_as_workdir(self):
plan = workspace_plan(
_spec(copy_cwd=False, user_cwd="/tmp/project"),
guest_home="/home/node",
)
self.assertFalse(plan.enabled)
self.assertEqual("/home/node", plan.guest_path)
self.assertEqual("/home/node", plan.workdir)
def test_enabled_uses_workspace_under_guest_home(self):
plan = workspace_plan(
_spec(copy_cwd=True, user_cwd="/tmp/project"),
guest_home="/guest/home",
)
self.assertTrue(plan.enabled)
self.assertEqual(Path("/tmp/project"), plan.host_path)
self.assertEqual("/guest/home/workspace", plan.guest_path)
self.assertEqual("/guest/home/workspace", plan.workdir)
def test_detects_host_git_dir(self):
with tempfile.TemporaryDirectory(prefix="bb-workspace.") as tmp:
Path(tmp, ".git").mkdir()
plan = workspace_plan(
_spec(copy_cwd=True, user_cwd=tmp),
guest_home="/home/node",
)
self.assertTrue(plan.has_host_git_dir)
if __name__ == "__main__":
unittest.main()