Compare commits

...

12 Commits

Author SHA1 Message Date
didericis 9470b8f955 chore: SAVEPOINT
lint / lint (push) Failing after 1m52s
test / unit (pull_request) Failing after 37s
test / integration (pull_request) Failing after 22s
2026-06-08 13:13:57 -04:00
didericis 249169eca1 Remove unused port declaration 2026-06-08 11:46:00 -04:00
didericis-claude dede230c4a refactor: move guest_home onto AgentProvisionPlan as source of truth
lint / lint (push) Failing after 1m27s
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Failing after 16s
guest_home is now a field on AgentProvisionPlan (set by each provider's
provision_plan() method). BottlePlan.guest_home becomes a read-only
property delegating to agent_provision.guest_home so existing callers
(provision_git, provision_skills, provision_prompt) are unchanged.

Both resolve_plan.py files drop guest_home from the plan constructor
call; the local variable still exists as an intermediary for the
workspace_plan call that precedes agent_provision_plan.
2026-06-08 14:58:31 +00:00
didericis-claude c39d5dc63f refactor: extract shared resolve_plan helpers into backend/resolve_common.py
lint / lint (push) Failing after 1m27s
test / unit (pull_request) Successful in 29s
test / integration (pull_request) Failing after 15s
Both docker and smolmachines resolve_plan.py duplicated: slug minting,
metadata writing, agent state dir setup, git gate / egress / supervise
preparation, env_vars merge, and manifest dockerfile path resolution.

These are now consolidated in bot_bottle/backend/resolve_common.py.
Each backend's resolve_plan retains only its own logic (container name
resolution + env-file for docker; subnet allocation + guest_env build
for smolmachines).
2026-06-08 14:46:04 +00:00
didericis-claude 4359bd6099 refactor: move bottle_state.py to top-level bot_bottle package
lint / lint (push) Failing after 1m27s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Failing after 16s
Both docker and smolmachines backends use bottle state helpers.
Moving to bot_bottle/ makes the sharing explicit and removes the
cross-backend dependency (smolmachines importing from ..docker).

All callers updated: docker backend, smolmachines backend, cli
modules, and tests.
2026-06-08 14:38:24 +00:00
didericis-claude f95eabeb86 refactor: rename prepare.py → resolve_plan.py in both backends
lint / lint (push) Failing after 1m45s
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 47s
2026-06-08 14:12:48 +00:00
didericis-claude b872985a65 refactor: prefix all manifest data classes with Manifest
lint / lint (push) Failing after 1m29s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Successful in 41s
Avoids name collisions with same-named runtime/plugin classes
(e.g. manifest AgentProvider vs plugin AgentProvider ABC,
manifest EgressRoute vs runtime EgressRoute). Renamed:

  AgentProvider        → ManifestAgentProvider   (manifest_agent.py)
  Agent                → ManifestAgent            (manifest_agent.py)
  EgressRoute          → ManifestEgressRoute      (manifest_egress.py)
  PathMatch            → ManifestPathMatch        (manifest_egress.py)
  HeaderMatch          → ManifestHeaderMatch      (manifest_egress.py)
  MatchEntry           → ManifestMatchEntry       (manifest_egress.py)
  EgressConfig         → ManifestEgressConfig     (manifest_egress.py)
  Bottle               → ManifestBottle           (manifest.py)
  ProvisionedKeyConfig → ManifestProvisionedKeyConfig (manifest_git.py)
  GitEntry             → ManifestGitEntry         (manifest_git.py)
  GitUser              → ManifestGitUser          (manifest_git.py)
2026-06-08 06:42:06 +00:00
didericis-claude a4e12855df refactor: set image/dockerfile from provider default first, override after
lint / lint (push) Failing after 1m36s
test / unit (pull_request) Successful in 34s
test / integration (pull_request) Successful in 44s
Since every provider always has a dockerfile, establish the default
image and dockerfile_path from the provider up front and override for
per-bottle or manifest-specified cases. Removes the image_default
intermediate variable and the trailing else branch.
2026-06-08 06:17:48 +00:00
didericis-claude e0ecb7ceb1 refactor: AgentProvider.dockerfile always returns Path, never None
lint / lint (push) Failing after 1m50s
test / unit (pull_request) Successful in 40s
test / integration (pull_request) Successful in 59s
The convention is that every provider declares a Dockerfile location;
callers that care whether the file actually exists check .is_file().
Drops all `is not None` guards on the property result.
2026-06-08 06:06:51 +00:00
didericis-claude 41590ede1f refactor: remove BOT_BOTTLE_IMAGE env override
lint / lint (push) Failing after 1m51s
test / unit (pull_request) Successful in 39s
test / integration (pull_request) Successful in 1m0s
Unused in tests, docs, or examples. Can be added back if/when merited.
2026-06-08 04:05:29 +00:00
didericis-claude 963a178b20 refactor: replace runtime.dockerfile with AgentProvider.dockerfile property
lint / lint (push) Failing after 1m37s
test / unit (pull_request) Successful in 38s
test / integration (pull_request) Successful in 57s
Drop the `dockerfile` field from `AgentProviderRuntime` and replace it
with a convention-based `dockerfile` property on `AgentProvider`: the
base class looks for a `Dockerfile` file next to the provider's own
`agent_provider.py` module (via `inspect.getfile`), returning its path
or None. Built-in providers inherit the default automatically; custom
user providers work the same way by dropping a Dockerfile next to their
plugin file; any provider needing a non-standard path can override.

