From 23d621c7b58ff50e5a0fe374bf73410e3d71e48e Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 8 Jun 2026 12:28:08 -0400 Subject: [PATCH] chore: SAVEPOINT --- bot_bottle/agent_provider.py | 27 +-- bot_bottle/backend/__init__.py | 89 +++++++- bot_bottle/backend/docker/bottle_plan.py | 4 - bot_bottle/backend/docker/compose.py | 4 +- bot_bottle/backend/docker/launch.py | 4 - bot_bottle/backend/docker/resolve_plan.py | 207 +++--------------- bot_bottle/backend/resolve_common.py | 6 +- .../backend/smolmachines/bottle_plan.py | 2 +- bot_bottle/backend/smolmachines/launch.py | 2 +- .../backend/smolmachines/resolve_plan.py | 116 ++++------ bot_bottle/supervise.py | 5 - tests/unit/test_agent_provider.py | 22 +- tests/unit/test_compose.py | 4 +- tests/unit/test_contrib_claude_provider.py | 2 +- tests/unit/test_contrib_codex_provider.py | 2 +- tests/unit/test_docker_launch_teardown.py | 2 +- tests/unit/test_docker_provision_git_user.py | 2 +- tests/unit/test_plan_print_parity.py | 4 +- tests/unit/test_smolmachines_provision.py | 2 +- 19 files changed, 200 insertions(+), 306 deletions(-) diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 67e14f2..b955618 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -220,18 +220,19 @@ class AgentProvider(ABC): Override for images that run as a different user or use a non-standard home directory.""" 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", - ) + # FIXME: re-enable workspace planning + # workspace = plan.workspace_plan + # if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir: + # guest_workspace_git = f"{workspace.guest_path}/.git" + # host_git = str(workspace.host_path / ".git") + # info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}") + # bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root") + # bottle.cp_in(host_git, guest_workspace_git) + # bottle.exec( + # f"chown -R {shlex.quote(workspace.owner)} " + # f"{shlex.quote(guest_workspace_git)}", + # user="root", + # ) manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) if manifest_bottle.git: @@ -327,7 +328,7 @@ def runtime_for(template: str) -> AgentProviderRuntime: return get_provider(template).runtime -def agent_provision_plan( +def build_agent_provision_plan( *, template: str, dockerfile: str, diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 1abde4d..afc577d 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -39,16 +39,27 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Generic, Sequence, TypeVar -from ..agent_provider import AgentProvisionPlan, get_provider +from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan from ..egress import EgressPlan from ..git_gate import GitGatePlan from ..log import die, info from ..manifest import ManifestGitEntry, Manifest from ..supervise import SupervisePlan from ..util import expand_tilde -from ..workspace import WorkspacePlan +from ..env import resolve_env, ResolvedEnv +# from ..workspace import WorkspacePlan from .print_util import print_multi, visible_agent_env_names from .util import host_skill_dir +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, +) @dataclass(frozen=True) @@ -100,7 +111,7 @@ class BottlePlan(ABC): egress_plan: EgressPlan supervise_plan: SupervisePlan | None agent_provision: AgentProvisionPlan - workspace_plan: WorkspacePlan + # workspace_plan: WorkspacePlan def print(self, *, remote_control: bool) -> None: """Render the y/N preflight summary to stderr.""" @@ -266,14 +277,70 @@ 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.""" 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) + manfiest_agent_provider = manifest_bottle.agent_provider + agent_provider = get_provider(manfiest_agent_provider.template) + agent_image = agent_provider.runtime.image + resolved_env = resolve_env(manifest, spec.agent_name) + + slug = mint_slug(spec) + write_launch_metadata(slug, spec, compose_project="", backend="smolmachines") + + agent_dockerfile_path = resolve_manifest_dockerfile(manfiest_agent_provider.dockerfile, spec) + instance_name = f"bot-bottle-{slug}" + + agent_dir, prompt_file = prepare_agent_state_dir(slug, spec) + + agent_provision_plan = build_agent_provision_plan( + template=manfiest_agent_provider.template, + dockerfile=agent_dockerfile_path, + state_dir=agent_dir, + guest_home="/home/node", # FIXME: should be coming from the agent plan + guest_env=self._build_guest_env(resolved_env), + forward_host_credentials=manfiest_agent_provider.forward_host_credentials, + auth_token=manfiest_agent_provider.auth_token, + host_env=dict(os.environ), + # trusted_project_path=workspace_plan.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, + instance_name=instance_name, # FIXME: move to agent provision plan + agent_image=agent_image, # FIXME: move to agent provision plan + prompt_file=prompt_file, # FIXME: move to agent provision plan + agent_dockerfile_path=agent_dockerfile_path, # FIXME: move to agent provision plan + agent_provision_plan=agent_provision_plan, + egress_plan=egress_plan, + supervise_plan=supervise_plan, + git_gate_plan=git_gate_plan, + stage_dir=stage_dir + ) + + def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]: + return {} + + def _preflight(self) -> None: + """ + tasks to do before resolving a plan + """ + pass def _validate(self, spec: BottleSpec) -> None: """Cross-backend pre-launch checks. Confirms the agent exists, @@ -325,7 +392,17 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): ) @abstractmethod - def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT: + def _resolve_plan(self, + spec: BottleSpec, + instance_name: str, + agent_image: str, + prompt_file: Path, + agent_provision_plan: AgentProvisionPlan, + agent_dockerfile_path: str, + 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.""" diff --git a/bot_bottle/backend/docker/bottle_plan.py b/bot_bottle/backend/docker/bottle_plan.py index 29a8100..e8aa99f 100644 --- a/bot_bottle/backend/docker/bottle_plan.py +++ b/bot_bottle/backend/docker/bottle_plan.py @@ -23,16 +23,12 @@ class DockerBottlePlan(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 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/launch.py b/bot_bottle/backend/docker/launch.py index 7171237..d90edf7 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -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/resolve_plan.py b/bot_bottle/backend/docker/resolve_plan.py index d702933..88061d6 100644 --- a/bot_bottle/backend/docker/resolve_plan.py +++ b/bot_bottle/backend/docker/resolve_plan.py @@ -1,207 +1,72 @@ """Prepare step for the Docker bottle backend. `resolve_plan` does all host-side resolution (image and container -names, env-file, prompt-file, proxy plan, runtime detection) and -returns a frozen DockerBottlePlan. No Docker resources are created; -the only side effects are scratch files under `stage_dir` and a probe -of `docker info`. Cross-backend host-side validation has already run +names, prompt-file, proxy plan, runtime detection) and returns a +frozen DockerBottlePlan. No Docker resources are created; the only +side effects are scratch files under `stage_dir` and a probe of +`docker info`. Cross-backend host-side validation has already run via the base class's `prepare` template before this is called. """ from __future__ import annotations -import os from pathlib import Path -from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, get_provider -from ...env import ResolvedEnv, resolve_env -from ...log import die -from ...workspace import workspace_plan as resolve_workspace_plan -from .. import BottleSpec -from ..resolve_common import ( - merge_provision_env_vars, - mint_slug, - prepare_agent_state_dir, - prepare_egress, - prepare_git_gate, - prepare_supervise, - resolve_manifest_dockerfile, - write_launch_metadata, -) from . import util as docker_mod from .bottle_plan import DockerBottlePlan -from ...bottle_state import ( - clear_preserve_marker, - per_bottle_dockerfile, - per_bottle_dockerfile_path, - per_bottle_image_tag, -) -from .sidecar_bundle import sidecar_bundle_container_name +from .. import BottleSpec +from ...env import ResolvedEnv +from ...agent_provider import AgentProvisionPlan +from ...egress import EgressPlan +from ...supervise import SupervisePlan +from ...git_gate import GitGatePlan + +def preflight(): + docker_mod.require_docker() + +def build_guest_env(resolved_env: ResolvedEnv): + # resolved = resolve_env(spec.manifest, spec.agent_name) + # forwarded_env: dict[str, str] = dict(resolved.forwarded) + return dict(resolved_env.literals) def resolve_plan( spec: BottleSpec, - *, + slug: str, + resolved_env: ResolvedEnv, + instance_name: str, + agent_image: str, + agent_dockerfile_path: str, + prompt_file: Path, + agent_provision_plan: AgentProvisionPlan, + egress_plan: EgressPlan, + supervise_plan: SupervisePlan, + git_gate_plan: GitGatePlan, stage_dir: Path, ) -> DockerBottlePlan: """Resolve Docker-specific names and write scratch files. Trusts that the agent and its skills/git-gate keys are present — validation already ran in the base class.""" - docker_mod.require_docker() - - manifest = spec.manifest - bottle = manifest.bottle_for(spec.agent_name) - provider = bottle.agent_provider - provider_obj = get_provider(provider.template) - provider_runtime = provider_obj.runtime - guest_home = "/home/node" - workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home) - - slug = mint_slug(spec) - write_launch_metadata(slug, spec, compose_project=f"bot-bottle-{slug}", backend="docker") - # Clear any leftover preserve marker from a prior capability-block - # so this fresh launch can be cleaned up at session-end unless - # the agent triggers another capability-block. - clear_preserve_marker(slug) - - # PRD 0016 capability-block: if a per-bottle Dockerfile has been - # written (via apply_capability_change), the base image becomes - # per_bottle_image_tag(slug) built from that file. --cwd still - # layers a derived image on top. - if provider.template in PROVIDER_TEMPLATES: - image = provider_runtime.image - else: - image = f"bot-bottle-{provider.template}:{slug}" - dockerfile_path = str(provider_obj.dockerfile) - if per_bottle_dockerfile(slug) is not None: - image = per_bottle_image_tag(slug) - dockerfile_path = str(per_bottle_dockerfile_path(slug)) - elif provider.dockerfile: - image = f"bot-bottle-{provider.template}:{slug}" - dockerfile_path = resolve_manifest_dockerfile(provider.dockerfile, spec) - derived_image = "" - runtime_image = image - if spec.copy_cwd: - derived_image = os.environ.get( - "BOT_BOTTLE_DERIVED_IMAGE", f"bot-bottle-cwd:{slug}" - ) - runtime_image = derived_image - - default_container = f"bot-bottle-{slug}" - pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "") - container_name_pinned = bool(pinned_container) - if container_name_pinned: - container_name = pinned_container - if docker_mod.container_exists(container_name): - die( - f"container '{container_name}' already exists " - f"(pinned via BOT_BOTTLE_CONTAINER). " - f"Remove it with 'docker rm -f {container_name}' or unset the override." - ) - else: - container_name = "" - for candidate in docker_mod.container_name_candidates(default_container): - if not docker_mod.container_exists(candidate): - container_name = candidate - break - if not container_name: - die( - f"could not find a free container name after " - f"{default_container}-{docker_mod.MAX_CONTAINER_SUFFIX}; " - f"clean up old containers with 'docker rm -f '" - ) - - # Probe the sidecar-bundle container name for an orphan from a - # previous run. Otherwise a stale bundle surfaces as a - # docker-create conflict deep inside launch() with no actionable - # hint; failing fast here points at the cleanup command. - bundle_name = sidecar_bundle_container_name(slug) - if docker_mod.container_exists(bundle_name): - die( - f"sidecar bundle container '{bundle_name}' already exists. " - f"This is an orphan from a previous run; clean it up with " - f"'./cli.py cleanup' (or 'docker rm -f {bundle_name}') and " - f"retry." - ) - - agent_dir, prompt_file = prepare_agent_state_dir(slug, spec) - env_file = agent_dir / "agent.env" - - git_gate_plan = prepare_git_gate(bottle, slug) - - resolved = resolve_env(manifest, spec.agent_name) - forwarded_env: dict[str, str] = dict(resolved.forwarded) - _write_env_file(resolved, env_file) + # ==== docker specific setup ==== use_runsc = docker_mod.runsc_available() - agent_provision = agent_provision_plan( - template=provider.template, - dockerfile=dockerfile_path, - state_dir=agent_dir, - guest_home=guest_home, - forward_host_credentials=provider.forward_host_credentials, - auth_token=provider.auth_token, - host_env=dict(os.environ), - trusted_project_path=workspace_plan.workdir, - label=spec.label, - color=spec.color, - ) - agent_provision = merge_provision_env_vars(agent_provision) - - egress_plan = prepare_egress(bottle, slug, agent_provision) - - # Current Dockerfile for the agent image. For `--cwd` derived - # images the base Dockerfile is what the agent should propose - # changes against (the derived layer is just a workspace copy). - # (routes.yaml used to land here too but PRD 0017 chunk 3 - # moved it behind the `list-egress-routes` MCP tool so the - # agent gets live state rather than a launch-time snapshot.) - supervise_dockerfile_path = ( - Path(dockerfile_path) if dockerfile_path else provider_obj.dockerfile - ) - dockerfile_content = ( - supervise_dockerfile_path.read_text(encoding="utf-8") - if supervise_dockerfile_path.is_file() - else "" - ) - supervise_plan = prepare_supervise(bottle, slug, dockerfile_content=dockerfile_content) return DockerBottlePlan( spec=spec, stage_dir=stage_dir, slug=slug, - container_name=container_name, - container_name_pinned=container_name_pinned, - image=image, - derived_image=derived_image, - runtime_image=runtime_image, - dockerfile_path=dockerfile_path, - env_file=env_file, - forwarded_env=forwarded_env, + container_name=instance_name, + # container_name_pinned=container_name_pinned, + image=agent_image, + dockerfile_path=agent_dockerfile_path, + forwarded_env=dict(resolved_env.forwarded), 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, + agent_provision=agent_provision_plan, + # 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) - - diff --git a/bot_bottle/backend/resolve_common.py b/bot_bottle/backend/resolve_common.py index 55d6c47..c316173 100644 --- a/bot_bottle/backend/resolve_common.py +++ b/bot_bottle/backend/resolve_common.py @@ -83,16 +83,14 @@ def prepare_egress( return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes) -def prepare_supervise( - bottle: ManifestBottle, slug: str, *, dockerfile_content: str = "", -) -> SupervisePlan | None: +def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None: """Prepare the supervise sidecar state dir. Returns None when bottle.supervise is falsy.""" if not bottle.supervise: return None supervise_dir = supervise_state_dir(slug) supervise_dir.mkdir(parents=True, exist_ok=True) - return Supervise().prepare(slug, supervise_dir, dockerfile_content=dockerfile_content) + return Supervise().prepare(slug, supervise_dir) def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan: diff --git a/bot_bottle/backend/smolmachines/bottle_plan.py b/bot_bottle/backend/smolmachines/bottle_plan.py index c9de884..c77a1ef 100644 --- a/bot_bottle/backend/smolmachines/bottle_plan.py +++ b/bot_bottle/backend/smolmachines/bottle_plan.py @@ -49,7 +49,7 @@ class SmolmachinesBottlePlan(BottlePlan): # `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 + agent_image: 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. diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index e78d4e4..cdcb843 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -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/resolve_plan.py b/bot_bottle/backend/smolmachines/resolve_plan.py index 474607f..23a9b83 100644 --- a/bot_bottle/backend/smolmachines/resolve_plan.py +++ b/bot_bottle/backend/smolmachines/resolve_plan.py @@ -10,52 +10,25 @@ No VM bringup — that's `launch.launch`'s job.""" from __future__ import annotations -import os from pathlib import Path -from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, get_provider +from .. 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 ...backend import BottleSpec -from ...env import resolve_env -from ...workspace import workspace_plan as resolve_workspace_plan -from ..resolve_common import ( - merge_provision_env_vars, - mint_slug, - prepare_agent_state_dir, - prepare_egress, - prepare_git_gate, - prepare_supervise, - resolve_manifest_dockerfile, - write_launch_metadata, -) +from ...env import ResolvedEnv +# from ...workspace import workspace_plan as resolve_workspace_plan from .bottle_plan import SmolmachinesBottlePlan from .util import smolmachines_bundle_subnet, smolmachines_preflight - -def resolve_plan( - spec: BottleSpec, *, stage_dir: Path -) -> SmolmachinesBottlePlan: - """Materialize the smolmachines plan. The bundle's docker - subnet + pinned IP are derived from the slug; the agent's - `.smolmachine` artifact is built (or cache-hit) here so - launch's `machine create --from` boots without a registry - pull. Per-bottle guest env + the TSI allow_cidrs land on the - plan for launch to pass straight through to - `machine create` flags.""" +def preflight(): smolmachines_preflight() - manifest = spec.manifest - bottle = manifest.bottle_for(spec.agent_name) - provider = bottle.agent_provider - provider_obj = get_provider(provider.template) - provider_runtime = provider_obj.runtime - guest_home = "/home/node" - workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home) - - slug = mint_slug(spec) - write_launch_metadata(slug, spec, compose_project="", backend="smolmachines") - - subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug) - +def build_guest_env(resolved_env: ResolvedEnv): # 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) @@ -64,45 +37,40 @@ def resolve_plan( # the known argv-exposure gap documented in PRD 0038. # HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated # in launch.py after bundle bringup. - resolved = resolve_env(manifest, spec.agent_name) - guest_env: dict[str, str] = { - **resolved.literals, - **resolved.forwarded, + return { + **resolved_env.literals, + **resolved_env.forwarded, "NO_PROXY": "localhost,127.0.0.1", "NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt", "SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt", "REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt", } - git_gate_plan = prepare_git_gate(bottle, slug) - agent_dir, prompt_file = prepare_agent_state_dir(slug, spec) - machine_name = f"bot-bottle-{slug}" - if provider.template in PROVIDER_TEMPLATES: - agent_image_ref = provider_runtime.image - else: - agent_image_ref = f"bot-bottle-{provider.template}:{slug}" - agent_dockerfile_path = str(provider_obj.dockerfile) - if provider.dockerfile: - agent_image_ref = f"bot-bottle-{provider.template}:{slug}" - agent_dockerfile_path = resolve_manifest_dockerfile(provider.dockerfile, spec) - agent_provision = agent_provision_plan( - template=provider.template, - dockerfile=agent_dockerfile_path, - state_dir=agent_dir, - guest_home=guest_home, - guest_env=guest_env, - forward_host_credentials=provider.forward_host_credentials, - auth_token=provider.auth_token, - host_env=dict(os.environ), - trusted_project_path=workspace_plan.workdir, - label=spec.label, - color=spec.color, - ) - agent_provision = merge_provision_env_vars(agent_provision) +def resolve_plan( + spec: BottleSpec, + slug: str, + resolved_env: ResolvedEnv, + instance_name: str, + agent_image: str, + agent_dockerfile_path: str, + prompt_file: Path, + agent_provision_plan: AgentProvisionPlan, + egress_plan: EgressPlan, + supervise_plan: SupervisePlan, + git_gate_plan: GitGatePlan, + stage_dir: Path, +) -> SmolmachinesBottlePlan: + """Materialize the smolmachines plan. The bundle's docker + subnet + pinned IP are derived from the slug; the agent's + `.smolmachine` artifact is built (or cache-hit) here so + launch's `machine create --from` boots without a registry + pull. Per-bottle guest env + the TSI allow_cidrs land on the + plan for launch to pass straight through to + `machine create` flags.""" - egress_plan = prepare_egress(bottle, slug, agent_provision) - supervise_plan = prepare_supervise(bottle, slug) + # ==== smolmachines specific setup ==== + subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug) return SmolmachinesBottlePlan( spec=spec, @@ -111,13 +79,13 @@ def resolve_plan( bundle_subnet=subnet, bundle_gateway=gateway, bundle_ip=bundle_ip, - machine_name=machine_name, - agent_image_ref=agent_image_ref, - guest_env=agent_provision.guest_env, + machine_name=instance_name, + agent_image=agent_image, + guest_env=agent_provision_plan.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, + agent_provision=agent_provision_plan, + # workspace_plan=workspace_plan, ) 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/tests/unit/test_agent_provider.py b/tests/unit/test_agent_provider.py index 7f33a0b..16e307c 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,7 +25,7 @@ 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( + plan = build_agent_provision_plan( guest_home="/home/node", template="codex", dockerfile="/tmp/Dockerfile.codex", @@ -50,7 +50,7 @@ class TestAgentProviderRuntime(unittest.TestCase): def test_codex_trusts_requested_project_path(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: - agent_provision_plan( + build_agent_provision_plan( guest_home="/home/node", template="codex", dockerfile="", @@ -68,7 +68,7 @@ class TestAgentProviderRuntime(unittest.TestCase): "auth_mode": "chatgpt", "tokens": {"access_token": _jwt(2000000000)}, })) - plan = agent_provision_plan( + plan = build_agent_provision_plan( guest_home="/home/node", template="codex", dockerfile="", @@ -88,7 +88,7 @@ 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( + plan = build_agent_provision_plan( guest_home="/home/node", template="claude", dockerfile="/tmp/Dockerfile.claude", @@ -110,7 +110,7 @@ class TestAgentProviderRuntime(unittest.TestCase): def test_claude_trusts_requested_project_path(self): with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: - agent_provision_plan( + build_agent_provision_plan( guest_home="/home/node", template="claude", dockerfile="", @@ -129,7 +129,7 @@ class TestAgentProviderRuntime(unittest.TestCase): "auth_mode": "chatgpt", "tokens": {"access_token": _jwt(2000000000)}, })) - plan = agent_provision_plan( + plan = build_agent_provision_plan( guest_home="/home/node", template="codex", dockerfile="", @@ -145,7 +145,7 @@ 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( + plan = build_agent_provision_plan( guest_home="/home/node", template="codex", dockerfile="", @@ -162,7 +162,7 @@ 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( + plan = build_agent_provision_plan( guest_home="/home/node", template="claude", dockerfile="", @@ -185,7 +185,7 @@ class TestAgentProviderRuntime(unittest.TestCase): "auth_mode": "chatgpt", "tokens": {"access_token": access}, })) - plan = agent_provision_plan( + plan = build_agent_provision_plan( guest_home="/home/node", template="codex", dockerfile="", @@ -200,7 +200,7 @@ 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( + plan = build_agent_provision_plan( guest_home="/home/node", template="codex", dockerfile="", diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 8dd6472..28e3367 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -156,7 +156,7 @@ def _plan( container_name_pinned=False, image="bot-bottle-claude:latest", derived_image="", - runtime_image="bot-bottle-claude:latest", + agent_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"}, @@ -210,7 +210,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.agent_image, s["image"]) def test_agent_only_on_internal_network(self): s = bottle_plan_to_compose(_plan())["services"]["agent"] diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index fd665ae..2426d2b 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -83,7 +83,7 @@ def _plan( container_name_pinned=False, image="bot-bottle-claude:latest", derived_image="", - runtime_image="bot-bottle-claude:latest", + agent_image="bot-bottle-claude:latest", dockerfile_path="", env_file=Path("/tmp/agent.env"), forwarded_env={}, diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index 1400241..6b3d518 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -84,7 +84,7 @@ def _plan( container_name_pinned=False, image="bot-bottle-codex:latest", derived_image="", - runtime_image="bot-bottle-codex:latest", + agent_image="bot-bottle-codex:latest", dockerfile_path="", env_file=Path("/tmp/agent.env"), forwarded_env={}, diff --git a/tests/unit/test_docker_launch_teardown.py b/tests/unit/test_docker_launch_teardown.py index 0bfeae5..77129f4 100644 --- a/tests/unit/test_docker_launch_teardown.py +++ b/tests/unit/test_docker_launch_teardown.py @@ -74,7 +74,7 @@ def _plan(tmp: str) -> DockerBottlePlan: container_name_pinned=False, image="bot-bottle-claude:latest", derived_image="", - runtime_image="bot-bottle-claude:latest", + agent_image="bot-bottle-claude:latest", dockerfile_path="", env_file=stage / "env", forwarded_env={}, diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index 046cde8..7b85a04 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -68,7 +68,7 @@ def _plan(*, git_user: dict | None = None, # type: ignore container_name_pinned=False, image="bot-bottle-claude:latest", derived_image="", - runtime_image="bot-bottle-claude:latest", + agent_image="bot-bottle-claude:latest", dockerfile_path="", env_file=Path("/tmp/agent.env"), forwarded_env={}, diff --git a/tests/unit/test_plan_print_parity.py b/tests/unit/test_plan_print_parity.py index 22c915d..0243b73 100644 --- a/tests/unit/test_plan_print_parity.py +++ b/tests/unit/test_plan_print_parity.py @@ -106,7 +106,7 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan: container_name_pinned=False, image="bot-bottle-claude:latest", derived_image="", - runtime_image="bot-bottle-claude:latest", + agent_image="bot-bottle-claude:latest", dockerfile_path="", env_file=stage / "env", forwarded_env={}, @@ -130,7 +130,7 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan: 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", + agent_image="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_provision.py b/tests/unit/test_smolmachines_provision.py index fad7839..6c7891b 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -147,7 +147,7 @@ def _plan( bundle_gateway="192.168.50.1", bundle_ip=bundle_ip, machine_name="bot-bottle-demo-abc12", - agent_image_ref="bot-bottle-claude:latest", + agent_image="bot-bottle-claude:latest", guest_env=dict(guest_env or {}), prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), git_gate_plan=GitGatePlan(