diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 4384d98..2bca283 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -20,6 +20,7 @@ Per PRD 0050 the per-provider implementations live under from __future__ import annotations import importlib.util +import inspect import os import shlex import tempfile @@ -51,7 +52,6 @@ class AgentProviderRuntime: template: str command: str image: str - dockerfile: str prompt_mode: PromptMode bypass_args: tuple[str, ...] resume_args: tuple[str, ...] @@ -103,6 +103,9 @@ class AgentProvisionPlan: prompt_mode: PromptMode image: str dockerfile: str + guest_home: str + instance_name: str + prompt_file: Path guest_env: dict[str, str] env_vars: dict[str, str] = field(default_factory=dict) dirs: tuple[AgentProvisionDir, ...] = () @@ -127,13 +130,31 @@ class AgentProvider(ABC): """The static command / image / prompt-mode table for this template.""" + @property + def guest_home(self) -> str: + """In-guest home directory for the agent user. Defaults to + `/home/node` to match the Debian-based bot-bottle-* images + (USER node). Override for plugins whose image runs as a + different user.""" + return "/home/node" + + @property + def dockerfile(self) -> Path: + """Path to the provider's Dockerfile. + + Default: the `Dockerfile` file next to this provider's + `agent_provider.py` module. Override to point at a non-standard + path.""" + return Path(inspect.getfile(type(self))).parent / "Dockerfile" + @abstractmethod def provision_plan( self, *, dockerfile: str, state_dir: Path, - guest_home: str, + instance_name: str, + prompt_file: Path, guest_env: dict[str, str] | None = None, auth_token: str = "", forward_host_credentials: bool = False, @@ -205,23 +226,10 @@ class AgentProvider(ABC): def provision_git(self, bottle: "Bottle", plan: "BottlePlan") -> None: """Configure git inside the agent container. - Default: Debian/node — copies .git when --cwd is set, writes the - git-gate insteadOf gitconfig, sets user.name/email as node. - Override for images that run as a different user or use a - non-standard home directory.""" + Default: Debian/node — writes the git-gate insteadOf gitconfig + and sets user.name/email as node. Workspace copy runs through + BottleBackend.provision_workspace against the running bottle.""" from .log import info - workspace = plan.workspace_plan - if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir: - guest_workspace_git = f"{workspace.guest_path}/.git" - host_git = str(workspace.host_path / ".git") - info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}") - bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root") - bottle.cp_in(host_git, guest_workspace_git) - bottle.exec( - f"chown -R {shlex.quote(workspace.owner)} " - f"{shlex.quote(guest_workspace_git)}", - user="root", - ) manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) if manifest_bottle.git: @@ -317,12 +325,13 @@ def runtime_for(template: str) -> AgentProviderRuntime: return get_provider(template).runtime -def agent_provision_plan( +def build_agent_provision_plan( *, template: str, dockerfile: str, state_dir: Path, - guest_home: str, + instance_name: str, + prompt_file: Path, guest_env: dict[str, str] | None = None, auth_token: str = "", forward_host_credentials: bool = False, @@ -336,7 +345,8 @@ def agent_provision_plan( return get_provider(template).provision_plan( dockerfile=dockerfile, state_dir=state_dir, - guest_home=guest_home, + instance_name=instance_name, + prompt_file=prompt_file, guest_env=guest_env, auth_token=auth_token, forward_host_credentials=forward_host_credentials, diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 1912809..28dbc7f 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -32,6 +32,7 @@ manifest does not carry a backend field; the host picks. from __future__ import annotations import os +import shlex import sys from abc import ABC, abstractmethod from contextlib import AbstractContextManager @@ -39,14 +40,15 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Generic, Sequence, TypeVar -from ..agent_provider import AgentProvisionPlan, get_provider +from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan from ..egress import EgressPlan from ..git_gate import GitGatePlan from ..log import die, info -from ..manifest import GitEntry, Manifest +from ..manifest import ManifestGitEntry, Manifest from ..supervise import SupervisePlan from ..util import expand_tilde -from ..workspace import WorkspacePlan +from ..env import resolve_env, ResolvedEnv +from ..workspace import WorkspacePlan, workspace_plan from .print_util import print_multi, visible_agent_env_names from .util import host_skill_dir @@ -78,9 +80,12 @@ class BottlePlan(ABC): spec: BottleSpec stage_dir: Path - guest_home: str git_gate_plan: GitGatePlan + @property + def guest_home(self) -> str: + return self.agent_provision.guest_home + @property def git_gate_insteadof_host(self) -> str: """Host (and optional port) used in git-gate insteadOf URLs. @@ -97,7 +102,10 @@ class BottlePlan(ABC): egress_plan: EgressPlan supervise_plan: SupervisePlan | None agent_provision: AgentProvisionPlan - workspace_plan: WorkspacePlan + + @property + def workspace_plan(self) -> WorkspacePlan: + return workspace_plan(self.spec, guest_home=self.guest_home) def print(self, *, remote_control: bool) -> None: """Render the y/N preflight summary to stderr.""" @@ -263,14 +271,87 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): name: str - def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT: + def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT: """Template method: run cross-backend host-side validation, then delegate to the subclass's `_resolve_plan` for the backend-specific resolution (names, scratch files, etc.). The validation step is enforced here so a future backend cannot accidentally skip it. No remote/runtime resources are created.""" + from .resolve_common import ( + merge_provision_env_vars, + mint_slug, + prepare_agent_state_dir, + prepare_egress, + prepare_git_gate, + prepare_supervise, + resolve_manifest_dockerfile, + write_launch_metadata, + ) + self._validate(spec) - return self._resolve_plan(spec, stage_dir=stage_dir) + + self._preflight() + + manifest = spec.manifest + manifest_bottle = manifest.bottle_for(spec.agent_name) + manifest_agent_provider = manifest_bottle.agent_provider + agent_provider = get_provider(manifest_agent_provider.template) + resolved_env = resolve_env(manifest, spec.agent_name) + workspace = workspace_plan(spec, guest_home=agent_provider.guest_home) + + slug = mint_slug(spec) + write_launch_metadata(slug, spec, compose_project="", backend=self.name) + + # Manifest may override the Dockerfile per-bottle; otherwise fall + # back to the provider plugin's bundled Dockerfile (next to its + # agent_provider.py module). + if manifest_agent_provider.dockerfile: + agent_dockerfile_path = resolve_manifest_dockerfile( + manifest_agent_provider.dockerfile, spec, + ) + else: + agent_dockerfile_path = str(agent_provider.dockerfile) + + agent_dir, prompt_file = prepare_agent_state_dir(slug, spec) + + agent_provision_plan = build_agent_provision_plan( + template=manifest_agent_provider.template, + dockerfile=agent_dockerfile_path, + state_dir=agent_dir, + instance_name=f"bot-bottle-{slug}", + prompt_file=prompt_file, + guest_env=self._build_guest_env(resolved_env), + forward_host_credentials=manifest_agent_provider.forward_host_credentials, + auth_token=manifest_agent_provider.auth_token, + host_env=dict(os.environ), + trusted_project_path=workspace.workdir, + label=spec.label, + color=spec.color, + ) + agent_provision_plan = merge_provision_env_vars(agent_provision_plan) + egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan) + supervise_plan = prepare_supervise(manifest_bottle, slug) + git_gate_plan = prepare_git_gate(manifest_bottle, slug) + + return self._resolve_plan( + spec, + slug=slug, + resolved_env=resolved_env, + agent_provision_plan=agent_provision_plan, + egress_plan=egress_plan, + supervise_plan=supervise_plan, + git_gate_plan=git_gate_plan, + stage_dir=stage_dir, + ) + + def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: + return {} + + def _preflight(self) -> None: + """ + tasks to do before resolving a plan + """ + pass def _validate(self, spec: BottleSpec) -> None: """Cross-backend pre-launch checks. Confirms the agent exists, @@ -297,7 +378,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): f"Create it under ~/.claude/skills/, then re-run." ) - def _validate_git_entries(self, entries: Sequence[GitEntry]) -> None: + def _validate_git_entries(self, entries: Sequence[ManifestGitEntry]) -> None: """Each entry's IdentityFile must exist on the host (after expanding leading ~) — the git-gate copies it in at start time to authenticate the upstream push (PRD 0008). Shape is already @@ -322,10 +403,21 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): ) @abstractmethod - def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT: + def _resolve_plan(self, + spec: BottleSpec, + *, + slug: str, + resolved_env: ResolvedEnv, + agent_provision_plan: AgentProvisionPlan, + egress_plan: EgressPlan, + git_gate_plan: GitGatePlan, + supervise_plan: SupervisePlan | None, + stage_dir: Path) -> PlanT: """Backend-specific plan resolution: image/container names, env-file, prompt-file, proxy plan, runtime detection. Called by - `prepare` after `_validate` succeeds.""" + `prepare` after `_validate` succeeds. Instance name, image, + prompt file, Dockerfile path, and guest home all live on + `agent_provision_plan` — the source of truth.""" @abstractmethod def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]: @@ -369,9 +461,30 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): return prompt_path def provision_workspace(self, plan: PlanT, bottle: "Bottle") -> None: - """Copy the operator workspace into the running bottle when - the backend cannot bake it into the agent image. Default is - no-op for backends like Docker that handle this before launch.""" + """Copy the operator workspace into the running bottle. + + This is the only supported workspace-provisioning path: Docker + does not build a derived image containing the current + workspace.""" + workspace = plan.workspace_plan + if not (workspace.enabled and workspace.copy_contents): + return + + guest_parent = workspace.guest_path.rsplit("/", 1)[0] or "/" + guest_path = shlex.quote(workspace.guest_path) + guest_parent = shlex.quote(guest_parent) + owner = shlex.quote(workspace.owner) + mode = shlex.quote(workspace.mode) + info(f"copying {workspace.host_path} -> {bottle.name}:{workspace.guest_path}") + bottle.exec( + f"rm -rf {guest_path} && mkdir -p {guest_parent}", + user="root", + ) + bottle.cp_in(str(workspace.host_path), workspace.guest_path) + bottle.exec( + f"chown -R {owner} {guest_path} && chmod {mode} {guest_path}", + user="root", + ) def supervise_mcp_url(self, plan: PlanT) -> str: """Return the agent-side URL of the per-bottle supervise diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index 36db005..2ebcf42 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -2,10 +2,10 @@ This module is a thin façade. The real work lives in four siblings: - - prepare.py — host-side resolution into a DockerBottlePlan - - launch.py — bring-up + teardown context manager - - cleanup.py — orphan enumeration + removal - - enumerate.py — active-agent listing + - resolve_plan.py — Docker-specific resolution into a DockerBottlePlan + - launch.py — bring-up + teardown context manager + - cleanup.py — orphan enumeration + removal + - enumerate.py — active-agent listing The base class's `prepare` template runs cross-backend host-side validation before calling `_resolve_plan` here. @@ -25,11 +25,16 @@ from pathlib import Path from typing import Generator, Sequence from ...supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT +from ...agent_provider import AgentProvisionPlan +from ...egress import EgressPlan +from ...env import ResolvedEnv +from ...git_gate import GitGatePlan +from ...supervise import SupervisePlan from .. import ActiveAgent, BottleBackend, BottleSpec from . import cleanup as _cleanup from . import enumerate as _enumerate from . import launch as _launch -from . import prepare as _prepare +from . import resolve_plan as _resolve_plan from .bottle import DockerBottle from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_plan import DockerBottlePlan @@ -48,8 +53,34 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup launch.""" return shutil.which("docker") is not None - def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: - return _prepare.resolve_plan(spec, stage_dir=stage_dir) + def _preflight(self) -> None: + _resolve_plan.preflight() + + def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: + return _resolve_plan.build_guest_env(resolved_env) + + def _resolve_plan( + self, + spec: BottleSpec, + *, + slug: str, + resolved_env: ResolvedEnv, + agent_provision_plan: AgentProvisionPlan, + egress_plan: EgressPlan, + git_gate_plan: GitGatePlan, + supervise_plan: SupervisePlan | None, + stage_dir: Path, + ) -> DockerBottlePlan: + return _resolve_plan.resolve_plan( + spec, + slug=slug, + resolved_env=resolved_env, + agent_provision_plan=agent_provision_plan, + egress_plan=egress_plan, + supervise_plan=supervise_plan, + git_gate_plan=git_gate_plan, + stage_dir=stage_dir, + ) @contextmanager def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]: diff --git a/bot_bottle/backend/docker/bottle_plan.py b/bot_bottle/backend/docker/bottle_plan.py index 29a8100..7ebc917 100644 --- a/bot_bottle/backend/docker/bottle_plan.py +++ b/bot_bottle/backend/docker/bottle_plan.py @@ -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//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 diff --git a/bot_bottle/backend/docker/capability_apply.py b/bot_bottle/backend/docker/capability_apply.py index d926215..0f8e6cf 100644 --- a/bot_bottle/backend/docker/capability_apply.py +++ b/bot_bottle/backend/docker/capability_apply.py @@ -32,10 +32,10 @@ from __future__ import annotations import shutil import subprocess -from pathlib import Path +from ...agent_provider import get_provider from ...log import info, warn -from .bottle_state import ( +from ...bottle_state import ( mark_preserved, per_bottle_dockerfile, transcript_snapshot_dir, @@ -93,11 +93,11 @@ def fetch_current_dockerfile(slug: str) -> str: override = per_bottle_dockerfile(slug) if override is not None: return override - repo_dockerfile = _repo_dockerfile_path() + repo_dockerfile = get_provider("claude").dockerfile if repo_dockerfile.is_file(): return repo_dockerfile.read_text() raise CapabilityApplyError( - f"no per-bottle Dockerfile for {slug} and no repo Dockerfile at " + f"no per-bottle Dockerfile for {slug} and no provider Dockerfile at " f"{repo_dockerfile}" ) @@ -125,13 +125,6 @@ def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]: # --- Internals ------------------------------------------------------------- -def _repo_dockerfile_path() -> Path: - """Path to the repo's Claude Dockerfile (one dir above this module's - package root). Resolved at call time so the path is correct - regardless of where this module is imported from.""" - # bot_bottle/backend/docker/capability_apply.py -> repo root - return Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude" - def snapshot_transcript(slug: str) -> None: """`docker cp` /home/node/.claude out of the agent container into diff --git a/bot_bottle/backend/docker/cleanup.py b/bot_bottle/backend/docker/cleanup.py index 57f365d..079de35 100644 --- a/bot_bottle/backend/docker/cleanup.py +++ b/bot_bottle/backend/docker/cleanup.py @@ -31,7 +31,7 @@ from ... import supervise as _supervise from ...log import info, warn from . import util as docker_mod from .bottle_cleanup_plan import DockerBottleCleanupPlan -from .bottle_state import bottle_state_dir, is_preserved +from ...bottle_state import bottle_state_dir, is_preserved from .compose import COMPOSE_PROJECT_PREFIX, list_compose_projects diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index 4ba695d..16a67d4 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -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: diff --git a/bot_bottle/backend/docker/enumerate.py b/bot_bottle/backend/docker/enumerate.py index 9348d82..af12af3 100644 --- a/bot_bottle/backend/docker/enumerate.py +++ b/bot_bottle/backend/docker/enumerate.py @@ -15,7 +15,7 @@ from __future__ import annotations import subprocess from .. import ActiveAgent -from .bottle_state import read_metadata +from ...bottle_state import read_metadata from .compose import compose_project_name, list_active_slugs diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 2d1bf05..11380d4 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -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//egress/). 3. Populate the inner plans with launch-time fields so the @@ -15,8 +15,8 @@ The flow is: 7. `docker compose up -d` (token + OAuth values flow into the compose subprocess env so `environment: [NAME]` bare-name entries inherit without rendering values into the file). - 8. Provision (CA install, prompt copy, skills, git, supervise - config) — unchanged, uses `docker exec`. + 8. Provision (CA install, prompt copy, skills, workspace, git, + supervise config) — unchanged, uses `docker exec` / `docker cp`. 9. Yield a DockerBottle handle. `exec_agent` runs claude via `docker exec -it` exactly like the pre-compose world. @@ -43,7 +43,7 @@ from . import network as network_mod from . import util as docker_mod from .bottle import DockerBottle from .bottle_plan import DockerBottlePlan -from .bottle_state import ( +from ...bottle_state import ( bottle_state_dir, egress_state_dir, git_gate_state_dir, @@ -97,10 +97,6 @@ def launch( plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path, ) - if plan.derived_image: - docker_mod.build_image_with_cwd( - plan.derived_image, plan.image, plan.workspace_plan - ) internal_network = network_mod.network_name_for_slug(plan.slug) egress_network = network_mod.network_egress_name_for_slug(plan.slug) diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py deleted file mode 100644 index 73ce414..0000000 --- a/bot_bottle/backend/docker/prepare.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Prepare step for the Docker bottle backend. - -`resolve_plan` does all host-side resolution (image and container -names, env-file, prompt-file, proxy plan, runtime detection) and -returns a frozen DockerBottlePlan. No Docker resources are created; -the only side effects are scratch files under `stage_dir` and a probe -of `docker info`. Cross-backend host-side validation has already run -via the base class's `prepare` template before this is called. -""" - -from __future__ import annotations - -import os -from datetime import datetime, timezone -from dataclasses import replace -from pathlib import Path - -from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, runtime_for -from ...egress import Egress -from ...env import ResolvedEnv, resolve_env -from ...git_gate import GitGate -from ...log import die -from ...supervise import Supervise -from ...workspace import workspace_plan as resolve_workspace_plan -from .. import BottleSpec -from . import util as docker_mod -from .bottle_plan import DockerBottlePlan -from .bottle_state import ( - BottleMetadata, - agent_state_dir, - bottle_identity, - clear_preserve_marker, - egress_state_dir, - git_gate_state_dir, - per_bottle_dockerfile, - per_bottle_dockerfile_path, - per_bottle_image_tag, - supervise_state_dir, - write_metadata, -) -from .sidecar_bundle import sidecar_bundle_container_name - - -def resolve_plan( - spec: BottleSpec, - *, - stage_dir: Path, -) -> DockerBottlePlan: - """Resolve Docker-specific names and write scratch files. Trusts - that the agent and its skills/git-gate keys are present — - validation already ran in the base class.""" - docker_mod.require_docker() - - git_gate = GitGate() - egress = Egress() - supervise = Supervise() - - manifest = spec.manifest - agent = manifest.agents[spec.agent_name] - bottle = manifest.bottle_for(spec.agent_name) - provider = bottle.agent_provider - provider_runtime = runtime_for(provider.template) - guest_home = "/home/node" - workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home) - - # PRD 0016 follow-up: identity, not bare slug. A fresh `start` - # mints a random-suffixed identity (so parallel runs of the same - # agent in the same cwd don't collide on container/network - # names); a `resume` passes the recorded identity in via - # spec.identity to continue an existing bottle's state. - slug = spec.identity or bottle_identity(spec.agent_name) - # Record the launch metadata so `cli.py resume ` can - # reconstruct the spec. Idempotent — re-writes on resume with a - # refreshed started_at. - write_metadata(BottleMetadata( - identity=slug, - agent_name=spec.agent_name, - cwd=spec.user_cwd if spec.copy_cwd else "", - copy_cwd=spec.copy_cwd, - started_at=datetime.now(timezone.utc).isoformat(), - compose_project=f"bot-bottle-{slug}", - backend="docker", - label=spec.label, - color=spec.color, - )) - # Clear any leftover preserve marker from a prior capability-block - # so this fresh launch can be cleaned up at session-end unless - # the agent triggers another capability-block. - clear_preserve_marker(slug) - - # PRD 0016 capability-block: if a per-bottle Dockerfile has been - # written (via apply_capability_change), the base image becomes - # per_bottle_image_tag(slug) built from that file. --cwd still - # layers a derived image on top. - dockerfile_path = "" - if per_bottle_dockerfile(slug) is not None: - image_default = per_bottle_image_tag(slug) - dockerfile_path = str(per_bottle_dockerfile_path(slug)) - elif provider.dockerfile: - image_default = f"bot-bottle-{provider.template}:{slug}" - dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec) - elif provider_runtime.dockerfile: - image_default = provider_runtime.image - dockerfile_path = provider_runtime.dockerfile - elif provider.template not in PROVIDER_TEMPLATES: - user_dockerfile = ( - Path.home() / ".bot-bottle" / "contrib" / provider.template / "Dockerfile" - ) - if user_dockerfile.is_file(): - image_default = f"bot-bottle-{provider.template}:{slug}" - dockerfile_path = str(user_dockerfile) - else: - image_default = provider_runtime.image - else: - image_default = provider_runtime.image - image = os.environ.get("BOT_BOTTLE_IMAGE", image_default) - derived_image = "" - runtime_image = image - if spec.copy_cwd: - derived_image = os.environ.get( - "BOT_BOTTLE_DERIVED_IMAGE", f"bot-bottle-cwd:{slug}" - ) - runtime_image = derived_image - - default_container = f"bot-bottle-{slug}" - pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "") - container_name_pinned = bool(pinned_container) - if container_name_pinned: - container_name = pinned_container - if docker_mod.container_exists(container_name): - die( - f"container '{container_name}' already exists " - f"(pinned via BOT_BOTTLE_CONTAINER). " - f"Remove it with 'docker rm -f {container_name}' or unset the override." - ) - else: - container_name = "" - for candidate in docker_mod.container_name_candidates(default_container): - if not docker_mod.container_exists(candidate): - container_name = candidate - break - if not container_name: - die( - f"could not find a free container name after " - f"{default_container}-{docker_mod.MAX_CONTAINER_SUFFIX}; " - f"clean up old containers with 'docker rm -f '" - ) - - # Probe the sidecar-bundle container name for an orphan from a - # previous run. Otherwise a stale bundle surfaces as a - # docker-create conflict deep inside launch() with no actionable - # hint; failing fast here points at the cleanup command. - bundle_name = sidecar_bundle_container_name(slug) - if docker_mod.container_exists(bundle_name): - die( - f"sidecar bundle container '{bundle_name}' already exists. " - f"This is an orphan from a previous run; clean it up with " - f"'./cli.py cleanup' (or 'docker rm -f {bundle_name}') and " - f"retry." - ) - - # PRD 0018 chunk 2: prepare-time scratch files live under - # ~/.bot-bottle/state/// so chunk 3's compose - # bind-mounts can point at stable paths. The state subdirs are - # cleaned up by start.py's session-end teardown unless something - # explicitly preserves the state dir (capability-block, crash). - agent_dir = agent_state_dir(slug) - agent_dir.mkdir(parents=True, exist_ok=True) - env_file = agent_dir / "agent.env" - prompt_file = agent_dir / "prompt.txt" - prompt_file.write_text("") - prompt_file.chmod(0o600) - - git_gate_dir = git_gate_state_dir(slug) - git_gate_dir.mkdir(parents=True, exist_ok=True) - git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir) - - resolved = resolve_env(manifest, spec.agent_name) - # Everything that should reach the bottle by-name (so its value - # never lands on argv or in env_file) goes into one dict. Nothing - # mutates the host os.environ. - forwarded_env: dict[str, str] = dict(resolved.forwarded) - _write_env_file(resolved, env_file) - prompt_file.write_text(agent.prompt) - - use_runsc = docker_mod.runsc_available() - agent_provision = agent_provision_plan( - template=provider.template, - dockerfile=dockerfile_path, - state_dir=agent_dir, - guest_home=guest_home, - forward_host_credentials=provider.forward_host_credentials, - auth_token=provider.auth_token, - host_env=dict(os.environ), - trusted_project_path=workspace_plan.workdir, - label=spec.label, - color=spec.color, - ) - guest_env = dict(agent_provision.guest_env) - for key, val in agent_provision.env_vars.items(): - guest_env.setdefault(key, val) - agent_provision = replace(agent_provision, guest_env=guest_env) - - egress_dir = egress_state_dir(slug) - egress_dir.mkdir(parents=True, exist_ok=True) - egress_plan = egress.prepare( - bottle, slug, egress_dir, agent_provision.egress_routes, - ) - - supervise_plan = None - if bottle.supervise: - # Current Dockerfile for the agent image. Read from the repo - # root; for `--cwd` derived images the base Dockerfile is what - # the agent should propose changes against (the derived layer - # is just a workspace copy). - # (routes.yaml used to land here too but PRD 0017 chunk 3 - # moved it behind the `list-egress-routes` MCP tool so the - # agent gets live state rather than a launch-time snapshot.) - supervise_dockerfile_path = ( - Path(dockerfile_path) - if dockerfile_path - else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude" - ) - dockerfile_content = ( - supervise_dockerfile_path.read_text(encoding="utf-8") - if supervise_dockerfile_path.is_file() - else "" - ) - supervise_dir = supervise_state_dir(slug) - supervise_dir.mkdir(parents=True, exist_ok=True) - supervise_plan = supervise.prepare( - slug, supervise_dir, - dockerfile_content=dockerfile_content, - ) - - return DockerBottlePlan( - spec=spec, - stage_dir=stage_dir, - guest_home=guest_home, - slug=slug, - container_name=container_name, - container_name_pinned=container_name_pinned, - image=image, - derived_image=derived_image, - runtime_image=runtime_image, - dockerfile_path=dockerfile_path, - env_file=env_file, - forwarded_env=forwarded_env, - prompt_file=prompt_file, - git_gate_plan=git_gate_plan, - egress_plan=egress_plan, - supervise_plan=supervise_plan, - use_runsc=use_runsc, - agent_provision=agent_provision, - workspace_plan=workspace_plan, - ) - - -def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None: - """Serialize the literal portion of a ResolvedEnv into docker's - `--env-file` syntax (NAME=VALUE per line, mode 600 since the file - may carry verbatim values from the manifest). Forwarded names ride - on the plan as a structured tuple instead.""" - env_lines: list[str] = [] - for name, value in resolved.literals.items(): - if "\n" in value: - die( - f"env entry {name} (literal) contains a newline; " - f"docker --env-file cannot represent multi-line values." - ) - env_lines.append(f"{name}={value}") - env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else "")) - env_file.chmod(0o600) - - -def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str: - path = Path(os.path.expanduser(path_value)) - if not path.is_absolute(): - path = Path(spec.user_cwd) / path - return str(path) diff --git a/bot_bottle/backend/docker/resolve_plan.py b/bot_bottle/backend/docker/resolve_plan.py new file mode 100644 index 0000000..f07ecec --- /dev/null +++ b/bot_bottle/backend/docker/resolve_plan.py @@ -0,0 +1,59 @@ +"""Prepare step for the Docker bottle backend. + +`resolve_plan` does all host-side resolution (image and container +names, prompt-file, proxy plan, runtime detection) and returns a +frozen DockerBottlePlan. No Docker resources are created; the only +side effects are scratch files under `stage_dir` and a probe of +`docker info`. Cross-backend host-side validation has already run +via the base class's `prepare` template before this is called. +""" + +from __future__ import annotations + +from pathlib import Path + +from . import util as docker_mod +from .bottle_plan import DockerBottlePlan +from .. import BottleSpec +from ...env import ResolvedEnv +from ...agent_provider import AgentProvisionPlan +from ...egress import EgressPlan +from ...supervise import SupervisePlan +from ...git_gate import GitGatePlan + +def preflight() -> None: + docker_mod.require_docker() + + +def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]: + return dict(resolved_env.literals) + + +def resolve_plan( + spec: BottleSpec, + slug: str, + resolved_env: ResolvedEnv, + agent_provision_plan: AgentProvisionPlan, + egress_plan: EgressPlan, + supervise_plan: SupervisePlan | None, + git_gate_plan: GitGatePlan, + stage_dir: Path, +) -> DockerBottlePlan: + """Resolve Docker-specific names and write scratch files. Trusts + that the agent and its skills/git-gate keys are present — + validation already ran in the base class.""" + + # ==== docker specific setup ==== + use_runsc = docker_mod.runsc_available() + + return DockerBottlePlan( + spec=spec, + stage_dir=stage_dir, + slug=slug, + forwarded_env=dict(resolved_env.forwarded), + git_gate_plan=git_gate_plan, + egress_plan=egress_plan, + supervise_plan=supervise_plan, + use_runsc=use_runsc, + agent_provision=agent_provision_plan, + ) diff --git a/bot_bottle/backend/docker/util.py b/bot_bottle/backend/docker/util.py index af955f5..85c4a42 100644 --- a/bot_bottle/backend/docker/util.py +++ b/bot_bottle/backend/docker/util.py @@ -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: diff --git a/bot_bottle/backend/resolve_common.py b/bot_bottle/backend/resolve_common.py new file mode 100644 index 0000000..c316173 --- /dev/null +++ b/bot_bottle/backend/resolve_common.py @@ -0,0 +1,122 @@ +"""Shared helpers used by both backends' resolve_plan steps. + +Each helper owns one well-defined step of the per-bottle plan +resolution so docker and smolmachines don't repeat the same logic. +Backend-specific steps (container names, env-file, per-bottle +Dockerfile overrides, subnet allocation) stay in the backend's own +resolve_plan.py. +""" + +from __future__ import annotations + +import os +from dataclasses import replace +from datetime import datetime, timezone +from pathlib import Path + +from ..agent_provider import AgentProvisionPlan +from ..bottle_state import ( + BottleMetadata, + agent_state_dir, + bottle_identity, + egress_state_dir, + git_gate_state_dir, + supervise_state_dir, + write_metadata, +) +from ..egress import Egress, EgressPlan +from ..git_gate import GitGate, GitGatePlan +from ..manifest import ManifestBottle +from ..supervise import Supervise, SupervisePlan +from . import BottleSpec + + +def mint_slug(spec: BottleSpec) -> str: + """Return the bottle identity: the recorded identity for a resume, + or a freshly minted one for a new start.""" + return spec.identity or bottle_identity(spec.agent_name) + + +def write_launch_metadata( + slug: str, spec: BottleSpec, *, compose_project: str, backend: str, +) -> None: + """Persist launch metadata so `cli.py resume ` can + reconstruct the spec. Idempotent — re-writes on resume with a + refreshed started_at.""" + write_metadata(BottleMetadata( + identity=slug, + agent_name=spec.agent_name, + cwd=spec.user_cwd if spec.copy_cwd else "", + copy_cwd=spec.copy_cwd, + started_at=datetime.now(timezone.utc).isoformat(), + compose_project=compose_project, + backend=backend, + label=spec.label, + color=spec.color, + )) + + +def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]: + """Create the agent state subdir, write the prompt file. + Returns (agent_dir, prompt_file).""" + manifest = spec.manifest + agent = manifest.agents[spec.agent_name] + agent_dir = agent_state_dir(slug) + agent_dir.mkdir(parents=True, exist_ok=True) + prompt_file = agent_dir / "prompt.txt" + prompt_file.write_text(agent.prompt or "") + prompt_file.chmod(0o600) + return agent_dir, prompt_file + + +def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan: + git_gate_dir = git_gate_state_dir(slug) + git_gate_dir.mkdir(parents=True, exist_ok=True) + return GitGate().prepare(bottle, slug, git_gate_dir) + + +def prepare_egress( + bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan, +) -> EgressPlan: + egress_dir = egress_state_dir(slug) + egress_dir.mkdir(parents=True, exist_ok=True) + return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes) + + +def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None: + """Prepare the supervise sidecar state dir. Returns None when + bottle.supervise is falsy.""" + if not bottle.supervise: + return None + supervise_dir = supervise_state_dir(slug) + supervise_dir.mkdir(parents=True, exist_ok=True) + return Supervise().prepare(slug, supervise_dir) + + +def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan: + """Fold provision.env_vars into guest_env (setdefault semantics) + and return a new plan with the merged guest_env.""" + merged = dict(provision.guest_env) + for key, val in provision.env_vars.items(): + merged.setdefault(key, val) + return replace(provision, guest_env=merged) + + +def resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str: + """Resolve a manifest-supplied dockerfile path relative to user_cwd.""" + path = Path(os.path.expanduser(path_value)) + if not path.is_absolute(): + path = Path(spec.user_cwd) / path + return str(path) + + +__all__ = [ + "merge_provision_env_vars", + "mint_slug", + "prepare_agent_state_dir", + "prepare_egress", + "prepare_git_gate", + "prepare_supervise", + "resolve_manifest_dockerfile", + "write_launch_metadata", +] diff --git a/bot_bottle/backend/smolmachines/backend.py b/bot_bottle/backend/smolmachines/backend.py index 6387da1..4cd7fef 100644 --- a/bot_bottle/backend/smolmachines/backend.py +++ b/bot_bottle/backend/smolmachines/backend.py @@ -13,16 +13,20 @@ from contextlib import contextmanager from pathlib import Path from typing import Generator, Sequence -from .. import ActiveAgent, Bottle, BottleBackend, BottleSpec +from ...agent_provider import AgentProvisionPlan +from ...egress import EgressPlan +from ...env import ResolvedEnv +from ...git_gate import GitGatePlan +from ...supervise import SupervisePlan +from .. import ActiveAgent, BottleBackend, BottleSpec from . import cleanup as _cleanup from . import enumerate as _enumerate from . import launch as _launch -from . import prepare as _prepare +from . import resolve_plan as _resolve_plan from . import smolvm as _smolvm from .bottle import SmolmachinesBottle from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan from .bottle_plan import SmolmachinesBottlePlan -from .provision import workspace as _workspace class SmolmachinesBottleBackend( @@ -41,10 +45,34 @@ class SmolmachinesBottleBackend( runtime check happens at `prepare`.""" return _smolvm.is_available() + def _preflight(self) -> None: + _resolve_plan.preflight() + + def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: + return _resolve_plan.build_guest_env(resolved_env) + def _resolve_plan( - self, spec: BottleSpec, *, stage_dir: Path + self, + spec: BottleSpec, + *, + slug: str, + resolved_env: ResolvedEnv, + agent_provision_plan: AgentProvisionPlan, + egress_plan: EgressPlan, + git_gate_plan: GitGatePlan, + supervise_plan: SupervisePlan | None, + stage_dir: Path, ) -> SmolmachinesBottlePlan: - return _prepare.resolve_plan(spec, stage_dir=stage_dir) + return _resolve_plan.resolve_plan( + spec, + slug=slug, + resolved_env=resolved_env, + agent_provision_plan=agent_provision_plan, + egress_plan=egress_plan, + supervise_plan=supervise_plan, + git_gate_plan=git_gate_plan, + stage_dir=stage_dir, + ) @contextmanager def launch( @@ -53,11 +81,6 @@ class SmolmachinesBottleBackend( with _launch.launch(plan, provision=self.provision) as bottle: yield bottle - def provision_workspace( - self, plan: SmolmachinesBottlePlan, bottle: Bottle - ) -> None: - _workspace.provision_workspace(plan, bottle) - def supervise_mcp_url(self, plan: SmolmachinesBottlePlan) -> str: """The smolmachines guest reaches the supervise sidecar via a host-published random port the launch step pinned earlier diff --git a/bot_bottle/backend/smolmachines/bottle_plan.py b/bot_bottle/backend/smolmachines/bottle_plan.py index c9de884..48d6a1f 100644 --- a/bot_bottle/backend/smolmachines/bottle_plan.py +++ b/bot_bottle/backend/smolmachines/bottle_plan.py @@ -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 diff --git a/bot_bottle/backend/smolmachines/enumerate.py b/bot_bottle/backend/smolmachines/enumerate.py index c01e4ac..cd24ec4 100644 --- a/bot_bottle/backend/smolmachines/enumerate.py +++ b/bot_bottle/backend/smolmachines/enumerate.py @@ -23,7 +23,7 @@ import json import subprocess from .. import ActiveAgent -from ..docker.bottle_state import read_metadata +from ...bottle_state import read_metadata from . import sidecar_bundle as _bundle diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index 0c5a6b5..cdcb843 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -41,7 +41,7 @@ from ..docker.git_gate import ( ) from ...git_gate import revoke_git_gate_provisioned_keys from ...log import warn -from ..docker.bottle_state import egress_state_dir, git_gate_state_dir +from ...bottle_state import egress_state_dir, git_gate_state_dir from . import loopback_alias as _loopback from . import sidecar_bundle as _bundle from . import smolvm as _smolvm @@ -90,7 +90,7 @@ def launch( # here, not in prepare, so the docker-build output doesn't # garble the dashboard's preflight modal. agent_from_path = _ensure_smolmachine( - plan.agent_image_ref, + plan.agent_image, dockerfile=plan.agent_dockerfile_path, ) diff --git a/bot_bottle/backend/smolmachines/prepare.py b/bot_bottle/backend/smolmachines/prepare.py deleted file mode 100644 index d69fb85..0000000 --- a/bot_bottle/backend/smolmachines/prepare.py +++ /dev/null @@ -1,185 +0,0 @@ -"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c). - -Resolves the per-bottle docker subnet + bundle IP and assembles -the guest env. The agent's docker image build → smolmachine -pack pipeline runs in `launch.launch`, not here, so the -dashboard's preflight modal isn't garbled by docker-build output -before the operator has confirmed. - -No VM bringup — that's `launch.launch`'s job.""" - -from __future__ import annotations - -import os -from datetime import datetime, timezone -from dataclasses import replace -from pathlib import Path - -from ...agent_provider import agent_provision_plan, runtime_for -from ...backend import BottleSpec -from ...backend.docker.bottle_state import ( - BottleMetadata, - agent_state_dir, - bottle_identity, - egress_state_dir, - git_gate_state_dir, - supervise_state_dir, - write_metadata, -) -from ...egress import Egress -from ...env import resolve_env -from ...git_gate import GitGate -from ...supervise import Supervise -from ...workspace import workspace_plan as resolve_workspace_plan -from .bottle_plan import SmolmachinesBottlePlan -from .util import smolmachines_bundle_subnet, smolmachines_preflight - - -# Gateway ports the bundle exposes inside its container — git-gate's -# git-daemon, supervise's MCP. The agent inside the smolvm guest -# dials these on the bundle's pinned IP. -_BUNDLE_GIT_GATE_PORT = 9418 -_BUNDLE_SUPERVISE_PORT = 9100 - - -def resolve_plan( - spec: BottleSpec, *, stage_dir: Path -) -> SmolmachinesBottlePlan: - """Materialize the smolmachines plan. The bundle's docker - subnet + pinned IP are derived from the slug; the agent's - `.smolmachine` artifact is built (or cache-hit) here so - launch's `machine create --from` boots without a registry - pull. Per-bottle guest env + the TSI allow_cidrs land on the - plan for launch to pass straight through to - `machine create` flags.""" - smolmachines_preflight() - - manifest = spec.manifest - bottle = manifest.bottle_for(spec.agent_name) - provider = bottle.agent_provider - provider_runtime = runtime_for(provider.template) - guest_home = "/home/node" - workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home) - - slug = spec.identity or bottle_identity(spec.agent_name) - - # Record minimal metadata so `cli.py resume` can recover the - # slug. Same schema as the docker backend. - write_metadata(BottleMetadata( - identity=slug, - agent_name=spec.agent_name, - cwd=spec.user_cwd if spec.copy_cwd else "", - copy_cwd=spec.copy_cwd, - started_at=datetime.now(timezone.utc).isoformat(), - compose_project="", - backend="smolmachines", - label=spec.label, - color=spec.color, - )) - - subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug) - - # Agent's env: resolve through resolve_env() so ?prompt entries - # are prompted and ${HOST_VAR} entries are interpolated — matching - # the Docker backend's contract. Forwarded (secret/interpolated) - # values still reach the guest as -e K=V smolvm flags because - # smolvm 0.8.0 has no env-file or stdin injection path; this is - # the known argv-exposure gap documented in PRD 0038. - # HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated - # in launch.py after bundle bringup. - resolved = resolve_env(manifest, spec.agent_name) - guest_env: dict[str, str] = { - **resolved.literals, - **resolved.forwarded, - "NO_PROXY": "localhost,127.0.0.1", - "NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt", - "SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt", - "REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", - } - - git_gate_dir = git_gate_state_dir(slug) - git_gate_dir.mkdir(parents=True, exist_ok=True) - git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir) - - # Prompt file is always written (mode 0o600) so the in-VM - # path always exists. Content is the agent's `prompt` - # field (markdown body) — empty for agents with no prompt. - # claude-code reads it via --append-system-prompt-file only - # when non-empty, but the file must exist either way to - # match the docker backend's contract. - agent_dir = agent_state_dir(slug) - agent_dir.mkdir(parents=True, exist_ok=True) - prompt_file = agent_dir / "prompt.txt" - agent = manifest.agents[spec.agent_name] - prompt_file.write_text(agent.prompt or "") - prompt_file.chmod(0o600) - - machine_name = f"bot-bottle-{slug}" - # Stash the agent image ref — `launch.launch` runs the - # build → pack pipeline at bringup. Honors BOT_BOTTLE_IMAGE - # to match the docker backend's `resolve_plan` default. - agent_dockerfile_path = "" - if provider.dockerfile: - agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec) - image_default = f"bot-bottle-{provider.template}:{slug}" - elif provider_runtime.dockerfile: - agent_dockerfile_path = provider_runtime.dockerfile - image_default = provider_runtime.image - else: - image_default = provider_runtime.image - agent_image_ref = os.environ.get("BOT_BOTTLE_IMAGE", image_default) - agent_provision = agent_provision_plan( - template=provider.template, - dockerfile=agent_dockerfile_path, - state_dir=agent_dir, - guest_home=guest_home, - guest_env=guest_env, - forward_host_credentials=provider.forward_host_credentials, - auth_token=provider.auth_token, - host_env=dict(os.environ), - trusted_project_path=workspace_plan.workdir, - label=spec.label, - color=spec.color, - ) - merged_guest_env = dict(agent_provision.guest_env) - for key, val in agent_provision.env_vars.items(): - merged_guest_env.setdefault(key, val) - agent_provision = replace(agent_provision, guest_env=merged_guest_env) - - egress_dir = egress_state_dir(slug) - egress_dir.mkdir(parents=True, exist_ok=True) - egress_plan = Egress().prepare( - bottle, slug, egress_dir, agent_provision.egress_routes, - ) - - supervise_plan = None - if bottle.supervise: - supervise_dir = supervise_state_dir(slug) - supervise_dir.mkdir(parents=True, exist_ok=True) - supervise_plan = Supervise().prepare(slug, supervise_dir) - - return SmolmachinesBottlePlan( - spec=spec, - stage_dir=stage_dir, - guest_home=guest_home, - slug=slug, - bundle_subnet=subnet, - bundle_gateway=gateway, - bundle_ip=bundle_ip, - machine_name=machine_name, - agent_image_ref=agent_image_ref, - guest_env=agent_provision.guest_env, - prompt_file=prompt_file, - git_gate_plan=git_gate_plan, - egress_plan=egress_plan, - supervise_plan=supervise_plan, - agent_provision=agent_provision, - workspace_plan=workspace_plan, - ) - - -def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str: - path = Path(os.path.expanduser(path_value)) - if not path.is_absolute(): - path = Path(spec.user_cwd) / path - return str(path) diff --git a/bot_bottle/backend/smolmachines/provision/__init__.py b/bot_bottle/backend/smolmachines/provision/__init__.py index 0a79a1a..9dd739e 100644 --- a/bot_bottle/backend/smolmachines/provision/__init__.py +++ b/bot_bottle/backend/smolmachines/provision/__init__.py @@ -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. """ diff --git a/bot_bottle/backend/smolmachines/provision/workspace.py b/bot_bottle/backend/smolmachines/provision/workspace.py deleted file mode 100644 index 3b7818f..0000000 --- a/bot_bottle/backend/smolmachines/provision/workspace.py +++ /dev/null @@ -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", - ) diff --git a/bot_bottle/backend/smolmachines/resolve_plan.py b/bot_bottle/backend/smolmachines/resolve_plan.py new file mode 100644 index 0000000..e49eece --- /dev/null +++ b/bot_bottle/backend/smolmachines/resolve_plan.py @@ -0,0 +1,80 @@ +"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c). + +Resolves the per-bottle docker subnet + bundle IP and assembles +the guest env. The agent's docker image build → smolmachine +pack pipeline runs in `launch.launch`, not here, so the +dashboard's preflight modal isn't garbled by docker-build output +before the operator has confirmed. + +No VM bringup — that's `launch.launch`'s job.""" + +from __future__ import annotations + +from pathlib import Path + +from .. import BottleSpec +from ...env import ResolvedEnv +from ...agent_provider import AgentProvisionPlan +from ...egress import EgressPlan +from ...supervise import SupervisePlan +from ...git_gate import GitGatePlan +from .bottle_plan import SmolmachinesBottlePlan +from .util import smolmachines_bundle_subnet, smolmachines_preflight + +def preflight() -> None: + smolmachines_preflight() + + +def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]: + # Agent's env: resolve through resolve_env() so ?prompt entries + # are prompted and ${HOST_VAR} entries are interpolated — matching + # the Docker backend's contract. Forwarded (secret/interpolated) + # values still reach the guest as -e K=V smolvm flags because + # smolvm 0.8.0 has no env-file or stdin injection path; this is + # the known argv-exposure gap documented in PRD 0038. + # HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated + # in launch.py after bundle bringup. + return { + **resolved_env.literals, + **resolved_env.forwarded, + "NO_PROXY": "localhost,127.0.0.1", + "NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt", + "SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt", + "REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", + } + + +def resolve_plan( + spec: BottleSpec, + slug: str, + resolved_env: ResolvedEnv, + agent_provision_plan: AgentProvisionPlan, + egress_plan: EgressPlan, + supervise_plan: SupervisePlan | None, + git_gate_plan: GitGatePlan, + stage_dir: Path, +) -> SmolmachinesBottlePlan: + """Materialize the smolmachines plan. The bundle's docker + subnet + pinned IP are derived from the slug; the agent's + `.smolmachine` artifact is built (or cache-hit) here so + launch's `machine create --from` boots without a registry + pull. Per-bottle guest env + the TSI allow_cidrs land on the + plan for launch to pass straight through to + `machine create` flags.""" + + # ==== smolmachines specific setup ==== + subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug) + + return SmolmachinesBottlePlan( + spec=spec, + stage_dir=stage_dir, + slug=slug, + bundle_subnet=subnet, + bundle_gateway=gateway, + bundle_ip=bundle_ip, + guest_env=agent_provision_plan.guest_env, + git_gate_plan=git_gate_plan, + egress_plan=egress_plan, + supervise_plan=supervise_plan, + agent_provision=agent_provision_plan, + ) diff --git a/bot_bottle/backend/docker/bottle_state.py b/bot_bottle/bottle_state.py similarity index 99% rename from bot_bottle/backend/docker/bottle_state.py rename to bot_bottle/bottle_state.py index 80c1eea..d7cb7de 100644 --- a/bot_bottle/backend/docker/bottle_state.py +++ b/bot_bottle/bottle_state.py @@ -37,8 +37,7 @@ from dataclasses import dataclass from pathlib import Path from typing import cast -from ... import supervise as _supervise -from . import util as docker_mod +from . import supervise as _supervise # Directory layout: ~/.bot-bottle/state//... @@ -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 `, 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}" diff --git a/bot_bottle/cli/resume.py b/bot_bottle/cli/resume.py index 55661a3..ee77088 100644 --- a/bot_bottle/cli/resume.py +++ b/bot_bottle/cli/resume.py @@ -18,7 +18,7 @@ from __future__ import annotations import argparse from ..backend import BottleSpec -from ..backend.docker.bottle_state import read_metadata +from ..bottle_state import read_metadata from ..log import die from ..manifest import Manifest from ._common import PROG, USER_CWD diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 6bc0fac..13f91cd 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -24,12 +24,12 @@ from ..backend import ( known_backend_names, ) from ..backend.docker.bottle_plan import DockerBottlePlan -from ..backend.docker.bottle_state import ( +from ..bottle_state import ( cleanup_state, is_preserved, mark_preserved, ) -from ..backend.docker.capability_apply import snapshot_transcript +# from ..backend.docker.capability_apply import snapshot_transcript from ..log import info from ..manifest import Manifest from ._common import PROG, USER_CWD, read_tty_line @@ -39,7 +39,7 @@ from . import tui def cmd_start(argv: list[str]) -> int: parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True) parser.add_argument("--dry-run", action="store_true") - parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image") + parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle") parser.add_argument("--remote-control", action="store_true") parser.add_argument( "--backend", @@ -172,7 +172,7 @@ def capture_claude_session_state(identity: str, exit_code: int) -> None: # instead of relying on each agent's transcript layout. if not identity: return - snapshot_transcript(identity) + # snapshot_transcript(identity) if exit_code != 0: mark_preserved(identity) diff --git a/bot_bottle/cli/supervise.py b/bot_bottle/cli/supervise.py index 20849c9..9812cf5 100644 --- a/bot_bottle/cli/supervise.py +++ b/bot_bottle/cli/supervise.py @@ -20,12 +20,17 @@ from datetime import datetime, timezone from pathlib import Path from .. import supervise as _supervise -from ..backend.docker.bottle_state import read_metadata -from ..backend.docker.capability_apply import ( - CapabilityApplyError, - apply_capability_change, -) +# from ..bottle_state import read_metadata +# from ..backend.docker.capability_apply import ( +# CapabilityApplyError, +# apply_capability_change, +# ) from ..log import Die, error, info + + +class CapabilityApplyError(RuntimeError): + """Placeholder while capability_apply is disabled.""" + from ..supervise import ( COMPONENT_FOR_TOOL, AuditEntry, @@ -124,20 +129,19 @@ def approve( ) -> None: """Apply the proposal, write the waiting response, and audit it.""" status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED - file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file diff_before, diff_after = "", "" - if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: - _meta = read_metadata(qp.proposal.bottle_slug) - if _meta is not None and not _meta.compose_project: - raise CapabilityApplyError( - "capability-block remediation is not supported for smolmachines " - "bottles. Reject this proposal or handle the capability change " - "manually, then restart the bottle." - ) - diff_before, diff_after = apply_capability_change( - qp.proposal.bottle_slug, file_to_apply, - ) + # if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: + # _meta = read_metadata(qp.proposal.bottle_slug) + # if _meta is not None and not _meta.compose_project: + # raise CapabilityApplyError( + # "capability-block remediation is not supported for smolmachines " + # "bottles. Reject this proposal or handle the capability change " + # "manually, then restart the bottle." + # ) + # diff_before, diff_after = apply_capability_change( + # qp.proposal.bottle_slug, file_to_apply, + # ) response = Response( proposal_id=qp.proposal.id, diff --git a/Dockerfile.claude b/bot_bottle/contrib/claude/Dockerfile similarity index 100% rename from Dockerfile.claude rename to bot_bottle/contrib/claude/Dockerfile diff --git a/bot_bottle/contrib/claude/agent_provider.py b/bot_bottle/contrib/claude/agent_provider.py index 7de7ff1..1085176 100644 --- a/bot_bottle/contrib/claude/agent_provider.py +++ b/bot_bottle/contrib/claude/agent_provider.py @@ -28,8 +28,6 @@ if TYPE_CHECKING: from ...backend import Bottle, BottlePlan -_REPO_ROOT = Path(__file__).resolve().parents[3] - _SUPERVISE_MCP_NAME = "supervise" @@ -44,7 +42,6 @@ _RUNTIME = AgentProviderRuntime( template="claude", command="claude", image="bot-bottle-claude:latest", - dockerfile=str(_REPO_ROOT / "Dockerfile.claude"), prompt_mode="append_file", bypass_args=("--dangerously-skip-permissions",), resume_args=("--continue",), @@ -62,7 +59,8 @@ class ClaudeAgentProvider(AgentProvider): *, dockerfile: str, state_dir: Path, - guest_home: str, + instance_name: str, + prompt_file: Path, guest_env: dict[str, str] | None = None, auth_token: str = "", forward_host_credentials: bool = False, @@ -73,6 +71,7 @@ class ClaudeAgentProvider(AgentProvider): ) -> AgentProvisionPlan: del forward_host_credentials, host_env # Codex-only knobs resolved_guest_env = dict(guest_env or {}) + guest_home = self.guest_home trusted_path = trusted_project_path or guest_home env_vars: dict[str, str] = { @@ -113,6 +112,9 @@ class ClaudeAgentProvider(AgentProvider): prompt_mode=_RUNTIME.prompt_mode, image=_RUNTIME.image, dockerfile=dockerfile, + guest_home=guest_home, + instance_name=instance_name, + prompt_file=prompt_file, env_vars=env_vars, guest_env=resolved_guest_env, files=files, diff --git a/Dockerfile.codex b/bot_bottle/contrib/codex/Dockerfile similarity index 100% rename from Dockerfile.codex rename to bot_bottle/contrib/codex/Dockerfile diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index b97d11d..4b2c99b 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -32,8 +32,6 @@ if TYPE_CHECKING: from ...backend import Bottle, BottlePlan -_REPO_ROOT = Path(__file__).resolve().parents[3] - _SUPERVISE_MCP_NAME = "supervise" @@ -52,7 +50,6 @@ _RUNTIME = AgentProviderRuntime( template="codex", command="codex", image="bot-bottle-codex:latest", - dockerfile=str(_REPO_ROOT / "Dockerfile.codex"), prompt_mode="read_prompt_file", bypass_args=("--dangerously-bypass-approvals-and-sandbox",), resume_args=("resume", "--last"), @@ -70,7 +67,8 @@ class CodexAgentProvider(AgentProvider): *, dockerfile: str, state_dir: Path, - guest_home: str, + instance_name: str, + prompt_file: Path, guest_env: dict[str, str] | None = None, auth_token: str = "", forward_host_credentials: bool = False, @@ -81,6 +79,7 @@ class CodexAgentProvider(AgentProvider): ) -> AgentProvisionPlan: del auth_token, label, color # Claude-only knobs resolved_guest_env = dict(guest_env or {}) + guest_home = self.guest_home trusted_path = trusted_project_path or guest_home env_vars: dict[str, str] = { @@ -150,6 +149,9 @@ class CodexAgentProvider(AgentProvider): prompt_mode=_RUNTIME.prompt_mode, image=_RUNTIME.image, dockerfile=dockerfile, + guest_home=guest_home, + instance_name=instance_name, + prompt_file=prompt_file, env_vars=env_vars, guest_env=resolved_guest_env, dirs=tuple(dirs), diff --git a/bot_bottle/egress.py b/bot_bottle/egress.py index 1b80147..527279e 100644 --- a/bot_bottle/egress.py +++ b/bot_bottle/egress.py @@ -24,7 +24,7 @@ from .egress_addon_core import ( from .log import die if TYPE_CHECKING: - from .manifest import Bottle + from .manifest import ManifestBottle CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" @@ -66,7 +66,7 @@ class EgressPlan: def egress_manifest_routes( - bottle: Bottle, + bottle: ManifestBottle, ) -> tuple[EgressRoute, ...]: out: list[EgressRoute] = [] for r in bottle.egress.routes: @@ -98,7 +98,7 @@ def egress_manifest_routes( def egress_routes_for_bottle( - bottle: Bottle, + bottle: ManifestBottle, provider_routes: tuple[EgressRoute, ...] = (), ) -> tuple[EgressRoute, ...]: manifest = egress_manifest_routes(bottle) @@ -280,7 +280,7 @@ def egress_resolve_token_values( class Egress(ABC): def prepare( self, - bottle: Bottle, + bottle: ManifestBottle, slug: str, stage_dir: Path, provider_routes: tuple[EgressRoute, ...] = (), diff --git a/bot_bottle/egress_addon.py b/bot_bottle/egress_addon.py index 2bfaa1a..8e57b69 100644 --- a/bot_bottle/egress_addon.py +++ b/bot_bottle/egress_addon.py @@ -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) diff --git a/bot_bottle/egress_addon_core.py b/bot_bottle/egress_addon_core.py index 6112814..4327948 100644 --- a/bot_bottle/egress_addon_core.py +++ b/bot_bottle/egress_addon_core.py @@ -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", diff --git a/bot_bottle/git_gate.py b/bot_bottle/git_gate.py index 58427a2..1384341 100644 --- a/bot_bottle/git_gate.py +++ b/bot_bottle/git_gate.py @@ -37,7 +37,7 @@ from dataclasses import dataclass from pathlib import Path from .log import info -from .manifest import Bottle, GitEntry +from .manifest import ManifestBottle, ManifestGitEntry # Short network alias for git-gate inside the sidecar bundle. The @@ -96,9 +96,9 @@ class GitGatePlan: egress_network: str = "" -def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]: +def git_gate_upstreams_for_bottle(bottle: ManifestBottle) -> tuple[GitGateUpstream, ...]: """Lift each `bottle.git` entry into a GitGateUpstream. Unique-Name - validation already ran in `manifest.Bottle.from_dict`.""" + validation already ran in `manifest.ManifestBottle.from_dict`.""" return tuple( GitGateUpstream( name=e.Name, @@ -113,7 +113,7 @@ def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...] def git_gate_render_gitconfig( - entries: tuple[GitEntry, ...], gate_host: str, *, scheme: str = "git", + entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git", ) -> str: """Render the agent's ~/.gitconfig content for git-gate `insteadOf` rewrites. Pure host-side, no docker / smolvm; @@ -361,7 +361,7 @@ exit 0 def _provision_dynamic_key( - entry: GitEntry, + entry: ManifestGitEntry, slug: str, stage_dir: Path, ) -> str: @@ -402,7 +402,7 @@ def _provision_dynamic_key( return str(key_file) -def revoke_git_gate_provisioned_keys(bottle: Bottle, stage_dir: Path) -> None: +def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) -> None: """Revoke all deploy keys provisioned for `bottle` during prepare. Called at teardown after containers stop. Raises if any revocation @@ -440,7 +440,7 @@ class GitGate(ABC): start/stop lifecycle is backend-specific and lives on concrete subclasses.""" - def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> GitGatePlan: + def prepare(self, bottle: ManifestBottle, slug: str, stage_dir: Path) -> GitGatePlan: """Compute the upstream table from `bottle.git` and write the entrypoint, pre-receive hook, and access-hook scripts (mode 600) under `stage_dir`. Pure host-side, no docker subprocess. diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 0e44347..4f856cf 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -50,26 +50,26 @@ from pathlib import Path from typing import Mapping from .manifest_util import ManifestError, as_json_object -from .manifest_agent import Agent, AgentProvider +from .manifest_agent import ManifestAgent, ManifestAgentProvider from .manifest_egress import ( EGRESS_AUTH_SCHEMES, - EgressConfig, - EgressRoute, + ManifestEgressConfig, + ManifestEgressRoute, ) -from .manifest_git import GitEntry, GitUser, parse_git_gate_config +from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config from .manifest_schema import BOTTLE_KEYS # Re-export everything that callers currently import from this module. __all__ = [ "ManifestError", - "GitEntry", - "GitUser", - "AgentProvider", + "ManifestGitEntry", + "ManifestGitUser", + "ManifestAgentProvider", "EGRESS_AUTH_SCHEMES", - "EgressRoute", - "EgressConfig", - "Agent", - "Bottle", + "ManifestEgressRoute", + "ManifestEgressConfig", + "ManifestAgent", + "ManifestBottle", "Manifest", ] @@ -86,16 +86,16 @@ def _section_dict(value: object, label: str) -> dict[str, object]: @dataclass(frozen=True) -class Bottle: +class ManifestBottle: env: Mapping[str, str] = field(default_factory=_empty_str_dict) - agent_provider: AgentProvider = field(default_factory=AgentProvider) - git: tuple[GitEntry, ...] = () + agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider) + git: tuple[ManifestGitEntry, ...] = () # Per-bottle git identity (issue #86). Empty default — bottles # that don't set `git-gate.user:` in the manifest skip the # `git config --global` step entirely. A bottle can declare a user # identity without any git-gate.repos upstreams, and vice versa. - git_user: GitUser = field(default_factory=GitUser) - egress: EgressConfig = field(default_factory=EgressConfig) + git_user: ManifestGitUser = field(default_factory=ManifestGitUser) + egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig) # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, # the launch step brings up a supervise sidecar that exposes MCP # tools to the agent (egress-block, capability-block) plus mounts @@ -105,7 +105,7 @@ class Bottle: supervise: bool = False @classmethod - def from_dict(cls, name: str, raw: object) -> "Bottle": + def from_dict(cls, name: str, raw: object) -> "ManifestBottle": d = as_json_object(raw, f"bottle '{name}'") if "runtime" in d: @@ -157,22 +157,22 @@ class Bottle: ) env[var] = value - git: tuple[GitEntry, ...] = () - git_user = GitUser() + git: tuple[ManifestGitEntry, ...] = () + git_user = ManifestGitUser() git_raw = d.get("git-gate") if git_raw is not None: git, git_user = parse_git_gate_config(name, git_raw) agent_provider = ( - AgentProvider.from_dict(name, d["agent_provider"]) + ManifestAgentProvider.from_dict(name, d["agent_provider"]) if "agent_provider" in d - else AgentProvider() + else ManifestAgentProvider() ) egress = ( - EgressConfig.from_dict(name, d["egress"]) + ManifestEgressConfig.from_dict(name, d["egress"]) if "egress" in d - else EgressConfig() + else ManifestEgressConfig() ) supervise_raw = d.get("supervise", False) @@ -190,8 +190,8 @@ class Bottle: @dataclass(frozen=True) class Manifest: - bottles: Mapping[str, Bottle] - agents: Mapping[str, Agent] + bottles: Mapping[str, ManifestBottle] + agents: Mapping[str, ManifestAgent] @classmethod def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest": @@ -305,8 +305,8 @@ class Manifest: bottles = resolve_bottles(raw_bottles) bottle_names = set(bottles.keys()) - agents: dict[str, Agent] = { - n: Agent.from_dict(n, a, bottle_names) for n, a in raw_agents.items() + agents: dict[str, ManifestAgent] = { + n: ManifestAgent.from_dict(n, a, bottle_names) for n, a in raw_agents.items() } return cls(bottles=bottles, agents=agents) @@ -338,7 +338,7 @@ class Manifest: ) raise ManifestError(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).") - def _effective_git_user(self, agent_name: str) -> GitUser: + def _effective_git_user(self, agent_name: str) -> ManifestGitUser: """Merge the agent's git.user over the referenced bottle's, per-field, agent-wins-on-non-empty (issue #94). Same overlay the `extends:` resolver applies between bottles @@ -348,12 +348,12 @@ class Manifest: over = agent.git_user if over.is_empty(): return base - return GitUser( + return ManifestGitUser( name=over.name or base.name, email=over.email or base.email, ) - def bottle_for(self, agent_name: str) -> Bottle: + def bottle_for(self, agent_name: str) -> ManifestBottle: """Resolve the Bottle the named agent references, with the agent's git.user overlaid on top. The validator guarantees both lookups succeed for a manifest built via from_json_obj. diff --git a/bot_bottle/manifest_agent.py b/bot_bottle/manifest_agent.py index 7495e35..927df42 100644 --- a/bot_bottle/manifest_agent.py +++ b/bot_bottle/manifest_agent.py @@ -7,12 +7,12 @@ from typing import cast from .agent_provider import PROVIDER_TEMPLATES from .manifest_util import ManifestError, as_json_object -from .manifest_git import GitUser +from .manifest_git import ManifestGitUser from .manifest_schema import AGENT_MODEL_KEYS @dataclass(frozen=True) -class AgentProvider: +class ManifestAgentProvider: """Provider/template for the agent process inside a bottle. `template` selects a built-in launch/runtime contract. `dockerfile` @@ -35,7 +35,7 @@ class AgentProvider: forward_host_credentials: bool = False @classmethod - def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider": + def from_dict(cls, bottle_name: str, raw: object) -> "ManifestAgentProvider": d = as_json_object(raw, f"bottle '{bottle_name}' agent_provider") for k in d: if k not in {"template", "dockerfile", "auth_token", "forward_host_credentials"}: @@ -98,7 +98,7 @@ class AgentProvider: @dataclass(frozen=True) -class Agent: +class ManifestAgent: bottle: str skills: tuple[str, ...] = () prompt: str = "" @@ -106,10 +106,10 @@ class Agent: # bottle's git-gate.user per-field at `Manifest.bottle_for`. Only # `user` is allowed at the agent level; `repos` stays bottle-only # because it carries credentials and host trust. - git_user: GitUser = GitUser() + git_user: ManifestGitUser = ManifestGitUser() @classmethod - def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent": + def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent": d = as_json_object(raw, f"agent '{name}'") unknown = set(d.keys()) - AGENT_MODEL_KEYS if unknown: @@ -164,7 +164,7 @@ class Agent: # git-gate: agents may declare only `git-gate.user` (name/email). # `git-gate.repos` is bottle-only — it carries credentials and host trust. - git_user = GitUser() + git_user = ManifestGitUser() git_raw = d.get("git-gate") if git_raw is not None: gd = as_json_object(git_raw, f"agent '{name}' git-gate") @@ -177,6 +177,6 @@ class Agent: f"(it carries credentials and host trust)." ) if "user" in gd: - git_user = GitUser.from_dict(name, gd["user"]) + git_user = ManifestGitUser.from_dict(name, gd["user"]) return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user) diff --git a/bot_bottle/manifest_egress.py b/bot_bottle/manifest_egress.py index cbdded4..73bda30 100644 --- a/bot_bottle/manifest_egress.py +++ b/bot_bottle/manifest_egress.py @@ -24,7 +24,7 @@ INBOUND_DETECTOR_NAMES = frozenset({"naive_injection_detection"}) def validate_egress_routes( bottle_name: str, - routes: tuple[EgressRoute, ...], + routes: tuple[ManifestEgressRoute, ...], ) -> None: seen_hosts: dict[str, None] = {} for r in routes: @@ -38,29 +38,29 @@ def validate_egress_routes( @dataclass(frozen=True) -class PathMatch: +class ManifestPathMatch: Type: str = "prefix" Value: str = "" @dataclass(frozen=True) -class HeaderMatch: +class ManifestHeaderMatch: Name: str = "" Value: str = "" Type: str = "exact" @dataclass(frozen=True) -class MatchEntry: - Paths: tuple[PathMatch, ...] = () +class ManifestMatchEntry: + Paths: tuple[ManifestPathMatch, ...] = () Methods: tuple[str, ...] = () - Headers: tuple[HeaderMatch, ...] = () + Headers: tuple[ManifestHeaderMatch, ...] = () @dataclass(frozen=True) -class EgressRoute: +class ManifestEgressRoute: Host: str - Matches: tuple[MatchEntry, ...] = () + Matches: tuple[ManifestMatchEntry, ...] = () AuthScheme: str = "" TokenRef: str = "" Role: tuple[str, ...] = () @@ -68,7 +68,7 @@ class EgressRoute: InboundDetectors: tuple[str, ...] | None = None @classmethod - def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute": + def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "ManifestEgressRoute": label = f"bottle '{bottle_name}' egress.routes[{idx}]" d = as_json_object(raw, label) host = d.get("host") @@ -76,7 +76,7 @@ class EgressRoute: raise ManifestError(f"{label} missing required string field 'host'") # --- matches --- - matches: tuple[MatchEntry, ...] = () + matches: tuple[ManifestMatchEntry, ...] = () matches_raw = d.get("matches") if matches_raw is not None: if not isinstance(matches_raw, list): @@ -85,7 +85,7 @@ class EgressRoute: f"(was {type(matches_raw).__name__})" ) matches_list = cast(list[object], matches_raw) - entries: list[MatchEntry] = [] + entries: list[ManifestMatchEntry] = [] for k, entry_raw in enumerate(matches_list): entries.append( _parse_match_entry(label, k, entry_raw) @@ -185,17 +185,17 @@ class EgressRoute: def _parse_match_entry( route_label: str, k: int, raw: object, -) -> MatchEntry: +) -> ManifestMatchEntry: label = f"{route_label} matches[{k}]" d = as_json_object(raw, label) - paths: tuple[PathMatch, ...] = () + paths: tuple[ManifestPathMatch, ...] = () paths_raw = d.get("paths") if paths_raw is not None: if not isinstance(paths_raw, list): raise ManifestError(f"{label} paths must be an array") paths_list = cast(list[object], paths_raw) - parsed_paths: list[PathMatch] = [] + parsed_paths: list[ManifestPathMatch] = [] for j, p_raw in enumerate(paths_list): parsed_paths.append(_parse_path_match(label, j, p_raw)) paths = tuple(parsed_paths) @@ -220,13 +220,13 @@ def _parse_match_entry( normalised.append(upper) methods = tuple(normalised) - headers: tuple[HeaderMatch, ...] = () + headers: tuple[ManifestHeaderMatch, ...] = () headers_raw = d.get("headers") if headers_raw is not None: if not isinstance(headers_raw, list): raise ManifestError(f"{label} headers must be an array") headers_list = cast(list[object], headers_raw) - parsed_headers: list[HeaderMatch] = [] + parsed_headers: list[ManifestHeaderMatch] = [] for j, h_raw in enumerate(headers_list): parsed_headers.append(_parse_header_match(label, j, h_raw)) headers = tuple(parsed_headers) @@ -235,12 +235,12 @@ def _parse_match_entry( if key not in ("paths", "methods", "headers"): raise ManifestError(f"{label} has unknown key {key!r}") - return MatchEntry(Paths=paths, Methods=methods, Headers=headers) + return ManifestMatchEntry(Paths=paths, Methods=methods, Headers=headers) def _parse_path_match( entry_label: str, j: int, raw: object, -) -> PathMatch: +) -> ManifestPathMatch: label = f"{entry_label} paths[{j}]" d = as_json_object(raw, label) ptype = d.get("type", "prefix") @@ -266,12 +266,12 @@ def _parse_path_match( for k in d: if k not in ("type", "value"): raise ManifestError(f"{label} has unknown key {k!r}") - return PathMatch(Type=ptype, Value=value) + return ManifestPathMatch(Type=ptype, Value=value) def _parse_header_match( entry_label: str, j: int, raw: object, -) -> HeaderMatch: +) -> ManifestHeaderMatch: label = f"{entry_label} headers[{j}]" d = as_json_object(raw, label) name = d.get("name") @@ -296,7 +296,7 @@ def _parse_header_match( for k in d: if k not in ("name", "value", "type"): raise ManifestError(f"{label} has unknown key {k!r}") - return HeaderMatch(Name=name, Value=value, Type=htype) + return ManifestHeaderMatch(Name=name, Value=value, Type=htype) def _parse_dlp_block( @@ -350,15 +350,15 @@ LOG_LEVELS = frozenset({0, 1, 2}) @dataclass(frozen=True) -class EgressConfig: - routes: tuple[EgressRoute, ...] = () +class ManifestEgressConfig: + routes: tuple[ManifestEgressRoute, ...] = () Log: int = 0 @classmethod - def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig": + def from_dict(cls, bottle_name: str, raw: object) -> "ManifestEgressConfig": d = as_json_object(raw, f"bottle '{bottle_name}' egress") routes_raw = d.get("routes") - routes: tuple[EgressRoute, ...] = () + routes: tuple[ManifestEgressRoute, ...] = () if routes_raw is not None: if not isinstance(routes_raw, list): raise ManifestError( @@ -367,7 +367,7 @@ class EgressConfig: ) routes_list = cast(list[object], routes_raw) routes = tuple( - EgressRoute.from_dict(bottle_name, i, entry) + ManifestEgressRoute.from_dict(bottle_name, i, entry) for i, entry in enumerate(routes_list) ) validate_egress_routes(bottle_name, routes) diff --git a/bot_bottle/manifest_extends.py b/bot_bottle/manifest_extends.py index 18d5674..37176f3 100644 --- a/bot_bottle/manifest_extends.py +++ b/bot_bottle/manifest_extends.py @@ -5,12 +5,12 @@ from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: - from .manifest import Bottle, GitEntry + from .manifest import ManifestBottle, ManifestGitEntry -def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, Bottle]: - """Apply `extends:` chains and return resolved Bottle objects.""" - cache: dict[str, Bottle] = {} +def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]: + """Apply `extends:` chains and return resolved ManifestBottle objects.""" + cache: dict[str, ManifestBottle] = {} for name in raws: if name not in cache: _resolve_one_bottle(name, raws, cache, ()) @@ -20,10 +20,10 @@ def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, Bottle]: def _resolve_one_bottle( name: str, raws: dict[str, dict[str, object]], - cache: dict[str, Bottle], + cache: dict[str, ManifestBottle], seen: tuple[str, ...], -) -> Bottle: - from .manifest import Bottle, ManifestError +) -> ManifestBottle: + from .manifest import ManifestBottle, ManifestError if name in cache: return cache[name] @@ -32,13 +32,13 @@ def _resolve_one_bottle( raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}") raw = raws[name] parent_name_raw = raw.get("extends") - # Strip `extends:` before passing to Bottle.from_dict so it - # is not accidentally treated as a real Bottle field by future + # Strip `extends:` before passing to ManifestBottle.from_dict so it + # is not accidentally treated as a real ManifestBottle field by future # schema additions. It is only meaningful here. child_raw = {k: v for k, v in raw.items() if k != "extends"} if parent_name_raw is None: - bottle = Bottle.from_dict(name, child_raw) + bottle = ManifestBottle.from_dict(name, child_raw) cache[name] = bottle return bottle @@ -66,27 +66,27 @@ def _resolve_one_bottle( def _merge_bottles( - parent: Bottle, + parent: ManifestBottle, child_raw: dict[str, object], name: str, -) -> Bottle: +) -> ManifestBottle: """Apply PRD 0025 merge rules.""" - from .manifest import Bottle, GitUser + from .manifest import ManifestBottle, ManifestGitUser from .manifest_egress import validate_egress_routes - # Parse the child's declared fields into a Bottle (with the + # Parse the child's declared fields into a ManifestBottle (with the # usual defaults for anything missing). Validation runs the same # way it would for a leaf bottle: typos / wrong types die here. - child = Bottle.from_dict(name, child_raw) + child = ManifestBottle.from_dict(name, child_raw) # env: dict merge, child wins on collision. merged_env = {**parent.env, **child.env} # git-gate.user: per-field overlay. Each non-empty field on child - # wins; empties fall through to parent. The default GitUser() + # wins; empties fall through to parent. The default ManifestGitUser() # is two empty strings, so a child that omits git-gate.user # inherits the parent's user verbatim. - merged_git_user = GitUser( + merged_git_user = ManifestGitUser( name=child.git_user.name or parent.git_user.name, email=child.git_user.email or parent.git_user.email, ) @@ -112,7 +112,7 @@ def _merge_bottles( ) validate_egress_routes(name, merged_egress.routes) - return Bottle( + return ManifestBottle( env=merged_env, agent_provider=merged_agent_provider, git=merged_git, @@ -133,9 +133,9 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool: def _merge_git_remotes( - parent: tuple[GitEntry, ...], - child: tuple[GitEntry, ...], -) -> tuple[GitEntry, ...]: + parent: tuple[ManifestGitEntry, ...], + child: tuple[ManifestGitEntry, ...], +) -> tuple[ManifestGitEntry, ...]: by_host = {entry.UpstreamHost: entry for entry in parent} for entry in child: by_host[entry.UpstreamHost] = entry diff --git a/bot_bottle/manifest_git.py b/bot_bottle/manifest_git.py index 81d845d..8ed0600 100644 --- a/bot_bottle/manifest_git.py +++ b/bot_bottle/manifest_git.py @@ -57,7 +57,7 @@ def parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]: return (user, host, port, path) -def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None: +def validate_unique_git_names(bottle_name: str, git: tuple[ManifestGitEntry, ...]) -> None: seen: dict[str, None] = {} for g in git: if g.Name in seen: @@ -69,7 +69,7 @@ def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> No @dataclass(frozen=True) -class ProvisionedKeyConfig: +class ManifestProvisionedKeyConfig: """Configuration for automatic deploy-key lifecycle management (PRD 0048). Used when a git-gate.repos entry opts out of a static identity file and instead wants a fresh SSH keypair @@ -87,7 +87,7 @@ class ProvisionedKeyConfig: @dataclass(frozen=True) -class GitEntry: +class ManifestGitEntry: """One upstream the per-agent git-gate (PRD 0008) is allowed to talk to. `Upstream` is the real remote URL the agent would push to if there were no gate; the gate hosts a bare repo at /git/.git @@ -107,7 +107,7 @@ class GitEntry: Upstream: str IdentityFile: str = "" KnownHostKey: str = "" - ProvisionedKey: Optional[ProvisionedKeyConfig] = None + ProvisionedKey: Optional[ManifestProvisionedKeyConfig] = None RemoteKey: str = "" UpstreamUser: str = "" UpstreamHost: str = "" @@ -117,7 +117,7 @@ class GitEntry: @classmethod def from_repos_entry( cls, bottle_name: str, repo_name: str, raw: object - ) -> "GitEntry": + ) -> "ManifestGitEntry": """Parse one entry from `git-gate.repos.`. YAML keys: `url` (required), exactly one of `identity` or @@ -160,7 +160,7 @@ class GitEntry: ) ident = "" - provisioned_key: Optional[ProvisionedKeyConfig] = None + provisioned_key: Optional[ManifestProvisionedKeyConfig] = None if has_identity: raw_ident = d.get("identity") if not isinstance(raw_ident, str) or not raw_ident: @@ -196,7 +196,7 @@ class GitEntry: def _parse_provisioned_key_config( bottle_name: str, label: str, raw: object -) -> ProvisionedKeyConfig: +) -> ManifestProvisionedKeyConfig: d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key") for k in d: if k not in {"provider", "token_env", "api_url"}: @@ -221,7 +221,7 @@ def _parse_provisioned_key_config( raise ManifestError( f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string" ) - return ProvisionedKeyConfig( + return ManifestProvisionedKeyConfig( provider=provider, token_env=token_env, api_url=api_url_raw, @@ -229,7 +229,7 @@ def _parse_provisioned_key_config( @dataclass(frozen=True) -class GitUser: +class ManifestGitUser: """Per-bottle `git config --global user.name` / `user.email` pair (issue #86). The agent's commits inside the bottle are attributed to this identity rather than the agent image's @@ -244,7 +244,7 @@ class GitUser: email: str = "" @classmethod - def from_dict(cls, bottle_name: str, raw: object) -> "GitUser": + def from_dict(cls, bottle_name: str, raw: object) -> "ManifestGitUser": d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user") for k in d: if k not in {"name", "email"}: @@ -279,7 +279,7 @@ class GitUser: def parse_git_gate_config( bottle_name: str, raw: object, -) -> tuple[tuple[GitEntry, ...], GitUser]: +) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]: d = as_json_object(raw, f"bottle '{bottle_name}' git-gate") for k in d: if k not in {"user", "repos"}: @@ -289,17 +289,17 @@ def parse_git_gate_config( ) git_user = ( - GitUser.from_dict(bottle_name, d["user"]) + ManifestGitUser.from_dict(bottle_name, d["user"]) if "user" in d - else GitUser() + else ManifestGitUser() ) - git: tuple[GitEntry, ...] = () + git: tuple[ManifestGitEntry, ...] = () repos_raw = d.get("repos") if repos_raw is not None: repos = as_json_object(repos_raw, f"bottle '{bottle_name}' git-gate.repos") git = tuple( - GitEntry.from_repos_entry(bottle_name, name, entry) + ManifestGitEntry.from_repos_entry(bottle_name, name, entry) for name, entry in repos.items() ) validate_unique_git_names(bottle_name, git) diff --git a/bot_bottle/manifest_loader.py b/bot_bottle/manifest_loader.py index 81a55f1..67d0e51 100644 --- a/bot_bottle/manifest_loader.py +++ b/bot_bottle/manifest_loader.py @@ -14,7 +14,7 @@ from .manifest_schema import ( from .yaml_subset import YamlSubsetError, parse_frontmatter if TYPE_CHECKING: - from .manifest import Agent, Bottle + from .manifest import ManifestAgent, ManifestBottle def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None: @@ -34,7 +34,7 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None: ) -def load_bottles_from_dir(bottles_dir: Path) -> dict[str, Bottle]: +def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]: """Walk `/*.md`, parse each as a bottle, and return `{name: Bottle}`. Missing dir returns an empty dict.""" from .manifest import ManifestError @@ -67,13 +67,13 @@ def load_agents_from_dir( bottle_names: set[str], *, source: str, # noqa: F841 — unused, but required by interface -) -> dict[str, Agent]: +) -> dict[str, ManifestAgent]: """Walk `/*.md`, parse each as an agent, and return `{name: Agent}`. The Markdown body becomes the agent's prompt. Missing dir returns an empty dict.""" - from .manifest import Agent, ManifestError + from .manifest import ManifestAgent, ManifestError - out: dict[str, Agent] = {} + out: dict[str, ManifestAgent] = {} if not agents_dir.is_dir(): return out for path in sorted(agents_dir.glob("*.md")): @@ -101,5 +101,5 @@ def load_agents_from_dir( } if "git-gate" in fm: agent_dict["git-gate"] = fm["git-gate"] - out[name] = Agent.from_dict(name, agent_dict, bottle_names) + out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names) return out diff --git a/bot_bottle/supervise.py b/bot_bottle/supervise.py index 68c1bf1..df01a67 100644 --- a/bot_bottle/supervise.py +++ b/bot_bottle/supervise.py @@ -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, diff --git a/scripts/demo-setup.sh b/scripts/demo-setup.sh index 845d456..840e699 100755 --- a/scripts/demo-setup.sh +++ b/scripts/demo-setup.sh @@ -35,5 +35,5 @@ chmod 600 "$fake_key_dir/fake-key" # Build the image graph quietly so the recorded run shows only the # bottle launch and the four `!` probes, not BuildKit progress. -docker build -q -f Dockerfile.claude -t bot-bottle-claude:latest . >/dev/null 2>&1 || true +docker build -q -f bot_bottle/contrib/claude/Dockerfile -t bot-bottle-claude:latest . >/dev/null 2>&1 || true docker build -q -f Dockerfile.git-gate -t bot-bottle-git-gate:latest . >/dev/null 2>&1 || true diff --git a/tests/integration/test_capability_apply.py b/tests/integration/test_capability_apply.py deleted file mode 100644 index 067d6f7..0000000 --- a/tests/integration/test_capability_apply.py +++ /dev/null @@ -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 ` 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-` (matches the agent container name pattern) - on the per-bottle internal network. - - A marker file under `/home/node/.claude/` so we can assert the - transcript snapshot path actually transferred bytes. - -Then `apply_capability_change` runs and we verify: - - Per-bottle Dockerfile written. - - Containers + networks removed. - - Transcript snapshot dir on the host has the marker file. - -docker exec / cp / rm work across the docker socket boundary, so -this test runs in DinD too — no act_runner skip needed. -""" - -from __future__ import annotations - -import os -import subprocess -import tempfile -import time -import unittest -from pathlib import Path - -from bot_bottle import supervise -from bot_bottle.backend.docker import bottle_state -from bot_bottle.backend.docker.capability_apply import apply_capability_change -from bot_bottle.backend.docker.network import ( - network_create_egress, - network_create_internal, - network_remove, -) -from bot_bottle.backend.docker.sidecar_bundle import ( - sidecar_bundle_container_name, -) -from tests._docker import skip_unless_docker - - -ALPINE_IMAGE = "alpine:latest" - - -@skip_unless_docker() -class TestCapabilityApply(unittest.TestCase): - @classmethod - def setUpClass(cls): - r = subprocess.run( - ["docker", "pull", ALPINE_IMAGE], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - if r.returncode != 0: - raise unittest.SkipTest(f"could not pull {ALPINE_IMAGE}") - - def setUp(self): - self.slug = f"cb-test-cap-{os.getpid()}-{int(time.time())}" - self.agent_name = f"bot-bottle-{self.slug}" - self.sidecar_names: list[str] = [] - self.internal_net = "" - self.egress_net = "" - # Fake home so tests don't touch ~/.bot-bottle/. - self._tmp = tempfile.TemporaryDirectory(prefix="cap-apply-int.") - self._original_root = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - - def tearDown(self): - supervise.bot_bottle_root = self._original_root # type: ignore[assignment] - for name in [self.agent_name, *self.sidecar_names]: - subprocess.run( - ["docker", "rm", "-f", name], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, - ) - for n in (self.internal_net, self.egress_net): - if n: - network_remove(n) - self._tmp.cleanup() - - def _bring_up_fake_bottle(self) -> None: - self.internal_net = network_create_internal(self.slug) - self.egress_net = network_create_egress(self.slug) - # Agent container with the canonical name. - r = subprocess.run( - [ - "docker", "run", "-d", - "--name", self.agent_name, - "--network", self.internal_net, - ALPINE_IMAGE, - "sh", "-c", - "mkdir -p /home/node/.claude && " - "echo 'transcript-marker' > /home/node/.claude/sessions.json && " - "sleep 3600", - ], - capture_output=True, text=True, check=False, - ) - self.assertEqual(0, r.returncode, r.stderr) - # Also start a fake sidecar bundle so teardown has something - # extra to clean up (mirrors a real bottle's container set). - sidecar = sidecar_bundle_container_name(self.slug) - subprocess.run( - [ - "docker", "run", "-d", - "--name", sidecar, - "--network", self.internal_net, - ALPINE_IMAGE, "sleep", "3600", - ], - capture_output=True, text=True, check=False, - ) - self.sidecar_names.append(sidecar) - - def _containers_named_like(self) -> list[str]: - """All running/stopped containers whose names start with - the bottle's slug — both agent + sidecars.""" - r = subprocess.run( - [ - "docker", "ps", "-a", - "--filter", f"name={self.agent_name}", - "--format", "{{.Names}}", - ], - capture_output=True, text=True, check=False, - ) - return [line for line in (r.stdout or "").splitlines() if line] - - def _networks_named_like(self) -> list[str]: - r = subprocess.run( - [ - "docker", "network", "ls", - "--filter", f"name={self.slug}", - "--format", "{{.Name}}", - ], - capture_output=True, text=True, check=False, - ) - return [line for line in (r.stdout or "").splitlines() if line] - - def test_apply_writes_dockerfile_and_tears_down(self): - self._bring_up_fake_bottle() - self.assertIn(self.agent_name, self._containers_named_like()) - - new_dockerfile = "FROM python:3.13\nRUN apk add ripgrep\n" - before, after = apply_capability_change(self.slug, new_dockerfile) - - # Before is the repo Dockerfile (no prior per-bottle override); - # after is what we passed in. - self.assertIn("FROM ", before) - self.assertEqual(new_dockerfile, after) - - # Per-bottle Dockerfile written on the host. - self.assertEqual( - new_dockerfile, - bottle_state.per_bottle_dockerfile(self.slug), - ) - - # Agent + sidecars gone. - self.assertEqual([], self._containers_named_like()) - # Networks removed (matching the slug substring). - nets = self._networks_named_like() - self.assertEqual([], nets) - # Mark them as already cleaned so tearDown is idempotent. - self.internal_net = "" - self.egress_net = "" - self.sidecar_names = [] - - def test_transcript_snapshot_captured(self): - self._bring_up_fake_bottle() - apply_capability_change(self.slug, "FROM x\n") - snap = bottle_state.transcript_snapshot_dir(self.slug) - self.assertTrue(snap.is_dir(), f"transcript snapshot dir {snap} missing") - # docker cp :/home/node/.claude produces - # /.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() diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index caece39..b547539 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -29,7 +29,7 @@ import unittest from pathlib import Path from bot_bottle.backend import BottleSpec, get_bottle_backend -from bot_bottle.backend.docker.bottle_state import cleanup_state +from bot_bottle.bottle_state import cleanup_state from bot_bottle.manifest import Manifest from tests._docker import skip_unless_docker diff --git a/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 7f33a0b..a23094b 100644 --- a/tests/unit/test_agent_provider.py +++ b/tests/unit/test_agent_provider.py @@ -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) diff --git a/tests/unit/test_backend_prepare.py b/tests/unit/test_backend_prepare.py new file mode 100644 index 0000000..3fab237 --- /dev/null +++ b/tests/unit/test_backend_prepare.py @@ -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() diff --git a/tests/unit/test_backend_workspace.py b/tests/unit/test_backend_workspace.py new file mode 100644 index 0000000..bcbfddf --- /dev/null +++ b/tests/unit/test_backend_workspace.py @@ -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() diff --git a/tests/unit/test_bottle_state.py b/tests/unit/test_bottle_state.py index 9714471..acda6c9 100644 --- a/tests/unit/test_bottle_state.py +++ b/tests/unit/test_bottle_state.py @@ -7,8 +7,8 @@ import unittest from pathlib import Path from bot_bottle import supervise -from bot_bottle.backend.docker import bottle_state -from bot_bottle.backend.docker.bottle_state import ( +from bot_bottle import bottle_state +from bot_bottle.bottle_state import ( BottleMetadata, read_metadata, write_metadata, @@ -260,7 +260,7 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase): def test_missing_backend_field_defaults_to_empty(self): # Old state dirs written before PRD 0040 have no backend key. import json - from bot_bottle.backend.docker import bottle_state as bs + from bot_bottle import bottle_state as bs path = bs.metadata_path("dev-b3") path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps({ diff --git a/tests/unit/test_capability_apply.py b/tests/unit/test_capability_apply.py deleted file mode 100644 index 3468f68..0000000 --- a/tests/unit/test_capability_apply.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Unit: capability_apply helpers (PRD 0016 Phase 2). - -docker cp / exec / rm / network rm paths are covered by the -integration test in Phase 4. Here we cover: - - fetch_current_dockerfile fallback chain (per-bottle → repo) - - apply_capability_change writes the per-bottle Dockerfile and - returns the correct (before, after). - - apply_capability_change rejects empty input. -""" - -import tempfile -import unittest -from pathlib import Path - -from bot_bottle import supervise -from bot_bottle.backend.docker import bottle_state, capability_apply -from bot_bottle.backend.docker.capability_apply import ( - CapabilityApplyError, - apply_capability_change, - fetch_current_dockerfile, -) - - -class _FakeHomeMixin: - def _setup_fake_home(self): - self._tmp = tempfile.TemporaryDirectory(prefix="cap-apply-test.") - original = supervise.bot_bottle_root - - def fake_root() -> Path: - return Path(self._tmp.name) / ".bot-bottle" - - supervise.bot_bottle_root = fake_root # type: ignore[assignment] - self._restore = lambda: setattr(supervise, "bot_bottle_root", original) - - def _teardown_fake_home(self): - self._restore() - self._tmp.cleanup() - - -class TestFetchCurrentDockerfile(_FakeHomeMixin, unittest.TestCase): - def setUp(self): - self._setup_fake_home() - - def tearDown(self): - self._teardown_fake_home() - - def test_returns_per_bottle_dockerfile_when_present(self): - bottle_state.write_per_bottle_dockerfile("dev", "FROM rebuilt\n") - self.assertEqual("FROM rebuilt\n", fetch_current_dockerfile("dev")) - - def test_falls_back_to_repo_dockerfile_when_no_override(self): - # The repo's Dockerfile actually exists; the test just checks - # we get its content (non-empty) when no per-bottle override - # is set. - content = fetch_current_dockerfile("dev-no-override") - self.assertIn("FROM ", content) - - -class TestApplyCapabilityChange(_FakeHomeMixin, unittest.TestCase): - def setUp(self): - self._setup_fake_home() - # Stub out the docker-dependent helpers. The orchestrator's - # job is to sequence write + snapshot + push + teardown; we - # validate that sequence here, not the docker primitives. - self._calls: list[str] = [] - self._orig_snapshot = capability_apply.snapshot_transcript - self._orig_push = capability_apply._push_working_tree - self._orig_teardown = capability_apply._teardown_bottle - - def stub_snapshot(slug: object) -> None: # type: ignore - self._calls.append(f"snapshot:{slug}") - - def stub_push(slug: object) -> None: # type: ignore - self._calls.append(f"push:{slug}") - - def stub_teardown(slug: object) -> None: # type: ignore - self._calls.append(f"teardown:{slug}") - - capability_apply.snapshot_transcript = stub_snapshot # type: ignore[assignment] - capability_apply._push_working_tree = stub_push # type: ignore[assignment] - capability_apply._teardown_bottle = stub_teardown # type: ignore[assignment] - - def tearDown(self): - capability_apply.snapshot_transcript = self._orig_snapshot # type: ignore[assignment] - capability_apply._push_working_tree = self._orig_push # type: ignore[assignment] - capability_apply._teardown_bottle = self._orig_teardown # type: ignore[assignment] - self._teardown_fake_home() - - def test_writes_per_bottle_dockerfile_and_returns_before_after(self): - bottle_state.write_per_bottle_dockerfile("dev", "FROM old\n") - before, after = apply_capability_change("dev", "FROM new\nRUN apk add ripgrep\n") - self.assertEqual("FROM old\n", before) - self.assertEqual("FROM new\nRUN apk add ripgrep\n", after) - self.assertEqual( - "FROM new\nRUN apk add ripgrep\n", - bottle_state.per_bottle_dockerfile("dev"), - ) - - def test_calls_snapshot_push_teardown_in_order(self): - apply_capability_change("dev", "FROM new\n") - # Snapshot + push must happen BEFORE write_per_bottle_dockerfile - # (so they capture pre-rebuild state) and BEFORE teardown (so - # the agent container still exists to docker exec / cp from). - # Teardown must be last. - self.assertEqual( - ["snapshot:dev", "push:dev", "teardown:dev"], - self._calls, - ) - - def test_marks_preserved_before_teardown(self): - # cli.py's session-end cleanup reads the marker after the - # bottle is torn down. The marker must therefore be written - # before teardown — otherwise the cleanup would see no - # marker and rm the state dir we just populated. - apply_capability_change("dev", "FROM new\n") - self.assertTrue(bottle_state.is_preserved("dev")) - - def test_first_change_falls_back_to_repo_dockerfile_for_before(self): - # No per-bottle override yet — before-diff comes from the - # repo's Dockerfile. - before, after = apply_capability_change("dev-fresh", "FROM new\n") - self.assertIn("FROM ", before) - self.assertEqual("FROM new\n", after) - - def test_empty_dockerfile_rejected(self): - with self.assertRaises(CapabilityApplyError): - apply_capability_change("dev", " \n\t\n") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_cli_start_settle.py b/tests/unit/test_cli_start_settle.py index 42ab560..83e8224 100644 --- a/tests/unit/test_cli_start_settle.py +++ b/tests/unit/test_cli_start_settle.py @@ -9,7 +9,7 @@ import unittest from pathlib import Path from bot_bottle import supervise -from bot_bottle.backend.docker import bottle_state +from bot_bottle import bottle_state from bot_bottle.cli import start as start_mod @@ -29,29 +29,20 @@ class _FakeHomeMixin: class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase): + # snapshot_transcript is commented out (capability_apply is disabled); + # capture_claude_session_state now only handles the preserve marker. def setUp(self): self._setup_fake_home() - # Stub the docker-dependent snapshot call so this stays a - # unit test. apply_capability_change's integration test - # covers the real docker cp path. - self._snap_calls: list[str] = [] - self._orig_snap = start_mod.snapshot_transcript - start_mod.snapshot_transcript = lambda identity: ( # type: ignore - self._snap_calls.append(identity) - ) def tearDown(self): - start_mod.snapshot_transcript = self._orig_snap self._teardown_fake_home() - def test_clean_exit_snapshots_but_does_not_mark(self): + def test_clean_exit_does_not_mark(self): start_mod.capture_claude_session_state("dev-abc", exit_code=0) - self.assertEqual(["dev-abc"], self._snap_calls) self.assertFalse(bottle_state.is_preserved("dev-abc")) - def test_crash_snapshots_and_marks(self): + def test_crash_marks_preserved(self): start_mod.capture_claude_session_state("dev-abc", exit_code=137) - self.assertEqual(["dev-abc"], self._snap_calls) self.assertTrue(bottle_state.is_preserved("dev-abc")) def test_ctrl_c_treated_as_crash(self): @@ -64,7 +55,7 @@ class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase): # Backends without an identity field shouldn't crash this # path (the _identity_from_plan helper falls back to ""). start_mod.capture_claude_session_state("", exit_code=137) - self.assertEqual([], self._snap_calls) + self.assertFalse(bottle_state.is_preserved("")) class TestSettleState(_FakeHomeMixin, unittest.TestCase): diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 251221c..3d4b0cd 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -33,7 +33,6 @@ from bot_bottle.egress import ( from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import Manifest from bot_bottle.supervise import SupervisePlan -from bot_bottle.workspace import workspace_plan SLUG = "demo-abc12" @@ -149,19 +148,10 @@ def _plan( spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress) return DockerBottlePlan( - guest_home="/home/node", spec=spec, stage_dir=STAGE, slug=SLUG, - container_name=f"bot-bottle-{SLUG}", - container_name_pinned=False, - image="bot-bottle-claude:latest", - derived_image="", - runtime_image="bot-bottle-claude:latest", - dockerfile_path="", - env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, - prompt_file=STAGE / "prompt", git_gate_plan=_git_gate_plan(upstreams), egress_plan=_egress_plan(routes), supervise_plan=_supervise_plan() if supervise else None, @@ -172,9 +162,11 @@ def _plan( prompt_mode="append_file", image="bot-bottle-claude:latest", dockerfile="", + guest_home="/home/node", + instance_name=f"bot-bottle-{SLUG}", + prompt_file=STAGE / "prompt", guest_env={}, ), - workspace_plan=workspace_plan(spec, guest_home="/home/node"), ) @@ -210,7 +202,7 @@ class TestAgentAlwaysPresent(unittest.TestCase): def test_agent_image_uses_runtime_image(self): plan = _plan() s = bottle_plan_to_compose(plan)["services"]["agent"] - self.assertEqual(plan.runtime_image, s["image"]) + self.assertEqual(plan.image, s["image"]) def test_agent_only_on_internal_network(self): s = bottle_plan_to_compose(_plan())["services"]["agent"] @@ -252,6 +244,9 @@ class TestAgentAlwaysPresent(unittest.TestCase): prompt_mode="read_prompt_file", image="bot-bottle-codex:latest", dockerfile="", + guest_home="/home/node", + instance_name=f"bot-bottle-{SLUG}", + prompt_file=STAGE / "prompt", guest_env={"CODEX_HOME": "/home/node/.codex"}, ) plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index a53a51b..df37fec 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -24,7 +24,6 @@ from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest from bot_bottle.supervise import SupervisePlan -from bot_bottle.workspace import workspace_plan _URL = "http://supervise:9100/" @@ -76,19 +75,10 @@ def _plan( current_config_dir=Path("/tmp/current-config"), ) return DockerBottlePlan( - guest_home="/home/node", spec=spec, stage_dir=Path("/tmp/stage"), slug="demo-abc12", - container_name="bot-bottle-demo-abc12", - container_name_pinned=False, - image="bot-bottle-claude:latest", - derived_image="", - runtime_image="bot-bottle-claude:latest", - dockerfile_path="", - env_file=Path("/tmp/agent.env"), forwarded_env={}, - prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), @@ -106,9 +96,12 @@ def _plan( use_runsc=False, agent_provision=agent_provision or AgentProvisionPlan( template="claude", command="claude", prompt_mode="append_file", - image="", dockerfile="", guest_env={}, + image="bot-bottle-claude:latest", dockerfile="", + guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), + guest_env={}, ), - workspace_plan=workspace_plan(spec, guest_home="/home/node"), ) @@ -211,7 +204,10 @@ class TestClaudeProvision(unittest.TestCase): def test_copies_files_and_chowns(self): provision = AgentProvisionPlan( template="claude", command="claude", prompt_mode="append_file", - image="", dockerfile="", guest_env={}, + image="", dockerfile="", guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/prompt.txt"), + guest_env={}, files=(AgentProvisionFile( Path("/tmp/claude.json"), "/home/node/.claude.json", ),), @@ -234,7 +230,10 @@ class TestClaudeProvision(unittest.TestCase): def test_dies_when_file_chown_fails(self): provision = AgentProvisionPlan( template="claude", command="claude", prompt_mode="append_file", - image="", dockerfile="", guest_env={}, + image="", dockerfile="", guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/prompt.txt"), + guest_env={}, files=(AgentProvisionFile( Path("/tmp/claude.json"), "/home/node/.claude.json", ),), @@ -250,7 +249,10 @@ class TestClaudeProvision(unittest.TestCase): def test_runs_verify_commands(self): provision = AgentProvisionPlan( template="claude", command="claude", prompt_mode="append_file", - image="", dockerfile="", guest_env={}, + image="", dockerfile="", guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/prompt.txt"), + guest_env={}, verify=(AgentProvisionCommand( ("/usr/bin/true",), "verify failed", ),), diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index db9f4a2..e0ab6fc 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -25,7 +25,6 @@ from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest from bot_bottle.supervise import SupervisePlan -from bot_bottle.workspace import workspace_plan _URL = "http://supervise:9100/" @@ -77,19 +76,10 @@ def _plan( current_config_dir=Path("/tmp/current-config"), ) return DockerBottlePlan( - guest_home="/home/node", spec=spec, stage_dir=Path("/tmp/stage"), slug="demo-abc12", - container_name="bot-bottle-demo-abc12", - container_name_pinned=False, - image="bot-bottle-codex:latest", - derived_image="", - runtime_image="bot-bottle-codex:latest", - dockerfile_path="", - env_file=Path("/tmp/agent.env"), forwarded_env={}, - prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), @@ -107,9 +97,12 @@ def _plan( use_runsc=False, agent_provision=agent_provision or AgentProvisionPlan( template="codex", command="codex", prompt_mode="read_prompt_file", - image="", dockerfile="", guest_env={}, + image="bot-bottle-codex:latest", dockerfile="", + guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), + guest_env={}, ), - workspace_plan=workspace_plan(spec, guest_home="/home/node"), ) @@ -177,7 +170,10 @@ class TestCodexProvision(unittest.TestCase): provision = AgentProvisionPlan( template="codex", command="codex", prompt_mode="read_prompt_file", - image="", dockerfile="", guest_env={}, + image="", dockerfile="", guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/prompt.txt"), + guest_env={}, dirs=(AgentProvisionDir("/home/node/.codex"),), files=(AgentProvisionFile( Path("/tmp/codex-config.toml"), @@ -201,7 +197,10 @@ class TestCodexProvision(unittest.TestCase): provision = AgentProvisionPlan( template="codex", command="codex", prompt_mode="read_prompt_file", - image="", dockerfile="", guest_env={}, + image="", dockerfile="", guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/prompt.txt"), + guest_env={}, pre_copy=(AgentProvisionCommand( ("find", "/home/node/.codex", "-name", "*.sqlite", "-delete"), "could not reset runtime db files", @@ -223,7 +222,10 @@ class TestCodexProvision(unittest.TestCase): provision = AgentProvisionPlan( template="codex", command="codex", prompt_mode="read_prompt_file", - image="", dockerfile="", guest_env={}, + image="", dockerfile="", guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/prompt.txt"), + guest_env={}, dirs=(AgentProvisionDir("/home/node/.codex"),), ) bottle = _make_bottle(exec_result=ExecResult(1, "", "mkdir: nope\n")) diff --git a/tests/unit/test_docker_cleanup.py b/tests/unit/test_docker_cleanup.py index 8d4c394..3516c66 100644 --- a/tests/unit/test_docker_cleanup.py +++ b/tests/unit/test_docker_cleanup.py @@ -16,7 +16,7 @@ import unittest from pathlib import Path from bot_bottle import supervise -from bot_bottle.backend.docker import bottle_state +from bot_bottle import bottle_state from bot_bottle.backend.docker.cleanup import _list_orphan_state_dirs diff --git a/tests/unit/test_docker_enumerate_active.py b/tests/unit/test_docker_enumerate_active.py index f0eb26a..b6aca74 100644 --- a/tests/unit/test_docker_enumerate_active.py +++ b/tests/unit/test_docker_enumerate_active.py @@ -24,7 +24,8 @@ import unittest from pathlib import Path from bot_bottle import supervise -from bot_bottle.backend.docker import bottle_state, enumerate as _enumerate +from bot_bottle import bottle_state +from bot_bottle.backend.docker import enumerate as _enumerate class TestParseServicesByProject(unittest.TestCase): diff --git a/tests/unit/test_docker_launch_teardown.py b/tests/unit/test_docker_launch_teardown.py index 8761237..58b9c83 100644 --- a/tests/unit/test_docker_launch_teardown.py +++ b/tests/unit/test_docker_launch_teardown.py @@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -from bot_bottle.workspace import workspace_plan def _manifest() -> Manifest: @@ -43,7 +42,6 @@ def _plan(tmp: str) -> DockerBottlePlan: identity="test-teardown-00001", ) return DockerBottlePlan( - guest_home="/home/node", spec=spec, stage_dir=stage, git_gate_plan=GitGatePlan( @@ -64,21 +62,15 @@ def _plan(tmp: str) -> DockerBottlePlan: template="claude", command="claude", prompt_mode="append_file", - image="", + image="bot-bottle-claude:latest", dockerfile="", + guest_home="/home/node", + instance_name="bot-bottle-test-teardown-abc", + prompt_file=stage / "prompt.txt", guest_env={}, ), - workspace_plan=workspace_plan(spec, guest_home="/home/node"), slug="test-teardown-00001", - container_name="bot-bottle-test-teardown-abc", - container_name_pinned=False, - image="bot-bottle-claude:latest", - derived_image="", - runtime_image="bot-bottle-claude:latest", - dockerfile_path="", - env_file=stage / "env", forwarded_env={}, - prompt_file=stage / "prompt.txt", use_runsc=False, ) diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index e0f9661..5513fc5 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -22,7 +22,6 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan from bot_bottle.manifest import Manifest -from bot_bottle.workspace import workspace_plan class _Provider(AgentProvider): @@ -30,7 +29,7 @@ class _Provider(AgentProvider): @property def runtime(self) -> AgentProviderRuntime: return AgentProviderRuntime( - template="test", command="test", image="", dockerfile="", + template="test", command="test", image="", prompt_mode="append_file", bypass_args=(), resume_args=(), remote_control_args=(), ) @@ -61,19 +60,10 @@ def _plan(*, git_user: dict | None = None, # type: ignore copy_cwd=copy_cwd, user_cwd=user_cwd, ) return DockerBottlePlan( - guest_home="/home/node", spec=spec, stage_dir=stage_dir or Path("/tmp/stage"), slug="demo-abc12", - container_name="bot-bottle-demo-abc12", - container_name_pinned=False, - image="bot-bottle-claude:latest", - derived_image="", - runtime_image="bot-bottle-claude:latest", - dockerfile_path="", - env_file=Path("/tmp/agent.env"), forwarded_env={}, - prompt_file=Path("/tmp/prompt.txt"), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), @@ -95,9 +85,11 @@ def _plan(*, git_user: dict | None = None, # type: ignore prompt_mode="append_file", image="bot-bottle-claude:latest", dockerfile="", + guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/prompt.txt"), guest_env={}, ), - workspace_plan=workspace_plan(spec, guest_home="/home/node"), ) @@ -131,25 +123,6 @@ class TestProvisionGitUser(unittest.TestCase): _PROVIDER.provision_git(bottle, _plan(stage_dir=self.stage)) self.assertEqual([], _git_config_exec_calls(bottle)) - def test_copies_cwd_git_to_workspace_plan_path(self): - cwd = self.stage / "cwd" - (cwd / ".git").mkdir(parents=True) - plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage) - bottle = _make_bottle() - _PROVIDER.provision_git(bottle, plan) - - bottle.cp_in.assert_called_once_with( - f"{cwd}/.git", - "/home/node/workspace/.git", - ) - chown_calls = [ - c for c in bottle.exec.call_args_list - if "chown" in (c.args[0] if c.args else "") - and "/home/node/workspace/.git" in (c.args[0] if c.args else "") - ] - self.assertEqual(1, len(chown_calls)) - self.assertIn("node:node", chown_calls[0].args[0]) - def test_sets_name_and_email(self): plan = _plan( git_user={"name": "Eric Bauerfeld", "email": "eric@dideric.is"}, diff --git a/tests/unit/test_docker_util_image.py b/tests/unit/test_docker_util_image.py index 2b35a67..67b5124 100644 --- a/tests/unit/test_docker_util_image.py +++ b/tests/unit/test_docker_util_image.py @@ -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() diff --git a/tests/unit/test_egress_addon_core.py b/tests/unit/test_egress_addon_core.py index 2c11486..85f44a9 100644 --- a/tests/unit/test_egress_addon_core.py +++ b/tests/unit/test_egress_addon_core.py @@ -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", diff --git a/tests/unit/test_manifest_git_user.py b/tests/unit/test_manifest_git_user.py index 324f898..fee0091 100644 --- a/tests/unit/test_manifest_git_user.py +++ b/tests/unit/test_manifest_git_user.py @@ -2,7 +2,7 @@ import unittest -from bot_bottle.manifest import ManifestError, GitUser, Manifest +from bot_bottle.manifest import ManifestError, ManifestGitUser, Manifest def _error_message(callable_, *args, **kwargs) -> str: # type: ignore @@ -99,13 +99,13 @@ class TestGitUserDirect(unittest.TestCase): """Direct GitUser dataclass exercises (no manifest wrapper).""" def test_is_empty_default(self): - self.assertTrue(GitUser().is_empty()) + self.assertTrue(ManifestGitUser().is_empty()) def test_is_empty_false_when_name_set(self): - self.assertFalse(GitUser(name="x").is_empty()) + self.assertFalse(ManifestGitUser(name="x").is_empty()) def test_is_empty_false_when_email_set(self): - self.assertFalse(GitUser(email="x@y").is_empty()) + self.assertFalse(ManifestGitUser(email="x@y").is_empty()) if __name__ == "__main__": diff --git a/tests/unit/test_manifest_runtime.py b/tests/unit/test_manifest_runtime.py index 9db3060..42def91 100644 --- a/tests/unit/test_manifest_runtime.py +++ b/tests/unit/test_manifest_runtime.py @@ -7,7 +7,7 @@ silently ignoring.""" import unittest from typing import Any -from bot_bottle.manifest import ManifestError, Bottle, Manifest +from bot_bottle.manifest import ManifestError, ManifestBottle, Manifest def _manifest_with_runtime(value: object) -> dict[str, Any]: @@ -26,7 +26,7 @@ class TestManifestRuntimeRemoved(unittest.TestCase): self.assertIn("dev", m.bottles) def test_bottle_dataclass_has_no_runtime_attribute(self): - self.assertFalse(hasattr(Bottle(), "runtime")) + self.assertFalse(hasattr(ManifestBottle(), "runtime")) def test_any_runtime_value_is_rejected(self): for value in ("runsc", "runc", "kata-runtime", "", 42, None): diff --git a/tests/unit/test_plan_print_parity.py b/tests/unit/test_plan_print_parity.py index 7ca7ed2..3001fc3 100644 --- a/tests/unit/test_plan_print_parity.py +++ b/tests/unit/test_plan_print_parity.py @@ -20,7 +20,6 @@ from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.manifest import Manifest -from bot_bottle.workspace import workspace_plan def _manifest() -> Manifest: @@ -79,13 +78,16 @@ def _egress_plan(tmp: str) -> EgressPlan: ) -def _agent_provision() -> AgentProvisionPlan: +def _agent_provision(tmp: str) -> AgentProvisionPlan: return AgentProvisionPlan( template="claude", command="claude", prompt_mode="append_file", - image="", + image="bot-bottle-claude:latest", dockerfile="", + guest_home="/home/node", + instance_name="bot-bottle-test-00001", + prompt_file=Path(tmp) / "prompt.txt", guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"}, ) @@ -93,24 +95,14 @@ def _agent_provision() -> AgentProvisionPlan: def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan: stage = Path(tmp) return DockerBottlePlan( - guest_home="/home/node", spec=spec, stage_dir=stage, git_gate_plan=_git_gate_plan(tmp), egress_plan=_egress_plan(tmp), supervise_plan=None, - agent_provision=_agent_provision(), - workspace_plan=workspace_plan(spec, guest_home="/home/node"), + agent_provision=_agent_provision(tmp), slug="test-00001", - container_name="bot-bottle-test-00001", - container_name_pinned=False, - image="bot-bottle-claude:latest", - derived_image="", - runtime_image="bot-bottle-claude:latest", - dockerfile_path="", - env_file=stage / "env", forwarded_env={}, - prompt_file=stage / "prompt.txt", use_runsc=False, ) @@ -118,22 +110,17 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan: def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan: stage = Path(tmp) return SmolmachinesBottlePlan( - guest_home="/home/node", spec=spec, stage_dir=stage, git_gate_plan=_git_gate_plan(tmp), egress_plan=_egress_plan(tmp), supervise_plan=None, - agent_provision=_agent_provision(), - workspace_plan=workspace_plan(spec, guest_home="/home/node"), + agent_provision=_agent_provision(tmp), slug="test-00001", bundle_subnet="10.99.0.0/24", bundle_gateway="10.99.0.1", bundle_ip="10.99.0.2", - machine_name="bot-bottle-test-00001", - agent_image_ref="bot-bottle-claude:latest", guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"}, - prompt_file=stage / "prompt.txt", ) diff --git a/tests/unit/test_smolmachines_prepare.py b/tests/unit/test_smolmachines_prepare.py deleted file mode 100644 index a1237b3..0000000 --- a/tests/unit/test_smolmachines_prepare.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Unit: smolmachines prepare.py env resolution (PRD 0038).""" - -from __future__ import annotations - -import os -import tempfile -import unittest -from pathlib import Path -from unittest.mock import MagicMock, patch - -from bot_bottle.agent_provider import AgentProvisionPlan -from bot_bottle.env import ResolvedEnv - - -class TestSmolmachinesResolveEnv(unittest.TestCase): - """resolve_plan() must call resolve_env() and build guest_env - from the resolved values rather than from raw bottle.env.""" - - def _run_resolve_plan( - self, - resolved: ResolvedEnv, - *, - extra_host_env: dict[str, str] | None = None, - ) -> dict[str, str]: - from bot_bottle.backend import BottleSpec - from bot_bottle.manifest import Manifest - - with tempfile.TemporaryDirectory() as tmp: - stage = Path(tmp) / "stage" - stage.mkdir() - - # Minimal manifest with one env literal so the spec is valid. - manifest = Manifest.from_json_obj({ - "agents": {"myagent": {"bottle": "mybottle"}}, - "bottles": {"mybottle": {"env": {"PLAIN": "literal-value"}}}, - }) - spec = BottleSpec( - manifest=manifest, - agent_name="myagent", - copy_cwd=False, - user_cwd=tmp, - identity="test-slug-00001", - ) - - from bot_bottle import supervise as _sup - orig_root = _sup.bot_bottle_root - _sup.bot_bottle_root = lambda: Path(tmp) / ".bot-bottle" # type: ignore[assignment] - - host_env = {**os.environ, **(extra_host_env or {})} # type: ignore - - try: - with ( - patch("bot_bottle.backend.smolmachines.prepare.resolve_env", - return_value=resolved) as mock_resolve, - patch("bot_bottle.backend.smolmachines.prepare.smolmachines_preflight"), - patch("bot_bottle.backend.smolmachines.prepare.smolmachines_bundle_subnet", - return_value=("10.99.0.0/24", "10.99.0.1", "10.99.0.2")), - patch("bot_bottle.backend.smolmachines.prepare.GitGate") as mock_gg, - patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg, - patch("bot_bottle.backend.smolmachines.prepare.Supervise"), - patch( - "bot_bottle.backend.smolmachines.prepare.agent_provision_plan" - ) as mock_app, - patch("bot_bottle.backend.smolmachines.prepare.runtime_for"), - ): - mock_gg.return_value.prepare.return_value = MagicMock() - mock_eg.return_value.prepare.return_value = MagicMock() - def _make_provision(**kwargs): # type: ignore - return AgentProvisionPlan( - template="claude", - command="claude", - prompt_mode="append_file", - dockerfile="", - image="bot-bottle-claude:latest", - guest_env=dict(kwargs.get("guest_env") or {}), - ) - mock_app.side_effect = lambda **kw: _make_provision(**kw) # type: ignore - - from bot_bottle.backend.smolmachines.prepare import resolve_plan - plan = resolve_plan(spec, stage_dir=stage) - - mock_resolve.assert_called_once_with(manifest, "myagent") - return dict(plan.guest_env) - finally: - _sup.bot_bottle_root = orig_root # type: ignore[assignment] - - def test_literal_env_reaches_guest_env(self): - resolved = ResolvedEnv( - literals={"PLAIN": "hello"}, - forwarded={}, - ) - guest_env = self._run_resolve_plan(resolved) - self.assertEqual("hello", guest_env["PLAIN"]) - - def test_forwarded_env_reaches_guest_env(self): - # Secrets / interpolated values land in forwarded; they must - # still reach the guest (argv exposure is the known gap). - resolved = ResolvedEnv( - literals={}, - forwarded={"SECRET": "s3cr3t", "INTERP": "resolved-val"}, - ) - guest_env = self._run_resolve_plan(resolved) - self.assertEqual("s3cr3t", guest_env["SECRET"]) - self.assertEqual("resolved-val", guest_env["INTERP"]) - - def test_raw_manifest_sentinel_not_in_guest_env(self): - # Before the fix, ?prompt and ${HOST} would appear verbatim. - # After the fix, resolve_env() is called so the caller sees - # the mocked resolved values (no raw sentinel survives). - resolved = ResolvedEnv( - literals={}, - forwarded={"MY_SECRET": "actual-value"}, - ) - guest_env = self._run_resolve_plan(resolved) - for v in guest_env.values(): - self.assertFalse( - v.startswith("?"), - f"raw secret sentinel survived in guest_env: {v!r}", - ) - self.assertFalse( - v.startswith("${"), - f"raw interpolation sentinel survived in guest_env: {v!r}", - ) - - def test_tls_trust_env_always_present(self): - resolved = ResolvedEnv(literals={}, forwarded={}) - guest_env = self._run_resolve_plan(resolved) - for key in ("NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE"): - self.assertIn(key, guest_env, f"{key} missing from guest_env") - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index 8515566..86ce6c7 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -26,16 +26,15 @@ from bot_bottle.backend.smolmachines.bottle import SmolmachinesBottle from bot_bottle.backend.smolmachines.bottle_plan import ( SmolmachinesBottlePlan, ) -from bot_bottle.backend.smolmachines.provision import ( - workspace as _workspace, -) +# from bot_bottle.backend.smolmachines.provision import ( +# workspace as _workspace, +# ) from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec from bot_bottle.backend.util import AGENT_CA_PATH from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream -from bot_bottle.manifest import GitEntry, Manifest +from bot_bottle.manifest import ManifestGitEntry, Manifest from bot_bottle.supervise import SupervisePlan -from bot_bottle.workspace import workspace_plan class _Provider(AgentProvider): @@ -43,7 +42,7 @@ class _Provider(AgentProvider): @property def runtime(self) -> AgentProviderRuntime: return AgentProviderRuntime( - template="test", command="test", image="", dockerfile="", + template="test", command="test", image="", prompt_mode="append_file", bypass_args=(), resume_args=(), remote_control_args=(), ) @@ -71,11 +70,6 @@ def _make_bottle( return bottle -def _exec_scripts(bottle: MagicMock) -> list[str]: - """All script strings passed to bottle.exec, in call order.""" - return [c.args[0] for c in bottle.exec.call_args_list] - - def _exec_users(bottle: MagicMock) -> list[str]: # type: ignore """user= kwarg from each bottle.exec call, in order.""" return [c.kwargs.get("user", "node") for c in bottle.exec.call_args_list] @@ -85,7 +79,7 @@ def _plan( *, agent_prompt: str = "", skills: list[str] | None = None, - git: list[GitEntry] = (), # type: ignore + git: list[ManifestGitEntry] = (), # type: ignore git_user: dict | None = None, # type: ignore copy_cwd: bool = False, user_cwd: str = "/tmp/x", @@ -140,17 +134,13 @@ def _plan( current_config_dir=Path("/tmp/current-config"), ) return SmolmachinesBottlePlan( - guest_home="/home/node", spec=spec, stage_dir=stage_dir or Path("/tmp/stage"), slug="demo-abc12", bundle_subnet="192.168.50.0/24", bundle_gateway="192.168.50.1", bundle_ip=bundle_ip, - machine_name="bot-bottle-demo-abc12", - agent_image_ref="bot-bottle-claude:latest", guest_env=dict(guest_env or {}), - prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), git_gate_plan=GitGatePlan( slug="demo-abc12", entrypoint_script=Path("/tmp/git-gate-entrypoint.sh"), @@ -173,7 +163,6 @@ def _plan( codex_auth_file=codex_auth_file, guest_env=dict(guest_env or {}), ), - workspace_plan=workspace_plan(spec, guest_home="/home/node"), ) @@ -188,8 +177,11 @@ def _agent_provision( template=template, command=template, prompt_mode="append_file", - image="", + image="bot-bottle-claude:latest", dockerfile="", + guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), guest_env=dict(guest_env or {}), ) auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex") @@ -227,6 +219,9 @@ def _agent_provision( prompt_mode="read_prompt_file", image="bot-bottle-codex:latest", dockerfile="", + guest_home="/home/node", + instance_name="bot-bottle-demo-abc12", + prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), guest_env=dict(guest_env or {}), dirs=(AgentProvisionDir(auth_dir),), files=tuple(files), @@ -341,9 +336,7 @@ class TestSmolmachinesBottleExec(unittest.TestCase): class TestProvisionGit(unittest.TestCase): - """provision_git dispatches two independent passes (cwd .git - copy + gitconfig insteadOf write); each no-ops on its own - when its condition doesn't hold.""" + """provision_git writes gitconfig insteadOf rules when configured.""" def setUp(self): self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-git.") # pylint: disable=consider-using-with @@ -358,41 +351,13 @@ class TestProvisionGit(unittest.TestCase): bottle.cp_in.assert_not_called() bottle.exec.assert_not_called() - def test_copies_cwd_git_when_copy_cwd_and_git_present(self): - # Stage a fake host .git dir under user_cwd so the path- - # check in provision_git fires. - cwd = self.stage / "cwd" - (cwd / ".git").mkdir(parents=True) - plan = _plan( - copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage, - ) - bottle = _make_bottle() - _PROVIDER.provision_git(bottle, plan) - bottle.cp_in.assert_called_once_with( - f"{cwd}/.git", - "/home/node/workspace/.git", - ) - scripts = _exec_scripts(bottle) - self.assertTrue(any("mkdir -p" in s and "/home/node/workspace" in s for s in scripts)) - # chown the workspace tree so the agent (node) owns it. - self.assertTrue( - any("chown -R" in s and "node:node" in s and "/home/node/workspace/.git" in s - for s in scripts) - ) - - def test_skips_cwd_when_copy_cwd_false(self): - plan = _plan(copy_cwd=False, stage_dir=self.stage) - bottle = _make_bottle() - _PROVIDER.provision_git(bottle, plan) - bottle.cp_in.assert_not_called() - def test_writes_gitconfig_with_ip_port_form_for_smolmachines(self): # Smolmachines's TSI-allowlisted guest dials git-gate via # smart HTTP at `127.0.0.1:` — the bundle's # git HTTP port is published on host loopback at launch # time, and the plan carries the discovered host port. plan = _plan( - git=[GitEntry( + git=[ManifestGitEntry( Name="bot-bottle", Upstream="ssh://git@host/repo.git", IdentityFile="~/.ssh/id_ed25519", @@ -505,43 +470,5 @@ class TestProvisionGitUser(unittest.TestCase): self.assertIn("bot@example.com", calls[0][0]) -class TestProvisionWorkspace(unittest.TestCase): - def setUp(self): - self._tmp = tempfile.TemporaryDirectory(prefix="cb-prov-workspace.") # pylint: disable=consider-using-with - self.stage = Path(self._tmp.name) - - def tearDown(self): - self._tmp.cleanup() - - def test_noop_when_copy_cwd_false(self): - plan = _plan(copy_cwd=False, stage_dir=self.stage) - bottle = _make_bottle() - _workspace.provision_workspace(plan, bottle) - bottle.cp_in.assert_not_called() - bottle.exec.assert_not_called() - - def test_copies_workspace_to_plan_path_and_chowns(self): - cwd = self.stage / "cwd" - cwd.mkdir() - plan = _plan(copy_cwd=True, user_cwd=str(cwd), stage_dir=self.stage) - bottle = _make_bottle() - _workspace.provision_workspace(plan, bottle) - - bottle.cp_in.assert_called_once_with( - str(cwd), - "/home/node/workspace", - ) - scripts = _exec_scripts(bottle) - self.assertTrue( - any("rm -rf /home/node/workspace" in s and "mkdir -p /home/node" in s - for s in scripts) - ) - self.assertTrue( - any("chown -R node:node /home/node/workspace" in s - and "chmod 755 /home/node/workspace" in s - for s in scripts) - ) - - if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_supervise.py b/tests/unit/test_supervise.py index c811d34..8eeb8db 100644 --- a/tests/unit/test_supervise.py +++ b/tests/unit/test_supervise.py @@ -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__": diff --git a/tests/unit/test_supervise_cli.py b/tests/unit/test_supervise_cli.py index d672a7f..f7ad6d4 100644 --- a/tests/unit/test_supervise_cli.py +++ b/tests/unit/test_supervise_cli.py @@ -14,7 +14,6 @@ from datetime import datetime, timezone from pathlib import Path from bot_bottle import supervise -from bot_bottle.backend.docker.capability_apply import CapabilityApplyError from bot_bottle.cli import supervise as supervise_cli from bot_bottle.supervise import ( Proposal, @@ -115,13 +114,8 @@ class TestDiscoverPending(_FakeHomeMixin, unittest.TestCase): class TestApproveReject(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() - self._original_apply_capability = supervise_cli.apply_capability_change - supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore - "FROM old\n", content, - ) def tearDown(self): - supervise_cli.apply_capability_change = self._original_apply_capability self._teardown_fake_home() def _enqueue(self, tool: str = TOOL_CAPABILITY_BLOCK): @@ -161,67 +155,9 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase): self.assertEqual([], read_audit_entries("egress", "dev")) -class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): - """PRD 0016 Phase 3: approve() on a capability-block proposal - calls apply_capability_change, archives the proposal afterward - (sidecar is gone so it can't archive itself), and writes no - audit entry (capability-block has none per PRD 0013).""" - - def setUp(self): - self._setup_fake_home() - self._original = supervise_cli.apply_capability_change - - def tearDown(self): - supervise_cli.apply_capability_change = self._original - self._teardown_fake_home() - - def _enqueue_capability(self, proposed: str = "FROM python:3.13\nRUN apk add ripgrep\n"): - p = Proposal.new( - bottle_slug="dev", tool=TOOL_CAPABILITY_BLOCK, - proposed_file=proposed, - justification="need ripgrep", - current_file_hash=sha256_hex(proposed), - now=FIXED, - ) - qdir = supervise.queue_dir_for_slug("dev") - qdir.mkdir(parents=True, exist_ok=True) - supervise.write_proposal(qdir, p) - return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir) - - def test_capability_block_calls_apply_with_proposed_file(self): - calls = [] - supervise_cli.apply_capability_change = lambda slug, content: ( # type: ignore - calls.append((slug, content)) or ("FROM old\n", content) - ) - qp = self._enqueue_capability("FROM bookworm\n") - supervise_cli.approve(qp) - self.assertEqual([("dev", "FROM bookworm\n")], calls) - - def test_apply_failure_blocks_response_and_keeps_pending(self): - supervise_cli.apply_capability_change = lambda slug, content: (_ for _ in ()).throw( # type: ignore - CapabilityApplyError("teardown failed") - ) - qp = self._enqueue_capability() - with self.assertRaises(CapabilityApplyError): - supervise_cli.approve(qp) - self.assertEqual( - [qp.proposal.id], - [p.id for p in supervise.list_pending_proposals(qp.queue_dir)], - ) - - def test_no_audit_log_for_capability(self): - supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore - qp = self._enqueue_capability() - supervise_cli.approve(qp) - self.assertEqual([], read_audit_entries("egress", "dev")) - - def test_proposal_archived_after_apply(self): - supervise_cli.apply_capability_change = lambda slug, content: ("FROM old\n", content) # type: ignore - qp = self._enqueue_capability() - supervise_cli.approve(qp) - self.assertEqual([], supervise.list_pending_proposals(qp.queue_dir)) - processed = list((qp.queue_dir / "processed").glob("*.json")) - self.assertEqual(2, len(processed)) +# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): +# # DISABLED — capability_apply functionality is currently commented out. +# pass class TestEditInEditor(unittest.TestCase): @@ -268,52 +204,9 @@ class TestEditInEditor(unittest.TestCase): os.environ["EDITOR"] = original_editor -class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase): - """approve() must refuse capability-block for smolmachines bottles and - pass it through for Docker bottles (PRD 0039).""" - - def setUp(self): - self._setup_fake_home() - self._original_apply_capability = supervise_cli.apply_capability_change - supervise_cli.apply_capability_change = lambda slug, content: ("", content) # type: ignore - - def tearDown(self): - supervise_cli.apply_capability_change = self._original_apply_capability - self._teardown_fake_home() - - def _enqueue_capability(self, slug: str = "dev") -> "supervise_cli.QueuedProposal": - p = _proposal(slug=slug, tool=TOOL_CAPABILITY_BLOCK) - qdir = supervise.queue_dir_for_slug(slug) - qdir.mkdir(parents=True, exist_ok=True) - supervise.write_proposal(qdir, p) - return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir) - - def _write_metadata(self, slug: str, compose_project: str) -> None: - from bot_bottle.backend.docker.bottle_state import BottleMetadata, write_metadata - write_metadata(BottleMetadata( - identity=slug, - agent_name="myagent", - cwd="", - copy_cwd=False, - started_at="2026-06-02T00:00:00+00:00", - compose_project=compose_project, - )) - - def test_smolmachines_bottle_raises_capability_apply_error(self): - self._write_metadata("dev", compose_project="") - qp = self._enqueue_capability("dev") - with self.assertRaises(CapabilityApplyError) as ctx: - supervise_cli.approve(qp) - self.assertIn("smolmachines", str(ctx.exception)) - - def test_docker_bottle_calls_apply_capability_change(self): - self._write_metadata("dev", compose_project="bot-bottle-dev") - qp = self._enqueue_capability("dev") - supervise_cli.approve(qp) # must not raise - - def test_no_metadata_falls_through_to_docker_path(self): - qp = self._enqueue_capability("dev") - supervise_cli.approve(qp) # must not raise +# class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase): +# # DISABLED — capability_apply functionality is currently commented out. +# pass if __name__ == "__main__": diff --git a/tests/unit/test_workspace.py b/tests/unit/test_workspace.py deleted file mode 100644 index 560aed0..0000000 --- a/tests/unit/test_workspace.py +++ /dev/null @@ -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()