All callers (`docker/prepare.py`, `smolmachines/prepare.py`,
`capability_apply.py`) now resolve the provider object once and call
`.dockerfile` directly instead of reading `runtime.dockerfile`.
2026-06-08 03:56:04 +00:00
didericis-claude e9adcdd91d refactor: move agent Dockerfiles into their contrib directories
lint / lint (push) Successful in 1m27s
test / unit (pull_request) Successful in 31s
test / integration (pull_request) Successful in 43s
Dockerfile.claude and Dockerfile.codex move from the repo root into
bot_bottle/contrib/claude/Dockerfile and bot_bottle/contrib/codex/Dockerfile
respectively, so all per-provider assets live alongside the provider code.

Closes #215
2026-06-08 03:38:19 +00:00
55 changed files with 607 additions and 720 deletions
+25 -14
View File
@@ -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,7 @@ class AgentProvisionPlan:
prompt_mode: PromptMode
image: str
dockerfile: str
guest_home: str
guest_env: dict[str, str]
env_vars: dict[str, str] = field(default_factory=dict)
dirs: tuple[AgentProvisionDir, ...] = ()
@@ -127,6 +128,15 @@ class AgentProvider(ABC):
"""The static command / image / prompt-mode table for this
template."""
@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,
@@ -210,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:
@@ -317,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,
+89 -9
View File
@@ -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 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
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)
@@ -78,9 +89,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 +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."""
@@ -263,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,
@@ -297,7 +367,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,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."""
+2 -2
View File
@@ -29,7 +29,7 @@ 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
@@ -49,7 +49,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
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)
return _resolve_plan.resolve_plan(spec, stage_dir=stage_dir)
@contextmanager
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
-4
View File
@@ -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/<slug>/Dockerfile) after a
# capability-block remediation (PRD 0016).
dockerfile_path: str
env_file: Path # docker --env-file: NAME=VALUE literals
# name -> value for vars forwarded into the docker-run child process
# via subprocess env (so values never land on argv or in a file).
# repr=False keeps secret/interpolated/OAuth values out of any
+4 -10
View File
@@ -34,8 +34,9 @@ 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 +94,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 +126,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
+1 -1
View File
@@ -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
+1 -3
View File
@@ -222,7 +222,7 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
env.append(name)
service: dict[str, Any] = {
"image": plan.runtime_image,
"image": plan.image,
"container_name": plan.container_name,
"command": ["sleep", "infinity"],
"networks": {"internal": None},
@@ -230,8 +230,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
}
if plan.use_runsc:
service["runtime"] = "runsc"
if plan.env_file and plan.env_file.exists() and plan.env_file.stat().st_size > 0:
service["env_file"] = [str(plan.env_file)]
volumes: list[dict[str, Any]] = []
if plan.supervise_plan is not None:
+1 -1
View File
@@ -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
+1 -5
View File
@@ -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)
-280
View File
@@ -1,280 +0,0 @@
"""Prepare step for the Docker bottle backend.
`resolve_plan` does all host-side resolution (image and container
names, env-file, prompt-file, proxy plan, runtime detection) and
returns a frozen DockerBottlePlan. No Docker resources are created;
the only side effects are scratch files under `stage_dir` and a probe
of `docker info`. Cross-backend host-side validation has already run
via the base class's `prepare` template before this is called.
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
from dataclasses import replace
from pathlib import Path
from ...agent_provider import PROVIDER_TEMPLATES, agent_provision_plan, runtime_for
from ...egress import Egress
from ...env import ResolvedEnv, resolve_env
from ...git_gate import GitGate
from ...log import die
from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .. import BottleSpec
from . import util as docker_mod
from .bottle_plan import DockerBottlePlan
from .bottle_state import (
BottleMetadata,
agent_state_dir,
bottle_identity,
clear_preserve_marker,
egress_state_dir,
git_gate_state_dir,
per_bottle_dockerfile,
per_bottle_dockerfile_path,
per_bottle_image_tag,
supervise_state_dir,
write_metadata,
)
from .sidecar_bundle import sidecar_bundle_container_name
def resolve_plan(
spec: BottleSpec,
*,
stage_dir: Path,
) -> DockerBottlePlan:
"""Resolve Docker-specific names and write scratch files. Trusts
that the agent and its skills/git-gate keys are present —
validation already ran in the base class."""
docker_mod.require_docker()
git_gate = GitGate()
egress = Egress()
supervise = Supervise()
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
guest_home = "/home/node"
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
# PRD 0016 follow-up: identity, not bare slug. A fresh `start`
# mints a random-suffixed identity (so parallel runs of the same
# agent in the same cwd don't collide on container/network
# names); a `resume` passes the recorded identity in via
# spec.identity to continue an existing bottle's state.
slug = spec.identity or bottle_identity(spec.agent_name)
# Record the launch metadata so `cli.py resume <identity>` can
# reconstruct the spec. Idempotent — re-writes on resume with a
# refreshed started_at.
write_metadata(BottleMetadata(
identity=slug,
agent_name=spec.agent_name,
cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(),
compose_project=f"bot-bottle-{slug}",
backend="docker",
label=spec.label,
color=spec.color,
))
# Clear any leftover preserve marker from a prior capability-block
# so this fresh launch can be cleaned up at session-end unless
# the agent triggers another capability-block.
clear_preserve_marker(slug)
# PRD 0016 capability-block: if a per-bottle Dockerfile has been
# written (via apply_capability_change), the base image becomes
# per_bottle_image_tag(slug) built from that file. --cwd still
# layers a derived image on top.
dockerfile_path = ""
if per_bottle_dockerfile(slug) is not None:
image_default = per_bottle_image_tag(slug)
dockerfile_path = str(per_bottle_dockerfile_path(slug))
elif provider.dockerfile:
image_default = f"bot-bottle-{provider.template}:{slug}"
dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
elif provider_runtime.dockerfile:
image_default = provider_runtime.image
dockerfile_path = provider_runtime.dockerfile
elif provider.template not in PROVIDER_TEMPLATES:
user_dockerfile = (
Path.home() / ".bot-bottle" / "contrib" / provider.template / "Dockerfile"
)
if user_dockerfile.is_file():
image_default = f"bot-bottle-{provider.template}:{slug}"
dockerfile_path = str(user_dockerfile)
else:
image_default = provider_runtime.image
else:
image_default = provider_runtime.image
image = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
derived_image = ""
runtime_image = image
if spec.copy_cwd:
derived_image = os.environ.get(
"BOT_BOTTLE_DERIVED_IMAGE", f"bot-bottle-cwd:{slug}"
)
runtime_image = derived_image
default_container = f"bot-bottle-{slug}"
pinned_container = os.environ.get("BOT_BOTTLE_CONTAINER", "")
container_name_pinned = bool(pinned_container)
if container_name_pinned:
container_name = pinned_container
if docker_mod.container_exists(container_name):
die(
f"container '{container_name}' already exists "
f"(pinned via BOT_BOTTLE_CONTAINER). "
f"Remove it with 'docker rm -f {container_name}' or unset the override."
)
else:
container_name = ""
for candidate in docker_mod.container_name_candidates(default_container):
if not docker_mod.container_exists(candidate):
container_name = candidate
break
if not container_name:
die(
f"could not find a free container name after "
f"{default_container}-{docker_mod.MAX_CONTAINER_SUFFIX}; "
f"clean up old containers with 'docker rm -f <name>'"
)
# Probe the sidecar-bundle container name for an orphan from a
# previous run. Otherwise a stale bundle surfaces as a
# docker-create conflict deep inside launch() with no actionable
# hint; failing fast here points at the cleanup command.
bundle_name = sidecar_bundle_container_name(slug)
if docker_mod.container_exists(bundle_name):
die(
f"sidecar bundle container '{bundle_name}' already exists. "
f"This is an orphan from a previous run; clean it up with "
f"'./cli.py cleanup' (or 'docker rm -f {bundle_name}') and "
f"retry."
)
# PRD 0018 chunk 2: prepare-time scratch files live under
# ~/.bot-bottle/state/<slug>/<service>/ so chunk 3's compose
# bind-mounts can point at stable paths. The state subdirs are
# cleaned up by start.py's session-end teardown unless something
# explicitly preserves the state dir (capability-block, crash).
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
env_file = agent_dir / "agent.env"
prompt_file = agent_dir / "prompt.txt"
prompt_file.write_text("")
prompt_file.chmod(0o600)
git_gate_dir = git_gate_state_dir(slug)
git_gate_dir.mkdir(parents=True, exist_ok=True)
git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir)
resolved = resolve_env(manifest, spec.agent_name)
# Everything that should reach the bottle by-name (so its value
# never lands on argv or in env_file) goes into one dict. Nothing
# mutates the host os.environ.
forwarded_env: dict[str, str] = dict(resolved.forwarded)
_write_env_file(resolved, env_file)
prompt_file.write_text(agent.prompt)
use_runsc = docker_mod.runsc_available()
agent_provision = agent_provision_plan(
template=provider.template,
dockerfile=dockerfile_path,
state_dir=agent_dir,
guest_home=guest_home,
forward_host_credentials=provider.forward_host_credentials,
auth_token=provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace_plan.workdir,
label=spec.label,
color=spec.color,
)
guest_env = dict(agent_provision.guest_env)
for key, val in agent_provision.env_vars.items():
guest_env.setdefault(key, val)
agent_provision = replace(agent_provision, guest_env=guest_env)
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = egress.prepare(
bottle, slug, egress_dir, agent_provision.egress_routes,
)
supervise_plan = None
if bottle.supervise:
# Current Dockerfile for the agent image. Read from the repo
# root; for `--cwd` derived images the base Dockerfile is what
# the agent should propose changes against (the derived layer
# is just a workspace copy).
# (routes.yaml used to land here too but PRD 0017 chunk 3
# moved it behind the `list-egress-routes` MCP tool so the
# agent gets live state rather than a launch-time snapshot.)
supervise_dockerfile_path = (
Path(dockerfile_path)
if dockerfile_path
else Path(__file__).resolve().parent.parent.parent.parent / "Dockerfile.claude"
)
dockerfile_content = (
supervise_dockerfile_path.read_text(encoding="utf-8")
if supervise_dockerfile_path.is_file()
else ""
)
supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True)
supervise_plan = supervise.prepare(
slug, supervise_dir,
dockerfile_content=dockerfile_content,
)
return DockerBottlePlan(
spec=spec,
stage_dir=stage_dir,
guest_home=guest_home,
slug=slug,
container_name=container_name,
container_name_pinned=container_name_pinned,
image=image,
derived_image=derived_image,
runtime_image=runtime_image,
dockerfile_path=dockerfile_path,
env_file=env_file,
forwarded_env=forwarded_env,
prompt_file=prompt_file,
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
use_runsc=use_runsc,
agent_provision=agent_provision,
workspace_plan=workspace_plan,
)
def _write_env_file(resolved: ResolvedEnv, env_file: Path) -> None:
"""Serialize the literal portion of a ResolvedEnv into docker's
`--env-file` syntax (NAME=VALUE per line, mode 600 since the file
may carry verbatim values from the manifest). Forwarded names ride
on the plan as a structured tuple instead."""
env_lines: list[str] = []
for name, value in resolved.literals.items():
if "\n" in value:
die(
f"env entry {name} (literal) contains a newline; "
f"docker --env-file cannot represent multi-line values."
)
env_lines.append(f"{name}={value}")
env_file.write_text("\n".join(env_lines) + ("\n" if env_lines else ""))
env_file.chmod(0o600)
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
path = Path(os.path.expanduser(path_value))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
return str(path)
+72
View File
@@ -0,0 +1,72 @@
"""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():
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 specific setup ====
use_runsc = docker_mod.runsc_available()
return DockerBottlePlan(
spec=spec,
stage_dir=stage_dir,
slug=slug,
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_plan,
# workspace_plan=workspace_plan,
)
+122
View File
@@ -0,0 +1,122 @@
"""Shared helpers used by both backends' resolve_plan steps.
Each helper owns one well-defined step of the per-bottle plan
resolution so docker and smolmachines don't repeat the same logic.
Backend-specific steps (container names, env-file, per-bottle
Dockerfile overrides, subnet allocation) stay in the backend's own
resolve_plan.py.
"""
from __future__ import annotations
import os
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from ..agent_provider import AgentProvisionPlan
from ..bottle_state import (
BottleMetadata,
agent_state_dir,
bottle_identity,
egress_state_dir,
git_gate_state_dir,
supervise_state_dir,
write_metadata,
)
from ..egress import Egress, EgressPlan
from ..git_gate import GitGate, GitGatePlan
from ..manifest import ManifestBottle
from ..supervise import Supervise, SupervisePlan
from . import BottleSpec
def mint_slug(spec: BottleSpec) -> str:
"""Return the bottle identity: the recorded identity for a resume,
or a freshly minted one for a new start."""
return spec.identity or bottle_identity(spec.agent_name)
def write_launch_metadata(
slug: str, spec: BottleSpec, *, compose_project: str, backend: str,
) -> None:
"""Persist launch metadata so `cli.py resume <identity>` can
reconstruct the spec. Idempotent — re-writes on resume with a
refreshed started_at."""
write_metadata(BottleMetadata(
identity=slug,
agent_name=spec.agent_name,
cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(),
compose_project=compose_project,
backend=backend,
label=spec.label,
color=spec.color,
))
def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]:
"""Create the agent state subdir, write the prompt file.
Returns (agent_dir, prompt_file)."""
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt"
prompt_file.write_text(agent.prompt or "")
prompt_file.chmod(0o600)
return agent_dir, prompt_file
def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan:
git_gate_dir = git_gate_state_dir(slug)
git_gate_dir.mkdir(parents=True, exist_ok=True)
return GitGate().prepare(bottle, slug, git_gate_dir)
def prepare_egress(
bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan,
) -> EgressPlan:
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
"""Prepare the supervise sidecar state dir. Returns None when
bottle.supervise is falsy."""
if not bottle.supervise:
return None
supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True)
return Supervise().prepare(slug, supervise_dir)
def merge_provision_env_vars(provision: AgentProvisionPlan) -> AgentProvisionPlan:
"""Fold provision.env_vars into guest_env (setdefault semantics)
and return a new plan with the merged guest_env."""
merged = dict(provision.guest_env)
for key, val in provision.env_vars.items():
merged.setdefault(key, val)
return replace(provision, guest_env=merged)
def resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
"""Resolve a manifest-supplied dockerfile path relative to user_cwd."""
path = Path(os.path.expanduser(path_value))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
return str(path)
__all__ = [
"merge_provision_env_vars",
"mint_slug",
"prepare_agent_state_dir",
"prepare_egress",
"prepare_git_gate",
"prepare_supervise",
"resolve_manifest_dockerfile",
"write_launch_metadata",
]
+2 -2
View File
@@ -17,7 +17,7 @@ from .. import ActiveAgent, Bottle, 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
@@ -44,7 +44,7 @@ class SmolmachinesBottleBackend(
def _resolve_plan(
self, spec: BottleSpec, *, stage_dir: Path
) -> SmolmachinesBottlePlan:
return _prepare.resolve_plan(spec, stage_dir=stage_dir)
return _resolve_plan.resolve_plan(spec, stage_dir=stage_dir)
@contextmanager
def launch(
@@ -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.
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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,
)
-185
View File
@@ -1,185 +0,0 @@
"""smolmachines `_resolve_plan` (PRD 0023 chunks 2d + 4c).
Resolves the per-bottle docker subnet + bundle IP and assembles
the guest env. The agent's docker image build → smolmachine
pack pipeline runs in `launch.launch`, not here, so the
dashboard's preflight modal isn't garbled by docker-build output
before the operator has confirmed.
No VM bringup that's `launch.launch`'s job."""
from __future__ import annotations
import os
from datetime import datetime, timezone
from dataclasses import replace
from pathlib import Path
from ...agent_provider import agent_provision_plan, runtime_for
from ...backend import BottleSpec
from ...backend.docker.bottle_state import (
BottleMetadata,
agent_state_dir,
bottle_identity,
egress_state_dir,
git_gate_state_dir,
supervise_state_dir,
write_metadata,
)
from ...egress import Egress
from ...env import resolve_env
from ...git_gate import GitGate
from ...supervise import Supervise
from ...workspace import workspace_plan as resolve_workspace_plan
from .bottle_plan import SmolmachinesBottlePlan
from .util import smolmachines_bundle_subnet, smolmachines_preflight
# Gateway ports the bundle exposes inside its container — git-gate's
# git-daemon, supervise's MCP. The agent inside the smolvm guest
# dials these on the bundle's pinned IP.
_BUNDLE_GIT_GATE_PORT = 9418
_BUNDLE_SUPERVISE_PORT = 9100
def resolve_plan(
spec: BottleSpec, *, stage_dir: Path
) -> SmolmachinesBottlePlan:
"""Materialize the smolmachines plan. The bundle's docker
subnet + pinned IP are derived from the slug; the agent's
`.smolmachine` artifact is built (or cache-hit) here so
launch's `machine create --from` boots without a registry
pull. Per-bottle guest env + the TSI allow_cidrs land on the
plan for launch to pass straight through to
`machine create` flags."""
smolmachines_preflight()
manifest = spec.manifest
bottle = manifest.bottle_for(spec.agent_name)
provider = bottle.agent_provider
provider_runtime = runtime_for(provider.template)
guest_home = "/home/node"
workspace_plan = resolve_workspace_plan(spec, guest_home=guest_home)
slug = spec.identity or bottle_identity(spec.agent_name)
# Record minimal metadata so `cli.py resume` can recover the
# slug. Same schema as the docker backend.
write_metadata(BottleMetadata(
identity=slug,
agent_name=spec.agent_name,
cwd=spec.user_cwd if spec.copy_cwd else "",
copy_cwd=spec.copy_cwd,
started_at=datetime.now(timezone.utc).isoformat(),
compose_project="",
backend="smolmachines",
label=spec.label,
color=spec.color,
))
subnet, gateway, bundle_ip = smolmachines_bundle_subnet(slug)
# Agent's env: resolve through resolve_env() so ?prompt entries
# are prompted and ${HOST_VAR} entries are interpolated — matching
# the Docker backend's contract. Forwarded (secret/interpolated)
# values still reach the guest as -e K=V smolvm flags because
# smolvm 0.8.0 has no env-file or stdin injection path; this is
# the known argv-exposure gap documented in PRD 0038.
# HTTPS_PROXY / GIT_GATE_URL / MCP_SUPERVISE_URL are populated
# in launch.py after bundle bringup.
resolved = resolve_env(manifest, spec.agent_name)
guest_env: dict[str, str] = {
**resolved.literals,
**resolved.forwarded,
"NO_PROXY": "localhost,127.0.0.1",
"NODE_EXTRA_CA_CERTS": "/etc/ssl/certs/ca-certificates.crt",
"SSL_CERT_FILE": "/etc/ssl/certs/ca-certificates.crt",
"REQUESTS_CA_BUNDLE": "/etc/ssl/certs/ca-certificates.crt",
}
git_gate_dir = git_gate_state_dir(slug)
git_gate_dir.mkdir(parents=True, exist_ok=True)
git_gate_plan = GitGate().prepare(bottle, slug, git_gate_dir)
# Prompt file is always written (mode 0o600) so the in-VM
# path always exists. Content is the agent's `prompt`
# field (markdown body) — empty for agents with no prompt.
# claude-code reads it via --append-system-prompt-file only
# when non-empty, but the file must exist either way to
# match the docker backend's contract.
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt"
agent = manifest.agents[spec.agent_name]
prompt_file.write_text(agent.prompt or "")
prompt_file.chmod(0o600)
machine_name = f"bot-bottle-{slug}"
# Stash the agent image ref — `launch.launch` runs the
# build → pack pipeline at bringup. Honors BOT_BOTTLE_IMAGE
# to match the docker backend's `resolve_plan` default.
agent_dockerfile_path = ""
if provider.dockerfile:
agent_dockerfile_path = _resolve_manifest_dockerfile(provider.dockerfile, spec)
image_default = f"bot-bottle-{provider.template}:{slug}"
elif provider_runtime.dockerfile:
agent_dockerfile_path = provider_runtime.dockerfile
image_default = provider_runtime.image
else:
image_default = provider_runtime.image
agent_image_ref = os.environ.get("BOT_BOTTLE_IMAGE", image_default)
agent_provision = agent_provision_plan(
template=provider.template,
dockerfile=agent_dockerfile_path,
state_dir=agent_dir,
guest_home=guest_home,
guest_env=guest_env,
forward_host_credentials=provider.forward_host_credentials,
auth_token=provider.auth_token,
host_env=dict(os.environ),
trusted_project_path=workspace_plan.workdir,
label=spec.label,
color=spec.color,
)
merged_guest_env = dict(agent_provision.guest_env)
for key, val in agent_provision.env_vars.items():
merged_guest_env.setdefault(key, val)
agent_provision = replace(agent_provision, guest_env=merged_guest_env)
egress_dir = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True)
egress_plan = Egress().prepare(
bottle, slug, egress_dir, agent_provision.egress_routes,
)
supervise_plan = None
if bottle.supervise:
supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True)
supervise_plan = Supervise().prepare(slug, supervise_dir)
return SmolmachinesBottlePlan(
spec=spec,
stage_dir=stage_dir,
guest_home=guest_home,
slug=slug,
bundle_subnet=subnet,
bundle_gateway=gateway,
bundle_ip=bundle_ip,
machine_name=machine_name,
agent_image_ref=agent_image_ref,
guest_env=agent_provision.guest_env,
prompt_file=prompt_file,
git_gate_plan=git_gate_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
agent_provision=agent_provision,
workspace_plan=workspace_plan,
)
def _resolve_manifest_dockerfile(path_value: str, spec: BottleSpec) -> str:
path = Path(os.path.expanduser(path_value))
if not path.is_absolute():
path = Path(spec.user_cwd) / path
return str(path)
@@ -0,0 +1,91 @@
"""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 ...backend import BottleSpec
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 preflight():
smolmachines_preflight()
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)
# 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,
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."""
# ==== 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,
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_plan,
# workspace_plan=workspace_plan,
)
@@ -37,8 +37,8 @@ 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
from .backend.docker import util as docker_mod
# Directory layout: ~/.bot-bottle/state/<identity>/...
+1 -1
View File
@@ -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
+1 -1
View File
@@ -24,7 +24,7 @@ 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,
+1 -1
View File
@@ -20,7 +20,7 @@ from datetime import datetime, timezone
from pathlib import Path
from .. import supervise as _supervise
from ..backend.docker.bottle_state import read_metadata
from ..bottle_state import read_metadata
from ..backend.docker.capability_apply import (
CapabilityApplyError,
apply_capability_change,
+1 -3
View File
@@ -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",),
@@ -113,6 +110,7 @@ class ClaudeAgentProvider(AgentProvider):
prompt_mode=_RUNTIME.prompt_mode,
image=_RUNTIME.image,
dockerfile=dockerfile,
guest_home=guest_home,
env_vars=env_vars,
guest_env=resolved_guest_env,
files=files,
+1 -3
View File
@@ -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"),
@@ -150,6 +147,7 @@ class CodexAgentProvider(AgentProvider):
prompt_mode=_RUNTIME.prompt_mode,
image=_RUNTIME.image,
dockerfile=dockerfile,
guest_home=guest_home,
env_vars=env_vars,
guest_env=resolved_guest_env,
dirs=tuple(dirs),
+4 -4
View File
@@ -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, ...] = (),
+7 -7
View File
@@ -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.
+30 -30
View File
@@ -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.
+8 -8
View File
@@ -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)
+26 -26
View File
@@ -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)
+21 -21
View File
@@ -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
+15 -15
View File
@@ -57,7 +57,7 @@ def parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
return (user, host, port, path)
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/<Name>.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.<repo_name>`.
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)
+6 -6
View File
@@ -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 `<bottles_dir>/*.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 `<agents_dir>/*.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
-5
View File
@@ -465,8 +465,6 @@ class Supervise(ABC):
self,
slug: str,
stage_dir: Path,
*,
dockerfile_content: str = "",
) -> SupervisePlan:
"""Stage the per-bottle queue dir on the host and the
current-config dir under `stage_dir`. Returns the plan;
@@ -476,9 +474,6 @@ class Supervise(ABC):
queue_dir.mkdir(parents=True, exist_ok=True)
current_config_dir = stage_dir / "current-config"
current_config_dir.mkdir(parents=True, exist_ok=True)
dockerfile_path = current_config_dir / CURRENT_CONFIG_DOCKERFILE
dockerfile_path.write_text(dockerfile_content)
dockerfile_path.chmod(0o644)
return SupervisePlan(
slug=slug,
queue_dir=queue_dir,
+1 -1
View File
@@ -35,5 +35,5 @@ chmod 600 "$fake_key_dir/fake-key"
# Build the image graph quietly so the recorded run shows only the
# 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
+1 -1
View File
@@ -31,7 +31,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.capability_apply import apply_capability_change
from bot_bottle.backend.docker.network import (
network_create_egress,
+1 -1
View File
@@ -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
+11 -11
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from bot_bottle.agent_provider import (
CODEX_HOST_CREDENTIAL_HOSTS,
agent_provision_plan,
build_agent_provision_plan,
)
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
@@ -25,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="",
+3 -3
View File
@@ -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({
+2 -1
View File
@@ -13,7 +13,8 @@ import unittest
from pathlib import Path
from bot_bottle import supervise
from bot_bottle.backend.docker import bottle_state, capability_apply
from bot_bottle import bottle_state
from bot_bottle.backend.docker import capability_apply
from bot_bottle.backend.docker.capability_apply import (
CapabilityApplyError,
apply_capability_change,
+1 -1
View File
@@ -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
+4 -3
View File
@@ -149,7 +149,6 @@ 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,
@@ -157,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"},
@@ -172,6 +171,7 @@ def _plan(
prompt_mode="append_file",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
@@ -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"]
@@ -252,6 +252,7 @@ class TestAgentAlwaysPresent(unittest.TestCase):
prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest",
dockerfile="",
guest_home="/home/node",
guest_env={"CODEX_HOME": "/home/node/.codex"},
)
plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore
+5 -6
View File
@@ -76,7 +76,6 @@ 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",
@@ -84,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={},
@@ -106,7 +105,7 @@ def _plan(
use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={},
image="", dockerfile="", guest_home="/home/node", guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
@@ -211,7 +210,7 @@ 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", guest_env={},
files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json",
),),
@@ -234,7 +233,7 @@ 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", guest_env={},
files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json",
),),
@@ -250,7 +249,7 @@ 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", guest_env={},
verify=(AgentProvisionCommand(
("/usr/bin/true",), "verify failed",
),),
+5 -6
View File
@@ -77,7 +77,6 @@ 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",
@@ -85,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={},
@@ -107,7 +106,7 @@ 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="", dockerfile="", guest_home="/home/node", guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
)
@@ -177,7 +176,7 @@ 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", guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),),
files=(AgentProvisionFile(
Path("/tmp/codex-config.toml"),
@@ -201,7 +200,7 @@ 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", guest_env={},
pre_copy=(AgentProvisionCommand(
("find", "/home/node/.codex", "-name", "*.sqlite", "-delete"),
"could not reset runtime db files",
@@ -223,7 +222,7 @@ 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", guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),),
)
bottle = _make_bottle(exec_result=ExecResult(1, "", "mkdir: nope\n"))
+1 -1
View File
@@ -16,7 +16,7 @@ import unittest
from pathlib import Path
from 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
+2 -1
View File
@@ -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):
+2 -2
View File
@@ -43,7 +43,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(
@@ -66,6 +65,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
prompt_mode="append_file",
image="",
dockerfile="",
guest_home="/home/node",
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
@@ -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={},
+3 -3
View File
@@ -30,7 +30,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,7 +61,6 @@ 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",
@@ -69,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={},
@@ -95,6 +94,7 @@ def _plan(*, git_user: dict | None = None, # type: ignore
prompt_mode="append_file",
image="bot-bottle-claude:latest",
dockerfile="",
guest_home="/home/node",
guest_env={},
),
workspace_plan=workspace_plan(spec, guest_home="/home/node"),
+4 -4
View File
@@ -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__":
+2 -2
View File
@@ -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):
+3 -4
View File
@@ -86,6 +86,7 @@ def _agent_provision() -> AgentProvisionPlan:
prompt_mode="append_file",
image="",
dockerfile="",
guest_home="/home/node",
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
)
@@ -93,7 +94,6 @@ 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),
@@ -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={},
@@ -118,7 +118,6 @@ 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),
@@ -131,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",
)
+9 -9
View File
@@ -50,18 +50,17 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
try:
with (
patch("bot_bottle.backend.smolmachines.prepare.resolve_env",
patch("bot_bottle.backend.smolmachines.resolve_plan.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",
patch("bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight"),
patch("bot_bottle.backend.smolmachines.resolve_plan.smolmachines_bundle_subnet",
return_value=("10.99.0.0/24", "10.99.0.1", "10.99.0.2")),
patch("bot_bottle.backend.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.resolve_common.GitGate") as mock_gg,
patch("bot_bottle.backend.resolve_common.Egress") as mock_eg,
patch("bot_bottle.backend.resolve_common.Supervise"),
patch(
"bot_bottle.backend.smolmachines.prepare.agent_provision_plan"
"bot_bottle.backend.smolmachines.resolve_plan.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()
@@ -72,11 +71,12 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
prompt_mode="append_file",
dockerfile="",
image="bot-bottle-claude:latest",
guest_home="/home/node",
guest_env=dict(kwargs.get("guest_env") or {}),
)
mock_app.side_effect = lambda **kw: _make_provision(**kw) # type: ignore
from bot_bottle.backend.smolmachines.prepare import resolve_plan
from bot_bottle.backend.smolmachines.resolve_plan import resolve_plan
plan = resolve_plan(spec, stage_dir=stage)
mock_resolve.assert_called_once_with(manifest, "myagent")
+7 -6
View File
@@ -33,7 +33,7 @@ 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
@@ -43,7 +43,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=(),
)
@@ -85,7 +85,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,7 +140,6 @@ 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",
@@ -148,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(
@@ -190,6 +189,7 @@ def _agent_provision(
prompt_mode="append_file",
image="",
dockerfile="",
guest_home="/home/node",
guest_env=dict(guest_env or {}),
)
auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex")
@@ -227,6 +227,7 @@ def _agent_provision(
prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest",
dockerfile="",
guest_home="/home/node",
guest_env=dict(guest_env or {}),
dirs=(AgentProvisionDir(auth_dir),),
files=tuple(files),
@@ -392,7 +393,7 @@ class TestProvisionGit(unittest.TestCase):
# 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",
+1 -1
View File
@@ -289,7 +289,7 @@ class TestCapabilityBlockSmolmachinesGuard(_FakeHomeMixin, unittest.TestCase):
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
from bot_bottle.bottle_state import BottleMetadata, write_metadata
write_metadata(BottleMetadata(
identity=slug,
agent_name="myagent",