refactor(backend): lift host-side validation onto the base class
test / unit (push) Successful in 12s
test / integration (push) Failing after 10s

Make BottleBackend.prepare a template method that runs a cross-backend
_validate step (agent exists, named skills present on host, SSH
IdentityFiles resolve) and then delegates to a subclass-implemented
_resolve_plan for backend-specific resolution.

A future backend that overrides _resolve_plan can no longer forget to
validate skills or SSH keys; the validation runs unconditionally via
prepare. Backends with additional preconditions can override _validate
and chain via super().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-12 10:51:19 -04:00
parent a23e89ef48
commit 339d40f8c9
3 changed files with 66 additions and 54 deletions
+50 -5
View File
@@ -34,10 +34,12 @@ from abc import ABC, abstractmethod
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Generic, TypeVar from typing import Any, Generic, Sequence, TypeVar
from ..log import die from ..log import die
from ..manifest import Manifest from ..manifest import Manifest, SshEntry
from ..util import expand_tilde
from .util import host_skill_dir
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -127,10 +129,53 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
name: str name: str
@abstractmethod
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT: def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
"""Resolve names, validate host-side prerequisites, write """Template method: run cross-backend host-side validation, then
scratch files. No remote/runtime resources created yet.""" delegate to the subclass's `_resolve_plan` for the
backend-specific resolution (names, scratch files, etc.). The
validation step is enforced here so a future backend cannot
accidentally skip it. No remote/runtime resources are created."""
self._validate(spec)
return self._resolve_plan(spec, stage_dir=stage_dir)
def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists,
the named skills are present on the host, and every SSH
IdentityFile resolves. Subclasses with additional preconditions
should override and call `super()._validate(spec)` first."""
manifest = spec.manifest
manifest.require_agent(spec.agent_name)
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
self._validate_skills(agent.skills)
self._validate_ssh_entries(bottle.ssh)
def _validate_skills(self, skills: Sequence[str]) -> None:
"""Each named skill must be a directory under the host's
`~/.claude/skills/`. The check is purely host-side, so the
default impl covers every backend."""
for name in skills:
path = host_skill_dir(name)
if not os.path.isdir(path):
die(
f"skill '{name}' not found on host at {path}. "
f"Create it under ~/.claude/skills/, then re-run."
)
def _validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None:
"""Each entry's IdentityFile must exist on the host (after
expanding leading ~). Shape is already enforced by Manifest
validation; this only checks file presence."""
for entry in entries:
key = expand_tilde(entry.IdentityFile)
if not os.path.isfile(key):
die(f"ssh key file not found for host '{entry.Host}': {key}")
@abstractmethod
def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names,
env-file, prompt-file, proxy plan, runtime detection. Called by
`prepare` after `_validate` succeeds."""
@abstractmethod @abstractmethod
def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]: def launch(self, plan: PlanT) -> AbstractContextManager[Bottle]:
+11 -44
View File
@@ -1,11 +1,9 @@
"""DockerBottleBackend — the Docker implementation of BottleBackend. """DockerBottleBackend — the Docker implementation of BottleBackend.
Methods: The base class's `prepare` template runs cross-backend host-side
.prepare(spec, stage_dir=...) -> DockerBottlePlan validation, then calls this module's `_resolve_plan` for the Docker-
.launch(plan) -> ContextManager[DockerBottle] specific resolution. Other public methods are backend-implemented as
.prepare_cleanup() -> DockerBottleCleanupPlan declared on `BottleBackend`.
.cleanup(plan) -> None
.list_active() -> None
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,15 +14,12 @@ import subprocess
import sys import sys
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
from typing import Generator, Sequence from typing import Generator
from ... import pipelock from ... import pipelock
from ...env import ResolvedEnv, resolve_env from ...env import ResolvedEnv, resolve_env
from ...log import die, info from ...log import die, info
from ...manifest import SshEntry
from ...util import expand_tilde
from .. import BottleBackend, BottleSpec from .. import BottleBackend, BottleSpec
from ..util import host_skill_dir
from . import network as network_mod 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
@@ -63,14 +58,15 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def __init__(self) -> None: def __init__(self) -> None:
self._proxy = DockerPipelockProxy() self._proxy = DockerPipelockProxy()
def prepare(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan: def _resolve_plan(self, spec: BottleSpec, *, stage_dir: Path) -> DockerBottlePlan:
"""Resolve names, validate, write scratch files. No Docker """Resolve Docker-specific names, write scratch files. No
resources are created; the only side effects are host-side Docker resources are created; the only side effects are
files under stage_dir and a probe of `docker info`.""" host-side files under stage_dir and a probe of `docker info`.
Cross-backend validation has already run via the base class's
`prepare` template."""
docker_mod.require_docker() docker_mod.require_docker()
manifest = spec.manifest manifest = spec.manifest
manifest.require_agent(spec.agent_name)
agent = manifest.agents[spec.agent_name] agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
@@ -109,11 +105,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
f"clean up old containers with 'docker rm -f <name>'" f"clean up old containers with 'docker rm -f <name>'"
) )
if agent.skills:
self.validate_skills(list(agent.skills))
if bottle.ssh:
self.validate_ssh_entries(bottle.ssh)
env_file = stage_dir / "agent.env" env_file = stage_dir / "agent.env"
prompt_file = stage_dir / "prompt.txt" prompt_file = stage_dir / "prompt.txt"
prompt_file.write_text("") prompt_file.write_text("")
@@ -266,33 +257,9 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None: def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None:
return _prompt.provision_prompt(plan, target) return _prompt.provision_prompt(plan, target)
def validate_skills(self, skills: list[str]) -> None:
"""Fail loudly if any named skill is missing from the host's
~/.claude/skills/. Called from `prepare` before the y/N so the
user doesn't get a launch prompt for a plan that's already
known to break."""
for name in skills:
path = host_skill_dir(name)
if not os.path.isdir(path):
die(
f"skill '{name}' not found on host at {path}. "
f"Create it under ~/.claude/skills/, then re-run."
)
def provision_skills(self, plan: DockerBottlePlan, target: str) -> None: def provision_skills(self, plan: DockerBottlePlan, target: str) -> None:
_skills.provision_skills(plan, target) _skills.provision_skills(plan, target)
def validate_ssh_entries(self, entries: Sequence[SshEntry]) -> None:
"""Each entry's IdentityFile must exist on the host (after
expanding leading ~). Host and IdentityFile shape are already
enforced by Manifest validation. Called from `prepare` before
the y/N so the user doesn't get prompted for a plan with a
missing key."""
for entry in entries:
key = expand_tilde(entry.IdentityFile)
if not os.path.isfile(key):
die(f"ssh key file not found for host '{entry.Host}': {key}")
def provision_ssh(self, plan: DockerBottlePlan, target: str) -> None: def provision_ssh(self, plan: DockerBottlePlan, target: str) -> None:
_ssh.provision_ssh(plan, target) _ssh.provision_ssh(plan, target)
@@ -1,10 +1,10 @@
"""Copy host-side skill directories into a running Docker bottle. """Copy host-side skill directories into a running Docker bottle.
Skills are validated on the host before launch by Skills are validated on the host before launch by the base class's
`DockerBottleBackend.validate_skills`; this module assumes that `BottleBackend._validate_skills` (called from `prepare`); this module
validation has already run. A skill disappearing between validation assumes that validation has already run. A skill disappearing between
and copy still dies loudly rather than silently producing a partial validation and copy still dies loudly rather than silently producing
container.""" a partial container."""
from __future__ import annotations from __future__ import annotations