refactor(manifest): split Manifest into ManifestIndex + Manifest single-value type

Manifest now holds exactly one agent and one effective bottle (with
git_user overlay already applied). The old multi-agent/bottle
collection is renamed ManifestIndex. BottleSpec.manifest starts as
ManifestIndex from the CLI and becomes Manifest after _validate()
calls load_for_agent(); all provisioning code downstream reads
spec.manifest.agent / spec.manifest.bottle instead of indexing by name.
This commit is contained in:
2026-06-23 00:56:30 +00:00
committed by didericis
parent 468ab8c290
commit 294a6ed023
41 changed files with 330 additions and 308 deletions
+1 -1
View File
@@ -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.spec.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)
+14 -13
View File
@@ -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 | Manifest
agent_name: str agent_name: str
copy_cwd: bool copy_cwd: bool
user_cwd: str user_cwd: str
@@ -112,9 +112,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 = spec.manifest # type: ignore[assignment]
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 +131,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}")
@@ -293,11 +293,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
self._preflight() self._preflight()
manifest = spec.manifest manifest = spec.manifest # type: ignore[assignment]
manifest_bottle = manifest.bottle_for(spec.agent_name) manifest_bottle = manifest.bottle
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)
@@ -364,9 +364,9 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
selected agent. Subclasses with additional preconditions should selected agent. Subclasses with additional preconditions should
override and call `super()._validate(spec)` first, using the override and call `super()._validate(spec)` first, using the
returned spec for further checks.""" returned spec for further checks."""
manifest = spec.manifest.load_for_agent(spec.agent_name) manifest = spec.manifest.load_for_agent(spec.agent_name) # type: ignore[union-attr]
spec = replace(spec, manifest=manifest) spec = replace(spec, manifest=manifest)
agent = manifest.agents[spec.agent_name] agent = manifest.agent
self._validate_skills(agent.skills) self._validate_skills(agent.skills)
self._validate_agent_provider_dockerfile(spec) self._validate_agent_provider_dockerfile(spec)
return spec return spec
@@ -384,7 +384,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
) )
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None: def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
bottle = spec.manifest.bottle_for(spec.agent_name) manifest = spec.manifest # type: ignore[assignment]
bottle = manifest.bottle
dockerfile = bottle.agent_provider.dockerfile dockerfile = bottle.agent_provider.dockerfile
if not dockerfile: if not dockerfile:
return return
@@ -394,7 +395,7 @@ 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
+1 -1
View File
@@ -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.spec.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:
+1 -1
View File
@@ -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.spec.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:
+2 -2
View File
@@ -69,8 +69,8 @@ def write_launch_metadata(
def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]: def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> 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 manifest = spec.manifest # type: ignore[assignment]
agent = manifest.agents[spec.agent_name] agent = manifest.agent
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"
+1 -1
View File
@@ -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.spec.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
+5 -5
View File
@@ -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,12 +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)
names = Manifest.resolve(USER_CWD) names = ManifestIndex.resolve(USER_CWD)
names.require_agent(args.name) names.require_agent(args.name)
manifest = names.load_for_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 ""
@@ -32,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:
+2 -2
View File
@@ -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,7 +40,7 @@ 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.all_agent_names: for name in manifest.all_agent_names:
print(name) print(name)
return 0 return 0
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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,7 +62,7 @@ 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:
+2 -2
View File
@@ -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.spec.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.spec.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:
+2 -2
View File
@@ -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.spec.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.spec.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:
+1 -1
View File
@@ -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.spec.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
View File
@@ -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
+92 -90
View File
@@ -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,18 +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; empty in from_json_obj (test/programmatic) mode. # 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. # Stores the manifest root dirs so load_for_agent can locate files later.
home_md: Path | None = field(default=None) home_md: Path | None = field(default=None)
cwd_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)
@@ -213,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.
@@ -252,15 +312,15 @@ class Manifest:
cls, cls,
home_dir: Path, home_dir: Path,
cwd_dir: Path | None, cwd_dir: Path | None,
) -> "Manifest": ) -> "ManifestIndex":
"""Return a names-only Manifest. No file content is read; only """Return a names-only ManifestIndex. No file content is read; only
filenames are scanned for the agent selector. Full parsing happens filenames are scanned for the agent selector. Full parsing happens
later, per-agent, via `load_for_agent`. later, per-agent, via `load_for_agent`.
A `bottles/` subdir under `cwd_dir` is logged as a warning and A `bottles/` subdir under `cwd_dir` is logged as a warning and
ignored the filesystem layout IS the trust boundary. ignored the filesystem layout IS the trust boundary.
Used by tests to build a Manifest from fixture directories Used by tests to build a ManifestIndex from fixture directories
without touching `os.environ`.""" without touching `os.environ`."""
if cwd_dir is not None: if cwd_dir is not None:
stale_bottles = cwd_dir / "bottles" stale_bottles = cwd_dir / "bottles"
@@ -278,8 +338,8 @@ class Manifest:
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir) return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
@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'")
@@ -317,30 +377,33 @@ class Manifest:
return sorted(self.agents.keys()) return sorted(self.agents.keys())
def load_for_agent(self, agent_name: str) -> "Manifest": def load_for_agent(self, agent_name: str) -> "Manifest":
"""Return a Manifest containing exactly one agent and its bottle. """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 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 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 mode (from_json_obj) the data is already parsed; this just filters
down to the requested agent and its bottle. 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. Always raises ManifestError if the agent is unknown or invalid.
Backends call this at preflight inside _validate.""" Backends call this at preflight inside _validate."""
if self.home_md is None: if self.home_md is None:
# Eager manifest (from_json_obj): data already parsed; filter to # Eager manifest (from_json_obj): data already parsed; filter to
# the one requested agent and its bottle so the returned manifest # the one requested agent and its bottle so the returned Manifest
# always contains exactly one agent and one bottle regardless of path. # always holds exactly one agent and one bottle regardless of path.
if agent_name not in self.agents: if agent_name not in self.agents:
available = ", ".join(sorted(self.agents.keys())) or "(none)" available = ", ".join(sorted(self.agents.keys())) or "(none)"
raise ManifestError( raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}" f"agent '{agent_name}' not defined. Available: {available}"
) )
agent = self.agents[agent_name] agent = self.agents[agent_name]
bottle_name = agent.bottle raw_bottle = self.bottles[agent.bottle]
return Manifest( merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
bottles={bottle_name: self.bottles[bottle_name]}, bottle = raw_bottle if merged == raw_bottle.git_user else replace(raw_bottle, git_user=merged)
agents={agent_name: agent}, return Manifest(agent=agent, bottle=bottle)
)
from .manifest_loader import load_bottle_chain_from_dir, scan_agent_names from .manifest_loader import load_bottle_chain_from_dir, scan_agent_names
from .manifest_schema import validate_agent_frontmatter_keys from .manifest_schema import validate_agent_frontmatter_keys
from .yaml_subset import YamlSubsetError, parse_frontmatter from .yaml_subset import YamlSubsetError, parse_frontmatter
@@ -350,15 +413,15 @@ class Manifest:
cwd_agents: dict[str, Path] = {} cwd_agents: dict[str, Path] = {}
if self.cwd_md is not None: if self.cwd_md is not None:
cwd_agents = scan_agent_names(self.cwd_md / "agents") cwd_agents = scan_agent_names(self.cwd_md / "agents")
merged = {**home_agents, **cwd_agents} merged_agents = {**home_agents, **cwd_agents}
if agent_name not in merged: if agent_name not in merged_agents:
available = ", ".join(sorted(merged.keys())) or "(none)" available = ", ".join(sorted(merged_agents.keys())) or "(none)"
raise ManifestError( raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}" f"agent '{agent_name}' not defined. Available: {available}"
) )
agent_path = merged[agent_name] agent_path = merged_agents[agent_name]
try: try:
fm, body = parse_frontmatter(agent_path.read_text()) fm, body = parse_frontmatter(agent_path.read_text())
except OSError as e: except OSError as e:
@@ -377,7 +440,7 @@ class Manifest:
# Load the bottle chain (may raise ManifestError). # Load the bottle chain (may raise ManifestError).
bottles_dir = self.home_md / "bottles" bottles_dir = self.home_md / "bottles"
bottle = load_bottle_chain_from_dir(bottle_name, bottles_dir) raw_bottle = load_bottle_chain_from_dir(bottle_name, bottles_dir)
# Build and validate the full ManifestAgent. # Build and validate the full ManifestAgent.
agent_dict: dict[str, object] = { agent_dict: dict[str, object] = {
@@ -389,12 +452,9 @@ class Manifest:
agent_dict["git-gate"] = fm["git-gate"] agent_dict["git-gate"] = fm["git-gate"]
agent = ManifestAgent.from_dict(agent_name, agent_dict, {bottle_name}) agent = ManifestAgent.from_dict(agent_name, agent_dict, {bottle_name})
return Manifest( merged_user = _merge_git_user(agent.git_user, raw_bottle.git_user)
bottles={bottle_name: bottle}, bottle = raw_bottle if merged_user == raw_bottle.git_user else replace(raw_bottle, git_user=merged_user)
agents={agent_name: agent}, return Manifest(agent=agent, bottle=bottle)
home_md=self.home_md,
cwd_md=self.cwd_md,
)
def has_agent(self, name: str) -> bool: def has_agent(self, name: str) -> bool:
return name in self.agents return name in self.agents
@@ -418,61 +478,3 @@ class Manifest:
raise ManifestError( raise ManifestError(
f"agent '{name}' not defined. Available: {available}" f"agent '{name}' not defined. Available: {available}"
) )
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).")
def _effective_git_user(self, agent_name: str) -> ManifestGitUser:
"""Merge the agent's git.user over the referenced bottle's,
per-field, agent-wins-on-non-empty (issue #94). Same overlay
the `extends:` resolver applies between bottles
(`_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 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)
+7 -7
View File
@@ -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": {
+2 -2
View File
@@ -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": {
+3 -3
View File
@@ -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": {
+3 -3
View File
@@ -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": {
+2 -2
View File
@@ -31,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()
@@ -150,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()
+3 -3
View File
@@ -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,7 +61,7 @@ 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"}},
}) })
+3 -3
View File
@@ -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 Manifest, 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({ manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": { "agents": {
"demo": { "demo": {
@@ -64,7 +64,7 @@ def _plan(
"bottle": "dev", "bottle": "dev",
}, },
}, },
}) }).load_for_agent("demo")
spec = BottleSpec( spec = BottleSpec(
manifest=manifest, agent_name="demo", manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x", copy_cwd=False, user_cwd="/tmp/x",
+3 -3
View File
@@ -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 Manifest, 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({ manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": { "agents": {
"demo": { "demo": {
@@ -64,7 +64,7 @@ def _plan(
"bottle": "dev", "bottle": "dev",
}, },
}, },
}) }).load_for_agent("demo")
spec = BottleSpec( spec = BottleSpec(
manifest=manifest, agent_name="demo", manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x", copy_cwd=False, user_cwd="/tmp/x",
+3 -3
View File
@@ -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 Manifest, 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({ manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": {"agent_provider": {"template": "pi"}}}, "bottles": {"dev": {"agent_provider": {"template": "pi"}}},
"agents": { "agents": {
"demo": { "demo": {
@@ -52,7 +52,7 @@ def _plan(
"bottle": "dev", "bottle": "dev",
}, },
}, },
}) }).load_for_agent("demo")
spec = BottleSpec( spec = BottleSpec(
manifest=manifest, agent_name="demo", manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x", copy_cwd=False, user_cwd="/tmp/x",
+6 -3
View File
@@ -21,14 +21,17 @@ 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
from bot_bottle.manifest import Manifest, ManifestIndex
def _manifest() -> Manifest: def _manifest() -> Manifest:
return Manifest.from_json_obj({ return ManifestIndex.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) }).load_for_agent("demo")
def _plan(tmp: str) -> DockerBottlePlan: def _plan(tmp: str) -> DockerBottlePlan:
+3 -3
View File
@@ -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 Manifest, ManifestIndex
class _Provider(AgentProvider): class _Provider(AgentProvider):
@@ -51,10 +51,10 @@ 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({ manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) }).load_for_agent("demo")
spec = BottleSpec( spec = BottleSpec(
manifest=manifest, agent_name="demo", manifest=manifest, agent_name="demo",
copy_cwd=copy_cwd, user_cwd=user_cwd, copy_cwd=copy_cwd, user_cwd=user_cwd,
+4 -4
View File
@@ -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"}],
+2 -2
View File
@@ -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",
+50 -29
View File
@@ -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=None, agent_git=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,13 +238,13 @@ 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)).load_for_agent("impl") 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_fails_at_preflight(self): def test_md_agent_repos_fails_at_preflight(self):
@@ -232,7 +253,7 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
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)
from bot_bottle.manifest import ManifestError from bot_bottle.manifest import ManifestError
names = Manifest.resolve(str(self.home)) names = ManifestIndex.resolve(str(self.home))
self.assertIn("impl", names.all_agent_names) self.assertIn("impl", names.all_agent_names)
with self.assertRaises(ManifestError) as ctx: with self.assertRaises(ManifestError) as ctx:
names.load_for_agent("impl") names.load_for_agent("impl")
+11 -11
View File
@@ -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"}},
+2 -2
View File
@@ -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},
+38 -38
View File
@@ -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"}},
}) })
+10 -10
View File
@@ -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"}},
+25 -30
View File
@@ -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, Manifest, 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,8 +71,8 @@ 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):
@@ -83,8 +83,7 @@ class TestBottleFileParses(_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)
m = self.resolve().load_for_agent("implementer") 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)
@@ -101,7 +100,7 @@ class TestAgentFileParses(_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)
m = self.resolve().load_for_agent("implementer") 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.
@@ -129,9 +128,9 @@ class TestCwdAgentOverridesHome(_ResolveCase):
""", """,
) )
m = self.resolve().load_for_agent("implementer") 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):
@@ -159,7 +158,7 @@ class TestCwdBottlesIgnored(_ResolveCase):
# 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"}},
@@ -216,8 +215,8 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase):
""", """,
) )
m = self.resolve().load_for_agent("implementer") 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):
@@ -229,7 +228,7 @@ class TestManifestEntryPointParity(_ResolveCase):
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL) _write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
md_manifest = self.resolve().load_for_agent("implementer") 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,7 +290,7 @@ class TestManifestEntryPointParity(_ResolveCase):
}, },
}) })
self.assertEqual("dev", manifest.agents["implementer"].bottle) self.assertEqual("dev", index.agents["implementer"].bottle)
class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase): class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase):
@@ -359,7 +358,7 @@ class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase):
self.assertIn("broken-agent", m.all_agent_names) self.assertIn("broken-agent", m.all_agent_names)
# Valid agent loads fine. # Valid agent loads fine.
full = m.load_for_agent("implementer") full = m.load_for_agent("implementer")
self.assertIn("implementer", full.agents) self.assertEqual("dev", full.agent.bottle)
# Broken bottle's agent raises at preflight. # Broken bottle's agent raises at preflight.
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
m.load_for_agent("broken-agent") m.load_for_agent("broken-agent")
@@ -385,7 +384,7 @@ 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))
@@ -411,7 +410,7 @@ class TestUnknownBottleReferenceFailsAtPreflight(_ResolveCase):
self.assertIn("implementer", m.all_agent_names) self.assertIn("implementer", m.all_agent_names)
# Valid agent loads fine. # Valid agent loads fine.
full = m.load_for_agent("implementer") full = m.load_for_agent("implementer")
self.assertIn("implementer", full.agents) self.assertEqual("dev", full.agent.bottle)
# Stray agent fails at preflight. # Stray agent fails at preflight.
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
m.load_for_agent("stray") m.load_for_agent("stray")
@@ -431,7 +430,3 @@ class TestFilenameValidation(_ResolveCase):
self.assertIn("implementer", m.all_agent_names) self.assertIn("implementer", m.all_agent_names)
self.assertNotIn("BadName", m.all_agent_names) self.assertNotIn("BadName", m.all_agent_names)
self.assertNotIn("badname", m.all_agent_names) self.assertNotIn("badname", m.all_agent_names)
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -7,7 +7,7 @@ silently ignoring."""
import unittest import unittest
from typing import Any from typing import Any
from bot_bottle.manifest import ManifestError, 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__":
+3 -3
View File
@@ -19,14 +19,14 @@ 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: def _manifest() -> Manifest:
return Manifest.from_json_obj({ return ManifestIndex.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) }).load_for_agent("demo")
def _spec(manifest: Manifest, tmp: str) -> BottleSpec: def _spec(manifest: Manifest, tmp: str) -> BottleSpec:
+2 -2
View File
@@ -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",
+3 -3
View File
@@ -33,7 +33,7 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, Manifest from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, Manifest, 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({ manifest = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": { "agents": {
"demo": { "demo": {
@@ -119,7 +119,7 @@ def _plan(
"bottle": "dev", "bottle": "dev",
}, },
}, },
}) }).load_for_agent("demo")
spec = BottleSpec( spec = BottleSpec(
manifest=manifest, manifest=manifest,
agent_name="demo", agent_name="demo",