Defer broken manifest parse errors to preflight #239

Merged
didericis merged 8 commits from lazy-manifest-parse-on-select into main 2026-06-22 23:59:01 -04:00
49 changed files with 634 additions and 452 deletions
+1 -1
View File
@@ -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.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)
+26 -22
View File
@@ -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
Outdated
Review

Can you justify why a bottle needs to reference a manifest index? I don't see a great reason to have that be here. Try to argue for it/win me over.

Can you justify why a bottle needs to reference a manifest index? I don't see a great reason to have that be here. Try to argue for it/win me over.
agent_name: str
copy_cwd: bool
user_cwd: str
@@ -80,6 +80,7 @@ class BottlePlan(ABC):
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
spec: BottleSpec
manifest: Manifest
stage_dir: Path
git_gate_plan: GitGatePlan
@@ -112,9 +113,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 = self.manifest
agent = manifest.agent
bottle = manifest.bottle
env_names = visible_agent_env_names(
sorted(
@@ -131,7 +132,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}")
@@ -289,15 +290,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
write_launch_metadata,
)
self._validate(spec)
manifest = self._validate(spec)
self._preflight()
manifest = spec.manifest
manifest_bottle = manifest.bottle_for(spec.agent_name)
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)
@@ -313,7 +313,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
else:
agent_dockerfile_path = str(agent_provider.dockerfile)
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
agent_provision_plan = build_agent_provision_plan(
template=manifest_agent_provider.template,
@@ -337,6 +337,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
return self._resolve_plan(
spec,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
@@ -355,16 +356,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""
pass
def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists
and the named skills are present on the host. Subclasses with
def _validate(self, spec: BottleSpec) -> Manifest:
"""Cross-backend pre-launch checks. Parses the selected agent and
its bottle (raising ManifestError on invalid content), confirms
skills are present on the host, and every git IdentityFile resolves.
Returns the loaded Manifest for the selected agent. Subclasses with
additional preconditions should override and call
`super()._validate(spec)` first."""
manifest = spec.manifest
manifest.require_agent(spec.agent_name)
agent = manifest.agents[spec.agent_name]
self._validate_skills(agent.skills)
self._validate_agent_provider_dockerfile(spec)
manifest = spec.manifest.load_for_agent(spec.agent_name)
self._validate_skills(manifest.agent.skills)
self._validate_agent_provider_dockerfile(spec, manifest)
return manifest
def _validate_skills(self, skills: Sequence[str]) -> None:
"""Each named skill must be a directory under the host's
@@ -378,8 +381,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
f"Create it under ~/.claude/skills/, then re-run."
)
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
bottle = spec.manifest.bottle_for(spec.agent_name)
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
bottle = manifest.bottle
dockerfile = bottle.agent_provider.dockerfile
if not dockerfile:
return
@@ -389,13 +392,14 @@ 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
def _resolve_plan(self,
spec: BottleSpec,
*,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
+3
View File
@@ -30,6 +30,7 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
@@ -63,6 +64,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
self,
spec: BottleSpec,
*,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -73,6 +75,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
) -> DockerBottlePlan:
return _resolve_plan.resolve_plan(
spec,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
+1 -1
View File
@@ -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.manifest.bottle
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
def teardown() -> None:
@@ -18,6 +18,7 @@ from .. import BottleSpec
from ...env import ResolvedEnv
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
from ...manifest import Manifest
from ...supervise import SupervisePlan
from ...git_gate import GitGatePlan
@@ -31,6 +32,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
def resolve_plan(
spec: BottleSpec,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -48,6 +50,7 @@ def resolve_plan(
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir,
slug=slug,
forwarded_env=dict(resolved_env.forwarded),
@@ -11,6 +11,7 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
@@ -45,6 +46,7 @@ class MacosContainerBottleBackend(
self,
spec: BottleSpec,
*,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -55,6 +57,7 @@ class MacosContainerBottleBackend(
) -> MacosContainerBottlePlan:
return _resolve_plan.resolve_plan(
spec,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
+1 -1
View File
@@ -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.manifest.bottle
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
def teardown() -> None:
@@ -9,6 +9,7 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import BottleSpec
from . import util as container_mod
from .bottle_plan import MacosContainerBottlePlan
@@ -24,6 +25,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
def resolve_plan(
spec: BottleSpec,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -34,6 +36,7 @@ def resolve_plan(
) -> MacosContainerBottlePlan:
return MacosContainerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir,
slug=slug,
forwarded_env=dict(resolved_env.forwarded),
+3 -4
View File
@@ -26,7 +26,7 @@ from ..bottle_state import (
)
from ..egress import Egress, EgressPlan
from ..git_gate import GitGate, GitGatePlan
from ..manifest import ManifestBottle
from ..manifest import Manifest, ManifestBottle
from ..supervise import Supervise, SupervisePlan
from . import BottleSpec
@@ -66,11 +66,10 @@ def write_launch_metadata(
))
def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]:
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
"""Create the agent state subdir, write the prompt file.
Returns (agent_dir, prompt_file)."""
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
agent = manifest.agent
agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt"
@@ -18,6 +18,7 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv
from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup
from . import enumerate as _enumerate
@@ -55,6 +56,7 @@ class SmolmachinesBottleBackend(
self,
spec: BottleSpec,
*,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -65,6 +67,7 @@ class SmolmachinesBottleBackend(
) -> SmolmachinesBottlePlan:
return _resolve_plan.resolve_plan(
spec,
manifest=manifest,
slug=slug,
resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan,
+1 -1
View File
@@ -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.manifest.bottle
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
if teardown_exc is not None:
raise teardown_exc
@@ -13,6 +13,7 @@ from __future__ import annotations
from pathlib import Path
from .. import BottleSpec
from ...manifest import Manifest
from ...env import ResolvedEnv
from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan
@@ -46,6 +47,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
def resolve_plan(
spec: BottleSpec,
manifest: Manifest,
slug: str,
resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan,
@@ -67,6 +69,7 @@ def resolve_plan(
return SmolmachinesBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir,
slug=slug,
bundle_subnet=subnet,
+7 -6
View File
@@ -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,11 +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)
manifest = Manifest.resolve(USER_CWD)
manifest.require_agent(args.name)
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 ""
@@ -31,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:
+3 -3
View File
@@ -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,8 +40,8 @@ def cmd_list(argv: list[str]) -> int:
args = parser.parse_args(argv)
if args.scope == "available":
manifest = Manifest.resolve(USER_CWD)
for name in manifest.agents.keys():
manifest = ManifestIndex.resolve(USER_CWD)
for name in manifest.all_agent_names:
print(name)
return 0
+2 -2
View File
@@ -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(
+3 -3
View File
@@ -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,12 +62,12 @@ 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:
agent_name = tui.filter_select(
sorted(manifest.agents.keys()),
manifest.all_agent_names,
title="Select agent",
)
if agent_name is None:
+2 -2
View File
@@ -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.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.manifest.agent
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
+2 -2
View File
@@ -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.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.manifest.agent
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else 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:
from ...backend.util import host_skill_dir
agent = plan.spec.manifest.agents[plan.spec.agent_name]
agent = plan.manifest.agent
if not agent.skills:
return
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
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
+192 -97
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
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",
]
1
@@ -189,14 +203,64 @@ class ManifestBottle:
)
def _merge_git_user(
agent_user: ManifestGitUser, base_user: ManifestGitUser
) -> ManifestGitUser:
"""Merge the agent's git.user over the bottle's, agent-wins-on-non-empty."""
if agent_user.is_empty():
return base_user
return ManifestGitUser(
name=agent_user.name or base_user.name,
email=agent_user.email or base_user.email,
)
@dataclass(frozen=True)
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; 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/<name>.md bottles (home-only)
1
@@ -209,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.
didericis marked this conversation as resolved Outdated
Outdated
Review

Also don't load the agents here anymore, just load agent names/defer loading the full agent until preflight

Also don't load the agents here anymore, just load agent names/defer loading the full agent until preflight
@@ -248,25 +312,16 @@ class Manifest:
cls,
home_dir: Path,
cwd_dir: Path | None,
) -> "Manifest":
"""Programmatic entry point. Loads bottles from
`<home_dir>/bottles/`, home agents from `<home_dir>/agents/`,
and (if `cwd_dir` is passed) cwd agents from
`<cwd_dir>/agents/`. Cwd agents override home agents on
name collision. A `bottles/` subdir under `cwd_dir` is
logged as a warning and ignored.
) -> "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`.
Used by tests to build a Manifest from fixture directories
A `bottles/` subdir under `cwd_dir` is logged as a warning and
ignored the filesystem layout IS the trust boundary.
Used by tests to build a ManifestIndex from fixture directories
without touching `os.environ`."""
bottles_dir = home_dir / "bottles"
from .manifest_loader import load_agents_from_dir, load_bottles_from_dir
bottles = load_bottles_from_dir(bottles_dir)
bottle_names = set(bottles.keys())
agents_dir = home_dir / "agents"
agents = load_agents_from_dir(agents_dir, bottle_names, source="$HOME")
if cwd_dir is not None:
stale_bottles = cwd_dir / "bottles"
if stale_bottles.is_dir():
@@ -280,17 +335,11 @@ class Manifest:
f"live under $HOME/.bot-bottle/bottles/ "
f"(PRD 0011). Move them or delete."
)
cwd_agents_dir = cwd_dir / "agents"
cwd_agents = load_agents_from_dir(
cwd_agents_dir, bottle_names, source="$CWD"
)
agents = {**agents, **cwd_agents}
return cls(bottles=bottles, agents=agents)
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'")
@@ -311,75 +360,121 @@ class Manifest:
}
return cls(bottles=bottles, agents=agents)
@property
def all_agent_names(self) -> list[str]:
"""Sorted list of all discoverable agent names.
In names-only mode (from resolve/from_md_dirs) this scans agent
filenames without reading their content. In eager mode (from
from_json_obj) it returns the pre-parsed agents' names."""
if self.home_md is not None:
from .manifest_loader import scan_agent_names
home_names = set(scan_agent_names(self.home_md / "agents").keys())
cwd_names: set[str] = set()
if self.cwd_md is not None:
cwd_names = set(scan_agent_names(self.cwd_md / "agents").keys())
return sorted(home_names | cwd_names)
return sorted(self.agents.keys())
def load_for_agent(self, agent_name: str) -> "Manifest":
"""Parse the named agent and its bottle; return a single-value Manifest.
In lazy mode (from resolve/from_md_dirs) the agent file and its
bottle chain are read from disk for the first time here. In eager
mode (from_json_obj) the data is already parsed; this just filters
down to the requested agent and its bottle.
The returned Manifest.bottle has the agent's git-gate.user already
overlaid (agent wins on non-empty, per-field).
Always raises ManifestError if the agent is unknown or invalid.
Backends call this at preflight inside _validate."""
if self.home_md is None:
# Eager manifest (from_json_obj): data already parsed; filter to
# the one requested agent and its bottle so the returned Manifest
# always holds exactly one agent and one bottle regardless of path.
if agent_name not in self.agents:
available = ", ".join(sorted(self.agents.keys())) or "(none)"
raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}"
)
agent = self.agents[agent_name]
raw_bottle = self.bottles[agent.bottle]
merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
bottle = raw_bottle if merged == raw_bottle.git_user else replace(raw_bottle, git_user=merged)
return Manifest(agent=agent, bottle=bottle)
from .manifest_loader import load_bottle_chain_from_dir, scan_agent_names
from .manifest_schema import validate_agent_frontmatter_keys
from .yaml_subset import YamlSubsetError, parse_frontmatter
# Locate the agent file; cwd wins over home on name collision.
home_agents = scan_agent_names(self.home_md / "agents")
cwd_agents: dict[str, Path] = {}
if self.cwd_md is not None:
cwd_agents = scan_agent_names(self.cwd_md / "agents")
merged_agents = {**home_agents, **cwd_agents}
if agent_name not in merged_agents:
available = ", ".join(sorted(merged_agents.keys())) or "(none)"
raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}"
)
agent_path = merged_agents[agent_name]
try:
fm, body = parse_frontmatter(agent_path.read_text())
except OSError as e:
raise ManifestError(f"could not read {agent_path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{agent_path}: {e}") from e
validate_agent_frontmatter_keys(agent_path, fm.keys())
bottle_name = fm.get("bottle")
if not isinstance(bottle_name, str) or not bottle_name:
raise ManifestError(
f"agent '{agent_name}' must declare a 'bottle' field "
f"naming a defined bottle"
)
# Load the bottle chain (may raise ManifestError).
bottles_dir = self.home_md / "bottles"
raw_bottle = load_bottle_chain_from_dir(bottle_name, bottles_dir)
# Build and validate the full ManifestAgent.
agent_dict: dict[str, object] = {
"bottle": bottle_name,
"skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
agent = ManifestAgent.from_dict(agent_name, agent_dict, {bottle_name})
merged_user = _merge_git_user(agent.git_user, raw_bottle.git_user)
bottle = raw_bottle if merged_user == raw_bottle.git_user else replace(raw_bottle, git_user=merged_user)
return Manifest(agent=agent, bottle=bottle)
def has_agent(self, name: str) -> bool:
return name in self.agents
def require_agent(self, name: str) -> None:
"""Check that `name` is a discoverable agent. In names-only mode
this checks whether the .md file exists; in eager mode it checks
the pre-parsed agents dict. Does NOT parse file content."""
if self.has_agent(name):
return
available = ", ".join(self.agents.keys())
if available:
msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
raise ManifestError(msg)
raise ManifestError(
f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
)
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}"
if self.home_md is not None:
# Names-only mode: check file existence without parsing.
home_path = self.home_md / "agents" / f"{name}.md"
cwd_path = (
self.cwd_md / "agents" / f"{name}.md"
if self.cwd_md else None
)
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,
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
return
available = ", ".join(self.all_agent_names) or "(none)"
raise ManifestError(
f"agent '{name}' not defined. Available: {available}"
)
def bottle_for(self, agent_name: str) -> ManifestBottle:
"""Resolve the Bottle the named agent references, with the
agent's git.user overlaid on top. The validator guarantees both
lookups succeed for a manifest built via from_json_obj.
The overlay lives here, the single point both backends call to
resolve an agent's bottle, so the docker / smolmachines git
provisioners pick up the merged identity unchanged."""
bottle = self.bottles[self.agents[agent_name].bottle]
merged = self._effective_git_user(agent_name)
if merged == bottle.git_user:
return bottle
return replace(bottle, git_user=merged)
def git_identity_summary(self, agent_name: str) -> str | None:
"""One-line effective git identity with per-field provenance
for launch summaries, e.g.
`name=claude (agent), email=eric@dideric.is (bottle)`.
Returns None when neither agent nor bottle sets an identity."""
over = self.agents[agent_name].git_user
merged = self._effective_git_user(agent_name)
if merged.is_empty():
return None
parts: list[str] = []
if merged.name:
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
if merged.email:
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
return ", ".join(parts)
3
+44 -58
View File
@@ -8,21 +8,19 @@ from typing import TYPE_CHECKING
from .log import warn
from .manifest_schema import (
entity_name_from_path,
validate_agent_frontmatter_keys,
validate_bottle_frontmatter_keys,
)
from .manifest_util import ManifestError
from .yaml_subset import YamlSubsetError, parse_frontmatter
if TYPE_CHECKING:
from .manifest import ManifestAgent, ManifestBottle
from .manifest import ManifestBottle
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
not. The manifest format changed in PRD 0011 and we do not want
to silently leave the JSON content unused."""
from .manifest import ManifestError
legacy = dir_path / "bot-bottle.json"
if legacy.is_file() and not md_dir.exists():
raise ManifestError(
@@ -34,48 +32,13 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
)
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError
from .manifest_extends import resolve_bottles
def scan_agent_names(agents_dir: Path) -> dict[str, Path]:
"""Scan `<agents_dir>/*.md` for valid filenames and return `{name: path}`.
raws: dict[str, dict[str, object]] = {}
if not bottles_dir.is_dir():
return {}
for path in sorted(bottles_dir.glob("*.md")):
name = entity_name_from_path(path)
if name is None:
warn(
f"skipping {path}: filename must match "
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
)
continue
try:
fm, _body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = fm
return resolve_bottles(raws)
def load_agents_from_dir(
agents_dir: Path,
bottle_names: set[str],
*,
source: str, # noqa: F841 — unused, but required by interface
) -> dict[str, ManifestAgent]:
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict."""
from .manifest import ManifestAgent, ManifestError
out: dict[str, ManifestAgent] = {}
No file content is read. Invalid filenames are skipped with a warning."""
result: dict[str, Path] = {}
if not agents_dir.is_dir():
return out
return result
for path in sorted(agents_dir.glob("*.md")):
name = entity_name_from_path(path)
if name is None:
@@ -84,22 +47,45 @@ def load_agents_from_dir(
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
)
continue
result[name] = path
return result
def load_bottle_chain_from_dir(
bottle_name: str, bottles_dir: Path
) -> ManifestBottle:
"""Load `bottle_name` and its full `extends:` chain from `bottles_dir`,
returning the resolved ManifestBottle.
Only the files in the extends chain are read unrelated bottle files
are never touched. Raises ManifestError on parse or validation failure."""
from .manifest_extends import resolve_bottles
raws: dict[str, dict[str, object]] = {}
to_load = [bottle_name]
while to_load:
name = to_load.pop()
if name in raws:
continue
path = bottles_dir / f"{name}.md"
if not path.is_file():
avail = ", ".join(
p.stem for p in sorted(bottles_dir.glob("*.md")) if p.is_file()
) or "(none)"
raise ManifestError(
f"bottle '{name}' not found at {path}. "
f"Available: {avail}"
)
try:
fm, body = parse_frontmatter(path.read_text())
fm, _body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e
validate_agent_frontmatter_keys(path, fm.keys())
# Build the dict Agent.from_dict expects. The body becomes
# prompt; Claude Code passthrough fields stay in fm and get
# ignored by Agent.from_dict (reads bottle/skills/git-gate/prompt).
agent_dict: dict[str, object] = {
"bottle": fm.get("bottle"),
"skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names)
return out
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = dict(fm)
parent = fm.get("extends")
if isinstance(parent, str):
to_load.append(parent)
return resolve_bottles(raws)[bottle_name]
+7 -7
View File
@@ -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:
@@ -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": {
+2 -2
View File
@@ -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
@@ -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,
@@ -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": {
+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.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": {
+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.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": {
+3 -2
View File
@@ -20,6 +20,7 @@ from bot_bottle.backend import ActiveAgent
def _make_manifest(agent_names: list[str]):
manifest = MagicMock()
manifest.agents = {name: MagicMock() for name in agent_names}
manifest.all_agent_names = sorted(agent_names)
return manifest
@@ -30,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()
@@ -149,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()
+11 -14
View File
@@ -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,22 +61,12 @@ 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"}},
})
def _spec(*, supervise: bool, with_git: bool, with_egress: bool) -> BottleSpec:
return BottleSpec(
manifest=_manifest(
supervise=supervise, with_git=with_git, with_egress=with_egress,
),
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp/x",
)
def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
return GitGatePlan(
@@ -146,9 +136,16 @@ def _plan(
roles=(),
),)
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress)
spec = BottleSpec(
manifest=index,
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp/x",
)
return DockerBottlePlan(
spec=spec,
manifest=index.load_for_agent("demo"),
stage_dir=STAGE,
slug=SLUG,
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
+5 -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.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.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({
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
@@ -65,8 +65,9 @@ def _plan(
},
},
})
manifest = index.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
manifest=index, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
)
supervise_plan = None
@@ -78,6 +79,7 @@ def _plan(
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
forwarded_env={},
+5 -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.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.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({
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
@@ -65,8 +65,9 @@ def _plan(
},
},
})
manifest = index.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
manifest=index, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
)
supervise_plan = None
@@ -78,6 +79,7 @@ def _plan(
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
forwarded_env={},
+5 -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.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import 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({
index = ManifestIndex.from_json_obj({
"bottles": {"dev": {"agent_provider": {"template": "pi"}}},
"agents": {
"demo": {
@@ -53,12 +53,14 @@ def _plan(
},
},
})
manifest = index.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
manifest=index, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x",
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=Path("/tmp/stage"),
slug="demo-abc12",
forwarded_env={},
+8 -9
View File
@@ -21,21 +21,19 @@ from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
def _manifest() -> Manifest:
return Manifest.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
_INDEX = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def _plan(tmp: str) -> DockerBottlePlan:
stage = Path(tmp)
manifest = _manifest()
manifest = _INDEX.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest,
manifest=_INDEX,
agent_name="demo",
copy_cwd=False,
user_cwd=tmp,
@@ -43,6 +41,7 @@ def _plan(tmp: str) -> DockerBottlePlan:
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage,
git_gate_plan=GitGatePlan(
slug="test-teardown-00001",
+5 -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.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import Manifest
from bot_bottle.manifest import ManifestIndex
class _Provider(AgentProvider):
@@ -51,16 +51,18 @@ 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({
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
manifest = index.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest, agent_name="demo",
manifest=index, agent_name="demo",
copy_cwd=copy_cwd, user_cwd=user_cwd,
)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
forwarded_env={},
+4 -4
View File
@@ -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"}],
+2 -2
View File
@@ -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",
@@ -11,6 +11,12 @@ from unittest.mock import patch
from bot_bottle.backend.macos_container import launch
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
from bot_bottle.manifest import ManifestIndex
_MANIFEST = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).load_for_agent("demo")
def _plan(
@@ -67,6 +73,7 @@ def _plan(
)
return cast(MacosContainerBottlePlan, SimpleNamespace(
spec=SimpleNamespace(),
manifest=_MANIFEST,
stage_dir=stage_dir,
slug="dev-abc",
container_name="bot-bottle-dev-abc",
@@ -193,6 +200,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
)
plan = MacosContainerBottlePlan(
spec=base.spec,
manifest=base.manifest,
stage_dir=base.stage_dir,
git_gate_plan=base.git_gate_plan,
egress_plan=base.egress_plan,
+58 -30
View File
@@ -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: dict[str, object] | None = None, agent_git: dict[str, object] | None = None) -> ManifestIndex:
"""Build an index with one agent 'impl' without loading it."""
bottle: dict = {} # type: ignore
if bottle_user is not None:
bottle = {"git-gate": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
if agent_git is not None:
agent["git-gate"] = agent_git
return ManifestIndex.from_json_obj({
"bottles": {"dev": bottle},
"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,19 +238,26 @@ 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))
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_dies(self):
def test_md_agent_repos_fails_at_preflight(self):
"""git-gate.repos on an agent is an error; resolve() still succeeds
so other agents remain accessible, but load_for_agent raises."""
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_REPOS)
msg = _error_message(Manifest.resolve, str(self.home))
from bot_bottle.manifest import ManifestError
names = ManifestIndex.resolve(str(self.home))
self.assertIn("impl", names.all_agent_names)
with self.assertRaises(ManifestError) as ctx:
names.load_for_agent("impl")
msg = str(ctx.exception)
self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg)
+11 -11
View File
@@ -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"}},
+2 -2
View File
@@ -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},
+38 -38
View File
@@ -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"}},
})
+10 -10
View File
@@ -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"}},
+97 -62
View File
@@ -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, 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,20 +71,19 @@ 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):
"""SC #1: a bottle file under $HOME/.bot-bottle/bottles/
parses into the expected Bottle shape."""
parses into the expected Bottle shape via load_for_agent."""
def test_loads(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
self.assertIn("dev", m.bottles)
routes = m.bottles["dev"].egress.routes
m = self.resolve().load_for_agent("implementer")
routes = m.bottle.egress.routes
self.assertEqual(2, len(routes))
self.assertEqual("api.anthropic.com", routes[0].Host)
self.assertEqual("Bearer", routes[0].AuthScheme)
@@ -94,14 +93,14 @@ class TestBottleFileParses(_ResolveCase):
class TestAgentFileParses(_ResolveCase):
"""SC #2: an agent file under $HOME/.bot-bottle/agents/
parses, the body becomes the prompt, the frontmatter fields
map to Agent fields."""
parses via load_for_agent; the body becomes the prompt, the
frontmatter fields map to Agent fields."""
def test_loads(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
a = m.agents["implementer"]
m = self.resolve().load_for_agent("implementer")
a = m.agent
self.assertEqual("dev", a.bottle)
self.assertEqual(("init-prd",), a.skills)
# Body became the prompt; whitespace stripped.
@@ -128,10 +127,10 @@ class TestCwdAgentOverridesHome(_ResolveCase):
CWD-OVERRIDE-PROMPT
""",
)
m = self.resolve()
self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt)
# Home bottle still present
self.assertEqual(2, len(m.bottles["dev"].egress.routes))
m = self.resolve().load_for_agent("implementer")
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):
@@ -155,11 +154,11 @@ class TestCwdBottlesIgnored(_ResolveCase):
---
""",
)
m = self.resolve()
m = self.resolve().load_for_agent("implementer")
# Home value wins because cwd bottles are ignored entirely.
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"}},
@@ -215,9 +214,9 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase):
Agent prompt body.
""",
)
m = self.resolve()
self.assertEqual("dev", m.agents["implementer"].bottle)
self.assertEqual(("init-prd",), m.agents["implementer"].skills)
m = self.resolve().load_for_agent("implementer")
self.assertEqual("dev", m.agent.bottle)
self.assertEqual(("init-prd",), m.agent.skills)
class TestManifestEntryPointParity(_ResolveCase):
@@ -228,8 +227,8 @@ class TestManifestEntryPointParity(_ResolveCase):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
md_manifest = self.resolve()
json_manifest = Manifest.from_json_obj({
md_manifest = self.resolve().load_for_agent("implementer")
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,37 +290,51 @@ class TestManifestEntryPointParity(_ResolveCase):
},
})
self.assertEqual("dev", manifest.agents["implementer"].bottle)
self.assertEqual("dev", index.agents["implementer"].bottle)
class TestUnknownAgentKeyDies(_ResolveCase):
"""A typo'd / unknown frontmatter key on an agent file dies
rather than silently ignoring."""
class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase):
"""A typo'd / unknown frontmatter key on an agent file does NOT crash
resolve(). The agent appears in all_agent_names for the selector.
The error surfaces only when load_for_agent is called for that agent."""
def test_dies(self):
def test_resolve_succeeds_despite_broken_agent(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "implementer.md",
self.home_cb / "agents" / "bad.md",
"""
---
bottle: dev
skillz: [init-prd]
---
...
""",
)
with self.assertRaises(ManifestError):
self.resolve()
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
# Resolve itself does not raise; broken agent appears in the name list.
self.assertIn("bad", m.all_agent_names)
self.assertIn("implementer", m.all_agent_names)
class TestUnknownBottleKeyDies(_ResolveCase):
"""A typo'd / unknown frontmatter key on a bottle file dies
rather than silently ignoring."""
def test_dies(self):
def test_load_for_agent_raises_for_broken_agent(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "bottles" / "dev.md",
self.home_cb / "agents" / "bad.md",
"""
---
bottle: dev
skillz: [init-prd]
---
""",
)
m = self.resolve()
with self.assertRaises(ManifestError):
m.load_for_agent("bad")
def test_broken_bottle_only_fails_at_preflight(self):
"""A broken bottle does not crash resolve; only load_for_agent for
an agent that references it raises. Unrelated agents still work."""
_write(
self.home_cb / "bottles" / "bad.md",
"""
---
credproxy:
@@ -329,9 +342,26 @@ class TestUnknownBottleKeyDies(_ResolveCase):
---
""",
)
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
_write(
self.home_cb / "agents" / "broken-agent.md",
"""
---
bottle: bad
---
""",
)
m = self.resolve()
# Both agents appear in the name list at resolve time.
self.assertIn("implementer", m.all_agent_names)
self.assertIn("broken-agent", m.all_agent_names)
# Valid agent loads fine.
full = m.load_for_agent("implementer")
self.assertEqual("dev", full.agent.bottle)
# Broken bottle's agent raises at preflight.
with self.assertRaises(ManifestError):
self.resolve()
m.load_for_agent("broken-agent")
class TestStaleJsonDies(_ResolveCase):
@@ -354,16 +384,16 @@ 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))
class TestUnknownBottleReferenceDies(_ResolveCase):
"""An agent file naming a bottle that doesn't exist on disk
dies with the existing "bottle not defined" error."""
class TestUnknownBottleReferenceFailsAtPreflight(_ResolveCase):
"""An agent file naming a non-existent bottle appears in all_agent_names
at resolve time; the error only surfaces when load_for_agent is called."""
def test_dies(self):
def test_stray_bottle_reference_fails_at_preflight(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "stray.md",
@@ -373,8 +403,17 @@ class TestUnknownBottleReferenceDies(_ResolveCase):
---
""",
)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
# Both names visible at resolve time.
self.assertIn("stray", m.all_agent_names)
self.assertIn("implementer", m.all_agent_names)
# Valid agent loads fine.
full = m.load_for_agent("implementer")
self.assertEqual("dev", full.agent.bottle)
# Stray agent fails at preflight.
with self.assertRaises(ManifestError):
self.resolve()
m.load_for_agent("stray")
class TestFilenameValidation(_ResolveCase):
@@ -388,10 +427,6 @@ class TestFilenameValidation(_ResolveCase):
# This file should be skipped — capital letters not allowed.
_write(self.home_cb / "agents" / "BadName.md", _AGENT_IMPL)
m = self.resolve()
self.assertIn("implementer", m.agents)
self.assertNotIn("BadName", m.agents)
self.assertNotIn("badname", m.agents)
if __name__ == "__main__":
unittest.main()
self.assertIn("implementer", m.all_agent_names)
self.assertNotIn("BadName", m.all_agent_names)
self.assertNotIn("badname", m.all_agent_names)
+3 -3
View File
@@ -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__":
+19 -18
View File
@@ -19,19 +19,18 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
from bot_bottle.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({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
_INDEX = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
})
def _spec(manifest: Manifest, tmp: str) -> BottleSpec:
def _spec(index: ManifestIndex, tmp: str) -> BottleSpec:
return BottleSpec(
manifest=manifest,
manifest=index,
agent_name="demo",
copy_cwd=False,
user_cwd=tmp,
@@ -92,10 +91,11 @@ def _agent_provision(tmp: str) -> AgentProvisionPlan:
)
def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
def _docker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> DockerBottlePlan:
stage = Path(tmp)
return DockerBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp),
@@ -107,10 +107,11 @@ def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
)
def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
def _smolmachines_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> SmolmachinesBottlePlan:
stage = Path(tmp)
return SmolmachinesBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp),
@@ -140,10 +141,10 @@ class TestGitGatePrintParity(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
manifest = _manifest()
spec = _spec(manifest, self._tmp)
self._docker_lines = _capture_print(_docker_plan(spec, self._tmp))
self._smol_lines = _capture_print(_smolmachines_plan(spec, self._tmp))
manifest = _INDEX.load_for_agent("demo")
spec = _spec(_INDEX, self._tmp)
self._docker_lines = _capture_print(_docker_plan(spec, manifest, self._tmp))
self._smol_lines = _capture_print(_smolmachines_plan(spec, manifest, self._tmp))
def _git_gate_lines(self, lines: list[str]) -> list[str]:
return [ln for ln in lines if "git gate" in ln]
@@ -170,10 +171,10 @@ class TestEgressPrintParity(unittest.TestCase):
def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
manifest = _manifest()
spec = _spec(manifest, self._tmp)
self._docker_lines = _capture_print(_docker_plan(spec, self._tmp))
self._smol_lines = _capture_print(_smolmachines_plan(spec, self._tmp))
manifest = _INDEX.load_for_agent("demo")
spec = _spec(_INDEX, self._tmp)
self._docker_lines = _capture_print(_docker_plan(spec, manifest, self._tmp))
self._smol_lines = _capture_print(_smolmachines_plan(spec, manifest, self._tmp))
def _egress_section(self, lines: list[str]) -> list[str]:
"""Return lines from the egress label through the last route entry.
+2 -2
View File
@@ -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",
+5 -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.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, 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({
index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json},
"agents": {
"demo": {
@@ -120,8 +120,9 @@ def _plan(
},
},
})
manifest = index.load_for_agent("demo")
spec = BottleSpec(
manifest=manifest,
manifest=index,
agent_name="demo",
copy_cwd=copy_cwd,
user_cwd=user_cwd,
@@ -135,6 +136,7 @@ def _plan(
)
return SmolmachinesBottlePlan(
spec=spec,
manifest=manifest,
stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12",
bundle_subnet="192.168.50.0/24",