chore: SAVEPOINT
lint / lint (push) Failing after 1m52s
test / unit (pull_request) Failing after 37s
test / integration (pull_request) Failing after 22s

This commit is contained in:
2026-06-08 12:28:08 -04:00
parent 249169eca1
commit 9470b8f955
19 changed files with 200 additions and 306 deletions
+14 -13
View File
@@ -220,18 +220,19 @@ class AgentProvider(ABC):
Override for images that run as a different user or use a Override for images that run as a different user or use a
non-standard home directory.""" non-standard home directory."""
from .log import info from .log import info
workspace = plan.workspace_plan # FIXME: re-enable workspace planning
if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir: # workspace = plan.workspace_plan
guest_workspace_git = f"{workspace.guest_path}/.git" # if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir:
host_git = str(workspace.host_path / ".git") # guest_workspace_git = f"{workspace.guest_path}/.git"
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}") # host_git = str(workspace.host_path / ".git")
bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root") # info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
bottle.cp_in(host_git, guest_workspace_git) # bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
bottle.exec( # bottle.cp_in(host_git, guest_workspace_git)
f"chown -R {shlex.quote(workspace.owner)} " # bottle.exec(
f"{shlex.quote(guest_workspace_git)}", # f"chown -R {shlex.quote(workspace.owner)} "
user="root", # f"{shlex.quote(guest_workspace_git)}",
) # user="root",
# )
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if manifest_bottle.git: if manifest_bottle.git:
@@ -327,7 +328,7 @@ def runtime_for(template: str) -> AgentProviderRuntime:
return get_provider(template).runtime return get_provider(template).runtime
def agent_provision_plan( def build_agent_provision_plan(
*, *,
template: str, template: str,
dockerfile: str, dockerfile: str,
+83 -6
View File
@@ -39,16 +39,27 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar from typing import Any, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan from ..egress import EgressPlan
from ..git_gate import GitGatePlan from ..git_gate import GitGatePlan
from ..log import die, info from ..log import die, info
from ..manifest import ManifestGitEntry, Manifest from ..manifest import ManifestGitEntry, Manifest
from ..supervise import SupervisePlan from ..supervise import SupervisePlan
from ..util import expand_tilde from ..util import expand_tilde
from ..workspace import WorkspacePlan from ..env import resolve_env, ResolvedEnv
# from ..workspace import WorkspacePlan
from .print_util import print_multi, visible_agent_env_names from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
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) @dataclass(frozen=True)
@@ -100,7 +111,7 @@ class BottlePlan(ABC):
egress_plan: EgressPlan egress_plan: EgressPlan
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan agent_provision: AgentProvisionPlan
workspace_plan: WorkspacePlan # workspace_plan: WorkspacePlan
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr.""" """Render the y/N preflight summary to stderr."""
@@ -266,14 +277,70 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
name: str name: str
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT: def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
"""Template method: run cross-backend host-side validation, then """Template method: run cross-backend host-side validation, then
delegate to the subclass's `_resolve_plan` for the delegate to the subclass's `_resolve_plan` for the
backend-specific resolution (names, scratch files, etc.). The backend-specific resolution (names, scratch files, etc.). The
validation step is enforced here so a future backend cannot validation step is enforced here so a future backend cannot
accidentally skip it. No remote/runtime resources are created.""" accidentally skip it. No remote/runtime resources are created."""
self._validate(spec) self._validate(spec)
return self._resolve_plan(spec, stage_dir=stage_dir)
self._preflight()
manifest = spec.manifest
manifest_bottle = manifest.bottle_for(spec.agent_name)
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: def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists, """Cross-backend pre-launch checks. Confirms the agent exists,
@@ -325,7 +392,17 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
) )
@abstractmethod @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, """Backend-specific plan resolution: image/container names,
env-file, prompt-file, proxy plan, runtime detection. Called by env-file, prompt-file, proxy plan, runtime detection. Called by
`prepare` after `_validate` succeeds.""" `prepare` after `_validate` succeeds."""
-4
View File
@@ -23,16 +23,12 @@ class DockerBottlePlan(BottlePlan):
slug: str slug: str
container_name: str container_name: str
container_name_pinned: bool
image: str 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 # Absolute path to the Dockerfile that builds `image`. Empty means
# use the repo's default Dockerfile. Populated to a per-bottle # use the repo's default Dockerfile. Populated to a per-bottle
# state file (~/.bot-bottle/state/<slug>/Dockerfile) after a # state file (~/.bot-bottle/state/<slug>/Dockerfile) after a
# capability-block remediation (PRD 0016). # capability-block remediation (PRD 0016).
dockerfile_path: str dockerfile_path: str
env_file: Path # docker --env-file: NAME=VALUE literals
# name -> value for vars forwarded into the docker-run child process # name -> value for vars forwarded into the docker-run child process
# via subprocess env (so values never land on argv or in a file). # via subprocess env (so values never land on argv or in a file).
# repr=False keeps secret/interpolated/OAuth values out of any # repr=False keeps secret/interpolated/OAuth values out of any
+1 -3
View File
@@ -222,7 +222,7 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
env.append(name) env.append(name)
service: dict[str, Any] = { service: dict[str, Any] = {
"image": plan.runtime_image, "image": plan.image,
"container_name": plan.container_name, "container_name": plan.container_name,
"command": ["sleep", "infinity"], "command": ["sleep", "infinity"],
"networks": {"internal": None}, "networks": {"internal": None},
@@ -230,8 +230,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
} }
if plan.use_runsc: if plan.use_runsc:
service["runtime"] = "runsc" service["runtime"] = "runsc"
if plan.env_file and plan.env_file.exists() and plan.env_file.stat().st_size > 0:
service["env_file"] = [str(plan.env_file)]
volumes: list[dict[str, Any]] = [] volumes: list[dict[str, Any]] = []
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
-4
View File
@@ -97,10 +97,6 @@ def launch(
plan.image, _REPO_DIR, plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path, dockerfile=plan.dockerfile_path,
) )
if plan.derived_image:
docker_mod.build_image_with_cwd(
plan.derived_image, plan.image, plan.workspace_plan
)
internal_network = network_mod.network_name_for_slug(plan.slug) internal_network = network_mod.network_name_for_slug(plan.slug)
egress_network = network_mod.network_egress_name_for_slug(plan.slug) egress_network = network_mod.network_egress_name_for_slug(plan.slug)
+36 -171
View File
@@ -1,207 +1,72 @@
"""Prepare step for the Docker bottle backend. """Prepare step for the Docker bottle backend.
`resolve_plan` does all host-side resolution (image and container `resolve_plan` does all host-side resolution (image and container
names, env-file, prompt-file, proxy plan, runtime detection) and names, prompt-file, proxy plan, runtime detection) and returns a
returns a frozen DockerBottlePlan. No Docker resources are created; frozen DockerBottlePlan. No Docker resources are created; the only
the only side effects are scratch files under `stage_dir` and a probe side effects are scratch files under `stage_dir` and a probe of
of `docker info`. Cross-backend host-side validation has already run `docker info`. Cross-backend host-side validation has already run
via the base class's `prepare` template before this is called. via the base class's `prepare` template before this is called.
""" """
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path 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 . import util as docker_mod
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from ...bottle_state import ( from .. import BottleSpec
clear_preserve_marker, from ...env import ResolvedEnv
per_bottle_dockerfile, from ...agent_provider import AgentProvisionPlan
per_bottle_dockerfile_path, from ...egress import EgressPlan
per_bottle_image_tag, from ...supervise import SupervisePlan
) from ...git_gate import GitGatePlan
from .sidecar_bundle import sidecar_bundle_container_name
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( def resolve_plan(
spec: BottleSpec, 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, stage_dir: Path,
) -> DockerBottlePlan: ) -> DockerBottlePlan:
"""Resolve Docker-specific names and write scratch files. Trusts """Resolve Docker-specific names and write scratch files. Trusts
that the agent and its skills/git-gate keys are present that the agent and its skills/git-gate keys are present
validation already ran in the base class.""" validation already ran in the base class."""
docker_mod.require_docker()
manifest = spec.manifest
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_obj = get_provider(provider.template)
provider_runtime = provider_obj.runtime
guest_home = "/home/node"
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project=f"bot-bottle-{slug}", backend="docker")
# Clear any leftover preserve marker from a prior capability-block
# so this fresh launch can be cleaned up at session-end unless
# the agent triggers another capability-block.
clear_preserve_marker(slug)
# PRD 0016 capability-block: if a per-bottle Dockerfile has been
# written (via apply_capability_change), the base image becomes
# per_bottle_image_tag(slug) built from that file. --cwd still
# layers a derived image on top.
if provider.template in PROVIDER_TEMPLATES:
image = provider_runtime.image
else:
image = f"bot-bottle-{provider.template}:{slug}"
dockerfile_path = str(provider_obj.dockerfile)
if per_bottle_dockerfile(slug) is not None:
image = per_bottle_image_tag(slug)
dockerfile_path = str(per_bottle_dockerfile_path(slug))
elif provider.dockerfile:
image = f"bot-bottle-{provider.template}:{slug}"
dockerfile_path = resolve_manifest_dockerfile(provider.dockerfile, spec)
derived_image = ""
runtime_image = image
if spec.copy_cwd:
derived_image = os.environ.get(
"BOT_BOTTLE_DERIVED_IMAGE", f"bot-bottle-cwd:{slug}"
)
runtime_image = derived_image
default_container = f"bot-bottle-{slug}"
pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "")
container_name_pinned = bool(pinned_container)
if container_name_pinned:
container_name = pinned_container
if docker_mod.container_exists(container_name):
die(
f"container '{container_name}' already exists "
f"(pinned via BOT_BOTTLE_CONTAINER). "
f"Remove it with 'docker rm -f {container_name}' or unset the override."
)
else:
container_name = ""
for candidate in docker_mod.container_name_candidates(default_container):
if not docker_mod.container_exists(candidate):
container_name = candidate
break
if not container_name:
die(
f"could not find a free container name after "
f"{default_container}-{docker_mod.MAX_CONTAINER_SUFFIX}; "
f"clean up old containers with 'docker rm -f <name>'"
)
# Probe the sidecar-bundle container name for an orphan from a
# previous run. Otherwise a stale bundle surfaces as a
# docker-create conflict deep inside launch() with no actionable
# hint; failing fast here points at the cleanup command.
bundle_name = sidecar_bundle_container_name(slug)
if docker_mod.container_exists(bundle_name):
die(
f"sidecar bundle container '{bundle_name}' already exists. "
f"This is an orphan from a previous run; clean it up with "
f"'./cli.py cleanup' (or 'docker rm -f {bundle_name}') and "
f"retry."
)
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
env_file = agent_dir / "agent.env"
git_gate_plan = prepare_git_gate(bottle, slug)
resolved = resolve_env(manifest, spec.agent_name)
forwarded_env: dict[str, str] = dict(resolved.forwarded)
_write_env_file(resolved, env_file)
# ==== docker specific setup ====
use_runsc = docker_mod.runsc_available() 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( return DockerBottlePlan(
spec=spec, spec=spec,
stage_dir=stage_dir, stage_dir=stage_dir,
slug=slug, slug=slug,
container_name=container_name, container_name=instance_name,
container_name_pinned=container_name_pinned, # container_name_pinned=container_name_pinned,
image=image, image=agent_image,
derived_image=derived_image, dockerfile_path=agent_dockerfile_path,
runtime_image=runtime_image, forwarded_env=dict(resolved_env.forwarded),
dockerfile_path=dockerfile_path,
env_file=env_file,
forwarded_env=forwarded_env,
prompt_file=prompt_file, prompt_file=prompt_file,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
use_runsc=use_runsc, use_runsc=use_runsc,
agent_provision=agent_provision, agent_provision=agent_provision_plan,
workspace_plan=workspace_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)
+2 -4
View File
@@ -83,16 +83,14 @@ def prepare_egress(
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes) return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
def prepare_supervise( def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
bottle: ManifestBottle, slug: str, *, dockerfile_content: str = "",
) -> SupervisePlan | None:
"""Prepare the supervise sidecar state dir. Returns None when """Prepare the supervise sidecar state dir. Returns None when
bottle.supervise is falsy.""" bottle.supervise is falsy."""
if not bottle.supervise: if not bottle.supervise:
return None return None
supervise_dir = supervise_state_dir(slug) supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True) 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: def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan:
@@ -49,7 +49,7 @@ class SmolmachinesBottlePlan(BottlePlan):
# `machine_create --from`. The pipeline runs at launch time # `machine_create --from`. The pipeline runs at launch time
# (not prepare time) so the docker build output doesn't garble # (not prepare time) so the docker build output doesn't garble
# the dashboard's preflight modal. # the dashboard's preflight modal.
agent_image_ref: str agent_image: str
# In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since # In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
# the guest has no DNS resolver inside the TSI allowlist. # the guest has no DNS resolver inside the TSI allowlist.
# Passed to `smolvm machine create` as `-e K=V` flags. # Passed to `smolvm machine create` as `-e K=V` flags.
+1 -1
View File
@@ -90,7 +90,7 @@ def launch(
# here, not in prepare, so the docker-build output doesn't # here, not in prepare, so the docker-build output doesn't
# garble the dashboard's preflight modal. # garble the dashboard's preflight modal.
agent_from_path = _ensure_smolmachine( agent_from_path = _ensure_smolmachine(
plan.agent_image_ref, plan.agent_image,
dockerfile=plan.agent_dockerfile_path, dockerfile=plan.agent_dockerfile_path,
) )
+42 -74
View File
@@ -10,52 +10,25 @@ No VM bringup — that's `launch.launch`'s job."""
from __future__ import annotations from __future__ import annotations
import os
from pathlib import Path 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 ...backend import BottleSpec
from ...env import resolve_env from ...env import ResolvedEnv
from ...workspace import workspace_plan as resolve_workspace_plan # 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 .bottle_plan import SmolmachinesBottlePlan from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight from .util import smolmachines_bundle_subnet, smolmachines_preflight
def 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."""
smolmachines_preflight() smolmachines_preflight()
manifest = spec.manifest def build_guest_env(resolved_env: ResolvedEnv):
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)
# Agent's env: resolve through resolve_env() so ?prompt entries # Agent's env: resolve through resolve_env() so ?prompt entries
# are prompted and ${HOST_VAR} entries are interpolated — matching # are prompted and ${HOST_VAR} entries are interpolated — matching
# the Docker backend's contract. Forwarded (secret/interpolated) # the Docker backend's contract. Forwarded (secret/interpolated)
@@ -64,45 +37,40 @@ def resolve_plan(
# the known argv-exposure gap documented in PRD 0038. # the known argv-exposure gap documented in PRD 0038.
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated # HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated
# in launch.py after bundle bringup. # in launch.py after bundle bringup.
resolved = resolve_env(manifest, spec.agent_name) return {
guest_env: dict[str, str] = { **resolved_env.literals,
**resolved.literals, **resolved_env.forwarded,
**resolved.forwarded,
"NO_PROXY": "localhost,127.0.0.1", "NO_PROXY": "localhost,127.0.0.1",
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt", "NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt", "SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
"REQUESTS_CA_BUNDLE": "/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}" def resolve_plan(
if provider.template in PROVIDER_TEMPLATES: spec: BottleSpec,
agent_image_ref = provider_runtime.image slug: str,
else: resolved_env: ResolvedEnv,
agent_image_ref = f"bot-bottle-{provider.template}:{slug}" instance_name: str,
agent_dockerfile_path = str(provider_obj.dockerfile) agent_image: str,
if provider.dockerfile: agent_dockerfile_path: str,
agent_image_ref = f"bot-bottle-{provider.template}:{slug}" prompt_file: Path,
agent_dockerfile_path = resolve_manifest_dockerfile(provider.dockerfile, spec) agent_provision_plan: AgentProvisionPlan,
agent_provision = agent_provision_plan( egress_plan: EgressPlan,
template=provider.template, supervise_plan: SupervisePlan,
dockerfile=agent_dockerfile_path, git_gate_plan: GitGatePlan,
state_dir=agent_dir, stage_dir: Path,
guest_home=guest_home, ) -> SmolmachinesBottlePlan:
guest_env=guest_env, """Materialize the smolmachines plan. The bundle's docker
forward_host_credentials=provider.forward_host_credentials, subnet + pinned IP are derived from the slug; the agent's
auth_token=provider.auth_token, `.smolmachine` artifact is built (or cache-hit) here so
host_env=dict(os.environ), launch's `machine create --from` boots without a registry
trusted_project_path=workspace_plan.workdir, pull. Per-bottle guest env + the TSI allow_cidrs land on the
label=spec.label, plan for launch to pass straight through to
color=spec.color, `machine create` flags."""
)
agent_provision = merge_provision_env_vars(agent_provision)
egress_plan = prepare_egress(bottle, slug, agent_provision) # ==== smolmachines specific setup ====
supervise_plan = prepare_supervise(bottle, slug) subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
spec=spec, spec=spec,
@@ -111,13 +79,13 @@ def resolve_plan(
bundle_subnet=subnet, bundle_subnet=subnet,
bundle_gateway=gateway, bundle_gateway=gateway,
bundle_ip=bundle_ip, bundle_ip=bundle_ip,
machine_name=machine_name, machine_name=instance_name,
agent_image_ref=agent_image_ref, agent_image=agent_image,
guest_env=agent_provision.guest_env, guest_env=agent_provision_plan.guest_env,
prompt_file=prompt_file, prompt_file=prompt_file,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
egress_plan=egress_plan, egress_plan=egress_plan,
supervise_plan=supervise_plan, supervise_plan=supervise_plan,
agent_provision=agent_provision, agent_provision=agent_provision_plan,
workspace_plan=workspace_plan, # workspace_plan=workspace_plan,
) )
-5
View File
@@ -465,8 +465,6 @@ class Supervise(ABC):
self, self,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
*,
dockerfile_content: str = "",
) -> SupervisePlan: ) -> SupervisePlan:
"""Stage the per-bottle queue dir on the host and the """Stage the per-bottle queue dir on the host and the
current-config dir under `stage_dir`. Returns the plan; current-config dir under `stage_dir`. Returns the plan;
@@ -476,9 +474,6 @@ class Supervise(ABC):
queue_dir.mkdir(parents=True, exist_ok=True) queue_dir.mkdir(parents=True, exist_ok=True)
current_config_dir = stage_dir / "current-config" current_config_dir = stage_dir / "current-config"
current_config_dir.mkdir(parents=True, exist_ok=True) current_config_dir.mkdir(parents=True, exist_ok=True)
dockerfile_path = current_config_dir / CURRENT_CONFIG_DOCKERFILE
dockerfile_path.write_text(dockerfile_content)
dockerfile_path.chmod(0o644)
return SupervisePlan( return SupervisePlan(
slug=slug, slug=slug,
queue_dir=queue_dir, queue_dir=queue_dir,
+11 -11
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from bot_bottle.agent_provider import ( from bot_bottle.agent_provider import (
CODEX_HOST_CREDENTIAL_HOSTS, CODEX_HOST_CREDENTIAL_HOSTS,
agent_provision_plan, build_agent_provision_plan,
) )
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
@@ -25,7 +25,7 @@ def _jwt(exp: int) -> str:
class TestAgentProviderRuntime(unittest.TestCase): class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_plan_declares_home_state(self): def test_codex_plan_declares_home_state(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="/tmp/Dockerfile.codex", dockerfile="/tmp/Dockerfile.codex",
@@ -50,7 +50,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_trusts_requested_project_path(self): def test_codex_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan( build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
@@ -68,7 +68,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt", "auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(2000000000)}, "tokens": {"access_token": _jwt(2000000000)},
})) }))
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
@@ -88,7 +88,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self): def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="claude", template="claude",
dockerfile="/tmp/Dockerfile.claude", dockerfile="/tmp/Dockerfile.claude",
@@ -110,7 +110,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_trusts_requested_project_path(self): def test_claude_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan( build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="claude", template="claude",
dockerfile="", dockerfile="",
@@ -129,7 +129,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt", "auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(2000000000)}, "tokens": {"access_token": _jwt(2000000000)},
})) }))
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
@@ -145,7 +145,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self): def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
@@ -162,7 +162,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_without_auth_token_has_passthrough_egress_route(self): def test_claude_without_auth_token_has_passthrough_egress_route(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="claude", template="claude",
dockerfile="", dockerfile="",
@@ -185,7 +185,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt", "auth_mode": "chatgpt",
"tokens": {"access_token": access}, "tokens": {"access_token": access},
})) }))
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
@@ -200,7 +200,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self): def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
+2 -2
View File
@@ -156,7 +156,7 @@ def _plan(
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
dockerfile_path="", dockerfile_path="",
env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
@@ -210,7 +210,7 @@ class TestAgentAlwaysPresent(unittest.TestCase):
def test_agent_image_uses_runtime_image(self): def test_agent_image_uses_runtime_image(self):
plan = _plan() plan = _plan()
s = bottle_plan_to_compose(plan)["services"]["agent"] s = bottle_plan_to_compose(plan)["services"]["agent"]
self.assertEqual(plan.runtime_image, s["image"]) self.assertEqual(plan.agent_image, s["image"])
def test_agent_only_on_internal_network(self): def test_agent_only_on_internal_network(self):
s = bottle_plan_to_compose(_plan())["services"]["agent"] s = bottle_plan_to_compose(_plan())["services"]["agent"]
+1 -1
View File
@@ -83,7 +83,7 @@ def _plan(
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
dockerfile_path="", dockerfile_path="",
env_file=Path("/tmp/agent.env"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
+1 -1
View File
@@ -84,7 +84,7 @@ def _plan(
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-codex:latest", image="bot-bottle-codex:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-codex:latest", agent_image="bot-bottle-codex:latest",
dockerfile_path="", dockerfile_path="",
env_file=Path("/tmp/agent.env"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
+1 -1
View File
@@ -74,7 +74,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
dockerfile_path="", dockerfile_path="",
env_file=stage / "env", env_file=stage / "env",
forwarded_env={}, forwarded_env={},
+1 -1
View File
@@ -68,7 +68,7 @@ def _plan(*, git_user: dict | None = None, # type: ignore
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
dockerfile_path="", dockerfile_path="",
env_file=Path("/tmp/agent.env"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
+2 -2
View File
@@ -106,7 +106,7 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
dockerfile_path="", dockerfile_path="",
env_file=stage / "env", env_file=stage / "env",
forwarded_env={}, forwarded_env={},
@@ -130,7 +130,7 @@ def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
bundle_gateway="10.99.0.1", bundle_gateway="10.99.0.1",
bundle_ip="10.99.0.2", bundle_ip="10.99.0.2",
machine_name="bot-bottle-test-00001", 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"}, guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
prompt_file=stage / "prompt.txt", prompt_file=stage / "prompt.txt",
) )
+1 -1
View File
@@ -147,7 +147,7 @@ def _plan(
bundle_gateway="192.168.50.1", bundle_gateway="192.168.50.1",
bundle_ip=bundle_ip, bundle_ip=bundle_ip,
machine_name="bot-bottle-demo-abc12", 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 {}), guest_env=dict(guest_env or {}),
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(