diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index 791604e..20f0bb7 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -240,7 +240,7 @@ class AgentProvider(ABC): BottleBackend.provision_workspace against the running bottle.""" 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: from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME) diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index 8e0299f..ad32351 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -45,7 +45,7 @@ from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provi from ..egress import EgressPlan from ..git_gate import GitGatePlan from ..log import die, info -from ..manifest import Manifest +from ..manifest import Manifest, ManifestIndex from ..supervise import SupervisePlan from ..util import expand_tilde from ..env import resolve_env, ResolvedEnv @@ -61,7 +61,7 @@ class BottleSpec: Resolved values (image names, container name, scratch paths, runsc availability) live on the plan, not the spec.""" - manifest: Manifest + manifest: ManifestIndex | Manifest agent_name: str copy_cwd: bool user_cwd: str @@ -112,9 +112,9 @@ class BottlePlan(ABC): """Render the y/N preflight summary to stderr.""" del remote_control spec = self.spec - manifest = spec.manifest - agent = manifest.agents[spec.agent_name] - bottle = manifest.bottle_for(spec.agent_name) + manifest = spec.manifest # type: ignore[assignment] + agent = manifest.agent + bottle = manifest.bottle env_names = visible_agent_env_names( sorted( @@ -131,7 +131,7 @@ class BottlePlan(ABC): print_multi("skills ", list(agent.skills)) info(f"bottle : {agent.bottle}") - identity = manifest.git_identity_summary(spec.agent_name) + identity = manifest.git_identity_summary() if identity: info(f" git identity : {identity}") @@ -293,11 +293,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): self._preflight() - manifest = spec.manifest - manifest_bottle = manifest.bottle_for(spec.agent_name) + manifest = spec.manifest # type: ignore[assignment] + manifest_bottle = manifest.bottle manifest_agent_provider = manifest_bottle.agent_provider 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) slug = mint_slug(spec) @@ -364,9 +364,9 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): selected agent. Subclasses with additional preconditions should override and call `super()._validate(spec)` first, using the 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) - agent = manifest.agents[spec.agent_name] + agent = manifest.agent self._validate_skills(agent.skills) self._validate_agent_provider_dockerfile(spec) return spec @@ -384,7 +384,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): ) 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 if not dockerfile: return @@ -394,7 +395,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): if not path.is_file(): die( 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 diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index c753e1f..f937b30 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -75,7 +75,7 @@ def launch( Teardown on exit.""" 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) def teardown() -> None: diff --git a/bot_bottle/backend/macos_container/launch.py b/bot_bottle/backend/macos_container/launch.py index a877bd7..9db7411 100644 --- a/bot_bottle/backend/macos_container/launch.py +++ b/bot_bottle/backend/macos_container/launch.py @@ -68,7 +68,7 @@ def launch( ) -> Generator[MacosContainerBottle, None, None]: """Build, run, provision, and yield an Apple Container bottle.""" 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) def teardown() -> None: diff --git a/bot_bottle/backend/resolve_common.py b/bot_bottle/backend/resolve_common.py index 549a466..affb345 100644 --- a/bot_bottle/backend/resolve_common.py +++ b/bot_bottle/backend/resolve_common.py @@ -69,8 +69,8 @@ def write_launch_metadata( def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]: """Create the agent state subdir, write the prompt file. Returns (agent_dir, prompt_file).""" - manifest = spec.manifest - agent = manifest.agents[spec.agent_name] + manifest = spec.manifest # type: ignore[assignment] + agent = manifest.agent agent_dir = agent_state_dir(slug) agent_dir.mkdir(parents=True, exist_ok=True) prompt_file = agent_dir / "prompt.txt" diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index 20c614b..e16b611 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -130,7 +130,7 @@ def _teardown_smolmachines( except BaseException as exc: # noqa: W0718 — teardown must not fail teardown_exc = exc 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)) if teardown_exc is not None: raise teardown_exc diff --git a/bot_bottle/cli/info.py b/bot_bottle/cli/info.py index 9d2a62c..61b8945 100644 --- a/bot_bottle/cli/info.py +++ b/bot_bottle/cli/info.py @@ -5,7 +5,7 @@ from __future__ import annotations import argparse from ..log import info -from ..manifest import Manifest +from ..manifest import ManifestIndex 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") args = parser.parse_args(argv) - names = Manifest.resolve(USER_CWD) + names = ManifestIndex.resolve(USER_CWD) names.require_agent(args.name) manifest = names.load_for_agent(args.name) - agent = manifest.agents[args.name] - bottle = manifest.bottle_for(args.name) + agent = manifest.agent + bottle = manifest.bottle env_names = list(bottle.env.keys()) 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)'}" ) info(f"bottle : {agent.bottle}") - identity = manifest.git_identity_summary(args.name) + identity = manifest.git_identity_summary() if identity: info(f" git identity : {identity}") if bottle.git: diff --git a/bot_bottle/cli/list.py b/bot_bottle/cli/list.py index cb2149a..b04e772 100644 --- a/bot_bottle/cli/list.py +++ b/bot_bottle/cli/list.py @@ -7,7 +7,7 @@ import os import sys from ..backend import enumerate_active_agents -from ..manifest import Manifest +from ..manifest import ManifestIndex from ._common import PROG, USER_CWD _ANSI_COLOR_CODES: dict[str, str] = { @@ -40,7 +40,7 @@ def cmd_list(argv: list[str]) -> int: args = parser.parse_args(argv) if args.scope == "available": - manifest = Manifest.resolve(USER_CWD) + manifest = ManifestIndex.resolve(USER_CWD) for name in manifest.all_agent_names: print(name) return 0 diff --git a/bot_bottle/cli/resume.py b/bot_bottle/cli/resume.py index ee77088..557c293 100644 --- a/bot_bottle/cli/resume.py +++ b/bot_bottle/cli/resume.py @@ -20,7 +20,7 @@ import argparse from ..backend import BottleSpec from ..bottle_state import read_metadata from ..log import die -from ..manifest import Manifest +from ..manifest import ManifestIndex from ._common import PROG, USER_CWD 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" ) - manifest = Manifest.resolve(USER_CWD) + manifest = ManifestIndex.resolve(USER_CWD) manifest.require_agent(metadata.agent_name) spec = BottleSpec( diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index 087f129..c752cfc 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -33,7 +33,7 @@ from ..bottle_state import ( ) # from ..backend.docker.capability_apply import snapshot_transcript from ..log import info -from ..manifest import Manifest +from ..manifest import ManifestIndex from ._common import PROG, USER_CWD, read_tty_line 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" - manifest = Manifest.resolve(USER_CWD) + manifest = ManifestIndex.resolve(USER_CWD) agent_name: str | None = args.name if agent_name is None: diff --git a/bot_bottle/contrib/claude/agent_provider.py b/bot_bottle/contrib/claude/agent_provider.py index 4ef1403..add66a2 100644 --- a/bot_bottle/contrib/claude/agent_provider.py +++ b/bot_bottle/contrib/claude/agent_provider.py @@ -211,7 +211,7 @@ class ClaudeAgentProvider(AgentProvider): when the agent has no skills.""" 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: return skills_dir = _skills_dir(plan.guest_home) @@ -240,7 +240,7 @@ class ClaudeAgentProvider(AgentProvider): f"chown node:node {prompt_path} && chmod 600 {prompt_path}", 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 def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None: diff --git a/bot_bottle/contrib/codex/agent_provider.py b/bot_bottle/contrib/codex/agent_provider.py index 7207eb1..8b5f485 100644 --- a/bot_bottle/contrib/codex/agent_provider.py +++ b/bot_bottle/contrib/codex/agent_provider.py @@ -177,7 +177,7 @@ class CodexAgentProvider(AgentProvider): skills.""" 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: return skills_dir = _skills_dir(plan.guest_home) @@ -206,7 +206,7 @@ class CodexAgentProvider(AgentProvider): f"chown node:node {prompt_path} && chmod 600 {prompt_path}", 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 def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None: diff --git a/bot_bottle/contrib/pi/agent_provider.py b/bot_bottle/contrib/pi/agent_provider.py index 874e82f..83d498d 100644 --- a/bot_bottle/contrib/pi/agent_provider.py +++ b/bot_bottle/contrib/pi/agent_provider.py @@ -232,7 +232,7 @@ class PiAgentProvider(AgentProvider): def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None: 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: return skills_dir = _skills_dir(plan.guest_home) diff --git a/bot_bottle/env.py b/bot_bottle/env.py index fe4362d..767ad04 100644 --- a/bot_bottle/env.py +++ b/bot_bottle/env.py @@ -114,7 +114,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str: return value -def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv: +def resolve_env(manifest: Manifest) -> ResolvedEnv: """Iterate the agent's env entries: - secret: prompt at runtime; 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.""" forwarded: dict[str, str] = {} literals: dict[str, str] = {} - bottle = manifest.bottle_for(agent) + bottle = manifest.bottle for name, raw in bottle.env.items(): if not name: continue diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 776d8bc..224e7ea 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -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 expressed as filesystem layout rather than resolver logic. -Validation runs once at load. Manifest.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. +Two types are exported: + + ManifestIndex — the multi-agent/bottle collection returned by + 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 @@ -71,6 +84,7 @@ __all__ = [ "ManifestEgressConfig", "ManifestAgent", "ManifestBottle", + "ManifestIndex", "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) 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] 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. home_md: Path | None = field(default=None) cwd_md: Path | None = field(default=None) @classmethod - def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest": - """Walk the per-file manifest tree and build a Manifest. + def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex": + """Walk the per-file manifest tree and build a ManifestIndex. Layout (PRD 0011): $HOME/.bot-bottle/bottles/.md — bottles (home-only) @@ -213,7 +273,7 @@ class Manifest: boundary. 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 monitor already-running agents without launch config. @@ -252,15 +312,15 @@ class Manifest: cls, home_dir: Path, cwd_dir: Path | None, - ) -> "Manifest": - """Return a names-only Manifest. No file content is read; only + ) -> "ManifestIndex": + """Return a names-only ManifestIndex. No file content is read; only filenames are scanned for the agent selector. Full parsing happens later, per-agent, via `load_for_agent`. A `bottles/` subdir under `cwd_dir` is logged as a warning and ignored — the filesystem layout IS the trust boundary. - Used by tests to build a Manifest from fixture directories + Used by tests to build a ManifestIndex from fixture directories without touching `os.environ`.""" if cwd_dir is not None: stale_bottles = cwd_dir / "bottles" @@ -278,8 +338,8 @@ class Manifest: return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir) @classmethod - def from_json_obj(cls, obj: object) -> "Manifest": - """Validate and build a Manifest from a raw JSON-like dict.""" + def from_json_obj(cls, obj: object) -> "ManifestIndex": + """Validate and build a ManifestIndex from a raw JSON-like dict.""" d = as_json_object(obj, "manifest") raw_bottles_obj = _section_dict(d.get("bottles"), "manifest 'bottles'") raw_agents = _section_dict(d.get("agents"), "manifest 'agents'") @@ -317,30 +377,33 @@ class Manifest: return sorted(self.agents.keys()) 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 bottle chain are read from disk for the first time here. In eager mode (from_json_obj) the data is already parsed; this just filters down to the requested agent and its bottle. + The returned Manifest.bottle has the agent's git-gate.user already + overlaid (agent wins on non-empty, per-field). + Always raises ManifestError if the agent is unknown or invalid. Backends call this at preflight inside _validate.""" if self.home_md is None: # Eager manifest (from_json_obj): data already parsed; filter to - # the one requested agent and its bottle so the returned manifest - # always contains exactly one agent and one bottle regardless of path. + # the one requested agent and its bottle so the returned Manifest + # always holds exactly one agent and one bottle regardless of path. if agent_name not in self.agents: available = ", ".join(sorted(self.agents.keys())) or "(none)" raise ManifestError( f"agent '{agent_name}' not defined. Available: {available}" ) agent = self.agents[agent_name] - bottle_name = agent.bottle - return Manifest( - bottles={bottle_name: self.bottles[bottle_name]}, - agents={agent_name: agent}, - ) + raw_bottle = self.bottles[agent.bottle] + merged = _merge_git_user(agent.git_user, raw_bottle.git_user) + bottle = raw_bottle if merged == raw_bottle.git_user else replace(raw_bottle, git_user=merged) + return Manifest(agent=agent, bottle=bottle) + from .manifest_loader import load_bottle_chain_from_dir, scan_agent_names from .manifest_schema import validate_agent_frontmatter_keys from .yaml_subset import YamlSubsetError, parse_frontmatter @@ -350,15 +413,15 @@ class Manifest: cwd_agents: dict[str, Path] = {} if self.cwd_md is not None: 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: - available = ", ".join(sorted(merged.keys())) or "(none)" + if agent_name not in merged_agents: + available = ", ".join(sorted(merged_agents.keys())) or "(none)" raise ManifestError( f"agent '{agent_name}' not defined. Available: {available}" ) - agent_path = merged[agent_name] + agent_path = merged_agents[agent_name] try: fm, body = parse_frontmatter(agent_path.read_text()) except OSError as e: @@ -377,7 +440,7 @@ class Manifest: # Load the bottle chain (may raise ManifestError). 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. agent_dict: dict[str, object] = { @@ -389,12 +452,9 @@ class Manifest: agent_dict["git-gate"] = fm["git-gate"] agent = ManifestAgent.from_dict(agent_name, agent_dict, {bottle_name}) - return Manifest( - bottles={bottle_name: bottle}, - agents={agent_name: agent}, - home_md=self.home_md, - cwd_md=self.cwd_md, - ) + merged_user = _merge_git_user(agent.git_user, raw_bottle.git_user) + bottle = raw_bottle if merged_user == raw_bottle.git_user else replace(raw_bottle, git_user=merged_user) + return Manifest(agent=agent, bottle=bottle) def has_agent(self, name: str) -> bool: return name in self.agents @@ -418,61 +478,3 @@ class Manifest: raise ManifestError( 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) diff --git a/tests/fixtures.py b/tests/fixtures.py index 6bd1d83..38a4e2b 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -10,7 +10,7 @@ import tempfile from pathlib import Path from typing import Any, Callable -from bot_bottle.manifest import Manifest +from bot_bottle.manifest import ManifestIndex def fixture_minimal_dict() -> dict[str, Any]: @@ -62,16 +62,16 @@ def fixture_with_git_dict() -> dict[str, Any]: } -def fixture_minimal() -> Manifest: - return Manifest.from_json_obj(fixture_minimal_dict()) +def fixture_minimal() -> ManifestIndex: + return ManifestIndex.from_json_obj(fixture_minimal_dict()) -def fixture_with_egress() -> Manifest: - return Manifest.from_json_obj(fixture_with_egress_dict()) +def fixture_with_egress() -> ManifestIndex: + return ManifestIndex.from_json_obj(fixture_with_egress_dict()) -def fixture_with_git() -> Manifest: - return Manifest.from_json_obj(fixture_with_git_dict()) +def fixture_with_git() -> ManifestIndex: + return ManifestIndex.from_json_obj(fixture_with_git_dict()) def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path: diff --git a/tests/integration/test_macos_container_launch.py b/tests/integration/test_macos_container_launch.py index 9ce384f..71678d5 100644 --- a/tests/integration/test_macos_container_launch.py +++ b/tests/integration/test_macos_container_launch.py @@ -29,7 +29,7 @@ from bot_bottle.backend.macos_container.util import ( dns_server as _container_dns_server, 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." @@ -52,8 +52,8 @@ def _minimal_agent_dockerfile(path: Path) -> None: ) -def _minimal_manifest(dockerfile: Path) -> Manifest: - return Manifest.from_json_obj({ +def _minimal_manifest(dockerfile: Path) -> ManifestIndex: + return ManifestIndex.from_json_obj({ "bottles": { "dev": { "agent_provider": { diff --git a/tests/integration/test_sandbox_escape.py b/tests/integration/test_sandbox_escape.py index e8fc97d..332ab2e 100644 --- a/tests/integration/test_sandbox_escape.py +++ b/tests/integration/test_sandbox_escape.py @@ -31,7 +31,7 @@ from pathlib import Path from bot_bottle.backend import BottleSpec, get_bottle_backend 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 @@ -101,7 +101,7 @@ class TestSandboxEscape(unittest.TestCase): cls._key_path.write_text("placeholder\n") cls._key_path.chmod(0o600) - manifest = Manifest.from_json_obj({ + manifest = ManifestIndex.from_json_obj({ "bottles": { "dev": { # Three fake secrets — different shapes — land diff --git a/tests/integration/test_sidecar_bundle_compose.py b/tests/integration/test_sidecar_bundle_compose.py index 4d6b9cd..b57600e 100644 --- a/tests/integration/test_sidecar_bundle_compose.py +++ b/tests/integration/test_sidecar_bundle_compose.py @@ -22,15 +22,15 @@ from pathlib import Path from unittest.mock import patch 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 -def _manifest() -> Manifest: +def _manifest() -> ManifestIndex: """Bottle with supervise on so the bundle exercises egress + supervise. Git is off because a meaningful git-gate test needs a real upstream and SSH keys — out of scope for a bundle smoke.""" - return Manifest.from_json_obj({ + return ManifestIndex.from_json_obj({ "bottles": { "dev": { "supervise": True, diff --git a/tests/integration/test_smolmachines_launch.py b/tests/integration/test_smolmachines_launch.py index 706eb3b..2f5606f 100644 --- a/tests/integration/test_smolmachines_launch.py +++ b/tests/integration/test_smolmachines_launch.py @@ -35,15 +35,15 @@ from pathlib import Path from bot_bottle.backend import BottleSpec, get_bottle_backend 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 _AGENT_PROMPT = "You are demo. Be brief." -def _minimal_manifest() -> Manifest: - return Manifest.from_json_obj({ +def _minimal_manifest() -> ManifestIndex: + return ManifestIndex.from_json_obj({ "bottles": { "dev": { "egress": { diff --git a/tests/unit/test_backend_prepare.py b/tests/unit/test_backend_prepare.py index ee673a1..013e5bb 100644 --- a/tests/unit/test_backend_prepare.py +++ b/tests/unit/test_backend_prepare.py @@ -18,11 +18,11 @@ from bot_bottle.backend import BottleSpec from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.resolve_common import mint_slug from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend -from bot_bottle.manifest import Manifest +from bot_bottle.manifest import ManifestIndex -def _manifest() -> Manifest: - return Manifest.from_json_obj({ +def _manifest() -> ManifestIndex: + return ManifestIndex.from_json_obj({ "bottles": { "dev": { "env": { diff --git a/tests/unit/test_backend_workspace.py b/tests/unit/test_backend_workspace.py index bcbfddf..5b7cfcd 100644 --- a/tests/unit/test_backend_workspace.py +++ b/tests/unit/test_backend_workspace.py @@ -17,11 +17,11 @@ from bot_bottle import supervise from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend -from bot_bottle.manifest import Manifest +from bot_bottle.manifest import ManifestIndex -def _manifest() -> Manifest: - return Manifest.from_json_obj({ +def _manifest() -> ManifestIndex: + return ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": { "demo": { diff --git a/tests/unit/test_cli_start_selector.py b/tests/unit/test_cli_start_selector.py index 809d1a8..928652f 100644 --- a/tests/unit/test_cli_start_selector.py +++ b/tests/unit/test_cli_start_selector.py @@ -31,7 +31,7 @@ class TestCmdStartSelector(unittest.TestCase): # Stub Manifest.resolve so no on-disk manifest is needed. self._manifest = _make_manifest(["researcher", "implementer"]) self._resolve_patch = patch( - "bot_bottle.cli.start.Manifest.resolve", + "bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest, ) self._resolve_patch.start() @@ -150,7 +150,7 @@ class TestCmdStartLabelCollision(unittest.TestCase): def setUp(self): 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( "bot_bottle.cli.start._launch_bottle", return_value=0, ).start() diff --git a/tests/unit/test_compose.py b/tests/unit/test_compose.py index 082f5ab..dafe3ef 100644 --- a/tests/unit/test_compose.py +++ b/tests/unit/test_compose.py @@ -31,7 +31,7 @@ from bot_bottle.egress import ( EgressRoute, ) 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 @@ -40,7 +40,7 @@ STAGE = Path("/tmp/cb-stage") 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. The renderer only reads from the plan, not the manifest, so this 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"}, }], } - return Manifest.from_json_obj({ + return ManifestIndex.from_json_obj({ "bottles": {"dev": bottle}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) diff --git a/tests/unit/test_contrib_claude_provider.py b/tests/unit/test_contrib_claude_provider.py index 42f034a..c8ce4c0 100644 --- a/tests/unit/test_contrib_claude_provider.py +++ b/tests/unit/test_contrib_claude_provider.py @@ -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.egress import EgressPlan 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 @@ -55,7 +55,7 @@ def _plan( bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore if supervise: bottle_json["supervise"] = True - manifest = Manifest.from_json_obj({ + manifest = ManifestIndex.from_json_obj({ "bottles": {"dev": bottle_json}, "agents": { "demo": { @@ -64,7 +64,7 @@ def _plan( "bottle": "dev", }, }, - }) + }).load_for_agent("demo") spec = BottleSpec( manifest=manifest, agent_name="demo", copy_cwd=False, user_cwd="/tmp/x", diff --git a/tests/unit/test_contrib_codex_provider.py b/tests/unit/test_contrib_codex_provider.py index 65a8ee2..4e5411c 100644 --- a/tests/unit/test_contrib_codex_provider.py +++ b/tests/unit/test_contrib_codex_provider.py @@ -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.egress import EgressPlan 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 @@ -55,7 +55,7 @@ def _plan( bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore if supervise: bottle_json["supervise"] = True - manifest = Manifest.from_json_obj({ + manifest = ManifestIndex.from_json_obj({ "bottles": {"dev": bottle_json}, "agents": { "demo": { @@ -64,7 +64,7 @@ def _plan( "bottle": "dev", }, }, - }) + }).load_for_agent("demo") spec = BottleSpec( manifest=manifest, agent_name="demo", copy_cwd=False, user_cwd="/tmp/x", diff --git a/tests/unit/test_contrib_pi_provider.py b/tests/unit/test_contrib_pi_provider.py index ade0db2..dd0a88c 100644 --- a/tests/unit/test_contrib_pi_provider.py +++ b/tests/unit/test_contrib_pi_provider.py @@ -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.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan -from bot_bottle.manifest import Manifest +from bot_bottle.manifest import Manifest, ManifestIndex _URL = "http://supervise:9100/" @@ -43,7 +43,7 @@ def _plan( skills: list[str] | None = None, agent_provision: AgentProvisionPlan | None = None, ) -> DockerBottlePlan: - manifest = Manifest.from_json_obj({ + manifest = ManifestIndex.from_json_obj({ "bottles": {"dev": {"agent_provider": {"template": "pi"}}}, "agents": { "demo": { @@ -52,7 +52,7 @@ def _plan( "bottle": "dev", }, }, - }) + }).load_for_agent("demo") spec = BottleSpec( manifest=manifest, agent_name="demo", copy_cwd=False, user_cwd="/tmp/x", diff --git a/tests/unit/test_docker_launch_teardown.py b/tests/unit/test_docker_launch_teardown.py index 58b9c83..2a765c9 100644 --- a/tests/unit/test_docker_launch_teardown.py +++ b/tests/unit/test_docker_launch_teardown.py @@ -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.egress import EgressPlan 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: - return Manifest.from_json_obj({ + return ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }) + }).load_for_agent("demo") def _plan(tmp: str) -> DockerBottlePlan: diff --git a/tests/unit/test_docker_provision_git_user.py b/tests/unit/test_docker_provision_git_user.py index 5513fc5..6c1220c 100644 --- a/tests/unit/test_docker_provision_git_user.py +++ b/tests/unit/test_docker_provision_git_user.py @@ -21,7 +21,7 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.egress import EgressPlan from bot_bottle.git_gate import GitGatePlan -from bot_bottle.manifest import Manifest +from bot_bottle.manifest import Manifest, ManifestIndex class _Provider(AgentProvider): @@ -51,10 +51,10 @@ def _plan(*, git_user: dict | None = None, # type: ignore bottle_json: dict = {} # type: ignore if git_user is not None: bottle_json["git-gate"] = {"user": git_user} - manifest = Manifest.from_json_obj({ + manifest = ManifestIndex.from_json_obj({ "bottles": {"dev": bottle_json}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }) + }).load_for_agent("demo") spec = BottleSpec( manifest=manifest, agent_name="demo", copy_cwd=copy_cwd, user_cwd=user_cwd, diff --git a/tests/unit/test_egress.py b/tests/unit/test_egress.py index 36c7bb9..05ea426 100644 --- a/tests/unit/test_egress.py +++ b/tests/unit/test_egress.py @@ -13,12 +13,12 @@ from bot_bottle.egress import ( egress_token_env_map, ) 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 def _bottle(routes): # type: ignore - return Manifest.from_json_obj({ + return ManifestIndex.from_json_obj({ "bottles": {"dev": {"egress": {"routes": routes}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] @@ -362,9 +362,9 @@ class TestRenderRoutes(unittest.TestCase): self.assertEqual("x.example", cfg.routes[0].host) 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 - m = Manifest.from_json_obj({ + m = ManifestIndex.from_json_obj({ "bottles": {"dev": {"egress": { "log": 1, "routes": [{"host": "x.example"}], diff --git a/tests/unit/test_git_gate.py b/tests/unit/test_git_gate.py index 29ade69..939d531 100644 --- a/tests/unit/test_git_gate.py +++ b/tests/unit/test_git_gate.py @@ -15,7 +15,7 @@ from bot_bottle.git_gate import ( git_gate_render_hook, 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 @@ -280,7 +280,7 @@ class TestPrepare(unittest.TestCase): self.assertEqual(0o600, os.stat(upstream.known_hosts_file).st_mode & 0o777) 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": { "foo": { "url": "ssh://git@github.com/didericis/foo.git", diff --git a/tests/unit/test_manifest_agent_git_user.py b/tests/unit/test_manifest_agent_git_user.py index 25d18f7..80d1afc 100644 --- a/tests/unit/test_manifest_agent_git_user.py +++ b/tests/unit/test_manifest_agent_git_user.py @@ -1,14 +1,14 @@ """Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047). 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 rejected on agents. `Manifest.git_identity_summary()` reports the effective identity with per-field `(agent)`/`(bottle)` provenance. -The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`; -a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the -`git-gate` threading into `agent_dict`).""" +The `from_json_obj` path drives `Agent.from_dict` + the overlay in +load_for_agent; a temp-dir case locks the md loader (the `_AGENT_KEYS` +allow + the `git-gate` threading into `agent_dict`).""" from __future__ import annotations @@ -19,7 +19,7 @@ import textwrap import unittest 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 @@ -32,13 +32,28 @@ def _error_message(callable_, *args, **kwargs) -> str: # 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 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 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}, "agents": {"impl": agent}, }) @@ -47,7 +62,7 @@ def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore class TestAgentGitUserOverlay(unittest.TestCase): def test_agent_supplies_both_fields(self): 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@b", u.email) @@ -56,7 +71,7 @@ class TestAgentGitUserOverlay(unittest.TestCase): bottle_user={"name": "B", "email": "b@c"}, 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("b@c", u.email) # bottle falls through @@ -65,34 +80,40 @@ class TestAgentGitUserOverlay(unittest.TestCase): bottle_user={"name": "B", "email": "b@c"}, 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("a@b", u.email) def test_agent_identity_with_bottle_declaring_none(self): - m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}}) - self.assertTrue(m.bottles["dev"].git_user.is_empty()) - self.assertFalse(m.bottle_for("impl").git_user.is_empty()) + idx = _index(agent_git={"user": {"name": "a", "email": "a@b"}}) + # Raw bottle has no git_user; loaded manifest has merged git_user from agent + 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): 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@c", u.email) - def test_bottle_for_returns_same_instance_when_no_overlay(self): - m = _manifest(bottle_user={"name": "B"}) - self.assertIs(m.bottles["dev"], m.bottle_for("impl")) + def test_no_overlay_uses_bottle_instance_directly(self): + idx = _index(bottle_user={"name": "B"}) + 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): - m = _manifest( + def test_noop_overlay_uses_bottle_instance_directly(self): + idx = _index( bottle_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): - m = Manifest.from_json_obj({ + idx = ManifestIndex.from_json_obj({ "bottles": {"dev": { "env": {"FOO": "bar"}, "supervise": True, @@ -103,7 +124,7 @@ class TestAgentGitUserOverlay(unittest.TestCase): "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({"FOO": "bar"}, dict(b.env)) self.assertTrue(b.supervise) @@ -131,7 +152,7 @@ class TestGitIdentitySummary(unittest.TestCase): m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}}) self.assertEqual( "name=a (agent), email=a@b (agent)", - m.git_identity_summary("impl"), + m.git_identity_summary(), ) def test_mixed_provenance(self): @@ -141,19 +162,19 @@ class TestGitIdentitySummary(unittest.TestCase): ) self.assertEqual( "name=a (agent), email=b@c (bottle)", - m.git_identity_summary("impl"), + m.git_identity_summary(), ) def test_bottle_only(self): m = _manifest(bottle_user={"name": "B", "email": "b@c"}) self.assertEqual( "name=B (bottle), email=b@c (bottle)", - m.git_identity_summary("impl"), + m.git_identity_summary(), ) def test_none_when_unset_anywhere(self): m = _manifest() - self.assertIsNone(m.git_identity_summary("impl")) + self.assertIsNone(m.git_identity_summary()) _BOTTLE_DEV = """ @@ -217,13 +238,13 @@ class TestAgentGitUserMdLoader(unittest.TestCase): def test_md_agent_git_user_overlays_bottle(self): self._write("bottles/dev.md", _BOTTLE_DEV) self._write("agents/impl.md", _AGENT_WITH_GIT) - m = Manifest.resolve(str(self.home)).load_for_agent("impl") - u = m.bottle_for("impl").git_user + m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl") + u = m.bottle.git_user self.assertEqual("agent-name", u.name) self.assertEqual("bottle@example.com", u.email) self.assertEqual( "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): @@ -232,7 +253,7 @@ class TestAgentGitUserMdLoader(unittest.TestCase): self._write("bottles/dev.md", _BOTTLE_DEV) self._write("agents/impl.md", _AGENT_WITH_REPOS) 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) with self.assertRaises(ManifestError) as ctx: names.load_for_agent("impl") diff --git a/tests/unit/test_manifest_egress.py b/tests/unit/test_manifest_egress.py index ed83c84..7daa67a 100644 --- a/tests/unit/test_manifest_egress.py +++ b/tests/unit/test_manifest_egress.py @@ -9,18 +9,18 @@ partial `auth` is an error, auth omission means unauthenticated.""" import unittest -from bot_bottle.manifest import ManifestError, Manifest +from bot_bottle.manifest import ManifestError, ManifestIndex def _bottle(routes): # type: ignore - return Manifest.from_json_obj({ + return ManifestIndex.from_json_obj({ "bottles": {"dev": {"egress": {"routes": routes}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] def _provider_bottle(provider, routes): # type: ignore - return Manifest.from_json_obj({ + return ManifestIndex.from_json_obj({ "bottles": { "dev": { "agent_provider": {"template": provider}, @@ -32,7 +32,7 @@ def _provider_bottle(provider, routes): # 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}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] @@ -433,7 +433,7 @@ class TestRouteValidation(unittest.TestCase): self.assertEqual((), b.egress.routes) def test_no_egress_block_means_empty(self): - b = Manifest.from_json_obj({ + b = ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] @@ -443,7 +443,7 @@ class TestRouteValidation(unittest.TestCase): class TestConfigShape(unittest.TestCase): def test_unknown_egress_key_rejected(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj({ + ManifestIndex.from_json_obj({ "bottles": {"dev": {"egress": {"wat": []}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, @@ -454,14 +454,14 @@ class TestConfigShape(unittest.TestCase): self.assertEqual(0, b.egress.Log) def test_log_level_1_accepted(self): - b = Manifest.from_json_obj({ + b = ManifestIndex.from_json_obj({ "bottles": {"dev": {"egress": {"log": 1, "routes": []}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] self.assertEqual(1, b.egress.Log) def test_log_level_2_accepted(self): - b = Manifest.from_json_obj({ + b = ManifestIndex.from_json_obj({ "bottles": {"dev": {"egress": {"log": 2, "routes": []}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }).bottles["dev"] @@ -469,7 +469,7 @@ class TestConfigShape(unittest.TestCase): def test_log_invalid_level_rejected(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj({ + ManifestIndex.from_json_obj({ "bottles": {"dev": {"egress": {"log": 3}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, @@ -477,7 +477,7 @@ class TestConfigShape(unittest.TestCase): def test_log_bool_rejected(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj({ + ManifestIndex.from_json_obj({ "bottles": {"dev": {"egress": {"log": True}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, @@ -485,7 +485,7 @@ class TestConfigShape(unittest.TestCase): def test_log_string_rejected(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj({ + ManifestIndex.from_json_obj({ "bottles": {"dev": {"egress": {"log": "full"}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, diff --git a/tests/unit/test_manifest_extends.py b/tests/unit/test_manifest_extends.py index 247ce26..b96ea8b 100644 --- a/tests/unit/test_manifest_extends.py +++ b/tests/unit/test_manifest_extends.py @@ -12,7 +12,7 @@ from __future__ import annotations 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 @@ -28,7 +28,7 @@ def _build(**bottles) -> Manifest: # type: ignore """Build a manifest with the given bottles and one trivial agent referencing the first bottle (so the manifest is valid).""" first = next(iter(bottles)) - return Manifest.from_json_obj({ + return ManifestIndex.from_json_obj({ "bottles": bottles, "agents": { "demo": {"skills": [], "prompt": "", "bottle": first}, diff --git a/tests/unit/test_manifest_git.py b/tests/unit/test_manifest_git.py index f8cccc6..8d1b855 100644 --- a/tests/unit/test_manifest_git.py +++ b/tests/unit/test_manifest_git.py @@ -2,7 +2,7 @@ import unittest -from bot_bottle.manifest import ManifestError, Manifest +from bot_bottle.manifest import ManifestError, ManifestIndex def _manifest(repos: dict) -> dict: # type: ignore @@ -14,7 +14,7 @@ def _manifest(repos: dict) -> dict: # type: ignore class TestGitEntryParsing(unittest.TestCase): def test_parses_minimal_entry(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "bot-bottle": { "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -30,7 +30,7 @@ class TestGitEntryParsing(unittest.TestCase): self.assertEqual("didericis/bot-bottle.git", e.UpstreamPath) def test_default_port_is_22(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/didericis/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -41,7 +41,7 @@ class TestGitEntryParsing(unittest.TestCase): self.assertEqual("github.com", e.UpstreamHost) def test_host_key_optional(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -50,7 +50,7 @@ class TestGitEntryParsing(unittest.TestCase): self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey) def test_host_key_stored(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/foo.git", "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) def test_repo_name_becomes_Name(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "my-repo": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -70,19 +70,19 @@ class TestGitEntryParsing(unittest.TestCase): def test_missing_url_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": {"key": {"provider": "static", "path": "/dev/null"}}, })) def test_missing_key_block_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": {"url": "ssh://git@github.com/foo.git"}, })) def test_unknown_key_in_entry_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -92,7 +92,7 @@ class TestGitEntryParsing(unittest.TestCase): def test_non_ssh_url_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "https://github.com/didericis/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -101,7 +101,7 @@ class TestGitEntryParsing(unittest.TestCase): def test_scp_style_url_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "git@github.com:didericis/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -110,7 +110,7 @@ class TestGitEntryParsing(unittest.TestCase): def test_url_without_user_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://github.com/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -119,7 +119,7 @@ class TestGitEntryParsing(unittest.TestCase): def test_url_without_path_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com", "key": {"provider": "static", "path": "/dev/null"}, @@ -128,7 +128,7 @@ class TestGitEntryParsing(unittest.TestCase): def test_non_numeric_port_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com:notaport/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -136,7 +136,7 @@ class TestGitEntryParsing(unittest.TestCase): })) def test_ip_literal_upstream(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "bot-bottle": { "url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -152,7 +152,7 @@ class TestGitEntryCrossValidation(unittest.TestCase): def test_two_repos_different_hosts_both_parsed(self): # Repo names come from dict keys; two distinct keys always produce # 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": { "foo": { "url": "ssh://git@a.example/x.git", @@ -170,7 +170,7 @@ class TestGitEntryCrossValidation(unittest.TestCase): def test_legacy_ssh_field_dies_with_hint(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj({ + ManifestIndex.from_json_obj({ "bottles": { "dev": { "ssh": [{ @@ -187,7 +187,7 @@ class TestGitEntryCrossValidation(unittest.TestCase): def test_name_with_single_quote_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "o'reilly": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -196,7 +196,7 @@ class TestGitEntryCrossValidation(unittest.TestCase): def test_name_with_space_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "my repo": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -205,7 +205,7 @@ class TestGitEntryCrossValidation(unittest.TestCase): def test_name_with_semicolon_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo;bar": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -214,7 +214,7 @@ class TestGitEntryCrossValidation(unittest.TestCase): def test_name_with_dollar_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo$bar": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -222,7 +222,7 @@ class TestGitEntryCrossValidation(unittest.TestCase): })) 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": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -233,7 +233,7 @@ class TestGitEntryCrossValidation(unittest.TestCase): def test_legacy_git_key_dies_with_hint(self): msg = "" try: - Manifest.from_json_obj({ + ManifestIndex.from_json_obj({ "bottles": {"dev": {"git": {"remotes": {}}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) @@ -247,7 +247,7 @@ class TestStaticKey(unittest.TestCase): """git-gate.repos entries with key.provider = "static".""" def test_static_key_minimal(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "bot-bottle": { "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "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) def test_static_key_sets_identity_file_at_parse_time(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "static", "path": "/dev/null"}, @@ -270,7 +270,7 @@ class TestStaticKey(unittest.TestCase): def test_static_key_missing_path_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "static"}, @@ -279,7 +279,7 @@ class TestStaticKey(unittest.TestCase): def test_static_key_unknown_field_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/foo.git", "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".""" def test_gitea_key_minimal(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "bot-bottle": { "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "key": { @@ -308,7 +308,7 @@ class TestGiteaKey(unittest.TestCase): self.assertEqual("", e.IdentityFile) def test_gitea_key_with_api_url(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "repo": { "url": "ssh://git@gitea.example.com/org/repo.git", "key": { @@ -321,7 +321,7 @@ class TestGiteaKey(unittest.TestCase): 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): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/didericis/foo.git", "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): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "gitea"}, @@ -340,7 +340,7 @@ class TestGiteaKey(unittest.TestCase): def test_gitea_key_unknown_field_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/foo.git", "key": { @@ -357,7 +357,7 @@ class TestKeyBlockValidation(unittest.TestCase): def test_missing_provider_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/foo.git", "key": {"path": "/dev/null"}, @@ -366,7 +366,7 @@ class TestKeyBlockValidation(unittest.TestCase): def test_unknown_provider_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": { "url": "ssh://git@github.com/foo.git", "key": {"provider": "github"}, @@ -375,14 +375,14 @@ class TestKeyBlockValidation(unittest.TestCase): def test_missing_key_block_dies(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest({ + ManifestIndex.from_json_obj(_manifest({ "foo": {"url": "ssh://git@github.com/foo.git"}, })) class TestEmptyGitGateField(unittest.TestCase): def test_no_git_gate_field_yields_empty_tuple(self): - m = Manifest.from_json_obj({ + m = ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) @@ -390,13 +390,13 @@ class TestEmptyGitGateField(unittest.TestCase): def test_git_gate_object_type_required(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj({ + ManifestIndex.from_json_obj({ "bottles": {"dev": {"git-gate": "not-a-dict"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) def test_empty_repos_yields_empty_tuple(self): - m = Manifest.from_json_obj({ + m = ManifestIndex.from_json_obj({ "bottles": {"dev": {"git-gate": {"repos": {}}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) diff --git a/tests/unit/test_manifest_git_user.py b/tests/unit/test_manifest_git_user.py index fee0091..074a12d 100644 --- a/tests/unit/test_manifest_git_user.py +++ b/tests/unit/test_manifest_git_user.py @@ -2,7 +2,7 @@ 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 @@ -23,7 +23,7 @@ def _manifest(git_user): # type: ignore class TestGitUserParsing(unittest.TestCase): def test_parses_both_fields(self): - m = Manifest.from_json_obj(_manifest({ + m = ManifestIndex.from_json_obj(_manifest({ "name": "Eric Bauerfeld", "email": "eric+claude@dideric.is", })) @@ -33,13 +33,13 @@ class TestGitUserParsing(unittest.TestCase): self.assertFalse(u.is_empty()) 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 self.assertEqual("Bot", u.name) self.assertEqual("", u.email) 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 self.assertEqual("", u.name) self.assertEqual("bot@example.com", u.email) @@ -47,7 +47,7 @@ class TestGitUserParsing(unittest.TestCase): def test_omitted_defaults_to_empty(self): # No git.user block at all → empty GitUser, is_empty True → # provisioner skips the `git config` step entirely. - m = Manifest.from_json_obj({ + m = ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) @@ -59,13 +59,13 @@ class TestGitUserParsing(unittest.TestCase): # / half-finished edit; fail loudly rather than silently # no-op (the operator clearly meant to configure something). msg = _error_message( - Manifest.from_json_obj, _manifest({"name": "", "email": ""}), + ManifestIndex.from_json_obj, _manifest({"name": "", "email": ""}), ) self.assertIn("neither name nor email", msg) def test_unknown_key_dies(self): msg = _error_message( - Manifest.from_json_obj, + ManifestIndex.from_json_obj, _manifest({"name": "Bot", "username": "bot"}), ) self.assertIn("unknown key", msg) @@ -73,19 +73,19 @@ class TestGitUserParsing(unittest.TestCase): def test_non_string_name_dies(self): 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) def test_non_string_email_dies(self): 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) def test_legacy_top_level_git_user_dies(self): msg = _error_message( - Manifest.from_json_obj, + ManifestIndex.from_json_obj, { "bottles": {"dev": {"git_user": {"name": "Bot"}}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, diff --git a/tests/unit/test_manifest_md_load.py b/tests/unit/test_manifest_md_load.py index b2d0cdf..4f04aae 100644 --- a/tests/unit/test_manifest_md_load.py +++ b/tests/unit/test_manifest_md_load.py @@ -11,7 +11,7 @@ import textwrap import unittest 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: @@ -45,7 +45,7 @@ _AGENT_IMPL = """ 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.""" def setUp(self) -> None: @@ -71,8 +71,8 @@ class _ResolveCase(unittest.TestCase): def cwd_cb(self) -> Path: return self.cwd_root / ".bot-bottle" - def resolve(self) -> Manifest: - return Manifest.resolve(str(self.cwd_root)) + def resolve(self) -> ManifestIndex: + return ManifestIndex.resolve(str(self.cwd_root)) class TestBottleFileParses(_ResolveCase): @@ -83,8 +83,7 @@ class TestBottleFileParses(_ResolveCase): _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) _write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL) m = self.resolve().load_for_agent("implementer") - self.assertIn("dev", m.bottles) - routes = m.bottles["dev"].egress.routes + routes = m.bottle.egress.routes self.assertEqual(2, len(routes)) self.assertEqual("api.anthropic.com", routes[0].Host) 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 / "agents" / "implementer.md", _AGENT_IMPL) m = self.resolve().load_for_agent("implementer") - a = m.agents["implementer"] + a = m.agent self.assertEqual("dev", a.bottle) self.assertEqual(("init-prd",), a.skills) # Body became the prompt; whitespace stripped. @@ -129,9 +128,9 @@ class TestCwdAgentOverridesHome(_ResolveCase): """, ) m = self.resolve().load_for_agent("implementer") - self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt) - # Home bottle still present - self.assertEqual(2, len(m.bottles["dev"].egress.routes)) + self.assertIn("CWD-OVERRIDE-PROMPT", m.agent.prompt) + # Home bottle still present with its two egress routes + self.assertEqual(2, len(m.bottle.egress.routes)) class TestCwdBottlesIgnored(_ResolveCase): @@ -159,7 +158,7 @@ class TestCwdBottlesIgnored(_ResolveCase): # Home value wins because cwd bottles are ignored entirely. self.assertEqual( "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): - """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 MD layout.""" def test_from_json_obj(self): - m = Manifest.from_json_obj({ + m = ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "hi", "bottle": "dev"}}, @@ -216,8 +215,8 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase): """, ) m = self.resolve().load_for_agent("implementer") - self.assertEqual("dev", m.agents["implementer"].bottle) - self.assertEqual(("init-prd",), m.agents["implementer"].skills) + self.assertEqual("dev", m.agent.bottle) + self.assertEqual(("init-prd",), m.agent.skills) class TestManifestEntryPointParity(_ResolveCase): @@ -229,7 +228,7 @@ class TestManifestEntryPointParity(_ResolveCase): _write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL) md_manifest = self.resolve().load_for_agent("implementer") - json_manifest = Manifest.from_json_obj({ + json_index = ManifestIndex.from_json_obj({ "bottles": { "dev": { "egress": { @@ -256,17 +255,17 @@ class TestManifestEntryPointParity(_ResolveCase): }) self.assertEqual( - md_manifest.agents["implementer"], - json_manifest.agents["implementer"], + md_manifest.agent, + json_index.agents["implementer"], ) self.assertEqual( - md_manifest.bottles["dev"].egress.routes, - json_manifest.bottles["dev"].egress.routes, + md_manifest.bottle.egress.routes, + json_index.bottles["dev"].egress.routes, ) def test_json_agent_rejects_unknown_keys(self): with self.assertRaises(ManifestError): - Manifest.from_json_obj({ + ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": { "implementer": { @@ -277,7 +276,7 @@ class TestManifestEntryPointParity(_ResolveCase): }) def test_json_agent_accepts_claude_code_passthrough_keys(self): - manifest = Manifest.from_json_obj({ + index = ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": { "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): @@ -359,7 +358,7 @@ class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase): self.assertIn("broken-agent", m.all_agent_names) # Valid agent loads fine. full = m.load_for_agent("implementer") - self.assertIn("implementer", full.agents) + self.assertEqual("dev", full.agent.bottle) # Broken bottle's agent raises at preflight. with self.assertRaises(ManifestError): m.load_for_agent("broken-agent") @@ -385,7 +384,7 @@ class TestNoManifestDies(_ResolveCase): self.resolve() 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.agents)) @@ -411,7 +410,7 @@ class TestUnknownBottleReferenceFailsAtPreflight(_ResolveCase): self.assertIn("implementer", m.all_agent_names) # Valid agent loads fine. full = m.load_for_agent("implementer") - self.assertIn("implementer", full.agents) + self.assertEqual("dev", full.agent.bottle) # Stray agent fails at preflight. with self.assertRaises(ManifestError): m.load_for_agent("stray") @@ -431,7 +430,3 @@ class TestFilenameValidation(_ResolveCase): self.assertIn("implementer", m.all_agent_names) self.assertNotIn("BadName", m.all_agent_names) self.assertNotIn("badname", m.all_agent_names) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/test_manifest_runtime.py b/tests/unit/test_manifest_runtime.py index 42def91..6015324 100644 --- a/tests/unit/test_manifest_runtime.py +++ b/tests/unit/test_manifest_runtime.py @@ -7,7 +7,7 @@ silently ignoring.""" import unittest 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]: @@ -19,7 +19,7 @@ def _manifest_with_runtime(value: object) -> dict[str, Any]: class TestManifestRuntimeRemoved(unittest.TestCase): def test_loads_when_runtime_absent(self): - m = Manifest.from_json_obj({ + m = ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) @@ -32,7 +32,7 @@ class TestManifestRuntimeRemoved(unittest.TestCase): for value in ("runsc", "runc", "kata-runtime", "", 42, None): with self.subTest(value=value): with self.assertRaises(ManifestError): - Manifest.from_json_obj(_manifest_with_runtime(value)) + ManifestIndex.from_json_obj(_manifest_with_runtime(value)) if __name__ == "__main__": diff --git a/tests/unit/test_plan_print_parity.py b/tests/unit/test_plan_print_parity.py index 3001fc3..f80b079 100644 --- a/tests/unit/test_plan_print_parity.py +++ b/tests/unit/test_plan_print_parity.py @@ -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.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream -from bot_bottle.manifest import Manifest +from bot_bottle.manifest import Manifest, ManifestIndex def _manifest() -> Manifest: - return Manifest.from_json_obj({ + return ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, - }) + }).load_for_agent("demo") def _spec(manifest: Manifest, tmp: str) -> BottleSpec: diff --git a/tests/unit/test_provision_git.py b/tests/unit/test_provision_git.py index e8eb549..6023333 100644 --- a/tests/unit/test_provision_git.py +++ b/tests/unit/test_provision_git.py @@ -10,7 +10,7 @@ from bot_bottle.git_gate import ( GIT_GATE_HOSTNAME, 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 @@ -72,7 +72,7 @@ class TestGitGateGitconfigRender(unittest.TestCase): def test_ip_upstream_emits_single_insteadof(self): # 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. - m = Manifest.from_json_obj({ + m = ManifestIndex.from_json_obj({ "bottles": {"dev": {"git-gate": {"repos": { "bot-bottle": { "url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index 3424c6b..3bbfe8c 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -33,7 +33,7 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec from bot_bottle.backend.util import AGENT_CA_PATH from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.git_gate import GitGatePlan, GitGateUpstream -from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, Manifest +from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, Manifest, ManifestIndex from bot_bottle.supervise import SupervisePlan @@ -110,7 +110,7 @@ def _plan( bottle_json["git-gate"] = git_gate_json if supervise: bottle_json["supervise"] = True - manifest = Manifest.from_json_obj({ + manifest = ManifestIndex.from_json_obj({ "bottles": {"dev": bottle_json}, "agents": { "demo": { @@ -119,7 +119,7 @@ def _plan( "bottle": "dev", }, }, - }) + }).load_for_agent("demo") spec = BottleSpec( manifest=manifest, agent_name="demo",