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 from __future__ import annotations
import importlib.util import importlib.util
import inspect
import os import os
import shlex import shlex
import tempfile import tempfile
@@ -51,7 +52,6 @@ class AgentProviderRuntime:
template: str template: str
command: str command: str
image: str image: str
dockerfile: str
prompt_mode: PromptMode prompt_mode: PromptMode
bypass_args: tuple[str, ...] bypass_args: tuple[str, ...]
resume_args: tuple[str, ...] resume_args: tuple[str, ...]
@@ -103,6 +103,7 @@ class AgentProvisionPlan:
prompt_mode: PromptMode prompt_mode: PromptMode
image: str image: str
dockerfile: str dockerfile: str
guest_home: str
guest_env: dict[str, str] guest_env: dict[str, str]
env_vars: dict[str, str] = field(default_factory=dict) env_vars: dict[str, str] = field(default_factory=dict)
dirs: tuple[AgentProvisionDir, ...] = () dirs: tuple[AgentProvisionDir, ...] = ()
@@ -127,6 +128,15 @@ class AgentProvider(ABC):
"""The static command / image / prompt-mode table for this """The static command / image / prompt-mode table for this
template.""" 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 @abstractmethod
def provision_plan( def provision_plan(
self, self,
@@ -210,18 +220,19 @@ class AgentProvider(ABC):
Override for images that run as a different user or use a Override for images that run as a different user or use a
non-standard home directory.""" non-standard home directory."""
from .log import info from .log import info
workspace = plan.workspace_plan # FIXME: re-enable workspace planning
if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir: # workspace = plan.workspace_plan
guest_workspace_git = f"{workspace.guest_path}/.git" # if workspace.enabled and workspace.copy_git and workspace.has_host_git_dir:
host_git = str(workspace.host_path / ".git") # guest_workspace_git = f"{workspace.guest_path}/.git"
info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}") # host_git = str(workspace.host_path / ".git")
bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root") # info(f"copying {host_git} -> {bottle.name}:{guest_workspace_git}")
bottle.cp_in(host_git, guest_workspace_git) # bottle.exec(f"mkdir -p {shlex.quote(workspace.guest_path)}", user="root")
bottle.exec( # bottle.cp_in(host_git, guest_workspace_git)
f"chown -R {shlex.quote(workspace.owner)} " # bottle.exec(
f"{shlex.quote(guest_workspace_git)}", # f"chown -R {shlex.quote(workspace.owner)} "
user="root", # f"{shlex.quote(guest_workspace_git)}",
) # user="root",
# )
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name) manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if manifest_bottle.git: if manifest_bottle.git:
@@ -317,7 +328,7 @@ def runtime_for(template: str) -> AgentProviderRuntime:
return get_provider(template).runtime return get_provider(template).runtime
def agent_provision_plan( def build_agent_provision_plan(
*, *,
template: str, template: str,
dockerfile: str, dockerfile: str,
+89 -9
View File
@@ -39,16 +39,27 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar from typing import Any, Generic, Sequence, TypeVar
from ..agent_provider import AgentProvisionPlan, get_provider from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provision_plan
from ..egress import EgressPlan from ..egress import EgressPlan
from ..git_gate import GitGatePlan from ..git_gate import GitGatePlan
from ..log import die, info from ..log import die, info
from ..manifest import GitEntry, Manifest from ..manifest import ManifestGitEntry, Manifest
from ..supervise import SupervisePlan from ..supervise import SupervisePlan
from ..util import expand_tilde from ..util import expand_tilde
from ..workspace import WorkspacePlan from ..env import resolve_env, ResolvedEnv
# from ..workspace import WorkspacePlan
from .print_util import print_multi, visible_agent_env_names from .print_util import print_multi, visible_agent_env_names
from .util import host_skill_dir from .util import host_skill_dir
from .resolve_common import (
merge_provision_env_vars,
mint_slug,
prepare_agent_state_dir,
prepare_egress,
prepare_git_gate,
prepare_supervise,
resolve_manifest_dockerfile,
write_launch_metadata,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -78,9 +89,12 @@ class BottlePlan(ABC):
spec: BottleSpec spec: BottleSpec
stage_dir: Path stage_dir: Path
guest_home: str
git_gate_plan: GitGatePlan git_gate_plan: GitGatePlan
@property
def guest_home(self) -> str:
return self.agent_provision.guest_home
@property @property
def git_gate_insteadof_host(self) -> str: def git_gate_insteadof_host(self) -> str:
"""Host (and optional port) used in git-gate insteadOf URLs. """Host (and optional port) used in git-gate insteadOf URLs.
@@ -97,7 +111,7 @@ class BottlePlan(ABC):
egress_plan: EgressPlan egress_plan: EgressPlan
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan | None
agent_provision: AgentProvisionPlan agent_provision: AgentProvisionPlan
workspace_plan: WorkspacePlan # workspace_plan: WorkspacePlan
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr.""" """Render the y/N preflight summary to stderr."""
@@ -263,14 +277,70 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
name: str name: str
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT: def prepare(self, spec: BottleSpec, stage_dir: Path) -> PlanT:
"""Template method: run cross-backend host-side validation, then """Template method: run cross-backend host-side validation, then
delegate to the subclass's `_resolve_plan` for the delegate to the subclass's `_resolve_plan` for the
backend-specific resolution (names, scratch files, etc.). The backend-specific resolution (names, scratch files, etc.). The
validation step is enforced here so a future backend cannot validation step is enforced here so a future backend cannot
accidentally skip it. No remote/runtime resources are created.""" accidentally skip it. No remote/runtime resources are created."""
self._validate(spec) self._validate(spec)
return self._resolve_plan(spec, stage_dir=stage_dir)
self._preflight()
manifest = spec.manifest
manifest_bottle = manifest.bottle_for(spec.agent_name)
manfiest_agent_provider = manifest_bottle.agent_provider
agent_provider = get_provider(manfiest_agent_provider.template)
agent_image = agent_provider.runtime.image
resolved_env = resolve_env(manifest, spec.agent_name)
slug = mint_slug(spec)
write_launch_metadata(slug, spec, compose_project="", backend="smolmachines")
agent_dockerfile_path = resolve_manifest_dockerfile(manfiest_agent_provider.dockerfile, spec)
instance_name = f"bot-bottle-{slug}"
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
agent_provision_plan = build_agent_provision_plan(
template=manfiest_agent_provider.template,
dockerfile=agent_dockerfile_path,
state_dir=agent_dir,
guest_home="/home/node", # FIXME: should be coming from the agent plan
guest_env=self._build_guest_env(resolved_env),
forward_host_credentials=manfiest_agent_provider.forward_host_credentials,
auth_token=manfiest_agent_provider.auth_token,
host_env=dict(os.environ),
# trusted_project_path=workspace_plan.workdir,
label=spec.label,
color=spec.color,
)
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
supervise_plan = prepare_supervise(manifest_bottle, slug)
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
return self._resolve_plan(
spec,
instance_name=instance_name, # FIXME: move to agent provision plan
agent_image=agent_image, # FIXME: move to agent provision plan
prompt_file=prompt_file, # FIXME: move to agent provision plan
agent_dockerfile_path=agent_dockerfile_path, # FIXME: move to agent provision plan
agent_provision_plan=agent_provision_plan,
egress_plan=egress_plan,
supervise_plan=supervise_plan,
git_gate_plan=git_gate_plan,
stage_dir=stage_dir
)
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
return {}
def _preflight(self) -> None:
"""
tasks to do before resolving a plan
"""
pass
def _validate(self, spec: BottleSpec) -> None: def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists, """Cross-backend pre-launch checks. Confirms the agent exists,
@@ -297,7 +367,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
f"Create it under ~/.claude/skills/, then re-run." 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 """Each entry's IdentityFile must exist on the host (after
expanding leading ~) — the git-gate copies it in at start time expanding leading ~) — the git-gate copies it in at start time
to authenticate the upstream push (PRD 0008). Shape is already to authenticate the upstream push (PRD 0008). Shape is already
@@ -322,7 +392,17 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
) )
@abstractmethod @abstractmethod
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT: def _resolve_plan(self,
spec: BottleSpec,
instance_name: str,
agent_image: str,
prompt_file: Path,
agent_provision_plan: AgentProvisionPlan,
agent_dockerfile_path: str,
egress_plan: EgressPlan,
git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None,
stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names, """Backend-specific plan resolution: image/container names,
env-file, prompt-file, proxy plan, runtime detection. Called by env-file, prompt-file, proxy plan, runtime detection. Called by
`prepare` after `_validate` succeeds.""" `prepare` after `_validate` succeeds."""
+2 -2
View File
@@ -29,7 +29,7 @@ from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
from . import prepare as _prepare from . import resolve_plan as _resolve_plan
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_cleanup_plan import DockerBottleCleanupPlan from .bottle_cleanup_plan import DockerBottleCleanupPlan
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
@@ -49,7 +49,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
return shutil.which("docker") is not None return shutil.which("docker") is not None
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: 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 @contextmanager
def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]: def launch(self, plan: DockerBottlePlan) -> Generator[DockerBottle, None, None]:
-4
View File
@@ -23,16 +23,12 @@ class DockerBottlePlan(BottlePlan):
slug: str slug: str
container_name: str container_name: str
container_name_pinned: bool
image: str image: str
derived_image: str # "" -> no derived image
runtime_image: str # image actually launched (derived or base)
# Absolute path to the Dockerfile that builds `image`. Empty means # Absolute path to the Dockerfile that builds `image`. Empty means
# use the repo's default Dockerfile. Populated to a per-bottle # use the repo's default Dockerfile. Populated to a per-bottle
# state file (~/.bot-bottle/state/<slug>/Dockerfile) after a # state file (~/.bot-bottle/state/<slug>/Dockerfile) after a
# capability-block remediation (PRD 0016). # capability-block remediation (PRD 0016).
dockerfile_path: str dockerfile_path: str
env_file: Path # docker --env-file: NAME=VALUE literals
# name -> value for vars forwarded into the docker-run child process # name -> value for vars forwarded into the docker-run child process
# via subprocess env (so values never land on argv or in a file). # via subprocess env (so values never land on argv or in a file).
# repr=False keeps secret/interpolated/OAuth values out of any # repr=False keeps secret/interpolated/OAuth values out of any
+4 -10
View File
@@ -34,8 +34,9 @@ import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from ...agent_provider import get_provider
from ...log import info, warn from ...log import info, warn
from .bottle_state import ( from ...bottle_state import (
mark_preserved, mark_preserved,
per_bottle_dockerfile, per_bottle_dockerfile,
transcript_snapshot_dir, transcript_snapshot_dir,
@@ -93,11 +94,11 @@ def fetch_current_dockerfile(slug: str) -> str:
override = per_bottle_dockerfile(slug) override = per_bottle_dockerfile(slug)
if override is not None: if override is not None:
return override return override
repo_dockerfile = _repo_dockerfile_path() repo_dockerfile = get_provider("claude").dockerfile
if repo_dockerfile.is_file(): if repo_dockerfile.is_file():
return repo_dockerfile.read_text() return repo_dockerfile.read_text()
raise CapabilityApplyError( 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}" f"{repo_dockerfile}"
) )
@@ -125,13 +126,6 @@ def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
# --- Internals ------------------------------------------------------------- # --- 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: def snapshot_transcript(slug: str) -> None:
"""`docker cp` /home/node/.claude out of the agent container into """`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 ...log import info, warn
from . import util as docker_mod from . import util as docker_mod
from .bottle_cleanup_plan import DockerBottleCleanupPlan 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 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) env.append(name)
service: dict[str, Any] = { service: dict[str, Any] = {
"image": plan.runtime_image, "image": plan.image,
"container_name": plan.container_name, "container_name": plan.container_name,
"command": ["sleep", "infinity"], "command": ["sleep", "infinity"],
"networks": {"internal": None}, "networks": {"internal": None},
@@ -230,8 +230,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
} }
if plan.use_runsc: if plan.use_runsc:
service["runtime"] = "runsc" service["runtime"] = "runsc"
if plan.env_file and plan.env_file.exists() and plan.env_file.stat().st_size > 0:
service["env_file"] = [str(plan.env_file)]
volumes: list[dict[str, Any]] = [] volumes: list[dict[str, Any]] = []
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
+1 -1
View File
@@ -15,7 +15,7 @@ from __future__ import annotations
import subprocess import subprocess
from .. import ActiveAgent from .. import ActiveAgent
from .bottle_state import read_metadata from ...bottle_state import read_metadata
from .compose import compose_project_name, list_active_slugs 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 . import util as docker_mod
from .bottle import DockerBottle from .bottle import DockerBottle
from .bottle_plan import DockerBottlePlan from .bottle_plan import DockerBottlePlan
from .bottle_state import ( from ...bottle_state import (
bottle_state_dir, bottle_state_dir,
egress_state_dir, egress_state_dir,
git_gate_state_dir, git_gate_state_dir,
@@ -97,10 +97,6 @@ def launch(
plan.image, _REPO_DIR, plan.image, _REPO_DIR,
dockerfile=plan.dockerfile_path, dockerfile=plan.dockerfile_path,
) )
if plan.derived_image:
docker_mod.build_image_with_cwd(
plan.derived_image, plan.image, plan.workspace_plan
)
internal_network = network_mod.network_name_for_slug(plan.slug) internal_network = network_mod.network_name_for_slug(plan.slug)
egress_network = network_mod.network_egress_name_for_slug(plan.slug) egress_network = network_mod.network_egress_name_for_slug(plan.slug)
-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 cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
from . import launch as _launch from . import launch as _launch
from . import prepare as _prepare from . import resolve_plan as _resolve_plan
from . import smolvm as _smolvm from . import smolvm as _smolvm
from .bottle import SmolmachinesBottle from .bottle import SmolmachinesBottle
from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan from .bottle_cleanup_plan import SmolmachinesBottleCleanupPlan
@@ -44,7 +44,7 @@ class SmolmachinesBottleBackend(
def _resolve_plan( def _resolve_plan(
self, spec: BottleSpec, *, stage_dir: Path self, spec: BottleSpec, *, stage_dir: Path
) -> SmolmachinesBottlePlan: ) -> SmolmachinesBottlePlan:
return _prepare.resolve_plan(spec, stage_dir=stage_dir) return _resolve_plan.resolve_plan(spec, stage_dir=stage_dir)
@contextmanager @contextmanager
def launch( def launch(
@@ -49,7 +49,7 @@ class SmolmachinesBottlePlan(BottlePlan):
# `machine_create --from`. The pipeline runs at launch time # `machine_create --from`. The pipeline runs at launch time
# (not prepare time) so the docker build output doesn't garble # (not prepare time) so the docker build output doesn't garble
# the dashboard's preflight modal. # the dashboard's preflight modal.
agent_image_ref: str agent_image: str
# In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since # In-guest env vars (HTTPS_PROXY etc) — IP-literal URLs since
# the guest has no DNS resolver inside the TSI allowlist. # the guest has no DNS resolver inside the TSI allowlist.
# Passed to `smolvm machine create` as `-e K=V` flags. # Passed to `smolvm machine create` as `-e K=V` flags.
+1 -1
View File
@@ -23,7 +23,7 @@ import json
import subprocess import subprocess
from .. import ActiveAgent from .. import ActiveAgent
from ..docker.bottle_state import read_metadata from ...bottle_state import read_metadata
from . import sidecar_bundle as _bundle 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 ...git_gate import revoke_git_gate_provisioned_keys
from ...log import warn 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 loopback_alias as _loopback
from . import sidecar_bundle as _bundle from . import sidecar_bundle as _bundle
from . import smolvm as _smolvm from . import smolvm as _smolvm
@@ -90,7 +90,7 @@ def launch(
# here, not in prepare, so the docker-build output doesn't # here, not in prepare, so the docker-build output doesn't
# garble the dashboard's preflight modal. # garble the dashboard's preflight modal.
agent_from_path = _ensure_smolmachine( agent_from_path = _ensure_smolmachine(
plan.agent_image_ref, plan.agent_image,
dockerfile=plan.agent_dockerfile_path, dockerfile=plan.agent_dockerfile_path,
) )
-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 pathlib import Path
from typing import cast from typing import cast
from ... import supervise as _supervise from . import supervise as _supervise
from . import util as docker_mod from .backend.docker import util as docker_mod
# Directory layout: ~/.bot-bottle/state/<identity>/... # Directory layout: ~/.bot-bottle/state/<identity>/...
+1 -1
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
import argparse import argparse
from ..backend import BottleSpec from ..backend import BottleSpec
from ..backend.docker.bottle_state import read_metadata from ..bottle_state import read_metadata
from ..log import die from ..log import die
from ..manifest import Manifest from ..manifest import Manifest
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
+1 -1
View File
@@ -24,7 +24,7 @@ from ..backend import (
known_backend_names, known_backend_names,
) )
from ..backend.docker.bottle_plan import DockerBottlePlan from ..backend.docker.bottle_plan import DockerBottlePlan
from ..backend.docker.bottle_state import ( from ..bottle_state import (
cleanup_state, cleanup_state,
is_preserved, is_preserved,
mark_preserved, mark_preserved,
+1 -1
View File
@@ -20,7 +20,7 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .. import supervise as _supervise 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 ( from ..backend.docker.capability_apply import (
CapabilityApplyError, CapabilityApplyError,
apply_capability_change, apply_capability_change,
+1 -3
View File
@@ -28,8 +28,6 @@ if TYPE_CHECKING:
from ...backend import Bottle, BottlePlan from ...backend import Bottle, BottlePlan
_REPO_ROOT = Path(__file__).resolve().parents[3]
_SUPERVISE_MCP_NAME = "supervise" _SUPERVISE_MCP_NAME = "supervise"
@@ -44,7 +42,6 @@ _RUNTIME = AgentProviderRuntime(
template="claude", template="claude",
command="claude", command="claude",
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.claude"),
prompt_mode="append_file", prompt_mode="append_file",
bypass_args=("--dangerously-skip-permissions",), bypass_args=("--dangerously-skip-permissions",),
resume_args=("--continue",), resume_args=("--continue",),
@@ -113,6 +110,7 @@ class ClaudeAgentProvider(AgentProvider):
prompt_mode=_RUNTIME.prompt_mode, prompt_mode=_RUNTIME.prompt_mode,
image=_RUNTIME.image, image=_RUNTIME.image,
dockerfile=dockerfile, dockerfile=dockerfile,
guest_home=guest_home,
env_vars=env_vars, env_vars=env_vars,
guest_env=resolved_guest_env, guest_env=resolved_guest_env,
files=files, files=files,
+1 -3
View File
@@ -32,8 +32,6 @@ if TYPE_CHECKING:
from ...backend import Bottle, BottlePlan from ...backend import Bottle, BottlePlan
_REPO_ROOT = Path(__file__).resolve().parents[3]
_SUPERVISE_MCP_NAME = "supervise" _SUPERVISE_MCP_NAME = "supervise"
@@ -52,7 +50,6 @@ _RUNTIME = AgentProviderRuntime(
template="codex", template="codex",
command="codex", command="codex",
image="bot-bottle-codex:latest", image="bot-bottle-codex:latest",
dockerfile=str(_REPO_ROOT / "Dockerfile.codex"),
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
bypass_args=("--dangerously-bypass-approvals-and-sandbox",), bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
resume_args=("resume", "--last"), resume_args=("resume", "--last"),
@@ -150,6 +147,7 @@ class CodexAgentProvider(AgentProvider):
prompt_mode=_RUNTIME.prompt_mode, prompt_mode=_RUNTIME.prompt_mode,
image=_RUNTIME.image, image=_RUNTIME.image,
dockerfile=dockerfile, dockerfile=dockerfile,
guest_home=guest_home,
env_vars=env_vars, env_vars=env_vars,
guest_env=resolved_guest_env, guest_env=resolved_guest_env,
dirs=tuple(dirs), dirs=tuple(dirs),
+4 -4
View File
@@ -24,7 +24,7 @@ from .egress_addon_core import (
from .log import die from .log import die
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import Bottle from .manifest import ManifestBottle
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
@@ -66,7 +66,7 @@ class EgressPlan:
def egress_manifest_routes( def egress_manifest_routes(
bottle: Bottle, bottle: ManifestBottle,
) -> tuple[EgressRoute, ...]: ) -> tuple[EgressRoute, ...]:
out: list[EgressRoute] = [] out: list[EgressRoute] = []
for r in bottle.egress.routes: for r in bottle.egress.routes:
@@ -98,7 +98,7 @@ def egress_manifest_routes(
def egress_routes_for_bottle( def egress_routes_for_bottle(
bottle: Bottle, bottle: ManifestBottle,
provider_routes: tuple[EgressRoute, ...] = (), provider_routes: tuple[EgressRoute, ...] = (),
) -> tuple[EgressRoute, ...]: ) -> tuple[EgressRoute, ...]:
manifest = egress_manifest_routes(bottle) manifest = egress_manifest_routes(bottle)
@@ -280,7 +280,7 @@ def egress_resolve_token_values(
class Egress(ABC): class Egress(ABC):
def prepare( def prepare(
self, self,
bottle: Bottle, bottle: ManifestBottle,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
provider_routes: tuple[EgressRoute, ...] = (), provider_routes: tuple[EgressRoute, ...] = (),
+7 -7
View File
@@ -37,7 +37,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .log import info 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 # Short network alias for git-gate inside the sidecar bundle. The
@@ -96,9 +96,9 @@ class GitGatePlan:
egress_network: str = "" 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 """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( return tuple(
GitGateUpstream( GitGateUpstream(
name=e.Name, name=e.Name,
@@ -113,7 +113,7 @@ def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]
def git_gate_render_gitconfig( def git_gate_render_gitconfig(
entries: tuple[GitEntry, ...], gate_host: str, *, scheme: str = "git", entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
) -> str: ) -> str:
"""Render the agent's ~/.gitconfig content for git-gate """Render the agent's ~/.gitconfig content for git-gate
`insteadOf` rewrites. Pure host-side, no docker / smolvm; `insteadOf` rewrites. Pure host-side, no docker / smolvm;
@@ -361,7 +361,7 @@ exit 0
def _provision_dynamic_key( def _provision_dynamic_key(
entry: GitEntry, entry: ManifestGitEntry,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
) -> str: ) -> str:
@@ -402,7 +402,7 @@ def _provision_dynamic_key(
return str(key_file) 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. """Revoke all deploy keys provisioned for `bottle` during prepare.
Called at teardown after containers stop. Raises if any revocation 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 start/stop lifecycle is backend-specific and lives on concrete
subclasses.""" 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 """Compute the upstream table from `bottle.git` and write the
entrypoint, pre-receive hook, and access-hook scripts (mode entrypoint, pre-receive hook, and access-hook scripts (mode
600) under `stage_dir`. Pure host-side, no docker subprocess. 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 typing import Mapping
from .manifest_util import ManifestError, as_json_object from .manifest_util import ManifestError, as_json_object
from .manifest_agent import Agent, AgentProvider from .manifest_agent import ManifestAgent, ManifestAgentProvider
from .manifest_egress import ( from .manifest_egress import (
EGRESS_AUTH_SCHEMES, EGRESS_AUTH_SCHEMES,
EgressConfig, ManifestEgressConfig,
EgressRoute, 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 from .manifest_schema import BOTTLE_KEYS
# Re-export everything that callers currently import from this module. # Re-export everything that callers currently import from this module.
__all__ = [ __all__ = [
"ManifestError", "ManifestError",
"GitEntry", "ManifestGitEntry",
"GitUser", "ManifestGitUser",
"AgentProvider", "ManifestAgentProvider",
"EGRESS_AUTH_SCHEMES", "EGRESS_AUTH_SCHEMES",
"EgressRoute", "ManifestEgressRoute",
"EgressConfig", "ManifestEgressConfig",
"Agent", "ManifestAgent",
"Bottle", "ManifestBottle",
"Manifest", "Manifest",
] ]
@@ -86,16 +86,16 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
@dataclass(frozen=True) @dataclass(frozen=True)
class Bottle: class ManifestBottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict) env: Mapping[str, str] = field(default_factory=_empty_str_dict)
agent_provider: AgentProvider = field(default_factory=AgentProvider) agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider)
git: tuple[GitEntry, ...] = () git: tuple[ManifestGitEntry, ...] = ()
# Per-bottle git identity (issue #86). Empty default — bottles # Per-bottle git identity (issue #86). Empty default — bottles
# that don't set `git-gate.user:` in the manifest skip the # that don't set `git-gate.user:` in the manifest skip the
# `git config --global` step entirely. A bottle can declare a user # `git config --global` step entirely. A bottle can declare a user
# identity without any git-gate.repos upstreams, and vice versa. # identity without any git-gate.repos upstreams, and vice versa.
git_user: GitUser = field(default_factory=GitUser) git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
egress: EgressConfig = field(default_factory=EgressConfig) egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes MCP # the launch step brings up a supervise sidecar that exposes MCP
# tools to the agent (egress-block, capability-block) plus mounts # tools to the agent (egress-block, capability-block) plus mounts
@@ -105,7 +105,7 @@ class Bottle:
supervise: bool = False supervise: bool = False
@classmethod @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}'") d = as_json_object(raw, f"bottle '{name}'")
if "runtime" in d: if "runtime" in d:
@@ -157,22 +157,22 @@ class Bottle:
) )
env[var] = value env[var] = value
git: tuple[GitEntry, ...] = () git: tuple[ManifestGitEntry, ...] = ()
git_user = GitUser() git_user = ManifestGitUser()
git_raw = d.get("git-gate") git_raw = d.get("git-gate")
if git_raw is not None: if git_raw is not None:
git, git_user = parse_git_gate_config(name, git_raw) git, git_user = parse_git_gate_config(name, git_raw)
agent_provider = ( agent_provider = (
AgentProvider.from_dict(name, d["agent_provider"]) ManifestAgentProvider.from_dict(name, d["agent_provider"])
if "agent_provider" in d if "agent_provider" in d
else AgentProvider() else ManifestAgentProvider()
) )
egress = ( egress = (
EgressConfig.from_dict(name, d["egress"]) ManifestEgressConfig.from_dict(name, d["egress"])
if "egress" in d if "egress" in d
else EgressConfig() else ManifestEgressConfig()
) )
supervise_raw = d.get("supervise", False) supervise_raw = d.get("supervise", False)
@@ -190,8 +190,8 @@ class Bottle:
@dataclass(frozen=True) @dataclass(frozen=True)
class Manifest: class Manifest:
bottles: Mapping[str, Bottle] bottles: Mapping[str, ManifestBottle]
agents: Mapping[str, Agent] agents: Mapping[str, ManifestAgent]
@classmethod @classmethod
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest": def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
@@ -305,8 +305,8 @@ class Manifest:
bottles = resolve_bottles(raw_bottles) bottles = resolve_bottles(raw_bottles)
bottle_names = set(bottles.keys()) bottle_names = set(bottles.keys())
agents: dict[str, Agent] = { agents: dict[str, ManifestAgent] = {
n: Agent.from_dict(n, a, bottle_names) for n, a in raw_agents.items() n: ManifestAgent.from_dict(n, a, bottle_names) for n, a in raw_agents.items()
} }
return cls(bottles=bottles, agents=agents) 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).") 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, """Merge the agent's git.user over the referenced bottle's,
per-field, agent-wins-on-non-empty (issue #94). Same overlay per-field, agent-wins-on-non-empty (issue #94). Same overlay
the `extends:` resolver applies between bottles the `extends:` resolver applies between bottles
@@ -348,12 +348,12 @@ class Manifest:
over = agent.git_user over = agent.git_user
if over.is_empty(): if over.is_empty():
return base return base
return GitUser( return ManifestGitUser(
name=over.name or base.name, name=over.name or base.name,
email=over.email or base.email, 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 """Resolve the Bottle the named agent references, with the
agent's git.user overlaid on top. The validator guarantees both agent's git.user overlaid on top. The validator guarantees both
lookups succeed for a manifest built via from_json_obj. 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 .agent_provider import PROVIDER_TEMPLATES
from .manifest_util import ManifestError, as_json_object 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 from .manifest_schema import AGENT_MODEL_KEYS
@dataclass(frozen=True) @dataclass(frozen=True)
class AgentProvider: class ManifestAgentProvider:
"""Provider/template for the agent process inside a bottle. """Provider/template for the agent process inside a bottle.
`template` selects a built-in launch/runtime contract. `dockerfile` `template` selects a built-in launch/runtime contract. `dockerfile`
@@ -35,7 +35,7 @@ class AgentProvider:
forward_host_credentials: bool = False forward_host_credentials: bool = False
@classmethod @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") d = as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
for k in d: for k in d:
if k not in {"template", "dockerfile", "auth_token", "forward_host_credentials"}: if k not in {"template", "dockerfile", "auth_token", "forward_host_credentials"}:
@@ -98,7 +98,7 @@ class AgentProvider:
@dataclass(frozen=True) @dataclass(frozen=True)
class Agent: class ManifestAgent:
bottle: str bottle: str
skills: tuple[str, ...] = () skills: tuple[str, ...] = ()
prompt: str = "" prompt: str = ""
@@ -106,10 +106,10 @@ class Agent:
# bottle's git-gate.user per-field at `Manifest.bottle_for`. Only # bottle's git-gate.user per-field at `Manifest.bottle_for`. Only
# `user` is allowed at the agent level; `repos` stays bottle-only # `user` is allowed at the agent level; `repos` stays bottle-only
# because it carries credentials and host trust. # because it carries credentials and host trust.
git_user: GitUser = GitUser() git_user: ManifestGitUser = ManifestGitUser()
@classmethod @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}'") d = as_json_object(raw, f"agent '{name}'")
unknown = set(d.keys()) - AGENT_MODEL_KEYS unknown = set(d.keys()) - AGENT_MODEL_KEYS
if unknown: if unknown:
@@ -164,7 +164,7 @@ class Agent:
# git-gate: agents may declare only `git-gate.user` (name/email). # git-gate: agents may declare only `git-gate.user` (name/email).
# `git-gate.repos` is bottle-only — it carries credentials and host trust. # `git-gate.repos` is bottle-only — it carries credentials and host trust.
git_user = GitUser() git_user = ManifestGitUser()
git_raw = d.get("git-gate") git_raw = d.get("git-gate")
if git_raw is not None: if git_raw is not None:
gd = as_json_object(git_raw, f"agent '{name}' git-gate") gd = as_json_object(git_raw, f"agent '{name}' git-gate")
@@ -177,6 +177,6 @@ class Agent:
f"(it carries credentials and host trust)." f"(it carries credentials and host trust)."
) )
if "user" in gd: 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) 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( def validate_egress_routes(
bottle_name: str, bottle_name: str,
routes: tuple[EgressRoute, ...], routes: tuple[ManifestEgressRoute, ...],
) -> None: ) -> None:
seen_hosts: dict[str, None] = {} seen_hosts: dict[str, None] = {}
for r in routes: for r in routes:
@@ -38,29 +38,29 @@ def validate_egress_routes(
@dataclass(frozen=True) @dataclass(frozen=True)
class PathMatch: class ManifestPathMatch:
Type: str = "prefix" Type: str = "prefix"
Value: str = "" Value: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
class HeaderMatch: class ManifestHeaderMatch:
Name: str = "" Name: str = ""
Value: str = "" Value: str = ""
Type: str = "exact" Type: str = "exact"
@dataclass(frozen=True) @dataclass(frozen=True)
class MatchEntry: class ManifestMatchEntry:
Paths: tuple[PathMatch, ...] = () Paths: tuple[ManifestPathMatch, ...] = ()
Methods: tuple[str, ...] = () Methods: tuple[str, ...] = ()
Headers: tuple[HeaderMatch, ...] = () Headers: tuple[ManifestHeaderMatch, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressRoute: class ManifestEgressRoute:
Host: str Host: str
Matches: tuple[MatchEntry, ...] = () Matches: tuple[ManifestMatchEntry, ...] = ()
AuthScheme: str = "" AuthScheme: str = ""
TokenRef: str = "" TokenRef: str = ""
Role: tuple[str, ...] = () Role: tuple[str, ...] = ()
@@ -68,7 +68,7 @@ class EgressRoute:
InboundDetectors: tuple[str, ...] | None = None InboundDetectors: tuple[str, ...] | None = None
@classmethod @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}]" label = f"bottle '{bottle_name}' egress.routes[{idx}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
host = d.get("host") host = d.get("host")
@@ -76,7 +76,7 @@ class EgressRoute:
raise ManifestError(f"{label} missing required string field 'host'") raise ManifestError(f"{label} missing required string field 'host'")
# --- matches --- # --- matches ---
matches: tuple[MatchEntry, ...] = () matches: tuple[ManifestMatchEntry, ...] = ()
matches_raw = d.get("matches") matches_raw = d.get("matches")
if matches_raw is not None: if matches_raw is not None:
if not isinstance(matches_raw, list): if not isinstance(matches_raw, list):
@@ -85,7 +85,7 @@ class EgressRoute:
f"(was {type(matches_raw).__name__})" f"(was {type(matches_raw).__name__})"
) )
matches_list = cast(list[object], matches_raw) matches_list = cast(list[object], matches_raw)
entries: list[MatchEntry] = [] entries: list[ManifestMatchEntry] = []
for k, entry_raw in enumerate(matches_list): for k, entry_raw in enumerate(matches_list):
entries.append( entries.append(
_parse_match_entry(label, k, entry_raw) _parse_match_entry(label, k, entry_raw)
@@ -185,17 +185,17 @@ class EgressRoute:
def _parse_match_entry( def _parse_match_entry(
route_label: str, k: int, raw: object, route_label: str, k: int, raw: object,
) -> MatchEntry: ) -> ManifestMatchEntry:
label = f"{route_label} matches[{k}]" label = f"{route_label} matches[{k}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
paths: tuple[PathMatch, ...] = () paths: tuple[ManifestPathMatch, ...] = ()
paths_raw = d.get("paths") paths_raw = d.get("paths")
if paths_raw is not None: if paths_raw is not None:
if not isinstance(paths_raw, list): if not isinstance(paths_raw, list):
raise ManifestError(f"{label} paths must be an array") raise ManifestError(f"{label} paths must be an array")
paths_list = cast(list[object], paths_raw) paths_list = cast(list[object], paths_raw)
parsed_paths: list[PathMatch] = [] parsed_paths: list[ManifestPathMatch] = []
for j, p_raw in enumerate(paths_list): for j, p_raw in enumerate(paths_list):
parsed_paths.append(_parse_path_match(label, j, p_raw)) parsed_paths.append(_parse_path_match(label, j, p_raw))
paths = tuple(parsed_paths) paths = tuple(parsed_paths)
@@ -220,13 +220,13 @@ def _parse_match_entry(
normalised.append(upper) normalised.append(upper)
methods = tuple(normalised) methods = tuple(normalised)
headers: tuple[HeaderMatch, ...] = () headers: tuple[ManifestHeaderMatch, ...] = ()
headers_raw = d.get("headers") headers_raw = d.get("headers")
if headers_raw is not None: if headers_raw is not None:
if not isinstance(headers_raw, list): if not isinstance(headers_raw, list):
raise ManifestError(f"{label} headers must be an array") raise ManifestError(f"{label} headers must be an array")
headers_list = cast(list[object], headers_raw) headers_list = cast(list[object], headers_raw)
parsed_headers: list[HeaderMatch] = [] parsed_headers: list[ManifestHeaderMatch] = []
for j, h_raw in enumerate(headers_list): for j, h_raw in enumerate(headers_list):
parsed_headers.append(_parse_header_match(label, j, h_raw)) parsed_headers.append(_parse_header_match(label, j, h_raw))
headers = tuple(parsed_headers) headers = tuple(parsed_headers)
@@ -235,12 +235,12 @@ def _parse_match_entry(
if key not in ("paths", "methods", "headers"): if key not in ("paths", "methods", "headers"):
raise ManifestError(f"{label} has unknown key {key!r}") 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( def _parse_path_match(
entry_label: str, j: int, raw: object, entry_label: str, j: int, raw: object,
) -> PathMatch: ) -> ManifestPathMatch:
label = f"{entry_label} paths[{j}]" label = f"{entry_label} paths[{j}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
ptype = d.get("type", "prefix") ptype = d.get("type", "prefix")
@@ -266,12 +266,12 @@ def _parse_path_match(
for k in d: for k in d:
if k not in ("type", "value"): if k not in ("type", "value"):
raise ManifestError(f"{label} has unknown key {k!r}") 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( def _parse_header_match(
entry_label: str, j: int, raw: object, entry_label: str, j: int, raw: object,
) -> HeaderMatch: ) -> ManifestHeaderMatch:
label = f"{entry_label} headers[{j}]" label = f"{entry_label} headers[{j}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
name = d.get("name") name = d.get("name")
@@ -296,7 +296,7 @@ def _parse_header_match(
for k in d: for k in d:
if k not in ("name", "value", "type"): if k not in ("name", "value", "type"):
raise ManifestError(f"{label} has unknown key {k!r}") 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( def _parse_dlp_block(
@@ -350,15 +350,15 @@ LOG_LEVELS = frozenset({0, 1, 2})
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressConfig: class ManifestEgressConfig:
routes: tuple[EgressRoute, ...] = () routes: tuple[ManifestEgressRoute, ...] = ()
Log: int = 0 Log: int = 0
@classmethod @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") d = as_json_object(raw, f"bottle '{bottle_name}' egress")
routes_raw = d.get("routes") routes_raw = d.get("routes")
routes: tuple[EgressRoute, ...] = () routes: tuple[ManifestEgressRoute, ...] = ()
if routes_raw is not None: if routes_raw is not None:
if not isinstance(routes_raw, list): if not isinstance(routes_raw, list):
raise ManifestError( raise ManifestError(
@@ -367,7 +367,7 @@ class EgressConfig:
) )
routes_list = cast(list[object], routes_raw) routes_list = cast(list[object], routes_raw)
routes = tuple( routes = tuple(
EgressRoute.from_dict(bottle_name, i, entry) ManifestEgressRoute.from_dict(bottle_name, i, entry)
for i, entry in enumerate(routes_list) for i, entry in enumerate(routes_list)
) )
validate_egress_routes(bottle_name, routes) validate_egress_routes(bottle_name, routes)
+21 -21
View File
@@ -5,12 +5,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if 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]: def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
"""Apply `extends:` chains and return resolved Bottle objects.""" """Apply `extends:` chains and return resolved ManifestBottle objects."""
cache: dict[str, Bottle] = {} cache: dict[str, ManifestBottle] = {}
for name in raws: for name in raws:
if name not in cache: if name not in cache:
_resolve_one_bottle(name, raws, 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( def _resolve_one_bottle(
name: str, name: str,
raws: dict[str, dict[str, object]], raws: dict[str, dict[str, object]],
cache: dict[str, Bottle], cache: dict[str, ManifestBottle],
seen: tuple[str, ...], seen: tuple[str, ...],
) -> Bottle: ) -> ManifestBottle:
from .manifest import Bottle, ManifestError from .manifest import ManifestBottle, ManifestError
if name in cache: if name in cache:
return cache[name] return cache[name]
@@ -32,13 +32,13 @@ def _resolve_one_bottle(
raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}") raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}")
raw = raws[name] raw = raws[name]
parent_name_raw = raw.get("extends") parent_name_raw = raw.get("extends")
# Strip `extends:` before passing to Bottle.from_dict so it # Strip `extends:` before passing to ManifestBottle.from_dict so it
# is not accidentally treated as a real Bottle field by future # is not accidentally treated as a real ManifestBottle field by future
# schema additions. It is only meaningful here. # schema additions. It is only meaningful here.
child_raw = {k: v for k, v in raw.items() if k != "extends"} child_raw = {k: v for k, v in raw.items() if k != "extends"}
if parent_name_raw is None: if parent_name_raw is None:
bottle = Bottle.from_dict(name, child_raw) bottle = ManifestBottle.from_dict(name, child_raw)
cache[name] = bottle cache[name] = bottle
return bottle return bottle
@@ -66,27 +66,27 @@ def _resolve_one_bottle(
def _merge_bottles( def _merge_bottles(
parent: Bottle, parent: ManifestBottle,
child_raw: dict[str, object], child_raw: dict[str, object],
name: str, name: str,
) -> Bottle: ) -> ManifestBottle:
"""Apply PRD 0025 merge rules.""" """Apply PRD 0025 merge rules."""
from .manifest import Bottle, GitUser from .manifest import ManifestBottle, ManifestGitUser
from .manifest_egress import validate_egress_routes 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 # usual defaults for anything missing). Validation runs the same
# way it would for a leaf bottle: typos / wrong types die here. # 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. # env: dict merge, child wins on collision.
merged_env = {**parent.env, **child.env} merged_env = {**parent.env, **child.env}
# git-gate.user: per-field overlay. Each non-empty field on child # 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 # is two empty strings, so a child that omits git-gate.user
# inherits the parent's user verbatim. # inherits the parent's user verbatim.
merged_git_user = GitUser( merged_git_user = ManifestGitUser(
name=child.git_user.name or parent.git_user.name, name=child.git_user.name or parent.git_user.name,
email=child.git_user.email or parent.git_user.email, email=child.git_user.email or parent.git_user.email,
) )
@@ -112,7 +112,7 @@ def _merge_bottles(
) )
validate_egress_routes(name, merged_egress.routes) validate_egress_routes(name, merged_egress.routes)
return Bottle( return ManifestBottle(
env=merged_env, env=merged_env,
agent_provider=merged_agent_provider, agent_provider=merged_agent_provider,
git=merged_git, git=merged_git,
@@ -133,9 +133,9 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
def _merge_git_remotes( def _merge_git_remotes(
parent: tuple[GitEntry, ...], parent: tuple[ManifestGitEntry, ...],
child: tuple[GitEntry, ...], child: tuple[ManifestGitEntry, ...],
) -> tuple[GitEntry, ...]: ) -> tuple[ManifestGitEntry, ...]:
by_host = {entry.UpstreamHost: entry for entry in parent} by_host = {entry.UpstreamHost: entry for entry in parent}
for entry in child: for entry in child:
by_host[entry.UpstreamHost] = entry 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) 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] = {} seen: dict[str, None] = {}
for g in git: for g in git:
if g.Name in seen: if g.Name in seen:
@@ -69,7 +69,7 @@ def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> No
@dataclass(frozen=True) @dataclass(frozen=True)
class ProvisionedKeyConfig: class ManifestProvisionedKeyConfig:
"""Configuration for automatic deploy-key lifecycle management """Configuration for automatic deploy-key lifecycle management
(PRD 0048). Used when a git-gate.repos entry opts out of a (PRD 0048). Used when a git-gate.repos entry opts out of a
static identity file and instead wants a fresh SSH keypair static identity file and instead wants a fresh SSH keypair
@@ -87,7 +87,7 @@ class ProvisionedKeyConfig:
@dataclass(frozen=True) @dataclass(frozen=True)
class GitEntry: class ManifestGitEntry:
"""One upstream the per-agent git-gate (PRD 0008) is allowed to """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 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 if there were no gate; the gate hosts a bare repo at /git/<Name>.git
@@ -107,7 +107,7 @@ class GitEntry:
Upstream: str Upstream: str
IdentityFile: str = "" IdentityFile: str = ""
KnownHostKey: str = "" KnownHostKey: str = ""
ProvisionedKey: Optional[ProvisionedKeyConfig] = None ProvisionedKey: Optional[ManifestProvisionedKeyConfig] = None
RemoteKey: str = "" RemoteKey: str = ""
UpstreamUser: str = "" UpstreamUser: str = ""
UpstreamHost: str = "" UpstreamHost: str = ""
@@ -117,7 +117,7 @@ class GitEntry:
@classmethod @classmethod
def from_repos_entry( def from_repos_entry(
cls, bottle_name: str, repo_name: str, raw: object cls, bottle_name: str, repo_name: str, raw: object
) -> "GitEntry": ) -> "ManifestGitEntry":
"""Parse one entry from `git-gate.repos.<repo_name>`. """Parse one entry from `git-gate.repos.<repo_name>`.
YAML keys: `url` (required), exactly one of `identity` or YAML keys: `url` (required), exactly one of `identity` or
@@ -160,7 +160,7 @@ class GitEntry:
) )
ident = "" ident = ""
provisioned_key: Optional[ProvisionedKeyConfig] = None provisioned_key: Optional[ManifestProvisionedKeyConfig] = None
if has_identity: if has_identity:
raw_ident = d.get("identity") raw_ident = d.get("identity")
if not isinstance(raw_ident, str) or not raw_ident: if not isinstance(raw_ident, str) or not raw_ident:
@@ -196,7 +196,7 @@ class GitEntry:
def _parse_provisioned_key_config( def _parse_provisioned_key_config(
bottle_name: str, label: str, raw: object bottle_name: str, label: str, raw: object
) -> ProvisionedKeyConfig: ) -> ManifestProvisionedKeyConfig:
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key") d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key")
for k in d: for k in d:
if k not in {"provider", "token_env", "api_url"}: if k not in {"provider", "token_env", "api_url"}:
@@ -221,7 +221,7 @@ def _parse_provisioned_key_config(
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string" f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
) )
return ProvisionedKeyConfig( return ManifestProvisionedKeyConfig(
provider=provider, provider=provider,
token_env=token_env, token_env=token_env,
api_url=api_url_raw, api_url=api_url_raw,
@@ -229,7 +229,7 @@ def _parse_provisioned_key_config(
@dataclass(frozen=True) @dataclass(frozen=True)
class GitUser: class ManifestGitUser:
"""Per-bottle `git config --global user.name` / `user.email` """Per-bottle `git config --global user.name` / `user.email`
pair (issue #86). The agent's commits inside the bottle are pair (issue #86). The agent's commits inside the bottle are
attributed to this identity rather than the agent image's attributed to this identity rather than the agent image's
@@ -244,7 +244,7 @@ class GitUser:
email: str = "" email: str = ""
@classmethod @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") d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user")
for k in d: for k in d:
if k not in {"name", "email"}: if k not in {"name", "email"}:
@@ -279,7 +279,7 @@ class GitUser:
def parse_git_gate_config( def parse_git_gate_config(
bottle_name: str, bottle_name: str,
raw: object, raw: object,
) -> tuple[tuple[GitEntry, ...], GitUser]: ) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate") d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
for k in d: for k in d:
if k not in {"user", "repos"}: if k not in {"user", "repos"}:
@@ -289,17 +289,17 @@ def parse_git_gate_config(
) )
git_user = ( git_user = (
GitUser.from_dict(bottle_name, d["user"]) ManifestGitUser.from_dict(bottle_name, d["user"])
if "user" in d if "user" in d
else GitUser() else ManifestGitUser()
) )
git: tuple[GitEntry, ...] = () git: tuple[ManifestGitEntry, ...] = ()
repos_raw = d.get("repos") repos_raw = d.get("repos")
if repos_raw is not None: if repos_raw is not None:
repos = as_json_object(repos_raw, f"bottle '{bottle_name}' git-gate.repos") repos = as_json_object(repos_raw, f"bottle '{bottle_name}' git-gate.repos")
git = tuple( git = tuple(
GitEntry.from_repos_entry(bottle_name, name, entry) ManifestGitEntry.from_repos_entry(bottle_name, name, entry)
for name, entry in repos.items() for name, entry in repos.items()
) )
validate_unique_git_names(bottle_name, git) 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 from .yaml_subset import YamlSubsetError, parse_frontmatter
if TYPE_CHECKING: 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: 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 """Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict.""" `{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError from .manifest import ManifestError
@@ -67,13 +67,13 @@ def load_agents_from_dir(
bottle_names: set[str], bottle_names: set[str],
*, *,
source: str, # noqa: F841 — unused, but required by interface 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 """Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt. `{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict.""" 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(): if not agents_dir.is_dir():
return out return out
for path in sorted(agents_dir.glob("*.md")): for path in sorted(agents_dir.glob("*.md")):
@@ -101,5 +101,5 @@ def load_agents_from_dir(
} }
if "git-gate" in fm: if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"] 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 return out
-5
View File
@@ -465,8 +465,6 @@ class Supervise(ABC):
self, self,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
*,
dockerfile_content: str = "",
) -> SupervisePlan: ) -> SupervisePlan:
"""Stage the per-bottle queue dir on the host and the """Stage the per-bottle queue dir on the host and the
current-config dir under `stage_dir`. Returns the plan; current-config dir under `stage_dir`. Returns the plan;
@@ -476,9 +474,6 @@ class Supervise(ABC):
queue_dir.mkdir(parents=True, exist_ok=True) queue_dir.mkdir(parents=True, exist_ok=True)
current_config_dir = stage_dir / "current-config" current_config_dir = stage_dir / "current-config"
current_config_dir.mkdir(parents=True, exist_ok=True) current_config_dir.mkdir(parents=True, exist_ok=True)
dockerfile_path = current_config_dir / CURRENT_CONFIG_DOCKERFILE
dockerfile_path.write_text(dockerfile_content)
dockerfile_path.chmod(0o644)
return SupervisePlan( return SupervisePlan(
slug=slug, slug=slug,
queue_dir=queue_dir, queue_dir=queue_dir,
+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 # Build the image graph quietly so the recorded run shows only the
# bottle launch and the four `!` probes, not BuildKit progress. # 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 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 pathlib import Path
from bot_bottle import supervise 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.capability_apply import apply_capability_change
from bot_bottle.backend.docker.network import ( from bot_bottle.backend.docker.network import (
network_create_egress, network_create_egress,
+1 -1
View File
@@ -29,7 +29,7 @@ import unittest
from pathlib import Path from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend 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 bot_bottle.manifest import Manifest
from tests._docker import skip_unless_docker 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 ( from bot_bottle.agent_provider import (
CODEX_HOST_CREDENTIAL_HOSTS, CODEX_HOST_CREDENTIAL_HOSTS,
agent_provision_plan, build_agent_provision_plan,
) )
from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF from bot_bottle.egress import CODEX_HOST_CREDENTIAL_TOKEN_REF
@@ -25,7 +25,7 @@ def _jwt(exp: int) -> str:
class TestAgentProviderRuntime(unittest.TestCase): class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_plan_declares_home_state(self): def test_codex_plan_declares_home_state(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="/tmp/Dockerfile.codex", dockerfile="/tmp/Dockerfile.codex",
@@ -50,7 +50,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_trusts_requested_project_path(self): def test_codex_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan( build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
@@ -68,7 +68,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt", "auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(2000000000)}, "tokens": {"access_token": _jwt(2000000000)},
})) }))
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
@@ -88,7 +88,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_with_auth_token_injects_provider_route_and_placeholder(self): def test_claude_with_auth_token_injects_provider_route_and_placeholder(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="claude", template="claude",
dockerfile="/tmp/Dockerfile.claude", dockerfile="/tmp/Dockerfile.claude",
@@ -110,7 +110,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_trusts_requested_project_path(self): def test_claude_trusts_requested_project_path(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
agent_provision_plan( build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="claude", template="claude",
dockerfile="", dockerfile="",
@@ -129,7 +129,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt", "auth_mode": "chatgpt",
"tokens": {"access_token": _jwt(2000000000)}, "tokens": {"access_token": _jwt(2000000000)},
})) }))
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
@@ -145,7 +145,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self): def test_codex_without_forward_host_credentials_has_passthrough_egress_routes(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
@@ -162,7 +162,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_claude_without_auth_token_has_passthrough_egress_route(self): def test_claude_without_auth_token_has_passthrough_egress_route(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="claude", template="claude",
dockerfile="", dockerfile="",
@@ -185,7 +185,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
"auth_mode": "chatgpt", "auth_mode": "chatgpt",
"tokens": {"access_token": access}, "tokens": {"access_token": access},
})) }))
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
@@ -200,7 +200,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self): def test_codex_without_forward_host_credentials_has_empty_provisioned_env(self):
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp: with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
plan = agent_provision_plan( plan = build_agent_provision_plan(
guest_home="/home/node", guest_home="/home/node",
template="codex", template="codex",
dockerfile="", dockerfile="",
+3 -3
View File
@@ -7,8 +7,8 @@ import unittest
from pathlib import Path from pathlib import Path
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.backend.docker import bottle_state from bot_bottle import bottle_state
from bot_bottle.backend.docker.bottle_state import ( from bot_bottle.bottle_state import (
BottleMetadata, BottleMetadata,
read_metadata, read_metadata,
write_metadata, write_metadata,
@@ -260,7 +260,7 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
def test_missing_backend_field_defaults_to_empty(self): def test_missing_backend_field_defaults_to_empty(self):
# Old state dirs written before PRD 0040 have no backend key. # Old state dirs written before PRD 0040 have no backend key.
import json 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 = bs.metadata_path("dev-b3")
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({ path.write_text(json.dumps({
+2 -1
View File
@@ -13,7 +13,8 @@ import unittest
from pathlib import Path from pathlib import Path
from bot_bottle import supervise 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 ( from bot_bottle.backend.docker.capability_apply import (
CapabilityApplyError, CapabilityApplyError,
apply_capability_change, apply_capability_change,
+1 -1
View File
@@ -9,7 +9,7 @@ import unittest
from pathlib import Path from pathlib import Path
from bot_bottle import supervise 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 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) spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=STAGE, stage_dir=STAGE,
slug=SLUG, slug=SLUG,
@@ -157,7 +156,7 @@ def _plan(
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
dockerfile_path="", dockerfile_path="",
env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file env_file=Path("/dev/null"), # exists, size 0 → renderer skips env_file
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
@@ -172,6 +171,7 @@ def _plan(
prompt_mode="append_file", prompt_mode="append_file",
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
dockerfile="", dockerfile="",
guest_home="/home/node",
guest_env={}, guest_env={},
), ),
workspace_plan=workspace_plan(spec, guest_home="/home/node"), 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): def test_agent_image_uses_runtime_image(self):
plan = _plan() plan = _plan()
s = bottle_plan_to_compose(plan)["services"]["agent"] s = bottle_plan_to_compose(plan)["services"]["agent"]
self.assertEqual(plan.runtime_image, s["image"]) self.assertEqual(plan.agent_image, s["image"])
def test_agent_only_on_internal_network(self): def test_agent_only_on_internal_network(self):
s = bottle_plan_to_compose(_plan())["services"]["agent"] s = bottle_plan_to_compose(_plan())["services"]["agent"]
@@ -252,6 +252,7 @@ class TestAgentAlwaysPresent(unittest.TestCase):
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest", image="bot-bottle-codex:latest",
dockerfile="", dockerfile="",
guest_home="/home/node",
guest_env={"CODEX_HOME": "/home/node/.codex"}, guest_env={"CODEX_HOME": "/home/node/.codex"},
) )
plan = type(plan)(**{**vars(plan), "agent_provision": provision}) # type: ignore 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"), current_config_dir=Path("/tmp/current-config"),
) )
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=Path("/tmp/stage"), stage_dir=Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
@@ -84,7 +83,7 @@ def _plan(
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
dockerfile_path="", dockerfile_path="",
env_file=Path("/tmp/agent.env"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
@@ -106,7 +105,7 @@ def _plan(
use_runsc=False, use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan( agent_provision=agent_provision or AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file", 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"), workspace_plan=workspace_plan(spec, guest_home="/home/node"),
) )
@@ -211,7 +210,7 @@ class TestClaudeProvision(unittest.TestCase):
def test_copies_files_and_chowns(self): def test_copies_files_and_chowns(self):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file", template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={}, image="", dockerfile="", guest_home="/home/node", guest_env={},
files=(AgentProvisionFile( files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json", Path("/tmp/claude.json"), "/home/node/.claude.json",
),), ),),
@@ -234,7 +233,7 @@ class TestClaudeProvision(unittest.TestCase):
def test_dies_when_file_chown_fails(self): def test_dies_when_file_chown_fails(self):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file", template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={}, image="", dockerfile="", guest_home="/home/node", guest_env={},
files=(AgentProvisionFile( files=(AgentProvisionFile(
Path("/tmp/claude.json"), "/home/node/.claude.json", Path("/tmp/claude.json"), "/home/node/.claude.json",
),), ),),
@@ -250,7 +249,7 @@ class TestClaudeProvision(unittest.TestCase):
def test_runs_verify_commands(self): def test_runs_verify_commands(self):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="claude", command="claude", prompt_mode="append_file", template="claude", command="claude", prompt_mode="append_file",
image="", dockerfile="", guest_env={}, image="", dockerfile="", guest_home="/home/node", guest_env={},
verify=(AgentProvisionCommand( verify=(AgentProvisionCommand(
("/usr/bin/true",), "verify failed", ("/usr/bin/true",), "verify failed",
),), ),),
+5 -6
View File
@@ -77,7 +77,6 @@ def _plan(
current_config_dir=Path("/tmp/current-config"), current_config_dir=Path("/tmp/current-config"),
) )
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=Path("/tmp/stage"), stage_dir=Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
@@ -85,7 +84,7 @@ def _plan(
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-codex:latest", image="bot-bottle-codex:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-codex:latest", agent_image="bot-bottle-codex:latest",
dockerfile_path="", dockerfile_path="",
env_file=Path("/tmp/agent.env"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
@@ -107,7 +106,7 @@ def _plan(
use_runsc=False, use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan( agent_provision=agent_provision or AgentProvisionPlan(
template="codex", command="codex", prompt_mode="read_prompt_file", 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"), workspace_plan=workspace_plan(spec, guest_home="/home/node"),
) )
@@ -177,7 +176,7 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="codex", command="codex", template="codex", command="codex",
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={}, image="", dockerfile="", guest_home="/home/node", guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),), dirs=(AgentProvisionDir("/home/node/.codex"),),
files=(AgentProvisionFile( files=(AgentProvisionFile(
Path("/tmp/codex-config.toml"), Path("/tmp/codex-config.toml"),
@@ -201,7 +200,7 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="codex", command="codex", template="codex", command="codex",
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={}, image="", dockerfile="", guest_home="/home/node", guest_env={},
pre_copy=(AgentProvisionCommand( pre_copy=(AgentProvisionCommand(
("find", "/home/node/.codex", "-name", "*.sqlite", "-delete"), ("find", "/home/node/.codex", "-name", "*.sqlite", "-delete"),
"could not reset runtime db files", "could not reset runtime db files",
@@ -223,7 +222,7 @@ class TestCodexProvision(unittest.TestCase):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
template="codex", command="codex", template="codex", command="codex",
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
image="", dockerfile="", guest_env={}, image="", dockerfile="", guest_home="/home/node", guest_env={},
dirs=(AgentProvisionDir("/home/node/.codex"),), dirs=(AgentProvisionDir("/home/node/.codex"),),
) )
bottle = _make_bottle(exec_result=ExecResult(1, "", "mkdir: nope\n")) 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 pathlib import Path
from bot_bottle import supervise 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 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 pathlib import Path
from bot_bottle import supervise 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): class TestParseServicesByProject(unittest.TestCase):
+2 -2
View File
@@ -43,7 +43,6 @@ def _plan(tmp: str) -> DockerBottlePlan:
identity="test-teardown-00001", identity="test-teardown-00001",
) )
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=stage, stage_dir=stage,
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
@@ -66,6 +65,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
prompt_mode="append_file", prompt_mode="append_file",
image="", image="",
dockerfile="", dockerfile="",
guest_home="/home/node",
guest_env={}, guest_env={},
), ),
workspace_plan=workspace_plan(spec, guest_home="/home/node"), workspace_plan=workspace_plan(spec, guest_home="/home/node"),
@@ -74,7 +74,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
dockerfile_path="", dockerfile_path="",
env_file=stage / "env", env_file=stage / "env",
forwarded_env={}, forwarded_env={},
+3 -3
View File
@@ -30,7 +30,7 @@ class _Provider(AgentProvider):
@property @property
def runtime(self) -> AgentProviderRuntime: def runtime(self) -> AgentProviderRuntime:
return AgentProviderRuntime( return AgentProviderRuntime(
template="test", command="test", image="", dockerfile="", template="test", command="test", image="",
prompt_mode="append_file", bypass_args=(), resume_args=(), prompt_mode="append_file", bypass_args=(), resume_args=(),
remote_control_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, copy_cwd=copy_cwd, user_cwd=user_cwd,
) )
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=stage_dir or Path("/tmp/stage"), stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
@@ -69,7 +68,7 @@ def _plan(*, git_user: dict | None = None, # type: ignore
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
dockerfile_path="", dockerfile_path="",
env_file=Path("/tmp/agent.env"), env_file=Path("/tmp/agent.env"),
forwarded_env={}, forwarded_env={},
@@ -95,6 +94,7 @@ def _plan(*, git_user: dict | None = None, # type: ignore
prompt_mode="append_file", prompt_mode="append_file",
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
dockerfile="", dockerfile="",
guest_home="/home/node",
guest_env={}, guest_env={},
), ),
workspace_plan=workspace_plan(spec, guest_home="/home/node"), workspace_plan=workspace_plan(spec, guest_home="/home/node"),
+4 -4
View File
@@ -2,7 +2,7 @@
import unittest 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 def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -99,13 +99,13 @@ class TestGitUserDirect(unittest.TestCase):
"""Direct GitUser dataclass exercises (no manifest wrapper).""" """Direct GitUser dataclass exercises (no manifest wrapper)."""
def test_is_empty_default(self): 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): 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): 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__": if __name__ == "__main__":
+2 -2
View File
@@ -7,7 +7,7 @@ silently ignoring."""
import unittest import unittest
from typing import Any 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]: def _manifest_with_runtime(value: object) -> dict[str, Any]:
@@ -26,7 +26,7 @@ class TestManifestRuntimeRemoved(unittest.TestCase):
self.assertIn("dev", m.bottles) self.assertIn("dev", m.bottles)
def test_bottle_dataclass_has_no_runtime_attribute(self): 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): def test_any_runtime_value_is_rejected(self):
for value in ("runsc", "runc", "kata-runtime", "", 42, None): 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", prompt_mode="append_file",
image="", image="",
dockerfile="", dockerfile="",
guest_home="/home/node",
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"}, 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: def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
stage = Path(tmp) stage = Path(tmp)
return DockerBottlePlan( return DockerBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=stage, stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp), git_gate_plan=_git_gate_plan(tmp),
@@ -106,7 +106,7 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
container_name_pinned=False, container_name_pinned=False,
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
derived_image="", derived_image="",
runtime_image="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
dockerfile_path="", dockerfile_path="",
env_file=stage / "env", env_file=stage / "env",
forwarded_env={}, forwarded_env={},
@@ -118,7 +118,6 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan: def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
stage = Path(tmp) stage = Path(tmp)
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=stage, stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp), 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_gateway="10.99.0.1",
bundle_ip="10.99.0.2", bundle_ip="10.99.0.2",
machine_name="bot-bottle-test-00001", machine_name="bot-bottle-test-00001",
agent_image_ref="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"}, guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
prompt_file=stage / "prompt.txt", prompt_file=stage / "prompt.txt",
) )
+9 -9
View File
@@ -50,18 +50,17 @@ class TestSmolmachinesResolveEnv(unittest.TestCase):
try: try:
with ( with (
patch("bot_bottle.backend.smolmachines.prepare.resolve_env", patch("bot_bottle.backend.smolmachines.resolve_plan.resolve_env",
return_value=resolved) as mock_resolve, return_value=resolved) as mock_resolve,
patch("bot_bottle.backend.smolmachines.prepare.smolmachines_preflight"), patch("bot_bottle.backend.smolmachines.resolve_plan.smolmachines_preflight"),
patch("bot_bottle.backend.smolmachines.prepare.smolmachines_bundle_subnet", 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")), 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.resolve_common.GitGate") as mock_gg,
patch("bot_bottle.backend.smolmachines.prepare.Egress") as mock_eg, patch("bot_bottle.backend.resolve_common.Egress") as mock_eg,
patch("bot_bottle.backend.smolmachines.prepare.Supervise"), patch("bot_bottle.backend.resolve_common.Supervise"),
patch( patch(
"bot_bottle.backend.smolmachines.prepare.agent_provision_plan" "bot_bottle.backend.smolmachines.resolve_plan.agent_provision_plan"
) as mock_app, ) as mock_app,
patch("bot_bottle.backend.smolmachines.prepare.runtime_for"),
): ):
mock_gg.return_value.prepare.return_value = MagicMock() mock_gg.return_value.prepare.return_value = MagicMock()
mock_eg.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", prompt_mode="append_file",
dockerfile="", dockerfile="",
image="bot-bottle-claude:latest", image="bot-bottle-claude:latest",
guest_home="/home/node",
guest_env=dict(kwargs.get("guest_env") or {}), guest_env=dict(kwargs.get("guest_env") or {}),
) )
mock_app.side_effect = lambda **kw: _make_provision(**kw) # type: ignore 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) plan = resolve_plan(spec, stage_dir=stage)
mock_resolve.assert_called_once_with(manifest, "myagent") 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.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream 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.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
@@ -43,7 +43,7 @@ class _Provider(AgentProvider):
@property @property
def runtime(self) -> AgentProviderRuntime: def runtime(self) -> AgentProviderRuntime:
return AgentProviderRuntime( return AgentProviderRuntime(
template="test", command="test", image="", dockerfile="", template="test", command="test", image="",
prompt_mode="append_file", bypass_args=(), resume_args=(), prompt_mode="append_file", bypass_args=(), resume_args=(),
remote_control_args=(), remote_control_args=(),
) )
@@ -85,7 +85,7 @@ def _plan(
*, *,
agent_prompt: str = "", agent_prompt: str = "",
skills: list[str] | None = None, skills: list[str] | None = None,
git: list[GitEntry] = (), # type: ignore git: list[ManifestGitEntry] = (), # type: ignore
git_user: dict | None = None, # type: ignore git_user: dict | None = None, # type: ignore
copy_cwd: bool = False, copy_cwd: bool = False,
user_cwd: str = "/tmp/x", user_cwd: str = "/tmp/x",
@@ -140,7 +140,6 @@ def _plan(
current_config_dir=Path("/tmp/current-config"), current_config_dir=Path("/tmp/current-config"),
) )
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
guest_home="/home/node",
spec=spec, spec=spec,
stage_dir=stage_dir or Path("/tmp/stage"), stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
@@ -148,7 +147,7 @@ def _plan(
bundle_gateway="192.168.50.1", bundle_gateway="192.168.50.1",
bundle_ip=bundle_ip, bundle_ip=bundle_ip,
machine_name="bot-bottle-demo-abc12", machine_name="bot-bottle-demo-abc12",
agent_image_ref="bot-bottle-claude:latest", agent_image="bot-bottle-claude:latest",
guest_env=dict(guest_env or {}), guest_env=dict(guest_env or {}),
prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"), prompt_file=Path("/tmp/state/demo-abc12/agent/prompt.txt"),
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
@@ -190,6 +189,7 @@ def _agent_provision(
prompt_mode="append_file", prompt_mode="append_file",
image="", image="",
dockerfile="", dockerfile="",
guest_home="/home/node",
guest_env=dict(guest_env or {}), guest_env=dict(guest_env or {}),
) )
auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex") auth_dir = (guest_env or {}).get("CODEX_HOME", "/home/node/.codex")
@@ -227,6 +227,7 @@ def _agent_provision(
prompt_mode="read_prompt_file", prompt_mode="read_prompt_file",
image="bot-bottle-codex:latest", image="bot-bottle-codex:latest",
dockerfile="", dockerfile="",
guest_home="/home/node",
guest_env=dict(guest_env or {}), guest_env=dict(guest_env or {}),
dirs=(AgentProvisionDir(auth_dir),), dirs=(AgentProvisionDir(auth_dir),),
files=tuple(files), files=tuple(files),
@@ -392,7 +393,7 @@ class TestProvisionGit(unittest.TestCase):
# git HTTP port is published on host loopback at launch # git HTTP port is published on host loopback at launch
# time, and the plan carries the discovered host port. # time, and the plan carries the discovered host port.
plan = _plan( plan = _plan(
git=[GitEntry( git=[ManifestGitEntry(
Name="bot-bottle", Name="bot-bottle",
Upstream="ssh://git@host/repo.git", Upstream="ssh://git@host/repo.git",
IdentityFile="~/.ssh/id_ed25519", 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) return supervise_cli.QueuedProposal(proposal=p, queue_dir=qdir)
def _write_metadata(self, slug: str, compose_project: str) -> None: 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( write_metadata(BottleMetadata(
identity=slug, identity=slug,
agent_name="myagent", agent_name="myagent",