Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c6d0642a94 | |||
| 5becef0d0b | |||
| 532072931f | |||
| 9ae49d21f7 | |||
| db712a523f | |||
| 04f8019e75 | |||
| 509adb7cbc | |||
| 21b3713264 |
@@ -240,7 +240,7 @@ class AgentProvider(ABC):
|
|||||||
BottleBackend.provision_workspace against the running bottle."""
|
BottleBackend.provision_workspace against the running bottle."""
|
||||||
from .log import info
|
from .log import info
|
||||||
|
|
||||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
manifest_bottle = plan.manifest.bottle
|
||||||
if manifest_bottle.git:
|
if manifest_bottle.git:
|
||||||
from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
||||||
gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME)
|
gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provi
|
|||||||
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 Manifest
|
from ..manifest import Manifest, ManifestIndex
|
||||||
from ..supervise import SupervisePlan
|
from ..supervise import SupervisePlan
|
||||||
from ..util import expand_tilde
|
from ..util import expand_tilde
|
||||||
from ..env import resolve_env, ResolvedEnv
|
from ..env import resolve_env, ResolvedEnv
|
||||||
@@ -61,7 +61,7 @@ class BottleSpec:
|
|||||||
Resolved values (image names, container name, scratch paths, runsc
|
Resolved values (image names, container name, scratch paths, runsc
|
||||||
availability) live on the plan, not the spec."""
|
availability) live on the plan, not the spec."""
|
||||||
|
|
||||||
manifest: Manifest
|
manifest: ManifestIndex
|
||||||
agent_name: str
|
agent_name: str
|
||||||
copy_cwd: bool
|
copy_cwd: bool
|
||||||
user_cwd: str
|
user_cwd: str
|
||||||
@@ -80,6 +80,7 @@ class BottlePlan(ABC):
|
|||||||
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
||||||
|
|
||||||
spec: BottleSpec
|
spec: BottleSpec
|
||||||
|
manifest: Manifest
|
||||||
stage_dir: Path
|
stage_dir: Path
|
||||||
git_gate_plan: GitGatePlan
|
git_gate_plan: GitGatePlan
|
||||||
|
|
||||||
@@ -112,9 +113,9 @@ class BottlePlan(ABC):
|
|||||||
"""Render the y/N preflight summary to stderr."""
|
"""Render the y/N preflight summary to stderr."""
|
||||||
del remote_control
|
del remote_control
|
||||||
spec = self.spec
|
spec = self.spec
|
||||||
manifest = spec.manifest
|
manifest = self.manifest
|
||||||
agent = manifest.agents[spec.agent_name]
|
agent = manifest.agent
|
||||||
bottle = manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle
|
||||||
|
|
||||||
env_names = visible_agent_env_names(
|
env_names = visible_agent_env_names(
|
||||||
sorted(
|
sorted(
|
||||||
@@ -131,7 +132,7 @@ class BottlePlan(ABC):
|
|||||||
print_multi("skills ", list(agent.skills))
|
print_multi("skills ", list(agent.skills))
|
||||||
info(f"bottle : {agent.bottle}")
|
info(f"bottle : {agent.bottle}")
|
||||||
|
|
||||||
identity = manifest.git_identity_summary(spec.agent_name)
|
identity = manifest.git_identity_summary()
|
||||||
if identity:
|
if identity:
|
||||||
info(f" git identity : {identity}")
|
info(f" git identity : {identity}")
|
||||||
|
|
||||||
@@ -289,15 +290,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
write_launch_metadata,
|
write_launch_metadata,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._validate(spec)
|
manifest = self._validate(spec)
|
||||||
|
|
||||||
self._preflight()
|
self._preflight()
|
||||||
|
|
||||||
manifest = spec.manifest
|
manifest_bottle = manifest.bottle
|
||||||
manifest_bottle = manifest.bottle_for(spec.agent_name)
|
|
||||||
manifest_agent_provider = manifest_bottle.agent_provider
|
manifest_agent_provider = manifest_bottle.agent_provider
|
||||||
agent_provider = get_provider(manifest_agent_provider.template)
|
agent_provider = get_provider(manifest_agent_provider.template)
|
||||||
resolved_env = resolve_env(manifest, spec.agent_name)
|
resolved_env = resolve_env(manifest)
|
||||||
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
||||||
|
|
||||||
slug = mint_slug(spec)
|
slug = mint_slug(spec)
|
||||||
@@ -313,7 +313,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
else:
|
else:
|
||||||
agent_dockerfile_path = str(agent_provider.dockerfile)
|
agent_dockerfile_path = str(agent_provider.dockerfile)
|
||||||
|
|
||||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
|
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
||||||
|
|
||||||
agent_provision_plan = build_agent_provision_plan(
|
agent_provision_plan = build_agent_provision_plan(
|
||||||
template=manifest_agent_provider.template,
|
template=manifest_agent_provider.template,
|
||||||
@@ -337,6 +337,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
|
|
||||||
return self._resolve_plan(
|
return self._resolve_plan(
|
||||||
spec,
|
spec,
|
||||||
|
manifest=manifest,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
resolved_env=resolved_env,
|
resolved_env=resolved_env,
|
||||||
agent_provision_plan=agent_provision_plan,
|
agent_provision_plan=agent_provision_plan,
|
||||||
@@ -355,16 +356,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _validate(self, spec: BottleSpec) -> None:
|
def _validate(self, spec: BottleSpec) -> Manifest:
|
||||||
"""Cross-backend pre-launch checks. Confirms the agent exists
|
"""Cross-backend pre-launch checks. Parses the selected agent and
|
||||||
and the named skills are present on the host. Subclasses with
|
its bottle (raising ManifestError on invalid content), confirms
|
||||||
|
skills are present on the host, and every git IdentityFile resolves.
|
||||||
|
|
||||||
|
Returns the loaded Manifest for the selected agent. Subclasses with
|
||||||
additional preconditions should override and call
|
additional preconditions should override and call
|
||||||
`super()._validate(spec)` first."""
|
`super()._validate(spec)` first."""
|
||||||
manifest = spec.manifest
|
manifest = spec.manifest.load_for_agent(spec.agent_name)
|
||||||
manifest.require_agent(spec.agent_name)
|
self._validate_skills(manifest.agent.skills)
|
||||||
agent = manifest.agents[spec.agent_name]
|
self._validate_agent_provider_dockerfile(spec, manifest)
|
||||||
self._validate_skills(agent.skills)
|
return manifest
|
||||||
self._validate_agent_provider_dockerfile(spec)
|
|
||||||
|
|
||||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||||
"""Each named skill must be a directory under the host's
|
"""Each named skill must be a directory under the host's
|
||||||
@@ -378,8 +381,8 @@ 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_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
|
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
|
||||||
bottle = spec.manifest.bottle_for(spec.agent_name)
|
bottle = manifest.bottle
|
||||||
dockerfile = bottle.agent_provider.dockerfile
|
dockerfile = bottle.agent_provider.dockerfile
|
||||||
if not dockerfile:
|
if not dockerfile:
|
||||||
return
|
return
|
||||||
@@ -389,13 +392,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
die(
|
die(
|
||||||
f"agent_provider.dockerfile for bottle "
|
f"agent_provider.dockerfile for bottle "
|
||||||
f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
|
f"'{manifest.agent.bottle}' not found: {path}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _resolve_plan(self,
|
def _resolve_plan(self,
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
*,
|
*,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from ...egress import EgressPlan
|
|||||||
from ...env import ResolvedEnv
|
from ...env import ResolvedEnv
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
|
from ...manifest import Manifest
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
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
|
||||||
@@ -63,6 +64,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
self,
|
self,
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
*,
|
*,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
@@ -73,6 +75,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
return _resolve_plan.resolve_plan(
|
return _resolve_plan.resolve_plan(
|
||||||
spec,
|
spec,
|
||||||
|
manifest=manifest,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
resolved_env=resolved_env,
|
resolved_env=resolved_env,
|
||||||
agent_provision_plan=agent_provision_plan,
|
agent_provision_plan=agent_provision_plan,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ def launch(
|
|||||||
Teardown on exit."""
|
Teardown on exit."""
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
|
|
||||||
_bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
_bottle_for_revoke = plan.manifest.bottle
|
||||||
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||||
|
|
||||||
def teardown() -> None:
|
def teardown() -> None:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .. import BottleSpec
|
|||||||
from ...env import ResolvedEnv
|
from ...env import ResolvedEnv
|
||||||
from ...agent_provider import AgentProvisionPlan
|
from ...agent_provider import AgentProvisionPlan
|
||||||
from ...egress import EgressPlan
|
from ...egress import EgressPlan
|
||||||
|
from ...manifest import Manifest
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
|||||||
|
|
||||||
def resolve_plan(
|
def resolve_plan(
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
@@ -48,6 +50,7 @@ def resolve_plan(
|
|||||||
|
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
forwarded_env=dict(resolved_env.forwarded),
|
forwarded_env=dict(resolved_env.forwarded),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from ...egress import EgressPlan
|
|||||||
from ...env import ResolvedEnv
|
from ...env import ResolvedEnv
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
|
from ...manifest import Manifest
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
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
|
||||||
@@ -45,6 +46,7 @@ class MacosContainerBottleBackend(
|
|||||||
self,
|
self,
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
*,
|
*,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
@@ -55,6 +57,7 @@ class MacosContainerBottleBackend(
|
|||||||
) -> MacosContainerBottlePlan:
|
) -> MacosContainerBottlePlan:
|
||||||
return _resolve_plan.resolve_plan(
|
return _resolve_plan.resolve_plan(
|
||||||
spec,
|
spec,
|
||||||
|
manifest=manifest,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
resolved_env=resolved_env,
|
resolved_env=resolved_env,
|
||||||
agent_provision_plan=agent_provision_plan,
|
agent_provision_plan=agent_provision_plan,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ def launch(
|
|||||||
) -> Generator[MacosContainerBottle, None, None]:
|
) -> Generator[MacosContainerBottle, None, None]:
|
||||||
"""Build, run, provision, and yield an Apple Container bottle."""
|
"""Build, run, provision, and yield an Apple Container bottle."""
|
||||||
stack = ExitStack()
|
stack = ExitStack()
|
||||||
bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
bottle_for_revoke = plan.manifest.bottle
|
||||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||||
|
|
||||||
def teardown() -> None:
|
def teardown() -> None:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from ...egress import EgressPlan
|
|||||||
from ...env import ResolvedEnv
|
from ...env import ResolvedEnv
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
|
from ...manifest import Manifest
|
||||||
from .. import BottleSpec
|
from .. import BottleSpec
|
||||||
from . import util as container_mod
|
from . import util as container_mod
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
from .bottle_plan import MacosContainerBottlePlan
|
||||||
@@ -24,6 +25,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
|||||||
|
|
||||||
def resolve_plan(
|
def resolve_plan(
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
@@ -34,6 +36,7 @@ def resolve_plan(
|
|||||||
) -> MacosContainerBottlePlan:
|
) -> MacosContainerBottlePlan:
|
||||||
return MacosContainerBottlePlan(
|
return MacosContainerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
forwarded_env=dict(resolved_env.forwarded),
|
forwarded_env=dict(resolved_env.forwarded),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ..bottle_state import (
|
|||||||
)
|
)
|
||||||
from ..egress import Egress, EgressPlan
|
from ..egress import Egress, EgressPlan
|
||||||
from ..git_gate import GitGate, GitGatePlan
|
from ..git_gate import GitGate, GitGatePlan
|
||||||
from ..manifest import ManifestBottle
|
from ..manifest import Manifest, ManifestBottle
|
||||||
from ..supervise import Supervise, SupervisePlan
|
from ..supervise import Supervise, SupervisePlan
|
||||||
from . import BottleSpec
|
from . import BottleSpec
|
||||||
|
|
||||||
@@ -66,11 +66,10 @@ def write_launch_metadata(
|
|||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]:
|
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
|
||||||
"""Create the agent state subdir, write the prompt file.
|
"""Create the agent state subdir, write the prompt file.
|
||||||
Returns (agent_dir, prompt_file)."""
|
Returns (agent_dir, prompt_file)."""
|
||||||
manifest = spec.manifest
|
agent = manifest.agent
|
||||||
agent = manifest.agents[spec.agent_name]
|
|
||||||
agent_dir = agent_state_dir(slug)
|
agent_dir = agent_state_dir(slug)
|
||||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||||
prompt_file = agent_dir / "prompt.txt"
|
prompt_file = agent_dir / "prompt.txt"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from ...egress import EgressPlan
|
|||||||
from ...env import ResolvedEnv
|
from ...env import ResolvedEnv
|
||||||
from ...git_gate import GitGatePlan
|
from ...git_gate import GitGatePlan
|
||||||
from ...supervise import SupervisePlan
|
from ...supervise import SupervisePlan
|
||||||
|
from ...manifest import Manifest
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
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
|
||||||
@@ -55,6 +56,7 @@ class SmolmachinesBottleBackend(
|
|||||||
self,
|
self,
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
*,
|
*,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
@@ -65,6 +67,7 @@ class SmolmachinesBottleBackend(
|
|||||||
) -> SmolmachinesBottlePlan:
|
) -> SmolmachinesBottlePlan:
|
||||||
return _resolve_plan.resolve_plan(
|
return _resolve_plan.resolve_plan(
|
||||||
spec,
|
spec,
|
||||||
|
manifest=manifest,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
resolved_env=resolved_env,
|
resolved_env=resolved_env,
|
||||||
agent_provision_plan=agent_provision_plan,
|
agent_provision_plan=agent_provision_plan,
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ def _teardown_smolmachines(
|
|||||||
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
||||||
teardown_exc = exc
|
teardown_exc = exc
|
||||||
warn(f"smolmachines teardown failed: {exc!r}")
|
warn(f"smolmachines teardown failed: {exc!r}")
|
||||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
bottle = plan.manifest.bottle
|
||||||
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
|
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
|
||||||
if teardown_exc is not None:
|
if teardown_exc is not None:
|
||||||
raise teardown_exc
|
raise teardown_exc
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .. import BottleSpec
|
from .. import BottleSpec
|
||||||
|
from ...manifest import Manifest
|
||||||
from ...env import ResolvedEnv
|
from ...env import ResolvedEnv
|
||||||
from ...agent_provider import AgentProvisionPlan
|
from ...agent_provider import AgentProvisionPlan
|
||||||
from ...egress import EgressPlan
|
from ...egress import EgressPlan
|
||||||
@@ -46,6 +47,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
|||||||
|
|
||||||
def resolve_plan(
|
def resolve_plan(
|
||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
|
manifest: Manifest,
|
||||||
slug: str,
|
slug: str,
|
||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
@@ -67,6 +69,7 @@ def resolve_plan(
|
|||||||
|
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
bundle_subnet=subnet,
|
bundle_subnet=subnet,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
from ..log import info
|
from ..log import info
|
||||||
from ..manifest import Manifest
|
from ..manifest import ManifestIndex
|
||||||
from ._common import PROG, USER_CWD
|
from ._common import PROG, USER_CWD
|
||||||
|
|
||||||
|
|
||||||
@@ -14,11 +14,12 @@ def cmd_info(argv: list[str]) -> int:
|
|||||||
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
names = ManifestIndex.resolve(USER_CWD)
|
||||||
manifest.require_agent(args.name)
|
names.require_agent(args.name)
|
||||||
|
manifest = names.load_for_agent(args.name)
|
||||||
|
|
||||||
agent = manifest.agents[args.name]
|
agent = manifest.agent
|
||||||
bottle = manifest.bottle_for(args.name)
|
bottle = manifest.bottle
|
||||||
env_names = list(bottle.env.keys())
|
env_names = list(bottle.env.keys())
|
||||||
prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else ""
|
prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else ""
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ def cmd_info(argv: list[str]) -> int:
|
|||||||
f"first line: {prompt_first_line or '(empty)'}"
|
f"first line: {prompt_first_line or '(empty)'}"
|
||||||
)
|
)
|
||||||
info(f"bottle : {agent.bottle}")
|
info(f"bottle : {agent.bottle}")
|
||||||
identity = manifest.git_identity_summary(args.name)
|
identity = manifest.git_identity_summary()
|
||||||
if identity:
|
if identity:
|
||||||
info(f" git identity : {identity}")
|
info(f" git identity : {identity}")
|
||||||
if bottle.git:
|
if bottle.git:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
from ..backend import enumerate_active_agents
|
from ..backend import enumerate_active_agents
|
||||||
from ..manifest import Manifest
|
from ..manifest import ManifestIndex
|
||||||
from ._common import PROG, USER_CWD
|
from ._common import PROG, USER_CWD
|
||||||
|
|
||||||
_ANSI_COLOR_CODES: dict[str, str] = {
|
_ANSI_COLOR_CODES: dict[str, str] = {
|
||||||
@@ -40,8 +40,8 @@ def cmd_list(argv: list[str]) -> int:
|
|||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.scope == "available":
|
if args.scope == "available":
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = ManifestIndex.resolve(USER_CWD)
|
||||||
for name in manifest.agents.keys():
|
for name in manifest.all_agent_names:
|
||||||
print(name)
|
print(name)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import argparse
|
|||||||
from ..backend import BottleSpec
|
from ..backend import BottleSpec
|
||||||
from ..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 ManifestIndex
|
||||||
from ._common import PROG, USER_CWD
|
from ._common import PROG, USER_CWD
|
||||||
from .start import _launch_bottle
|
from .start import _launch_bottle
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ def cmd_resume(argv: list[str]) -> int:
|
|||||||
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
|
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
|
||||||
)
|
)
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = ManifestIndex.resolve(USER_CWD)
|
||||||
manifest.require_agent(metadata.agent_name)
|
manifest.require_agent(metadata.agent_name)
|
||||||
|
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from ..bottle_state import (
|
|||||||
)
|
)
|
||||||
# from ..backend.docker.capability_apply import snapshot_transcript
|
# from ..backend.docker.capability_apply import snapshot_transcript
|
||||||
from ..log import info
|
from ..log import info
|
||||||
from ..manifest import Manifest
|
from ..manifest import ManifestIndex
|
||||||
from ._common import PROG, USER_CWD, read_tty_line
|
from ._common import PROG, USER_CWD, read_tty_line
|
||||||
from . import tui
|
from . import tui
|
||||||
|
|
||||||
@@ -62,12 +62,12 @@ def cmd_start(argv: list[str]) -> int:
|
|||||||
|
|
||||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||||
|
|
||||||
manifest = Manifest.resolve(USER_CWD)
|
manifest = ManifestIndex.resolve(USER_CWD)
|
||||||
|
|
||||||
agent_name: str | None = args.name
|
agent_name: str | None = args.name
|
||||||
if agent_name is None:
|
if agent_name is None:
|
||||||
agent_name = tui.filter_select(
|
agent_name = tui.filter_select(
|
||||||
sorted(manifest.agents.keys()),
|
manifest.all_agent_names,
|
||||||
title="Select agent",
|
title="Select agent",
|
||||||
)
|
)
|
||||||
if agent_name is None:
|
if agent_name is None:
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
when the agent has no skills."""
|
when the agent has no skills."""
|
||||||
from ...backend.util import host_skill_dir
|
from ...backend.util import host_skill_dir
|
||||||
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.manifest.agent
|
||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
@@ -240,7 +240,7 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||||
user="root",
|
user="root",
|
||||||
)
|
)
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.manifest.agent
|
||||||
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
|
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
|
||||||
|
|
||||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
skills."""
|
skills."""
|
||||||
from ...backend.util import host_skill_dir
|
from ...backend.util import host_skill_dir
|
||||||
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.manifest.agent
|
||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
@@ -206,7 +206,7 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||||
user="root",
|
user="root",
|
||||||
)
|
)
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.manifest.agent
|
||||||
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
|
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
|
||||||
|
|
||||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class PiAgentProvider(AgentProvider):
|
|||||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||||
from ...backend.util import host_skill_dir
|
from ...backend.util import host_skill_dir
|
||||||
|
|
||||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
agent = plan.manifest.agent
|
||||||
if not agent.skills:
|
if not agent.skills:
|
||||||
return
|
return
|
||||||
skills_dir = _skills_dir(plan.guest_home)
|
skills_dir = _skills_dir(plan.guest_home)
|
||||||
|
|||||||
+2
-2
@@ -114,7 +114,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
|
def resolve_env(manifest: Manifest) -> ResolvedEnv:
|
||||||
"""Iterate the agent's env entries:
|
"""Iterate the agent's env entries:
|
||||||
- secret: prompt at runtime; carry value in forwarded
|
- secret: prompt at runtime; carry value in forwarded
|
||||||
- interpolated: read $HOST_VAR from os.environ; carry value in forwarded
|
- interpolated: read $HOST_VAR from os.environ; carry value in forwarded
|
||||||
@@ -124,7 +124,7 @@ def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
|
|||||||
backend injects forwarded values via its launcher's env parameter."""
|
backend injects forwarded values via its launcher's env parameter."""
|
||||||
forwarded: dict[str, str] = {}
|
forwarded: dict[str, str] = {}
|
||||||
literals: dict[str, str] = {}
|
literals: dict[str, str] = {}
|
||||||
bottle = manifest.bottle_for(agent)
|
bottle = manifest.bottle
|
||||||
for name, raw in bottle.env.items():
|
for name, raw in bottle.env.items():
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
|
|||||||
+192
-97
@@ -36,10 +36,23 @@ Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a
|
|||||||
warn at load time and contributes nothing. The trust boundary is
|
warn at load time and contributes nothing. The trust boundary is
|
||||||
expressed as filesystem layout rather than resolver logic.
|
expressed as filesystem layout rather than resolver logic.
|
||||||
|
|
||||||
Validation runs once at load. Manifest.from_json_obj is preserved
|
Two types are exported:
|
||||||
as a programmatic entry point (used by tests) that takes a dict
|
|
||||||
with the same field names — useful for building manifests without
|
ManifestIndex — the multi-agent/bottle collection returned by
|
||||||
on-disk files.
|
resolve() and from_json_obj(). Used for agent
|
||||||
|
selection (all_agent_names), validation
|
||||||
|
(require_agent), and lazy loading (load_for_agent).
|
||||||
|
This is the pre-preflight form.
|
||||||
|
|
||||||
|
Manifest — a single-agent/bottle value type holding exactly
|
||||||
|
one agent: ManifestAgent and one bottle:
|
||||||
|
ManifestBottle (with the agent's git-gate.user
|
||||||
|
already overlaid). Returned by load_for_agent().
|
||||||
|
This is the post-preflight form passed to backends.
|
||||||
|
|
||||||
|
ManifestIndex.from_json_obj is preserved as a programmatic entry
|
||||||
|
point (used by tests) that takes a dict with the same field names —
|
||||||
|
useful for building manifests without on-disk files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -71,6 +84,7 @@ __all__ = [
|
|||||||
"ManifestEgressConfig",
|
"ManifestEgressConfig",
|
||||||
"ManifestAgent",
|
"ManifestAgent",
|
||||||
"ManifestBottle",
|
"ManifestBottle",
|
||||||
|
"ManifestIndex",
|
||||||
"Manifest",
|
"Manifest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -189,14 +203,64 @@ class ManifestBottle:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_git_user(
|
||||||
|
agent_user: ManifestGitUser, base_user: ManifestGitUser
|
||||||
|
) -> ManifestGitUser:
|
||||||
|
"""Merge the agent's git.user over the bottle's, agent-wins-on-non-empty."""
|
||||||
|
if agent_user.is_empty():
|
||||||
|
return base_user
|
||||||
|
return ManifestGitUser(
|
||||||
|
name=agent_user.name or base_user.name,
|
||||||
|
email=agent_user.email or base_user.email,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Manifest:
|
class Manifest:
|
||||||
|
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
|
||||||
|
|
||||||
|
`bottle` is the effective bottle with the agent's git-gate.user already
|
||||||
|
overlaid per-field (agent wins on non-empty). Backends and provisioners
|
||||||
|
use this directly — no agent_name lookup needed."""
|
||||||
|
|
||||||
|
agent: ManifestAgent
|
||||||
|
bottle: ManifestBottle
|
||||||
|
|
||||||
|
def git_identity_summary(self) -> str | None:
|
||||||
|
"""One-line effective git identity with per-field provenance, e.g.
|
||||||
|
`name=claude (agent), email=eric@dideric.is (bottle)`.
|
||||||
|
Returns None when neither agent nor bottle sets an identity."""
|
||||||
|
over = self.agent.git_user # agent's declared git_user (pre-merge)
|
||||||
|
merged = self.bottle.git_user # effective git_user (post-merge)
|
||||||
|
if merged.is_empty():
|
||||||
|
return None
|
||||||
|
parts: list[str] = []
|
||||||
|
if merged.name:
|
||||||
|
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
|
||||||
|
if merged.email:
|
||||||
|
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ManifestIndex:
|
||||||
|
"""Multi-agent/bottle collection. The pre-preflight form.
|
||||||
|
|
||||||
|
In lazy mode (from resolve()/from_md_dirs()) only filenames are scanned;
|
||||||
|
no file content is read. In eager mode (from from_json_obj()) all agents
|
||||||
|
and bottles are pre-parsed. Call load_for_agent() to get a single-value
|
||||||
|
Manifest ready for backend use."""
|
||||||
|
|
||||||
bottles: Mapping[str, ManifestBottle]
|
bottles: Mapping[str, ManifestBottle]
|
||||||
agents: Mapping[str, ManifestAgent]
|
agents: Mapping[str, ManifestAgent]
|
||||||
|
# Set by from_md_dirs; None in from_json_obj (test/programmatic) mode.
|
||||||
|
# Stores the manifest root dirs so load_for_agent can locate files later.
|
||||||
|
home_md: Path | None = field(default=None)
|
||||||
|
cwd_md: Path | None = field(default=None)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
|
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex":
|
||||||
"""Walk the per-file manifest tree and build a Manifest.
|
"""Walk the per-file manifest tree and build a ManifestIndex.
|
||||||
|
|
||||||
Layout (PRD 0011):
|
Layout (PRD 0011):
|
||||||
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
|
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
|
||||||
@@ -209,7 +273,7 @@ class Manifest:
|
|||||||
boundary.
|
boundary.
|
||||||
|
|
||||||
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
|
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
|
||||||
returns an empty manifest instead of dying. This is for
|
returns an empty index instead of dying. This is for
|
||||||
passive UI surfaces like the dashboard, which can still
|
passive UI surfaces like the dashboard, which can still
|
||||||
monitor already-running agents without launch config.
|
monitor already-running agents without launch config.
|
||||||
|
|
||||||
@@ -248,25 +312,16 @@ class Manifest:
|
|||||||
cls,
|
cls,
|
||||||
home_dir: Path,
|
home_dir: Path,
|
||||||
cwd_dir: Path | None,
|
cwd_dir: Path | None,
|
||||||
) -> "Manifest":
|
) -> "ManifestIndex":
|
||||||
"""Programmatic entry point. Loads bottles from
|
"""Return a names-only ManifestIndex. No file content is read; only
|
||||||
`<home_dir>/bottles/`, home agents from `<home_dir>/agents/`,
|
filenames are scanned for the agent selector. Full parsing happens
|
||||||
and (if `cwd_dir` is passed) cwd agents from
|
later, per-agent, via `load_for_agent`.
|
||||||
`<cwd_dir>/agents/`. Cwd agents override home agents on
|
|
||||||
name collision. A `bottles/` subdir under `cwd_dir` is
|
|
||||||
logged as a warning and ignored.
|
|
||||||
|
|
||||||
Used by tests to build a Manifest from fixture directories
|
A `bottles/` subdir under `cwd_dir` is logged as a warning and
|
||||||
|
ignored — the filesystem layout IS the trust boundary.
|
||||||
|
|
||||||
|
Used by tests to build a ManifestIndex from fixture directories
|
||||||
without touching `os.environ`."""
|
without touching `os.environ`."""
|
||||||
bottles_dir = home_dir / "bottles"
|
|
||||||
from .manifest_loader import load_agents_from_dir, load_bottles_from_dir
|
|
||||||
|
|
||||||
bottles = load_bottles_from_dir(bottles_dir)
|
|
||||||
|
|
||||||
bottle_names = set(bottles.keys())
|
|
||||||
agents_dir = home_dir / "agents"
|
|
||||||
agents = load_agents_from_dir(agents_dir, bottle_names, source="$HOME")
|
|
||||||
|
|
||||||
if cwd_dir is not None:
|
if cwd_dir is not None:
|
||||||
stale_bottles = cwd_dir / "bottles"
|
stale_bottles = cwd_dir / "bottles"
|
||||||
if stale_bottles.is_dir():
|
if stale_bottles.is_dir():
|
||||||
@@ -280,17 +335,11 @@ class Manifest:
|
|||||||
f"live under $HOME/.bot-bottle/bottles/ "
|
f"live under $HOME/.bot-bottle/bottles/ "
|
||||||
f"(PRD 0011). Move them or delete."
|
f"(PRD 0011). Move them or delete."
|
||||||
)
|
)
|
||||||
cwd_agents_dir = cwd_dir / "agents"
|
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
|
||||||
cwd_agents = load_agents_from_dir(
|
|
||||||
cwd_agents_dir, bottle_names, source="$CWD"
|
|
||||||
)
|
|
||||||
agents = {**agents, **cwd_agents}
|
|
||||||
|
|
||||||
return cls(bottles=bottles, agents=agents)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_json_obj(cls, obj: object) -> "Manifest":
|
def from_json_obj(cls, obj: object) -> "ManifestIndex":
|
||||||
"""Validate and build a Manifest from a raw JSON-like dict."""
|
"""Validate and build a ManifestIndex from a raw JSON-like dict."""
|
||||||
d = as_json_object(obj, "manifest")
|
d = as_json_object(obj, "manifest")
|
||||||
raw_bottles_obj = _section_dict(d.get("bottles"), "manifest 'bottles'")
|
raw_bottles_obj = _section_dict(d.get("bottles"), "manifest 'bottles'")
|
||||||
raw_agents = _section_dict(d.get("agents"), "manifest 'agents'")
|
raw_agents = _section_dict(d.get("agents"), "manifest 'agents'")
|
||||||
@@ -311,75 +360,121 @@ class Manifest:
|
|||||||
}
|
}
|
||||||
return cls(bottles=bottles, agents=agents)
|
return cls(bottles=bottles, agents=agents)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def all_agent_names(self) -> list[str]:
|
||||||
|
"""Sorted list of all discoverable agent names.
|
||||||
|
|
||||||
|
In names-only mode (from resolve/from_md_dirs) this scans agent
|
||||||
|
filenames without reading their content. In eager mode (from
|
||||||
|
from_json_obj) it returns the pre-parsed agents' names."""
|
||||||
|
if self.home_md is not None:
|
||||||
|
from .manifest_loader import scan_agent_names
|
||||||
|
home_names = set(scan_agent_names(self.home_md / "agents").keys())
|
||||||
|
cwd_names: set[str] = set()
|
||||||
|
if self.cwd_md is not None:
|
||||||
|
cwd_names = set(scan_agent_names(self.cwd_md / "agents").keys())
|
||||||
|
return sorted(home_names | cwd_names)
|
||||||
|
return sorted(self.agents.keys())
|
||||||
|
|
||||||
|
def load_for_agent(self, agent_name: str) -> "Manifest":
|
||||||
|
"""Parse the named agent and its bottle; return a single-value Manifest.
|
||||||
|
|
||||||
|
In lazy mode (from resolve/from_md_dirs) the agent file and its
|
||||||
|
bottle chain are read from disk for the first time here. In eager
|
||||||
|
mode (from_json_obj) the data is already parsed; this just filters
|
||||||
|
down to the requested agent and its bottle.
|
||||||
|
|
||||||
|
The returned Manifest.bottle has the agent's git-gate.user already
|
||||||
|
overlaid (agent wins on non-empty, per-field).
|
||||||
|
|
||||||
|
Always raises ManifestError if the agent is unknown or invalid.
|
||||||
|
Backends call this at preflight inside _validate."""
|
||||||
|
if self.home_md is None:
|
||||||
|
# Eager manifest (from_json_obj): data already parsed; filter to
|
||||||
|
# the one requested agent and its bottle so the returned Manifest
|
||||||
|
# always holds exactly one agent and one bottle regardless of path.
|
||||||
|
if agent_name not in self.agents:
|
||||||
|
available = ", ".join(sorted(self.agents.keys())) or "(none)"
|
||||||
|
raise ManifestError(
|
||||||
|
f"agent '{agent_name}' not defined. Available: {available}"
|
||||||
|
)
|
||||||
|
agent = self.agents[agent_name]
|
||||||
|
raw_bottle = self.bottles[agent.bottle]
|
||||||
|
merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
|
||||||
|
bottle = raw_bottle if merged == raw_bottle.git_user else replace(raw_bottle, git_user=merged)
|
||||||
|
return Manifest(agent=agent, bottle=bottle)
|
||||||
|
|
||||||
|
from .manifest_loader import load_bottle_chain_from_dir, scan_agent_names
|
||||||
|
from .manifest_schema import validate_agent_frontmatter_keys
|
||||||
|
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
|
# Locate the agent file; cwd wins over home on name collision.
|
||||||
|
home_agents = scan_agent_names(self.home_md / "agents")
|
||||||
|
cwd_agents: dict[str, Path] = {}
|
||||||
|
if self.cwd_md is not None:
|
||||||
|
cwd_agents = scan_agent_names(self.cwd_md / "agents")
|
||||||
|
merged_agents = {**home_agents, **cwd_agents}
|
||||||
|
|
||||||
|
if agent_name not in merged_agents:
|
||||||
|
available = ", ".join(sorted(merged_agents.keys())) or "(none)"
|
||||||
|
raise ManifestError(
|
||||||
|
f"agent '{agent_name}' not defined. Available: {available}"
|
||||||
|
)
|
||||||
|
|
||||||
|
agent_path = merged_agents[agent_name]
|
||||||
|
try:
|
||||||
|
fm, body = parse_frontmatter(agent_path.read_text())
|
||||||
|
except OSError as e:
|
||||||
|
raise ManifestError(f"could not read {agent_path}: {e}") from e
|
||||||
|
except YamlSubsetError as e:
|
||||||
|
raise ManifestError(f"{agent_path}: {e}") from e
|
||||||
|
|
||||||
|
validate_agent_frontmatter_keys(agent_path, fm.keys())
|
||||||
|
|
||||||
|
bottle_name = fm.get("bottle")
|
||||||
|
if not isinstance(bottle_name, str) or not bottle_name:
|
||||||
|
raise ManifestError(
|
||||||
|
f"agent '{agent_name}' must declare a 'bottle' field "
|
||||||
|
f"naming a defined bottle"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load the bottle chain (may raise ManifestError).
|
||||||
|
bottles_dir = self.home_md / "bottles"
|
||||||
|
raw_bottle = load_bottle_chain_from_dir(bottle_name, bottles_dir)
|
||||||
|
|
||||||
|
# Build and validate the full ManifestAgent.
|
||||||
|
agent_dict: dict[str, object] = {
|
||||||
|
"bottle": bottle_name,
|
||||||
|
"skills": fm.get("skills", []),
|
||||||
|
"prompt": body.strip(),
|
||||||
|
}
|
||||||
|
if "git-gate" in fm:
|
||||||
|
agent_dict["git-gate"] = fm["git-gate"]
|
||||||
|
agent = ManifestAgent.from_dict(agent_name, agent_dict, {bottle_name})
|
||||||
|
|
||||||
|
merged_user = _merge_git_user(agent.git_user, raw_bottle.git_user)
|
||||||
|
bottle = raw_bottle if merged_user == raw_bottle.git_user else replace(raw_bottle, git_user=merged_user)
|
||||||
|
return Manifest(agent=agent, bottle=bottle)
|
||||||
|
|
||||||
def has_agent(self, name: str) -> bool:
|
def has_agent(self, name: str) -> bool:
|
||||||
return name in self.agents
|
return name in self.agents
|
||||||
|
|
||||||
def require_agent(self, name: str) -> None:
|
def require_agent(self, name: str) -> None:
|
||||||
|
"""Check that `name` is a discoverable agent. In names-only mode
|
||||||
|
this checks whether the .md file exists; in eager mode it checks
|
||||||
|
the pre-parsed agents dict. Does NOT parse file content."""
|
||||||
if self.has_agent(name):
|
if self.has_agent(name):
|
||||||
return
|
return
|
||||||
available = ", ".join(self.agents.keys())
|
if self.home_md is not None:
|
||||||
if available:
|
# Names-only mode: check file existence without parsing.
|
||||||
msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
|
home_path = self.home_md / "agents" / f"{name}.md"
|
||||||
raise ManifestError(msg)
|
cwd_path = (
|
||||||
raise ManifestError(
|
self.cwd_md / "agents" / f"{name}.md"
|
||||||
f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
|
if self.cwd_md else None
|
||||||
)
|
|
||||||
|
|
||||||
def has_bottle(self, name: str) -> bool:
|
|
||||||
return name in self.bottles
|
|
||||||
|
|
||||||
def require_bottle(self, name: str) -> None:
|
|
||||||
if self.has_bottle(name):
|
|
||||||
return
|
|
||||||
available = ", ".join(self.bottles.keys())
|
|
||||||
if available:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' not defined in bot-bottle.json. "
|
|
||||||
f"Available bottles: {available}"
|
|
||||||
)
|
)
|
||||||
raise ManifestError(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
|
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
|
||||||
|
return
|
||||||
def _effective_git_user(self, agent_name: str) -> ManifestGitUser:
|
available = ", ".join(self.all_agent_names) or "(none)"
|
||||||
"""Merge the agent's git.user over the referenced bottle's,
|
raise ManifestError(
|
||||||
per-field, agent-wins-on-non-empty (issue #94). Same overlay
|
f"agent '{name}' not defined. Available: {available}"
|
||||||
the `extends:` resolver applies between bottles
|
|
||||||
(`_merge_bottles`)."""
|
|
||||||
agent = self.agents[agent_name]
|
|
||||||
base = self.bottles[agent.bottle].git_user
|
|
||||||
over = agent.git_user
|
|
||||||
if over.is_empty():
|
|
||||||
return base
|
|
||||||
return ManifestGitUser(
|
|
||||||
name=over.name or base.name,
|
|
||||||
email=over.email or base.email,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def bottle_for(self, agent_name: str) -> ManifestBottle:
|
|
||||||
"""Resolve the Bottle the named agent references, with the
|
|
||||||
agent's git.user overlaid on top. The validator guarantees both
|
|
||||||
lookups succeed for a manifest built via from_json_obj.
|
|
||||||
|
|
||||||
The overlay lives here, the single point both backends call to
|
|
||||||
resolve an agent's bottle, so the docker / smolmachines git
|
|
||||||
provisioners pick up the merged identity unchanged."""
|
|
||||||
bottle = self.bottles[self.agents[agent_name].bottle]
|
|
||||||
merged = self._effective_git_user(agent_name)
|
|
||||||
if merged == bottle.git_user:
|
|
||||||
return bottle
|
|
||||||
return replace(bottle, git_user=merged)
|
|
||||||
|
|
||||||
def git_identity_summary(self, agent_name: str) -> str | None:
|
|
||||||
"""One-line effective git identity with per-field provenance
|
|
||||||
for launch summaries, e.g.
|
|
||||||
`name=claude (agent), email=eric@dideric.is (bottle)`.
|
|
||||||
Returns None when neither agent nor bottle sets an identity."""
|
|
||||||
over = self.agents[agent_name].git_user
|
|
||||||
merged = self._effective_git_user(agent_name)
|
|
||||||
if merged.is_empty():
|
|
||||||
return None
|
|
||||||
parts: list[str] = []
|
|
||||||
if merged.name:
|
|
||||||
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
|
|
||||||
if merged.email:
|
|
||||||
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
|
|
||||||
return ", ".join(parts)
|
|
||||||
|
|||||||
@@ -8,21 +8,19 @@ from typing import TYPE_CHECKING
|
|||||||
from .log import warn
|
from .log import warn
|
||||||
from .manifest_schema import (
|
from .manifest_schema import (
|
||||||
entity_name_from_path,
|
entity_name_from_path,
|
||||||
validate_agent_frontmatter_keys,
|
|
||||||
validate_bottle_frontmatter_keys,
|
validate_bottle_frontmatter_keys,
|
||||||
)
|
)
|
||||||
|
from .manifest_util import ManifestError
|
||||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .manifest import ManifestAgent, ManifestBottle
|
from .manifest import 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:
|
||||||
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
|
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
|
||||||
not. The manifest format changed in PRD 0011 and we do not want
|
not. The manifest format changed in PRD 0011 and we do not want
|
||||||
to silently leave the JSON content unused."""
|
to silently leave the JSON content unused."""
|
||||||
from .manifest import ManifestError
|
|
||||||
|
|
||||||
legacy = dir_path / "bot-bottle.json"
|
legacy = dir_path / "bot-bottle.json"
|
||||||
if legacy.is_file() and not md_dir.exists():
|
if legacy.is_file() and not md_dir.exists():
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
@@ -34,48 +32,13 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
|
def scan_agent_names(agents_dir: Path) -> dict[str, Path]:
|
||||||
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
|
"""Scan `<agents_dir>/*.md` for valid filenames and return `{name: path}`.
|
||||||
`{name: Bottle}`. Missing dir returns an empty dict."""
|
|
||||||
from .manifest import ManifestError
|
|
||||||
from .manifest_extends import resolve_bottles
|
|
||||||
|
|
||||||
raws: dict[str, dict[str, object]] = {}
|
No file content is read. Invalid filenames are skipped with a warning."""
|
||||||
if not bottles_dir.is_dir():
|
result: dict[str, Path] = {}
|
||||||
return {}
|
|
||||||
for path in sorted(bottles_dir.glob("*.md")):
|
|
||||||
name = entity_name_from_path(path)
|
|
||||||
if name is None:
|
|
||||||
warn(
|
|
||||||
f"skipping {path}: filename must match "
|
|
||||||
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
fm, _body = parse_frontmatter(path.read_text())
|
|
||||||
except OSError as e:
|
|
||||||
raise ManifestError(f"could not read {path}: {e}") from e
|
|
||||||
except YamlSubsetError as e:
|
|
||||||
raise ManifestError(f"{path}: {e}") from e
|
|
||||||
validate_bottle_frontmatter_keys(path, fm.keys())
|
|
||||||
raws[name] = fm
|
|
||||||
return resolve_bottles(raws)
|
|
||||||
|
|
||||||
|
|
||||||
def load_agents_from_dir(
|
|
||||||
agents_dir: Path,
|
|
||||||
bottle_names: set[str],
|
|
||||||
*,
|
|
||||||
source: str, # noqa: F841 — unused, but required by interface
|
|
||||||
) -> dict[str, ManifestAgent]:
|
|
||||||
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
|
|
||||||
`{name: Agent}`. The Markdown body becomes the agent's prompt.
|
|
||||||
Missing dir returns an empty dict."""
|
|
||||||
from .manifest import ManifestAgent, ManifestError
|
|
||||||
|
|
||||||
out: dict[str, ManifestAgent] = {}
|
|
||||||
if not agents_dir.is_dir():
|
if not agents_dir.is_dir():
|
||||||
return out
|
return result
|
||||||
for path in sorted(agents_dir.glob("*.md")):
|
for path in sorted(agents_dir.glob("*.md")):
|
||||||
name = entity_name_from_path(path)
|
name = entity_name_from_path(path)
|
||||||
if name is None:
|
if name is None:
|
||||||
@@ -84,22 +47,45 @@ def load_agents_from_dir(
|
|||||||
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
result[name] = path
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def load_bottle_chain_from_dir(
|
||||||
|
bottle_name: str, bottles_dir: Path
|
||||||
|
) -> ManifestBottle:
|
||||||
|
"""Load `bottle_name` and its full `extends:` chain from `bottles_dir`,
|
||||||
|
returning the resolved ManifestBottle.
|
||||||
|
|
||||||
|
Only the files in the extends chain are read — unrelated bottle files
|
||||||
|
are never touched. Raises ManifestError on parse or validation failure."""
|
||||||
|
from .manifest_extends import resolve_bottles
|
||||||
|
|
||||||
|
raws: dict[str, dict[str, object]] = {}
|
||||||
|
to_load = [bottle_name]
|
||||||
|
while to_load:
|
||||||
|
name = to_load.pop()
|
||||||
|
if name in raws:
|
||||||
|
continue
|
||||||
|
path = bottles_dir / f"{name}.md"
|
||||||
|
if not path.is_file():
|
||||||
|
avail = ", ".join(
|
||||||
|
p.stem for p in sorted(bottles_dir.glob("*.md")) if p.is_file()
|
||||||
|
) or "(none)"
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' not found at {path}. "
|
||||||
|
f"Available: {avail}"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
fm, body = parse_frontmatter(path.read_text())
|
fm, _body = parse_frontmatter(path.read_text())
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise ManifestError(f"could not read {path}: {e}") from e
|
raise ManifestError(f"could not read {path}: {e}") from e
|
||||||
except YamlSubsetError as e:
|
except YamlSubsetError as e:
|
||||||
raise ManifestError(f"{path}: {e}") from e
|
raise ManifestError(f"{path}: {e}") from e
|
||||||
validate_agent_frontmatter_keys(path, fm.keys())
|
validate_bottle_frontmatter_keys(path, fm.keys())
|
||||||
# Build the dict Agent.from_dict expects. The body becomes
|
raws[name] = dict(fm)
|
||||||
# prompt; Claude Code passthrough fields stay in fm and get
|
parent = fm.get("extends")
|
||||||
# ignored by Agent.from_dict (reads bottle/skills/git-gate/prompt).
|
if isinstance(parent, str):
|
||||||
agent_dict: dict[str, object] = {
|
to_load.append(parent)
|
||||||
"bottle": fm.get("bottle"),
|
|
||||||
"skills": fm.get("skills", []),
|
return resolve_bottles(raws)[bottle_name]
|
||||||
"prompt": body.strip(),
|
|
||||||
}
|
|
||||||
if "git-gate" in fm:
|
|
||||||
agent_dict["git-gate"] = fm["git-gate"]
|
|
||||||
out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names)
|
|
||||||
return out
|
|
||||||
|
|||||||
+7
-7
@@ -10,7 +10,7 @@ import tempfile
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def fixture_minimal_dict() -> dict[str, Any]:
|
def fixture_minimal_dict() -> dict[str, Any]:
|
||||||
@@ -62,16 +62,16 @@ def fixture_with_git_dict() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def fixture_minimal() -> Manifest:
|
def fixture_minimal() -> ManifestIndex:
|
||||||
return Manifest.from_json_obj(fixture_minimal_dict())
|
return ManifestIndex.from_json_obj(fixture_minimal_dict())
|
||||||
|
|
||||||
|
|
||||||
def fixture_with_egress() -> Manifest:
|
def fixture_with_egress() -> ManifestIndex:
|
||||||
return Manifest.from_json_obj(fixture_with_egress_dict())
|
return ManifestIndex.from_json_obj(fixture_with_egress_dict())
|
||||||
|
|
||||||
|
|
||||||
def fixture_with_git() -> Manifest:
|
def fixture_with_git() -> ManifestIndex:
|
||||||
return Manifest.from_json_obj(fixture_with_git_dict())
|
return ManifestIndex.from_json_obj(fixture_with_git_dict())
|
||||||
|
|
||||||
|
|
||||||
def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path:
|
def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ from bot_bottle.backend.macos_container.util import (
|
|||||||
dns_server as _container_dns_server,
|
dns_server as _container_dns_server,
|
||||||
is_available as _container_available,
|
is_available as _container_available,
|
||||||
)
|
)
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief."
|
_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief."
|
||||||
@@ -52,8 +52,8 @@ def _minimal_agent_dockerfile(path: Path) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _minimal_manifest(dockerfile: Path) -> Manifest:
|
def _minimal_manifest(dockerfile: Path) -> ManifestIndex:
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"agent_provider": {
|
"agent_provider": {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ 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.bottle_state import cleanup_state
|
from bot_bottle.bottle_state import cleanup_state
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ class TestSandboxEscape(unittest.TestCase):
|
|||||||
cls._key_path.write_text("placeholder\n")
|
cls._key_path.write_text("placeholder\n")
|
||||||
cls._key_path.chmod(0o600)
|
cls._key_path.chmod(0o600)
|
||||||
|
|
||||||
manifest = Manifest.from_json_obj({
|
manifest = ManifestIndex.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
# Three fake secrets — different shapes — land
|
# Three fake secrets — different shapes — land
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ from pathlib import Path
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
def _manifest() -> Manifest:
|
def _manifest() -> ManifestIndex:
|
||||||
"""Bottle with supervise on so the bundle exercises egress +
|
"""Bottle with supervise on so the bundle exercises egress +
|
||||||
supervise. Git is off because a meaningful git-gate test needs
|
supervise. Git is off because a meaningful git-gate test needs
|
||||||
a real upstream and SSH keys — out of scope for a bundle smoke."""
|
a real upstream and SSH keys — out of scope for a bundle smoke."""
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"supervise": True,
|
"supervise": True,
|
||||||
|
|||||||
@@ -35,15 +35,15 @@ 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.smolmachines.smolvm import is_available as _smolvm_available
|
from bot_bottle.backend.smolmachines.smolvm import is_available as _smolvm_available
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
_AGENT_PROMPT = "You are demo. Be brief."
|
_AGENT_PROMPT = "You are demo. Be brief."
|
||||||
|
|
||||||
|
|
||||||
def _minimal_manifest() -> Manifest:
|
def _minimal_manifest() -> ManifestIndex:
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"egress": {
|
"egress": {
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ from bot_bottle.backend import BottleSpec
|
|||||||
from bot_bottle.backend.docker import DockerBottleBackend
|
from bot_bottle.backend.docker import DockerBottleBackend
|
||||||
from bot_bottle.backend.resolve_common import mint_slug
|
from bot_bottle.backend.resolve_common import mint_slug
|
||||||
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
|
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _manifest() -> Manifest:
|
def _manifest() -> ManifestIndex:
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"env": {
|
"env": {
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ from bot_bottle import supervise
|
|||||||
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||||
from bot_bottle.backend.docker import DockerBottleBackend
|
from bot_bottle.backend.docker import DockerBottleBackend
|
||||||
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
|
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _manifest() -> Manifest:
|
def _manifest() -> ManifestIndex:
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
"agents": {
|
"agents": {
|
||||||
"demo": {
|
"demo": {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from bot_bottle.backend import ActiveAgent
|
|||||||
def _make_manifest(agent_names: list[str]):
|
def _make_manifest(agent_names: list[str]):
|
||||||
manifest = MagicMock()
|
manifest = MagicMock()
|
||||||
manifest.agents = {name: MagicMock() for name in agent_names}
|
manifest.agents = {name: MagicMock() for name in agent_names}
|
||||||
|
manifest.all_agent_names = sorted(agent_names)
|
||||||
return manifest
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class TestCmdStartSelector(unittest.TestCase):
|
|||||||
# Stub Manifest.resolve so no on-disk manifest is needed.
|
# Stub Manifest.resolve so no on-disk manifest is needed.
|
||||||
self._manifest = _make_manifest(["researcher", "implementer"])
|
self._manifest = _make_manifest(["researcher", "implementer"])
|
||||||
self._resolve_patch = patch(
|
self._resolve_patch = patch(
|
||||||
"bot_bottle.cli.start.Manifest.resolve",
|
"bot_bottle.cli.start.ManifestIndex.resolve",
|
||||||
return_value=self._manifest,
|
return_value=self._manifest,
|
||||||
)
|
)
|
||||||
self._resolve_patch.start()
|
self._resolve_patch.start()
|
||||||
@@ -149,7 +150,7 @@ class TestCmdStartLabelCollision(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self._manifest = _make_manifest(["researcher"])
|
self._manifest = _make_manifest(["researcher"])
|
||||||
patch("bot_bottle.cli.start.Manifest.resolve", return_value=self._manifest).start()
|
patch("bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest).start()
|
||||||
self._launch_mock = patch(
|
self._launch_mock = patch(
|
||||||
"bot_bottle.cli.start._launch_bottle", return_value=0,
|
"bot_bottle.cli.start._launch_bottle", return_value=0,
|
||||||
).start()
|
).start()
|
||||||
|
|||||||
+11
-14
@@ -31,7 +31,7 @@ from bot_bottle.egress import (
|
|||||||
EgressRoute,
|
EgressRoute,
|
||||||
)
|
)
|
||||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from bot_bottle.supervise import SupervisePlan
|
from bot_bottle.supervise import SupervisePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ STAGE = Path("/tmp/cb-stage")
|
|||||||
STATE = Path("/tmp/cb-state")
|
STATE = Path("/tmp/cb-state")
|
||||||
|
|
||||||
|
|
||||||
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest:
|
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex:
|
||||||
"""Minimal manifest with the toggles the chunk-1 matrix needs.
|
"""Minimal manifest with the toggles the chunk-1 matrix needs.
|
||||||
The renderer only reads from the plan, not the manifest, so this
|
The renderer only reads from the plan, not the manifest, so this
|
||||||
is just here to back BottleSpec."""
|
is just here to back BottleSpec."""
|
||||||
@@ -61,22 +61,12 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
|
|||||||
"auth": {"scheme": "Bearer", "token_ref": "TOK"},
|
"auth": {"scheme": "Bearer", "token_ref": "TOK"},
|
||||||
}],
|
}],
|
||||||
}
|
}
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle},
|
"bottles": {"dev": bottle},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def _spec(*, supervise: bool, with_git: bool, with_egress: bool) -> BottleSpec:
|
|
||||||
return BottleSpec(
|
|
||||||
manifest=_manifest(
|
|
||||||
supervise=supervise, with_git=with_git, with_egress=with_egress,
|
|
||||||
),
|
|
||||||
agent_name="demo",
|
|
||||||
copy_cwd=False,
|
|
||||||
user_cwd="/tmp/x",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
|
def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
|
||||||
return GitGatePlan(
|
return GitGatePlan(
|
||||||
@@ -146,9 +136,16 @@ def _plan(
|
|||||||
roles=(),
|
roles=(),
|
||||||
),)
|
),)
|
||||||
|
|
||||||
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
||||||
|
spec = BottleSpec(
|
||||||
|
manifest=index,
|
||||||
|
agent_name="demo",
|
||||||
|
copy_cwd=False,
|
||||||
|
user_cwd="/tmp/x",
|
||||||
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=index.load_for_agent("demo"),
|
||||||
stage_dir=STAGE,
|
stage_dir=STAGE,
|
||||||
slug=SLUG,
|
slug=SLUG,
|
||||||
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
|
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
|||||||
from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider
|
from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from bot_bottle.supervise import SupervisePlan
|
from bot_bottle.supervise import SupervisePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ def _plan(
|
|||||||
bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore
|
bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore
|
||||||
if supervise:
|
if supervise:
|
||||||
bottle_json["supervise"] = True
|
bottle_json["supervise"] = True
|
||||||
manifest = Manifest.from_json_obj({
|
index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle_json},
|
"bottles": {"dev": bottle_json},
|
||||||
"agents": {
|
"agents": {
|
||||||
"demo": {
|
"demo": {
|
||||||
@@ -65,8 +65,9 @@ def _plan(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
manifest = index.load_for_agent("demo")
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest, agent_name="demo",
|
manifest=index, agent_name="demo",
|
||||||
copy_cwd=False, user_cwd="/tmp/x",
|
copy_cwd=False, user_cwd="/tmp/x",
|
||||||
)
|
)
|
||||||
supervise_plan = None
|
supervise_plan = None
|
||||||
@@ -78,6 +79,7 @@ def _plan(
|
|||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=Path("/tmp/stage"),
|
stage_dir=Path("/tmp/stage"),
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
forwarded_env={},
|
forwarded_env={},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
|||||||
from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider
|
from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from bot_bottle.supervise import SupervisePlan
|
from bot_bottle.supervise import SupervisePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ def _plan(
|
|||||||
bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore
|
bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore
|
||||||
if supervise:
|
if supervise:
|
||||||
bottle_json["supervise"] = True
|
bottle_json["supervise"] = True
|
||||||
manifest = Manifest.from_json_obj({
|
index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle_json},
|
"bottles": {"dev": bottle_json},
|
||||||
"agents": {
|
"agents": {
|
||||||
"demo": {
|
"demo": {
|
||||||
@@ -65,8 +65,9 @@ def _plan(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
manifest = index.load_for_agent("demo")
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest, agent_name="demo",
|
manifest=index, agent_name="demo",
|
||||||
copy_cwd=False, user_cwd="/tmp/x",
|
copy_cwd=False, user_cwd="/tmp/x",
|
||||||
)
|
)
|
||||||
supervise_plan = None
|
supervise_plan = None
|
||||||
@@ -78,6 +79,7 @@ def _plan(
|
|||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=Path("/tmp/stage"),
|
stage_dir=Path("/tmp/stage"),
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
forwarded_env={},
|
forwarded_env={},
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
|||||||
from bot_bottle.contrib.pi.agent_provider import PiAgentProvider
|
from bot_bottle.contrib.pi.agent_provider import PiAgentProvider
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
_URL = "http://supervise:9100/"
|
_URL = "http://supervise:9100/"
|
||||||
@@ -43,7 +43,7 @@ def _plan(
|
|||||||
skills: list[str] | None = None,
|
skills: list[str] | None = None,
|
||||||
agent_provision: AgentProvisionPlan | None = None,
|
agent_provision: AgentProvisionPlan | None = None,
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
manifest = Manifest.from_json_obj({
|
index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"agent_provider": {"template": "pi"}}},
|
"bottles": {"dev": {"agent_provider": {"template": "pi"}}},
|
||||||
"agents": {
|
"agents": {
|
||||||
"demo": {
|
"demo": {
|
||||||
@@ -53,12 +53,14 @@ def _plan(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
manifest = index.load_for_agent("demo")
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest, agent_name="demo",
|
manifest=index, agent_name="demo",
|
||||||
copy_cwd=False, user_cwd="/tmp/x",
|
copy_cwd=False, user_cwd="/tmp/x",
|
||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=Path("/tmp/stage"),
|
stage_dir=Path("/tmp/stage"),
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
forwarded_env={},
|
forwarded_env={},
|
||||||
|
|||||||
@@ -21,21 +21,19 @@ from bot_bottle.backend.docker import launch as launch_mod
|
|||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
_INDEX = ManifestIndex.from_json_obj({
|
||||||
def _manifest() -> Manifest:
|
"bottles": {"dev": {}},
|
||||||
return Manifest.from_json_obj({
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
"bottles": {"dev": {}},
|
})
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _plan(tmp: str) -> DockerBottlePlan:
|
def _plan(tmp: str) -> DockerBottlePlan:
|
||||||
stage = Path(tmp)
|
stage = Path(tmp)
|
||||||
manifest = _manifest()
|
manifest = _INDEX.load_for_agent("demo")
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest,
|
manifest=_INDEX,
|
||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=False,
|
copy_cwd=False,
|
||||||
user_cwd=tmp,
|
user_cwd=tmp,
|
||||||
@@ -43,6 +41,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
|
|||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=stage,
|
stage_dir=stage,
|
||||||
git_gate_plan=GitGatePlan(
|
git_gate_plan=GitGatePlan(
|
||||||
slug="test-teardown-00001",
|
slug="test-teardown-00001",
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
|||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
class _Provider(AgentProvider):
|
class _Provider(AgentProvider):
|
||||||
@@ -51,16 +51,18 @@ def _plan(*, git_user: dict | None = None, # type: ignore
|
|||||||
bottle_json: dict = {} # type: ignore
|
bottle_json: dict = {} # type: ignore
|
||||||
if git_user is not None:
|
if git_user is not None:
|
||||||
bottle_json["git-gate"] = {"user": git_user}
|
bottle_json["git-gate"] = {"user": git_user}
|
||||||
manifest = Manifest.from_json_obj({
|
index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle_json},
|
"bottles": {"dev": bottle_json},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
|
manifest = index.load_for_agent("demo")
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest, agent_name="demo",
|
manifest=index, agent_name="demo",
|
||||||
copy_cwd=copy_cwd, user_cwd=user_cwd,
|
copy_cwd=copy_cwd, user_cwd=user_cwd,
|
||||||
)
|
)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=stage_dir or Path("/tmp/stage"),
|
stage_dir=stage_dir or Path("/tmp/stage"),
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
forwarded_env={},
|
forwarded_env={},
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ from bot_bottle.egress import (
|
|||||||
egress_token_env_map,
|
egress_token_env_map,
|
||||||
)
|
)
|
||||||
from bot_bottle.log import Die
|
from bot_bottle.log import Die
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from bot_bottle.yaml_subset import parse_yaml_subset
|
from bot_bottle.yaml_subset import parse_yaml_subset
|
||||||
|
|
||||||
|
|
||||||
def _bottle(routes): # type: ignore
|
def _bottle(routes): # type: ignore
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"egress": {"routes": routes}}},
|
"bottles": {"dev": {"egress": {"routes": routes}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
@@ -362,9 +362,9 @@ class TestRenderRoutes(unittest.TestCase):
|
|||||||
self.assertEqual("x.example", cfg.routes[0].host)
|
self.assertEqual("x.example", cfg.routes[0].host)
|
||||||
|
|
||||||
def test_log_via_manifest_flows_to_render(self):
|
def test_log_via_manifest_flows_to_render(self):
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from bot_bottle.egress_addon_core import load_config, LOG_BLOCKS
|
from bot_bottle.egress_addon_core import load_config, LOG_BLOCKS
|
||||||
m = Manifest.from_json_obj({
|
m = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"egress": {
|
"bottles": {"dev": {"egress": {
|
||||||
"log": 1,
|
"log": 1,
|
||||||
"routes": [{"host": "x.example"}],
|
"routes": [{"host": "x.example"}],
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from bot_bottle.git_gate import (
|
|||||||
git_gate_render_hook,
|
git_gate_render_hook,
|
||||||
git_gate_upstreams_for_bottle,
|
git_gate_upstreams_for_bottle,
|
||||||
)
|
)
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from tests.fixtures import fixture_minimal, fixture_with_git
|
from tests.fixtures import fixture_minimal, fixture_with_git
|
||||||
|
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ class TestPrepare(unittest.TestCase):
|
|||||||
self.assertEqual(0o600, os.stat(upstream.known_hosts_file).st_mode & 0o777)
|
self.assertEqual(0o600, os.stat(upstream.known_hosts_file).st_mode & 0o777)
|
||||||
|
|
||||||
def test_prepare_skips_known_hosts_file_when_key_missing(self):
|
def test_prepare_skips_known_hosts_file_when_key_missing(self):
|
||||||
manifest = Manifest.from_json_obj({
|
manifest = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"git-gate": {"repos": {
|
"bottles": {"dev": {"git-gate": {"repos": {
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/didericis/foo.git",
|
"url": "ssh://git@github.com/didericis/foo.git",
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from bot_bottle.backend.macos_container import launch
|
from bot_bottle.backend.macos_container import launch
|
||||||
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
||||||
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
_MANIFEST = ManifestIndex.from_json_obj({
|
||||||
|
"bottles": {"dev": {}},
|
||||||
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
}).load_for_agent("demo")
|
||||||
|
|
||||||
|
|
||||||
def _plan(
|
def _plan(
|
||||||
@@ -67,6 +73,7 @@ def _plan(
|
|||||||
)
|
)
|
||||||
return cast(MacosContainerBottlePlan, SimpleNamespace(
|
return cast(MacosContainerBottlePlan, SimpleNamespace(
|
||||||
spec=SimpleNamespace(),
|
spec=SimpleNamespace(),
|
||||||
|
manifest=_MANIFEST,
|
||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
slug="dev-abc",
|
slug="dev-abc",
|
||||||
container_name="bot-bottle-dev-abc",
|
container_name="bot-bottle-dev-abc",
|
||||||
@@ -193,6 +200,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
plan = MacosContainerBottlePlan(
|
plan = MacosContainerBottlePlan(
|
||||||
spec=base.spec,
|
spec=base.spec,
|
||||||
|
manifest=base.manifest,
|
||||||
stage_dir=base.stage_dir,
|
stage_dir=base.stage_dir,
|
||||||
git_gate_plan=base.git_gate_plan,
|
git_gate_plan=base.git_gate_plan,
|
||||||
egress_plan=base.egress_plan,
|
egress_plan=base.egress_plan,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
"""Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047).
|
"""Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047).
|
||||||
|
|
||||||
An agent file may declare `git-gate.user` (name/email). At
|
An agent file may declare `git-gate.user` (name/email). At
|
||||||
`Manifest.bottle_for()` it overlays the referenced bottle's
|
`ManifestIndex.load_for_agent()` it overlays the referenced bottle's
|
||||||
`git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is
|
`git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is
|
||||||
rejected on agents. `Manifest.git_identity_summary()` reports the
|
rejected on agents. `Manifest.git_identity_summary()` reports the
|
||||||
effective identity with per-field `(agent)`/`(bottle)` provenance.
|
effective identity with per-field `(agent)`/`(bottle)` provenance.
|
||||||
|
|
||||||
The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`;
|
The `from_json_obj` path drives `Agent.from_dict` + the overlay in
|
||||||
a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the
|
load_for_agent; a temp-dir case locks the md loader (the `_AGENT_KEYS`
|
||||||
`git-gate` threading into `agent_dict`)."""
|
allow + the `git-gate` threading into `agent_dict`)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ import textwrap
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bot_bottle.manifest import ManifestError, Manifest
|
from bot_bottle.manifest import ManifestError, Manifest, ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
||||||
@@ -32,13 +32,28 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
|||||||
|
|
||||||
|
|
||||||
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
|
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
|
||||||
|
"""Build an index with one agent 'impl' and load it, returning a Manifest."""
|
||||||
bottle: dict = {} # type: ignore
|
bottle: dict = {} # type: ignore
|
||||||
if bottle_user is not None:
|
if bottle_user is not None:
|
||||||
bottle = {"git-gate": {"user": bottle_user}}
|
bottle = {"git-gate": {"user": bottle_user}}
|
||||||
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||||
if agent_git is not None:
|
if agent_git is not None:
|
||||||
agent["git-gate"] = agent_git
|
agent["git-gate"] = agent_git
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
|
"bottles": {"dev": bottle},
|
||||||
|
"agents": {"impl": agent},
|
||||||
|
}).load_for_agent("impl")
|
||||||
|
|
||||||
|
|
||||||
|
def _index(*, bottle_user: dict[str, object] | None = None, agent_git: dict[str, object] | None = None) -> ManifestIndex:
|
||||||
|
"""Build an index with one agent 'impl' without loading it."""
|
||||||
|
bottle: dict = {} # type: ignore
|
||||||
|
if bottle_user is not None:
|
||||||
|
bottle = {"git-gate": {"user": bottle_user}}
|
||||||
|
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||||
|
if agent_git is not None:
|
||||||
|
agent["git-gate"] = agent_git
|
||||||
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle},
|
"bottles": {"dev": bottle},
|
||||||
"agents": {"impl": agent},
|
"agents": {"impl": agent},
|
||||||
})
|
})
|
||||||
@@ -47,7 +62,7 @@ def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
|
|||||||
class TestAgentGitUserOverlay(unittest.TestCase):
|
class TestAgentGitUserOverlay(unittest.TestCase):
|
||||||
def test_agent_supplies_both_fields(self):
|
def test_agent_supplies_both_fields(self):
|
||||||
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||||
u = m.bottle_for("impl").git_user
|
u = m.bottle.git_user
|
||||||
self.assertEqual("a", u.name)
|
self.assertEqual("a", u.name)
|
||||||
self.assertEqual("a@b", u.email)
|
self.assertEqual("a@b", u.email)
|
||||||
|
|
||||||
@@ -56,7 +71,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
|||||||
bottle_user={"name": "B", "email": "b@c"},
|
bottle_user={"name": "B", "email": "b@c"},
|
||||||
agent_git={"user": {"name": "a"}},
|
agent_git={"user": {"name": "a"}},
|
||||||
)
|
)
|
||||||
u = m.bottle_for("impl").git_user
|
u = m.bottle.git_user
|
||||||
self.assertEqual("a", u.name) # agent wins
|
self.assertEqual("a", u.name) # agent wins
|
||||||
self.assertEqual("b@c", u.email) # bottle falls through
|
self.assertEqual("b@c", u.email) # bottle falls through
|
||||||
|
|
||||||
@@ -65,34 +80,40 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
|||||||
bottle_user={"name": "B", "email": "b@c"},
|
bottle_user={"name": "B", "email": "b@c"},
|
||||||
agent_git={"user": {"email": "a@b"}},
|
agent_git={"user": {"email": "a@b"}},
|
||||||
)
|
)
|
||||||
u = m.bottle_for("impl").git_user
|
u = m.bottle.git_user
|
||||||
self.assertEqual("B", u.name)
|
self.assertEqual("B", u.name)
|
||||||
self.assertEqual("a@b", u.email)
|
self.assertEqual("a@b", u.email)
|
||||||
|
|
||||||
def test_agent_identity_with_bottle_declaring_none(self):
|
def test_agent_identity_with_bottle_declaring_none(self):
|
||||||
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
idx = _index(agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||||
self.assertTrue(m.bottles["dev"].git_user.is_empty())
|
# Raw bottle has no git_user; loaded manifest has merged git_user from agent
|
||||||
self.assertFalse(m.bottle_for("impl").git_user.is_empty())
|
self.assertTrue(idx.bottles["dev"].git_user.is_empty())
|
||||||
|
m = idx.load_for_agent("impl")
|
||||||
|
self.assertFalse(m.bottle.git_user.is_empty())
|
||||||
|
|
||||||
def test_bottle_only_identity_preserved_when_agent_silent(self):
|
def test_bottle_only_identity_preserved_when_agent_silent(self):
|
||||||
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
|
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
|
||||||
u = m.bottle_for("impl").git_user
|
u = m.bottle.git_user
|
||||||
self.assertEqual("B", u.name)
|
self.assertEqual("B", u.name)
|
||||||
self.assertEqual("b@c", u.email)
|
self.assertEqual("b@c", u.email)
|
||||||
|
|
||||||
def test_bottle_for_returns_same_instance_when_no_overlay(self):
|
def test_no_overlay_uses_bottle_instance_directly(self):
|
||||||
m = _manifest(bottle_user={"name": "B"})
|
idx = _index(bottle_user={"name": "B"})
|
||||||
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
|
m = idx.load_for_agent("impl")
|
||||||
|
# Agent has no git_user — bottle instance should be the same object
|
||||||
|
self.assertIs(idx.bottles["dev"], m.bottle)
|
||||||
|
|
||||||
def test_bottle_for_returns_same_instance_when_overlay_is_noop(self):
|
def test_noop_overlay_uses_bottle_instance_directly(self):
|
||||||
m = _manifest(
|
idx = _index(
|
||||||
bottle_user={"name": "B", "email": "b@c"},
|
bottle_user={"name": "B", "email": "b@c"},
|
||||||
agent_git={"user": {"name": "B", "email": "b@c"}},
|
agent_git={"user": {"name": "B", "email": "b@c"}},
|
||||||
)
|
)
|
||||||
self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
|
m = idx.load_for_agent("impl")
|
||||||
|
# Agent git_user == bottle git_user — no replace needed
|
||||||
|
self.assertEqual(idx.bottles["dev"].git_user, m.bottle.git_user)
|
||||||
|
|
||||||
def test_other_bottle_fields_untouched_by_overlay(self):
|
def test_other_bottle_fields_untouched_by_overlay(self):
|
||||||
m = Manifest.from_json_obj({
|
idx = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {
|
"bottles": {"dev": {
|
||||||
"env": {"FOO": "bar"},
|
"env": {"FOO": "bar"},
|
||||||
"supervise": True,
|
"supervise": True,
|
||||||
@@ -103,7 +124,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
|||||||
"git-gate": {"user": {"name": "a"}},
|
"git-gate": {"user": {"name": "a"}},
|
||||||
}},
|
}},
|
||||||
})
|
})
|
||||||
b = m.bottle_for("impl")
|
b = idx.load_for_agent("impl").bottle
|
||||||
self.assertEqual("a", b.git_user.name)
|
self.assertEqual("a", b.git_user.name)
|
||||||
self.assertEqual({"FOO": "bar"}, dict(b.env))
|
self.assertEqual({"FOO": "bar"}, dict(b.env))
|
||||||
self.assertTrue(b.supervise)
|
self.assertTrue(b.supervise)
|
||||||
@@ -131,7 +152,7 @@ class TestGitIdentitySummary(unittest.TestCase):
|
|||||||
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"name=a (agent), email=a@b (agent)",
|
"name=a (agent), email=a@b (agent)",
|
||||||
m.git_identity_summary("impl"),
|
m.git_identity_summary(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_mixed_provenance(self):
|
def test_mixed_provenance(self):
|
||||||
@@ -141,19 +162,19 @@ class TestGitIdentitySummary(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"name=a (agent), email=b@c (bottle)",
|
"name=a (agent), email=b@c (bottle)",
|
||||||
m.git_identity_summary("impl"),
|
m.git_identity_summary(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_bottle_only(self):
|
def test_bottle_only(self):
|
||||||
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
|
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"name=B (bottle), email=b@c (bottle)",
|
"name=B (bottle), email=b@c (bottle)",
|
||||||
m.git_identity_summary("impl"),
|
m.git_identity_summary(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_none_when_unset_anywhere(self):
|
def test_none_when_unset_anywhere(self):
|
||||||
m = _manifest()
|
m = _manifest()
|
||||||
self.assertIsNone(m.git_identity_summary("impl"))
|
self.assertIsNone(m.git_identity_summary())
|
||||||
|
|
||||||
|
|
||||||
_BOTTLE_DEV = """
|
_BOTTLE_DEV = """
|
||||||
@@ -217,19 +238,26 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
|
|||||||
def test_md_agent_git_user_overlays_bottle(self):
|
def test_md_agent_git_user_overlays_bottle(self):
|
||||||
self._write("bottles/dev.md", _BOTTLE_DEV)
|
self._write("bottles/dev.md", _BOTTLE_DEV)
|
||||||
self._write("agents/impl.md", _AGENT_WITH_GIT)
|
self._write("agents/impl.md", _AGENT_WITH_GIT)
|
||||||
m = Manifest.resolve(str(self.home))
|
m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl")
|
||||||
u = m.bottle_for("impl").git_user
|
u = m.bottle.git_user
|
||||||
self.assertEqual("agent-name", u.name)
|
self.assertEqual("agent-name", u.name)
|
||||||
self.assertEqual("bottle@example.com", u.email)
|
self.assertEqual("bottle@example.com", u.email)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"name=agent-name (agent), email=bottle@example.com (bottle)",
|
"name=agent-name (agent), email=bottle@example.com (bottle)",
|
||||||
m.git_identity_summary("impl"),
|
m.git_identity_summary(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_md_agent_repos_dies(self):
|
def test_md_agent_repos_fails_at_preflight(self):
|
||||||
|
"""git-gate.repos on an agent is an error; resolve() still succeeds
|
||||||
|
so other agents remain accessible, but load_for_agent raises."""
|
||||||
self._write("bottles/dev.md", _BOTTLE_DEV)
|
self._write("bottles/dev.md", _BOTTLE_DEV)
|
||||||
self._write("agents/impl.md", _AGENT_WITH_REPOS)
|
self._write("agents/impl.md", _AGENT_WITH_REPOS)
|
||||||
msg = _error_message(Manifest.resolve, str(self.home))
|
from bot_bottle.manifest import ManifestError
|
||||||
|
names = ManifestIndex.resolve(str(self.home))
|
||||||
|
self.assertIn("impl", names.all_agent_names)
|
||||||
|
with self.assertRaises(ManifestError) as ctx:
|
||||||
|
names.load_for_agent("impl")
|
||||||
|
msg = str(ctx.exception)
|
||||||
self.assertIn("git-gate.repos", msg)
|
self.assertIn("git-gate.repos", msg)
|
||||||
self.assertIn("bottle-only", msg)
|
self.assertIn("bottle-only", msg)
|
||||||
|
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ partial `auth` is an error, auth omission means unauthenticated."""
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.manifest import ManifestError, Manifest
|
from bot_bottle.manifest import ManifestError, ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _bottle(routes): # type: ignore
|
def _bottle(routes): # type: ignore
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"egress": {"routes": routes}}},
|
"bottles": {"dev": {"egress": {"routes": routes}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
|
|
||||||
|
|
||||||
def _provider_bottle(provider, routes): # type: ignore
|
def _provider_bottle(provider, routes): # type: ignore
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"agent_provider": {"template": provider},
|
"agent_provider": {"template": provider},
|
||||||
@@ -32,7 +32,7 @@ def _provider_bottle(provider, routes): # type: ignore
|
|||||||
|
|
||||||
|
|
||||||
def _provider_config_bottle(agent_provider): # type: ignore
|
def _provider_config_bottle(agent_provider): # type: ignore
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"agent_provider": agent_provider}},
|
"bottles": {"dev": {"agent_provider": agent_provider}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
@@ -433,7 +433,7 @@ class TestRouteValidation(unittest.TestCase):
|
|||||||
self.assertEqual((), b.egress.routes)
|
self.assertEqual((), b.egress.routes)
|
||||||
|
|
||||||
def test_no_egress_block_means_empty(self):
|
def test_no_egress_block_means_empty(self):
|
||||||
b = Manifest.from_json_obj({
|
b = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
@@ -443,7 +443,7 @@ class TestRouteValidation(unittest.TestCase):
|
|||||||
class TestConfigShape(unittest.TestCase):
|
class TestConfigShape(unittest.TestCase):
|
||||||
def test_unknown_egress_key_rejected(self):
|
def test_unknown_egress_key_rejected(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj({
|
ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"egress": {"wat": []}}},
|
"bottles": {"dev": {"egress": {"wat": []}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "",
|
"agents": {"demo": {"skills": [], "prompt": "",
|
||||||
"bottle": "dev"}},
|
"bottle": "dev"}},
|
||||||
@@ -454,14 +454,14 @@ class TestConfigShape(unittest.TestCase):
|
|||||||
self.assertEqual(0, b.egress.Log)
|
self.assertEqual(0, b.egress.Log)
|
||||||
|
|
||||||
def test_log_level_1_accepted(self):
|
def test_log_level_1_accepted(self):
|
||||||
b = Manifest.from_json_obj({
|
b = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"egress": {"log": 1, "routes": []}}},
|
"bottles": {"dev": {"egress": {"log": 1, "routes": []}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
self.assertEqual(1, b.egress.Log)
|
self.assertEqual(1, b.egress.Log)
|
||||||
|
|
||||||
def test_log_level_2_accepted(self):
|
def test_log_level_2_accepted(self):
|
||||||
b = Manifest.from_json_obj({
|
b = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"egress": {"log": 2, "routes": []}}},
|
"bottles": {"dev": {"egress": {"log": 2, "routes": []}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
}).bottles["dev"]
|
}).bottles["dev"]
|
||||||
@@ -469,7 +469,7 @@ class TestConfigShape(unittest.TestCase):
|
|||||||
|
|
||||||
def test_log_invalid_level_rejected(self):
|
def test_log_invalid_level_rejected(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj({
|
ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"egress": {"log": 3}}},
|
"bottles": {"dev": {"egress": {"log": 3}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "",
|
"agents": {"demo": {"skills": [], "prompt": "",
|
||||||
"bottle": "dev"}},
|
"bottle": "dev"}},
|
||||||
@@ -477,7 +477,7 @@ class TestConfigShape(unittest.TestCase):
|
|||||||
|
|
||||||
def test_log_bool_rejected(self):
|
def test_log_bool_rejected(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj({
|
ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"egress": {"log": True}}},
|
"bottles": {"dev": {"egress": {"log": True}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "",
|
"agents": {"demo": {"skills": [], "prompt": "",
|
||||||
"bottle": "dev"}},
|
"bottle": "dev"}},
|
||||||
@@ -485,7 +485,7 @@ class TestConfigShape(unittest.TestCase):
|
|||||||
|
|
||||||
def test_log_string_rejected(self):
|
def test_log_string_rejected(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj({
|
ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"egress": {"log": "full"}}},
|
"bottles": {"dev": {"egress": {"log": "full"}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "",
|
"agents": {"demo": {"skills": [], "prompt": "",
|
||||||
"bottle": "dev"}},
|
"bottle": "dev"}},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.manifest import ManifestError, Manifest
|
from bot_bottle.manifest import ManifestError, ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
||||||
@@ -28,7 +28,7 @@ def _build(**bottles) -> Manifest: # type: ignore
|
|||||||
"""Build a manifest with the given bottles and one trivial agent
|
"""Build a manifest with the given bottles and one trivial agent
|
||||||
referencing the first bottle (so the manifest is valid)."""
|
referencing the first bottle (so the manifest is valid)."""
|
||||||
first = next(iter(bottles))
|
first = next(iter(bottles))
|
||||||
return Manifest.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": bottles,
|
"bottles": bottles,
|
||||||
"agents": {
|
"agents": {
|
||||||
"demo": {"skills": [], "prompt": "", "bottle": first},
|
"demo": {"skills": [], "prompt": "", "bottle": first},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.manifest import ManifestError, Manifest
|
from bot_bottle.manifest import ManifestError, ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _manifest(repos: dict) -> dict: # type: ignore
|
def _manifest(repos: dict) -> dict: # type: ignore
|
||||||
@@ -14,7 +14,7 @@ def _manifest(repos: dict) -> dict: # type: ignore
|
|||||||
|
|
||||||
class TestGitEntryParsing(unittest.TestCase):
|
class TestGitEntryParsing(unittest.TestCase):
|
||||||
def test_parses_minimal_entry(self):
|
def test_parses_minimal_entry(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"bot-bottle": {
|
"bot-bottle": {
|
||||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -30,7 +30,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
self.assertEqual("didericis/bot-bottle.git", e.UpstreamPath)
|
self.assertEqual("didericis/bot-bottle.git", e.UpstreamPath)
|
||||||
|
|
||||||
def test_default_port_is_22(self):
|
def test_default_port_is_22(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/didericis/foo.git",
|
"url": "ssh://git@github.com/didericis/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -41,7 +41,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
self.assertEqual("github.com", e.UpstreamHost)
|
self.assertEqual("github.com", e.UpstreamHost)
|
||||||
|
|
||||||
def test_host_key_optional(self):
|
def test_host_key_optional(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -50,7 +50,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey)
|
self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey)
|
||||||
|
|
||||||
def test_host_key_stored(self):
|
def test_host_key_stored(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -60,7 +60,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
self.assertEqual("ssh-ed25519 AAAA", m.bottles["dev"].git[0].KnownHostKey)
|
self.assertEqual("ssh-ed25519 AAAA", m.bottles["dev"].git[0].KnownHostKey)
|
||||||
|
|
||||||
def test_repo_name_becomes_Name(self):
|
def test_repo_name_becomes_Name(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"my-repo": {
|
"my-repo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -70,19 +70,19 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
|
|
||||||
def test_missing_url_dies(self):
|
def test_missing_url_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {"key": {"provider": "static", "path": "/dev/null"}},
|
"foo": {"key": {"provider": "static", "path": "/dev/null"}},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
def test_missing_key_block_dies(self):
|
def test_missing_key_block_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {"url": "ssh://git@github.com/foo.git"},
|
"foo": {"url": "ssh://git@github.com/foo.git"},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
def test_unknown_key_in_entry_dies(self):
|
def test_unknown_key_in_entry_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -92,7 +92,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
|
|
||||||
def test_non_ssh_url_dies(self):
|
def test_non_ssh_url_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "https://github.com/didericis/foo.git",
|
"url": "https://github.com/didericis/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -101,7 +101,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
|
|
||||||
def test_scp_style_url_dies(self):
|
def test_scp_style_url_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "git@github.com:didericis/foo.git",
|
"url": "git@github.com:didericis/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -110,7 +110,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
|
|
||||||
def test_url_without_user_dies(self):
|
def test_url_without_user_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://github.com/foo.git",
|
"url": "ssh://github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -119,7 +119,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
|
|
||||||
def test_url_without_path_dies(self):
|
def test_url_without_path_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com",
|
"url": "ssh://git@github.com",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -128,7 +128,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
|
|
||||||
def test_non_numeric_port_dies(self):
|
def test_non_numeric_port_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com:notaport/foo.git",
|
"url": "ssh://git@github.com:notaport/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -136,7 +136,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
def test_ip_literal_upstream(self):
|
def test_ip_literal_upstream(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"bot-bottle": {
|
"bot-bottle": {
|
||||||
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -152,7 +152,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
def test_two_repos_different_hosts_both_parsed(self):
|
def test_two_repos_different_hosts_both_parsed(self):
|
||||||
# Repo names come from dict keys; two distinct keys always produce
|
# Repo names come from dict keys; two distinct keys always produce
|
||||||
# two distinct entries (uniqueness is guaranteed at the YAML/dict level).
|
# two distinct entries (uniqueness is guaranteed at the YAML/dict level).
|
||||||
m = Manifest.from_json_obj({
|
m = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"git-gate": {"repos": {
|
"bottles": {"dev": {"git-gate": {"repos": {
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@a.example/x.git",
|
"url": "ssh://git@a.example/x.git",
|
||||||
@@ -170,7 +170,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_legacy_ssh_field_dies_with_hint(self):
|
def test_legacy_ssh_field_dies_with_hint(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj({
|
ManifestIndex.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"ssh": [{
|
"ssh": [{
|
||||||
@@ -187,7 +187,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_name_with_single_quote_dies(self):
|
def test_name_with_single_quote_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"o'reilly": {
|
"o'reilly": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -196,7 +196,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_name_with_space_dies(self):
|
def test_name_with_space_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"my repo": {
|
"my repo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -205,7 +205,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_name_with_semicolon_dies(self):
|
def test_name_with_semicolon_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo;bar": {
|
"foo;bar": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -214,7 +214,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_name_with_dollar_dies(self):
|
def test_name_with_dollar_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo$bar": {
|
"foo$bar": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -222,7 +222,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
def test_valid_name_with_dots_and_hyphens_accepted(self):
|
def test_valid_name_with_dots_and_hyphens_accepted(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"my.repo-name_1": {
|
"my.repo-name_1": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -233,7 +233,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
def test_legacy_git_key_dies_with_hint(self):
|
def test_legacy_git_key_dies_with_hint(self):
|
||||||
msg = ""
|
msg = ""
|
||||||
try:
|
try:
|
||||||
Manifest.from_json_obj({
|
ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"git": {"remotes": {}}}},
|
"bottles": {"dev": {"git": {"remotes": {}}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
@@ -247,7 +247,7 @@ class TestStaticKey(unittest.TestCase):
|
|||||||
"""git-gate.repos entries with key.provider = "static"."""
|
"""git-gate.repos entries with key.provider = "static"."""
|
||||||
|
|
||||||
def test_static_key_minimal(self):
|
def test_static_key_minimal(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"bot-bottle": {
|
"bot-bottle": {
|
||||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||||
"key": {"provider": "static", "path": "/home/user/.ssh/id_ed25519"},
|
"key": {"provider": "static", "path": "/home/user/.ssh/id_ed25519"},
|
||||||
@@ -260,7 +260,7 @@ class TestStaticKey(unittest.TestCase):
|
|||||||
self.assertEqual("/home/user/.ssh/id_ed25519", e.IdentityFile)
|
self.assertEqual("/home/user/.ssh/id_ed25519", e.IdentityFile)
|
||||||
|
|
||||||
def test_static_key_sets_identity_file_at_parse_time(self):
|
def test_static_key_sets_identity_file_at_parse_time(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
@@ -270,7 +270,7 @@ class TestStaticKey(unittest.TestCase):
|
|||||||
|
|
||||||
def test_static_key_missing_path_dies(self):
|
def test_static_key_missing_path_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static"},
|
"key": {"provider": "static"},
|
||||||
@@ -279,7 +279,7 @@ class TestStaticKey(unittest.TestCase):
|
|||||||
|
|
||||||
def test_static_key_unknown_field_dies(self):
|
def test_static_key_unknown_field_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null", "api_url": "x"},
|
"key": {"provider": "static", "path": "/dev/null", "api_url": "x"},
|
||||||
@@ -291,7 +291,7 @@ class TestGiteaKey(unittest.TestCase):
|
|||||||
"""git-gate.repos entries with key.provider = "gitea"."""
|
"""git-gate.repos entries with key.provider = "gitea"."""
|
||||||
|
|
||||||
def test_gitea_key_minimal(self):
|
def test_gitea_key_minimal(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"bot-bottle": {
|
"bot-bottle": {
|
||||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||||
"key": {
|
"key": {
|
||||||
@@ -308,7 +308,7 @@ class TestGiteaKey(unittest.TestCase):
|
|||||||
self.assertEqual("", e.IdentityFile)
|
self.assertEqual("", e.IdentityFile)
|
||||||
|
|
||||||
def test_gitea_key_with_api_url(self):
|
def test_gitea_key_with_api_url(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"repo": {
|
"repo": {
|
||||||
"url": "ssh://git@gitea.example.com/org/repo.git",
|
"url": "ssh://git@gitea.example.com/org/repo.git",
|
||||||
"key": {
|
"key": {
|
||||||
@@ -321,7 +321,7 @@ class TestGiteaKey(unittest.TestCase):
|
|||||||
self.assertEqual("https://gitea.example.com", m.bottles["dev"].git[0].Key.api_url)
|
self.assertEqual("https://gitea.example.com", m.bottles["dev"].git[0].Key.api_url)
|
||||||
|
|
||||||
def test_gitea_key_has_no_identity_file_at_parse_time(self):
|
def test_gitea_key_has_no_identity_file_at_parse_time(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/didericis/foo.git",
|
"url": "ssh://git@github.com/didericis/foo.git",
|
||||||
"key": {"provider": "gitea", "forge_token_env": "T"},
|
"key": {"provider": "gitea", "forge_token_env": "T"},
|
||||||
@@ -331,7 +331,7 @@ class TestGiteaKey(unittest.TestCase):
|
|||||||
|
|
||||||
def test_gitea_key_missing_forge_token_env_dies(self):
|
def test_gitea_key_missing_forge_token_env_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "gitea"},
|
"key": {"provider": "gitea"},
|
||||||
@@ -340,7 +340,7 @@ class TestGiteaKey(unittest.TestCase):
|
|||||||
|
|
||||||
def test_gitea_key_unknown_field_dies(self):
|
def test_gitea_key_unknown_field_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {
|
"key": {
|
||||||
@@ -357,7 +357,7 @@ class TestKeyBlockValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_missing_provider_dies(self):
|
def test_missing_provider_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"path": "/dev/null"},
|
"key": {"path": "/dev/null"},
|
||||||
@@ -366,7 +366,7 @@ class TestKeyBlockValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_unknown_provider_dies(self):
|
def test_unknown_provider_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "github"},
|
"key": {"provider": "github"},
|
||||||
@@ -375,14 +375,14 @@ class TestKeyBlockValidation(unittest.TestCase):
|
|||||||
|
|
||||||
def test_missing_key_block_dies(self):
|
def test_missing_key_block_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
ManifestIndex.from_json_obj(_manifest({
|
||||||
"foo": {"url": "ssh://git@github.com/foo.git"},
|
"foo": {"url": "ssh://git@github.com/foo.git"},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
class TestEmptyGitGateField(unittest.TestCase):
|
class TestEmptyGitGateField(unittest.TestCase):
|
||||||
def test_no_git_gate_field_yields_empty_tuple(self):
|
def test_no_git_gate_field_yields_empty_tuple(self):
|
||||||
m = Manifest.from_json_obj({
|
m = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
@@ -390,13 +390,13 @@ class TestEmptyGitGateField(unittest.TestCase):
|
|||||||
|
|
||||||
def test_git_gate_object_type_required(self):
|
def test_git_gate_object_type_required(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj({
|
ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"git-gate": "not-a-dict"}},
|
"bottles": {"dev": {"git-gate": "not-a-dict"}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_empty_repos_yields_empty_tuple(self):
|
def test_empty_repos_yields_empty_tuple(self):
|
||||||
m = Manifest.from_json_obj({
|
m = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"git-gate": {"repos": {}}}},
|
"bottles": {"dev": {"git-gate": {"repos": {}}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from bot_bottle.manifest import ManifestError, ManifestGitUser, Manifest
|
from bot_bottle.manifest import ManifestError, ManifestGitUser, ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
||||||
@@ -23,7 +23,7 @@ def _manifest(git_user): # type: ignore
|
|||||||
|
|
||||||
class TestGitUserParsing(unittest.TestCase):
|
class TestGitUserParsing(unittest.TestCase):
|
||||||
def test_parses_both_fields(self):
|
def test_parses_both_fields(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = ManifestIndex.from_json_obj(_manifest({
|
||||||
"name": "Eric Bauerfeld",
|
"name": "Eric Bauerfeld",
|
||||||
"email": "eric+claude@dideric.is",
|
"email": "eric+claude@dideric.is",
|
||||||
}))
|
}))
|
||||||
@@ -33,13 +33,13 @@ class TestGitUserParsing(unittest.TestCase):
|
|||||||
self.assertFalse(u.is_empty())
|
self.assertFalse(u.is_empty())
|
||||||
|
|
||||||
def test_name_only(self):
|
def test_name_only(self):
|
||||||
m = Manifest.from_json_obj(_manifest({"name": "Bot"}))
|
m = ManifestIndex.from_json_obj(_manifest({"name": "Bot"}))
|
||||||
u = m.bottles["dev"].git_user
|
u = m.bottles["dev"].git_user
|
||||||
self.assertEqual("Bot", u.name)
|
self.assertEqual("Bot", u.name)
|
||||||
self.assertEqual("", u.email)
|
self.assertEqual("", u.email)
|
||||||
|
|
||||||
def test_email_only(self):
|
def test_email_only(self):
|
||||||
m = Manifest.from_json_obj(_manifest({"email": "bot@example.com"}))
|
m = ManifestIndex.from_json_obj(_manifest({"email": "bot@example.com"}))
|
||||||
u = m.bottles["dev"].git_user
|
u = m.bottles["dev"].git_user
|
||||||
self.assertEqual("", u.name)
|
self.assertEqual("", u.name)
|
||||||
self.assertEqual("bot@example.com", u.email)
|
self.assertEqual("bot@example.com", u.email)
|
||||||
@@ -47,7 +47,7 @@ class TestGitUserParsing(unittest.TestCase):
|
|||||||
def test_omitted_defaults_to_empty(self):
|
def test_omitted_defaults_to_empty(self):
|
||||||
# No git.user block at all → empty GitUser, is_empty True →
|
# No git.user block at all → empty GitUser, is_empty True →
|
||||||
# provisioner skips the `git config` step entirely.
|
# provisioner skips the `git config` step entirely.
|
||||||
m = Manifest.from_json_obj({
|
m = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
@@ -59,13 +59,13 @@ class TestGitUserParsing(unittest.TestCase):
|
|||||||
# / half-finished edit; fail loudly rather than silently
|
# / half-finished edit; fail loudly rather than silently
|
||||||
# no-op (the operator clearly meant to configure something).
|
# no-op (the operator clearly meant to configure something).
|
||||||
msg = _error_message(
|
msg = _error_message(
|
||||||
Manifest.from_json_obj, _manifest({"name": "", "email": ""}),
|
ManifestIndex.from_json_obj, _manifest({"name": "", "email": ""}),
|
||||||
)
|
)
|
||||||
self.assertIn("neither name nor email", msg)
|
self.assertIn("neither name nor email", msg)
|
||||||
|
|
||||||
def test_unknown_key_dies(self):
|
def test_unknown_key_dies(self):
|
||||||
msg = _error_message(
|
msg = _error_message(
|
||||||
Manifest.from_json_obj,
|
ManifestIndex.from_json_obj,
|
||||||
_manifest({"name": "Bot", "username": "bot"}),
|
_manifest({"name": "Bot", "username": "bot"}),
|
||||||
)
|
)
|
||||||
self.assertIn("unknown key", msg)
|
self.assertIn("unknown key", msg)
|
||||||
@@ -73,19 +73,19 @@ class TestGitUserParsing(unittest.TestCase):
|
|||||||
|
|
||||||
def test_non_string_name_dies(self):
|
def test_non_string_name_dies(self):
|
||||||
msg = _error_message(
|
msg = _error_message(
|
||||||
Manifest.from_json_obj, _manifest({"name": 42}),
|
ManifestIndex.from_json_obj, _manifest({"name": 42}),
|
||||||
)
|
)
|
||||||
self.assertIn("git-gate.user.name must be a string", msg)
|
self.assertIn("git-gate.user.name must be a string", msg)
|
||||||
|
|
||||||
def test_non_string_email_dies(self):
|
def test_non_string_email_dies(self):
|
||||||
msg = _error_message(
|
msg = _error_message(
|
||||||
Manifest.from_json_obj, _manifest({"email": ["x@y.z"]}),
|
ManifestIndex.from_json_obj, _manifest({"email": ["x@y.z"]}),
|
||||||
)
|
)
|
||||||
self.assertIn("git-gate.user.email must be a string", msg)
|
self.assertIn("git-gate.user.email must be a string", msg)
|
||||||
|
|
||||||
def test_legacy_top_level_git_user_dies(self):
|
def test_legacy_top_level_git_user_dies(self):
|
||||||
msg = _error_message(
|
msg = _error_message(
|
||||||
Manifest.from_json_obj,
|
ManifestIndex.from_json_obj,
|
||||||
{
|
{
|
||||||
"bottles": {"dev": {"git_user": {"name": "Bot"}}},
|
"bottles": {"dev": {"git_user": {"name": "Bot"}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import textwrap
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from bot_bottle.manifest import ManifestError, Manifest
|
from bot_bottle.manifest import ManifestError, ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _write(p: Path, text: str) -> None:
|
def _write(p: Path, text: str) -> None:
|
||||||
@@ -45,7 +45,7 @@ _AGENT_IMPL = """
|
|||||||
|
|
||||||
|
|
||||||
class _ResolveCase(unittest.TestCase):
|
class _ResolveCase(unittest.TestCase):
|
||||||
"""Drives `Manifest.resolve(cwd)` against a temp $HOME and a
|
"""Drives `ManifestIndex.resolve(cwd)` against a temp $HOME and a
|
||||||
temp cwd. Subclasses lay down fixture files in setUp."""
|
temp cwd. Subclasses lay down fixture files in setUp."""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -71,20 +71,19 @@ class _ResolveCase(unittest.TestCase):
|
|||||||
def cwd_cb(self) -> Path:
|
def cwd_cb(self) -> Path:
|
||||||
return self.cwd_root / ".bot-bottle"
|
return self.cwd_root / ".bot-bottle"
|
||||||
|
|
||||||
def resolve(self) -> Manifest:
|
def resolve(self) -> ManifestIndex:
|
||||||
return Manifest.resolve(str(self.cwd_root))
|
return ManifestIndex.resolve(str(self.cwd_root))
|
||||||
|
|
||||||
|
|
||||||
class TestBottleFileParses(_ResolveCase):
|
class TestBottleFileParses(_ResolveCase):
|
||||||
"""SC #1: a bottle file under $HOME/.bot-bottle/bottles/
|
"""SC #1: a bottle file under $HOME/.bot-bottle/bottles/
|
||||||
parses into the expected Bottle shape."""
|
parses into the expected Bottle shape via load_for_agent."""
|
||||||
|
|
||||||
def test_loads(self):
|
def test_loads(self):
|
||||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
||||||
m = self.resolve()
|
m = self.resolve().load_for_agent("implementer")
|
||||||
self.assertIn("dev", m.bottles)
|
routes = m.bottle.egress.routes
|
||||||
routes = m.bottles["dev"].egress.routes
|
|
||||||
self.assertEqual(2, len(routes))
|
self.assertEqual(2, len(routes))
|
||||||
self.assertEqual("api.anthropic.com", routes[0].Host)
|
self.assertEqual("api.anthropic.com", routes[0].Host)
|
||||||
self.assertEqual("Bearer", routes[0].AuthScheme)
|
self.assertEqual("Bearer", routes[0].AuthScheme)
|
||||||
@@ -94,14 +93,14 @@ class TestBottleFileParses(_ResolveCase):
|
|||||||
|
|
||||||
class TestAgentFileParses(_ResolveCase):
|
class TestAgentFileParses(_ResolveCase):
|
||||||
"""SC #2: an agent file under $HOME/.bot-bottle/agents/
|
"""SC #2: an agent file under $HOME/.bot-bottle/agents/
|
||||||
parses, the body becomes the prompt, the frontmatter fields
|
parses via load_for_agent; the body becomes the prompt, the
|
||||||
map to Agent fields."""
|
frontmatter fields map to Agent fields."""
|
||||||
|
|
||||||
def test_loads(self):
|
def test_loads(self):
|
||||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
||||||
m = self.resolve()
|
m = self.resolve().load_for_agent("implementer")
|
||||||
a = m.agents["implementer"]
|
a = m.agent
|
||||||
self.assertEqual("dev", a.bottle)
|
self.assertEqual("dev", a.bottle)
|
||||||
self.assertEqual(("init-prd",), a.skills)
|
self.assertEqual(("init-prd",), a.skills)
|
||||||
# Body became the prompt; whitespace stripped.
|
# Body became the prompt; whitespace stripped.
|
||||||
@@ -128,10 +127,10 @@ class TestCwdAgentOverridesHome(_ResolveCase):
|
|||||||
CWD-OVERRIDE-PROMPT
|
CWD-OVERRIDE-PROMPT
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
m = self.resolve()
|
m = self.resolve().load_for_agent("implementer")
|
||||||
self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt)
|
self.assertIn("CWD-OVERRIDE-PROMPT", m.agent.prompt)
|
||||||
# Home bottle still present
|
# Home bottle still present with its two egress routes
|
||||||
self.assertEqual(2, len(m.bottles["dev"].egress.routes))
|
self.assertEqual(2, len(m.bottle.egress.routes))
|
||||||
|
|
||||||
|
|
||||||
class TestCwdBottlesIgnored(_ResolveCase):
|
class TestCwdBottlesIgnored(_ResolveCase):
|
||||||
@@ -155,11 +154,11 @@ class TestCwdBottlesIgnored(_ResolveCase):
|
|||||||
---
|
---
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
m = self.resolve()
|
m = self.resolve().load_for_agent("implementer")
|
||||||
# Home value wins because cwd bottles are ignored entirely.
|
# Home value wins because cwd bottles are ignored entirely.
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"api.anthropic.com",
|
"api.anthropic.com",
|
||||||
m.bottles["dev"].egress.routes[0].Host,
|
m.bottle.egress.routes[0].Host,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -176,12 +175,12 @@ class TestStdlibOnly(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestExistingFromJsonObjStillWorks(unittest.TestCase):
|
class TestExistingFromJsonObjStillWorks(unittest.TestCase):
|
||||||
"""SC #6: `Manifest.from_json_obj` continues to work as a
|
"""SC #6: `ManifestIndex.from_json_obj` continues to work as a
|
||||||
programmatic entry point even though disk loading moved to the
|
programmatic entry point even though disk loading moved to the
|
||||||
MD layout."""
|
MD layout."""
|
||||||
|
|
||||||
def test_from_json_obj(self):
|
def test_from_json_obj(self):
|
||||||
m = Manifest.from_json_obj({
|
m = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "hi",
|
"agents": {"demo": {"skills": [], "prompt": "hi",
|
||||||
"bottle": "dev"}},
|
"bottle": "dev"}},
|
||||||
@@ -215,9 +214,9 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase):
|
|||||||
Agent prompt body.
|
Agent prompt body.
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
m = self.resolve()
|
m = self.resolve().load_for_agent("implementer")
|
||||||
self.assertEqual("dev", m.agents["implementer"].bottle)
|
self.assertEqual("dev", m.agent.bottle)
|
||||||
self.assertEqual(("init-prd",), m.agents["implementer"].skills)
|
self.assertEqual(("init-prd",), m.agent.skills)
|
||||||
|
|
||||||
|
|
||||||
class TestManifestEntryPointParity(_ResolveCase):
|
class TestManifestEntryPointParity(_ResolveCase):
|
||||||
@@ -228,8 +227,8 @@ class TestManifestEntryPointParity(_ResolveCase):
|
|||||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
||||||
|
|
||||||
md_manifest = self.resolve()
|
md_manifest = self.resolve().load_for_agent("implementer")
|
||||||
json_manifest = Manifest.from_json_obj({
|
json_index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"egress": {
|
"egress": {
|
||||||
@@ -256,17 +255,17 @@ class TestManifestEntryPointParity(_ResolveCase):
|
|||||||
})
|
})
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
md_manifest.agents["implementer"],
|
md_manifest.agent,
|
||||||
json_manifest.agents["implementer"],
|
json_index.agents["implementer"],
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
md_manifest.bottles["dev"].egress.routes,
|
md_manifest.bottle.egress.routes,
|
||||||
json_manifest.bottles["dev"].egress.routes,
|
json_index.bottles["dev"].egress.routes,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_json_agent_rejects_unknown_keys(self):
|
def test_json_agent_rejects_unknown_keys(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj({
|
ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
"agents": {
|
"agents": {
|
||||||
"implementer": {
|
"implementer": {
|
||||||
@@ -277,7 +276,7 @@ class TestManifestEntryPointParity(_ResolveCase):
|
|||||||
})
|
})
|
||||||
|
|
||||||
def test_json_agent_accepts_claude_code_passthrough_keys(self):
|
def test_json_agent_accepts_claude_code_passthrough_keys(self):
|
||||||
manifest = Manifest.from_json_obj({
|
index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
"agents": {
|
"agents": {
|
||||||
"implementer": {
|
"implementer": {
|
||||||
@@ -291,37 +290,51 @@ class TestManifestEntryPointParity(_ResolveCase):
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
self.assertEqual("dev", manifest.agents["implementer"].bottle)
|
self.assertEqual("dev", index.agents["implementer"].bottle)
|
||||||
|
|
||||||
|
|
||||||
class TestUnknownAgentKeyDies(_ResolveCase):
|
class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase):
|
||||||
"""A typo'd / unknown frontmatter key on an agent file dies
|
"""A typo'd / unknown frontmatter key on an agent file does NOT crash
|
||||||
rather than silently ignoring."""
|
resolve(). The agent appears in all_agent_names for the selector.
|
||||||
|
The error surfaces only when load_for_agent is called for that agent."""
|
||||||
|
|
||||||
def test_dies(self):
|
def test_resolve_succeeds_despite_broken_agent(self):
|
||||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
_write(
|
_write(
|
||||||
self.home_cb / "agents" / "implementer.md",
|
self.home_cb / "agents" / "bad.md",
|
||||||
"""
|
"""
|
||||||
---
|
---
|
||||||
bottle: dev
|
bottle: dev
|
||||||
skillz: [init-prd]
|
skillz: [init-prd]
|
||||||
---
|
---
|
||||||
|
|
||||||
...
|
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
with self.assertRaises(ManifestError):
|
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
||||||
self.resolve()
|
m = self.resolve()
|
||||||
|
# Resolve itself does not raise; broken agent appears in the name list.
|
||||||
|
self.assertIn("bad", m.all_agent_names)
|
||||||
|
self.assertIn("implementer", m.all_agent_names)
|
||||||
|
|
||||||
|
def test_load_for_agent_raises_for_broken_agent(self):
|
||||||
class TestUnknownBottleKeyDies(_ResolveCase):
|
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
"""A typo'd / unknown frontmatter key on a bottle file dies
|
|
||||||
rather than silently ignoring."""
|
|
||||||
|
|
||||||
def test_dies(self):
|
|
||||||
_write(
|
_write(
|
||||||
self.home_cb / "bottles" / "dev.md",
|
self.home_cb / "agents" / "bad.md",
|
||||||
|
"""
|
||||||
|
---
|
||||||
|
bottle: dev
|
||||||
|
skillz: [init-prd]
|
||||||
|
---
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
m = self.resolve()
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
m.load_for_agent("bad")
|
||||||
|
|
||||||
|
def test_broken_bottle_only_fails_at_preflight(self):
|
||||||
|
"""A broken bottle does not crash resolve; only load_for_agent for
|
||||||
|
an agent that references it raises. Unrelated agents still work."""
|
||||||
|
_write(
|
||||||
|
self.home_cb / "bottles" / "bad.md",
|
||||||
"""
|
"""
|
||||||
---
|
---
|
||||||
credproxy:
|
credproxy:
|
||||||
@@ -329,9 +342,26 @@ class TestUnknownBottleKeyDies(_ResolveCase):
|
|||||||
---
|
---
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
||||||
|
_write(
|
||||||
|
self.home_cb / "agents" / "broken-agent.md",
|
||||||
|
"""
|
||||||
|
---
|
||||||
|
bottle: bad
|
||||||
|
---
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
m = self.resolve()
|
||||||
|
# Both agents appear in the name list at resolve time.
|
||||||
|
self.assertIn("implementer", m.all_agent_names)
|
||||||
|
self.assertIn("broken-agent", m.all_agent_names)
|
||||||
|
# Valid agent loads fine.
|
||||||
|
full = m.load_for_agent("implementer")
|
||||||
|
self.assertEqual("dev", full.agent.bottle)
|
||||||
|
# Broken bottle's agent raises at preflight.
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
self.resolve()
|
m.load_for_agent("broken-agent")
|
||||||
|
|
||||||
|
|
||||||
class TestStaleJsonDies(_ResolveCase):
|
class TestStaleJsonDies(_ResolveCase):
|
||||||
@@ -354,16 +384,16 @@ class TestNoManifestDies(_ResolveCase):
|
|||||||
self.resolve()
|
self.resolve()
|
||||||
|
|
||||||
def test_missing_ok_returns_empty_manifest(self):
|
def test_missing_ok_returns_empty_manifest(self):
|
||||||
m = Manifest.resolve(str(self.cwd_root), missing_ok=True)
|
m = ManifestIndex.resolve(str(self.cwd_root), missing_ok=True)
|
||||||
self.assertEqual({}, dict(m.bottles))
|
self.assertEqual({}, dict(m.bottles))
|
||||||
self.assertEqual({}, dict(m.agents))
|
self.assertEqual({}, dict(m.agents))
|
||||||
|
|
||||||
|
|
||||||
class TestUnknownBottleReferenceDies(_ResolveCase):
|
class TestUnknownBottleReferenceFailsAtPreflight(_ResolveCase):
|
||||||
"""An agent file naming a bottle that doesn't exist on disk
|
"""An agent file naming a non-existent bottle appears in all_agent_names
|
||||||
dies with the existing "bottle not defined" error."""
|
at resolve time; the error only surfaces when load_for_agent is called."""
|
||||||
|
|
||||||
def test_dies(self):
|
def test_stray_bottle_reference_fails_at_preflight(self):
|
||||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||||
_write(
|
_write(
|
||||||
self.home_cb / "agents" / "stray.md",
|
self.home_cb / "agents" / "stray.md",
|
||||||
@@ -373,8 +403,17 @@ class TestUnknownBottleReferenceDies(_ResolveCase):
|
|||||||
---
|
---
|
||||||
""",
|
""",
|
||||||
)
|
)
|
||||||
|
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
||||||
|
m = self.resolve()
|
||||||
|
# Both names visible at resolve time.
|
||||||
|
self.assertIn("stray", m.all_agent_names)
|
||||||
|
self.assertIn("implementer", m.all_agent_names)
|
||||||
|
# Valid agent loads fine.
|
||||||
|
full = m.load_for_agent("implementer")
|
||||||
|
self.assertEqual("dev", full.agent.bottle)
|
||||||
|
# Stray agent fails at preflight.
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
self.resolve()
|
m.load_for_agent("stray")
|
||||||
|
|
||||||
|
|
||||||
class TestFilenameValidation(_ResolveCase):
|
class TestFilenameValidation(_ResolveCase):
|
||||||
@@ -388,10 +427,6 @@ class TestFilenameValidation(_ResolveCase):
|
|||||||
# This file should be skipped — capital letters not allowed.
|
# This file should be skipped — capital letters not allowed.
|
||||||
_write(self.home_cb / "agents" / "BadName.md", _AGENT_IMPL)
|
_write(self.home_cb / "agents" / "BadName.md", _AGENT_IMPL)
|
||||||
m = self.resolve()
|
m = self.resolve()
|
||||||
self.assertIn("implementer", m.agents)
|
self.assertIn("implementer", m.all_agent_names)
|
||||||
self.assertNotIn("BadName", m.agents)
|
self.assertNotIn("BadName", m.all_agent_names)
|
||||||
self.assertNotIn("badname", m.agents)
|
self.assertNotIn("badname", m.all_agent_names)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ silently ignoring."""
|
|||||||
import unittest
|
import unittest
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from bot_bottle.manifest import ManifestError, ManifestBottle, Manifest
|
from bot_bottle.manifest import ManifestError, ManifestBottle, ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _manifest_with_runtime(value: object) -> dict[str, Any]:
|
def _manifest_with_runtime(value: object) -> dict[str, Any]:
|
||||||
@@ -19,7 +19,7 @@ def _manifest_with_runtime(value: object) -> dict[str, Any]:
|
|||||||
|
|
||||||
class TestManifestRuntimeRemoved(unittest.TestCase):
|
class TestManifestRuntimeRemoved(unittest.TestCase):
|
||||||
def test_loads_when_runtime_absent(self):
|
def test_loads_when_runtime_absent(self):
|
||||||
m = Manifest.from_json_obj({
|
m = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {}},
|
"bottles": {"dev": {}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
@@ -32,7 +32,7 @@ class TestManifestRuntimeRemoved(unittest.TestCase):
|
|||||||
for value in ("runsc", "runc", "kata-runtime", "", 42, None):
|
for value in ("runsc", "runc", "kata-runtime", "", 42, None):
|
||||||
with self.subTest(value=value):
|
with self.subTest(value=value):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest_with_runtime(value))
|
ManifestIndex.from_json_obj(_manifest_with_runtime(value))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -19,19 +19,18 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
|||||||
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
|
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
|
||||||
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 Manifest
|
from bot_bottle.manifest import Manifest, ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
def _manifest() -> Manifest:
|
_INDEX = ManifestIndex.from_json_obj({
|
||||||
return Manifest.from_json_obj({
|
"bottles": {"dev": {}},
|
||||||
"bottles": {"dev": {}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
})
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _spec(manifest: Manifest, tmp: str) -> BottleSpec:
|
def _spec(index: ManifestIndex, tmp: str) -> BottleSpec:
|
||||||
return BottleSpec(
|
return BottleSpec(
|
||||||
manifest=manifest,
|
manifest=index,
|
||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=False,
|
copy_cwd=False,
|
||||||
user_cwd=tmp,
|
user_cwd=tmp,
|
||||||
@@ -92,10 +91,11 @@ def _agent_provision(tmp: str) -> AgentProvisionPlan:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
|
def _docker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> DockerBottlePlan:
|
||||||
stage = Path(tmp)
|
stage = Path(tmp)
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=stage,
|
stage_dir=stage,
|
||||||
git_gate_plan=_git_gate_plan(tmp),
|
git_gate_plan=_git_gate_plan(tmp),
|
||||||
egress_plan=_egress_plan(tmp),
|
egress_plan=_egress_plan(tmp),
|
||||||
@@ -107,10 +107,11 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
|
def _smolmachines_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> SmolmachinesBottlePlan:
|
||||||
stage = Path(tmp)
|
stage = Path(tmp)
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=stage,
|
stage_dir=stage,
|
||||||
git_gate_plan=_git_gate_plan(tmp),
|
git_gate_plan=_git_gate_plan(tmp),
|
||||||
egress_plan=_egress_plan(tmp),
|
egress_plan=_egress_plan(tmp),
|
||||||
@@ -140,10 +141,10 @@ class TestGitGatePrintParity(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
|
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
|
||||||
manifest = _manifest()
|
manifest = _INDEX.load_for_agent("demo")
|
||||||
spec = _spec(manifest, self._tmp)
|
spec = _spec(_INDEX, self._tmp)
|
||||||
self._docker_lines = _capture_print(_docker_plan(spec, self._tmp))
|
self._docker_lines = _capture_print(_docker_plan(spec, manifest, self._tmp))
|
||||||
self._smol_lines = _capture_print(_smolmachines_plan(spec, self._tmp))
|
self._smol_lines = _capture_print(_smolmachines_plan(spec, manifest, self._tmp))
|
||||||
|
|
||||||
def _git_gate_lines(self, lines: list[str]) -> list[str]:
|
def _git_gate_lines(self, lines: list[str]) -> list[str]:
|
||||||
return [ln for ln in lines if "git gate" in ln]
|
return [ln for ln in lines if "git gate" in ln]
|
||||||
@@ -170,10 +171,10 @@ class TestEgressPrintParity(unittest.TestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
|
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
|
||||||
manifest = _manifest()
|
manifest = _INDEX.load_for_agent("demo")
|
||||||
spec = _spec(manifest, self._tmp)
|
spec = _spec(_INDEX, self._tmp)
|
||||||
self._docker_lines = _capture_print(_docker_plan(spec, self._tmp))
|
self._docker_lines = _capture_print(_docker_plan(spec, manifest, self._tmp))
|
||||||
self._smol_lines = _capture_print(_smolmachines_plan(spec, self._tmp))
|
self._smol_lines = _capture_print(_smolmachines_plan(spec, manifest, self._tmp))
|
||||||
|
|
||||||
def _egress_section(self, lines: list[str]) -> list[str]:
|
def _egress_section(self, lines: list[str]) -> list[str]:
|
||||||
"""Return lines from the egress label through the last route entry.
|
"""Return lines from the egress label through the last route entry.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from bot_bottle.git_gate import (
|
|||||||
GIT_GATE_HOSTNAME,
|
GIT_GATE_HOSTNAME,
|
||||||
git_gate_render_gitconfig,
|
git_gate_render_gitconfig,
|
||||||
)
|
)
|
||||||
from bot_bottle.manifest import Manifest
|
from bot_bottle.manifest import ManifestIndex
|
||||||
from tests.fixtures import fixture_minimal, fixture_with_git
|
from tests.fixtures import fixture_minimal, fixture_with_git
|
||||||
|
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
|
|||||||
def test_ip_upstream_emits_single_insteadof(self):
|
def test_ip_upstream_emits_single_insteadof(self):
|
||||||
# In the new format the dict key is the repo name, not a host
|
# In the new format the dict key is the repo name, not a host
|
||||||
# alias, so there is only one insteadOf rule — for the IP URL.
|
# alias, so there is only one insteadOf rule — for the IP URL.
|
||||||
m = Manifest.from_json_obj({
|
m = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {"git-gate": {"repos": {
|
"bottles": {"dev": {"git-gate": {"repos": {
|
||||||
"bot-bottle": {
|
"bot-bottle": {
|
||||||
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
||||||
|
|||||||
@@ -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 ManifestGitEntry, ManifestKeyConfig, Manifest
|
from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, ManifestIndex
|
||||||
from bot_bottle.supervise import SupervisePlan
|
from bot_bottle.supervise import SupervisePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ def _plan(
|
|||||||
bottle_json["git-gate"] = git_gate_json
|
bottle_json["git-gate"] = git_gate_json
|
||||||
if supervise:
|
if supervise:
|
||||||
bottle_json["supervise"] = True
|
bottle_json["supervise"] = True
|
||||||
manifest = Manifest.from_json_obj({
|
index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle_json},
|
"bottles": {"dev": bottle_json},
|
||||||
"agents": {
|
"agents": {
|
||||||
"demo": {
|
"demo": {
|
||||||
@@ -120,8 +120,9 @@ def _plan(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
manifest = index.load_for_agent("demo")
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=manifest,
|
manifest=index,
|
||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
copy_cwd=copy_cwd,
|
copy_cwd=copy_cwd,
|
||||||
user_cwd=user_cwd,
|
user_cwd=user_cwd,
|
||||||
@@ -135,6 +136,7 @@ def _plan(
|
|||||||
)
|
)
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
|
manifest=manifest,
|
||||||
stage_dir=stage_dir or Path("/tmp/stage"),
|
stage_dir=stage_dir or Path("/tmp/stage"),
|
||||||
slug="demo-abc12",
|
slug="demo-abc12",
|
||||||
bundle_subnet="192.168.50.0/24",
|
bundle_subnet="192.168.50.0/24",
|
||||||
|
|||||||
Reference in New Issue
Block a user