Compare commits

...

8 Commits

Author SHA1 Message Date
didericis-claude c6d0642a94 refactor(types): move loaded manifest from BottleSpec to BottlePlan
lint / lint (push) Successful in 1m35s
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 19s
BottleSpec.manifest was ManifestIndex | Manifest — a union encoding
two lifecycle stages in one field. The union was unjustifiable:
it forced a type-narrowing workaround (loaded_manifest property)
on every consumer.

Clean split:
- BottleSpec.manifest: ManifestIndex (always; CLI-supplied intent)
- BottlePlan.manifest: Manifest (always; loaded by _validate())

_validate() returns the loaded Manifest directly. prepare() passes
it to _resolve_plan(), which stores it on the plan. All provisioner
code now reads plan.manifest.agent / plan.manifest.bottle — no
union, no asserts, no type: ignore.
2026-06-22 23:43:08 -04:00
didericis-claude 5becef0d0b fix(types): add BottleSpec.loaded_manifest to satisfy pyright on union type
BottleSpec.manifest is ManifestIndex | Manifest (pre/post _validate()).
Downstream code always runs post-validate so it needs Manifest, but
pyright flagged every .agent/.bottle access. The new loaded_manifest
property asserts isinstance and returns Manifest, giving pyright a
narrowed type without scattering type: ignore everywhere.

Also remove unused Manifest imports from test files and annotate the
_index() helper in test_manifest_agent_git_user.
2026-06-22 23:43:08 -04:00
didericis-claude 532072931f refactor(manifest): split Manifest into ManifestIndex + Manifest single-value type
Manifest now holds exactly one agent and one effective bottle (with
git_user overlay already applied). The old multi-agent/bottle
collection is renamed ManifestIndex. BottleSpec.manifest starts as
ManifestIndex from the CLI and becomes Manifest after _validate()
calls load_for_agent(); all provisioning code downstream reads
spec.manifest.agent / spec.manifest.bottle instead of indexing by name.
2026-06-22 23:43:08 -04:00
didericis-claude 9ae49d21f7 docs: clarify load_for_agent invariant in docstring 2026-06-22 23:43:08 -04:00
didericis-claude db712a523f fix: load_for_agent always returns single-agent manifest
Filter to exactly one agent and one bottle in both the lazy (md-dirs)
and eager (from_json_obj) paths so the returned manifest invariant
holds regardless of how the manifest was constructed.
2026-06-22 23:43:08 -04:00
didericis-claude 04f8019e75 refactor: scan filenames at resolve, parse only selected agent at preflight
Manifest.resolve() now returns an empty-dict manifest with only directory
paths recorded (home_md, cwd_md). No content is read from any .md file
until load_for_agent() is called for a specific agent at preflight.

- Manifest.from_md_dirs: scan-only, no frontmatter parsing
- Manifest.load_for_agent: parses the selected agent file and its bottle
  chain; works on eager (from_json_obj) manifests too by returning self
- Manifest.all_agent_names: scans filenames in lazy mode
- backend._validate: calls load_for_agent and propagates upgraded spec
- cli/info.py, cli/list.py, cli/start.py: use load_for_agent / all_agent_names
- manifest_extends.py: reverted to original (no partial-resolve helpers)
- manifest_loader.py: only scan_agent_names + load_bottle_chain_from_dir
- Tests updated to call load_for_agent before accessing agents/bottles;
  test_md_agent_repos_deferred renamed to test_md_agent_repos_fails_at_preflight
2026-06-22 23:43:08 -04:00
didericis-claude 509adb7cbc fix: resolve pyright reportUnusedImport in manifest_extends
Import ManifestError at module level from manifest_util (no circular
dep) and remove the redundant local imports from function bodies that
were shadowing it. ManifestBottle retains its local import pattern to
avoid the circular manifest ↔ manifest_extends dependency.
2026-06-22 23:43:08 -04:00
didericis-claude 21b3713264 feat: defer broken manifest parse errors to preflight
Broken bottle/agent files no longer block the agent selector or prevent
unrelated agents from loading. Per-file parse errors are collected in
`Manifest.broken_agents`; the CLI selector includes them via
`all_agent_names`, and the error surfaces only when the specific agent
is selected and launch is attempted (in `require_agent`/`bottle_for`).

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