Compare commits

..

2 Commits

Author SHA1 Message Date
didericis-claude df469b2f47 docs: add role and git.fetch to egress route fields table
Both fields were missing from the reference table added in the preceding
commit — `role` is visible in examples/bottles/claude.md and `git.fetch`
is documented in PRD 0052 but neither appeared in the README table.
2026-06-22 18:31:32 +00:00
didericis d1d9e7a105 docs: document egress matches, dlp fields, and detector defaults
lint / lint (push) Successful in 1m32s
2026-06-19 21:58:20 -04:00
11 changed files with 167 additions and 261 deletions
+26 -2
View File
@@ -14,7 +14,7 @@
## Features ## Features
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default. - **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only. - **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential. - **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load. - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
@@ -106,8 +106,15 @@ egress:
routes: routes:
- host: gitea.dideric.is - host: gitea.dideric.is
auth: auth:
scheme: token scheme: token # Bearer | token
token_ref: BOT_BOTTLE_GITEA_TOKEN token_ref: BOT_BOTTLE_GITEA_TOKEN
matches: # optional — restrict to specific paths/methods/headers
- paths:
- {type: prefix, value: /api/v1/}
methods: [GET, POST, PATCH, DELETE]
dlp: # optional — per-route detector overrides (default: all on)
outbound_detectors: [token_patterns, known_secrets]
inbound_detectors: false # disable response scanning for this host
--- ---
The `gitea-dev` bottle. Provider auth via the inherited Claude route; The `gitea-dev` bottle. Provider auth via the inherited Claude route;
@@ -126,6 +133,23 @@ skills:
You help maintain Gitea-hosted projects. You help maintain Gitea-hosted projects.
```` ````
**Egress route fields:**
| Field | Required | Description |
|---|---|---|
| `host` | yes | Hostname to allowlist. One entry per host. |
| `role` | no | Provider-specific role string (e.g. `claude_code_oauth`). Wires built-in auth flows; set by provider templates, not manually. |
| `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. |
| `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. |
| `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. |
| `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. |
| `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. |
| `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. |
| `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). |
| `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). |
| `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). |
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`. More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
## Trademarks ## Trademarks
+9 -14
View File
@@ -37,7 +37,7 @@ import shlex
import sys import sys
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from dataclasses import dataclass, replace from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar from typing import Any, Generic, Sequence, TypeVar
@@ -289,7 +289,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
write_launch_metadata, write_launch_metadata,
) )
spec = self._validate(spec) self._validate(spec)
self._preflight() self._preflight()
@@ -355,23 +355,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
""" """
pass pass
def _validate(self, spec: BottleSpec) -> BottleSpec: def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Parses the selected agent and """Cross-backend pre-launch checks. Confirms the agent exists,
its bottle (raising ManifestError on invalid content), confirms the named skills are present on the host, and every git
skills are present on the host, and every git IdentityFile resolves. IdentityFile resolves. Subclasses with additional preconditions
should override and call `super()._validate(spec)` first."""
Returns a new BottleSpec whose manifest is fully loaded for the manifest = spec.manifest
selected agent. Subclasses with additional preconditions should manifest.require_agent(spec.agent_name)
override and call `super()._validate(spec)` first, using the
returned spec for further checks."""
manifest = spec.manifest.load_for_agent(spec.agent_name)
spec = replace(spec, manifest=manifest)
agent = manifest.agents[spec.agent_name] agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
self._validate_skills(agent.skills) self._validate_skills(agent.skills)
self._validate_git_entries(bottle.git) self._validate_git_entries(bottle.git)
self._validate_agent_provider_dockerfile(spec) self._validate_agent_provider_dockerfile(spec)
return 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
+2 -3
View File
@@ -14,9 +14,8 @@ def cmd_info(argv: list[str]) -> int:
parser.add_argument("name", help="agent name defined in bot-bottle.json") parser.add_argument("name", help="agent name defined in bot-bottle.json")
args = parser.parse_args(argv) args = parser.parse_args(argv)
names = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
names.require_agent(args.name) manifest.require_agent(args.name)
manifest = names.load_for_agent(args.name)
agent = manifest.agents[args.name] agent = manifest.agents[args.name]
bottle = manifest.bottle_for(args.name) bottle = manifest.bottle_for(args.name)
+1 -1
View File
@@ -52,7 +52,7 @@ def cmd_list(argv: list[str]) -> int:
if args.scope == "available": if args.scope == "available":
manifest = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
for name in manifest.all_agent_names: for name in manifest.agents.keys():
print(name) print(name)
return 0 return 0
+1 -1
View File
@@ -65,7 +65,7 @@ def cmd_start(argv: list[str]) -> int:
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(
manifest.all_agent_names, sorted(manifest.agents.keys()),
title="Select agent", title="Select agent",
) )
if agent_name is None: if agent_name is None:
+29 -112
View File
@@ -193,10 +193,6 @@ class ManifestBottle:
class Manifest: class Manifest:
bottles: Mapping[str, ManifestBottle] bottles: Mapping[str, ManifestBottle]
agents: Mapping[str, ManifestAgent] agents: Mapping[str, ManifestAgent]
# Set by from_md_dirs; empty in from_json_obj (test/programmatic) mode.
# 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) -> "Manifest":
@@ -253,15 +249,24 @@ class Manifest:
home_dir: Path, home_dir: Path,
cwd_dir: Path | None, cwd_dir: Path | None,
) -> "Manifest": ) -> "Manifest":
"""Return a names-only Manifest. No file content is read; only """Programmatic entry point. Loads bottles from
filenames are scanned for the agent selector. Full parsing happens `<home_dir>/bottles/`, home agents from `<home_dir>/agents/`,
later, per-agent, via `load_for_agent`. and (if `cwd_dir` is passed) cwd agents from
`<cwd_dir>/agents/`. Cwd agents override home agents on
A `bottles/` subdir under `cwd_dir` is logged as a warning and name collision. A `bottles/` subdir under `cwd_dir` is
ignored — the filesystem layout IS the trust boundary. logged as a warning and ignored.
Used by tests to build a Manifest from fixture directories Used by tests to build a Manifest 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():
@@ -275,7 +280,13 @@ 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."
) )
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir) cwd_agents_dir = cwd_dir / "agents"
cwd_agents = load_agents_from_dir(
cwd_agents_dir, bottle_names, source="$CWD"
)
agents = {**agents, **cwd_agents}
return cls(bottles=bottles, agents=agents)
@classmethod @classmethod
def from_json_obj(cls, obj: object) -> "Manifest": def from_json_obj(cls, obj: object) -> "Manifest":
@@ -300,113 +311,18 @@ 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 and return a full Manifest for `agent_name` and its bottle.
Only the selected agent's file and the bottle files in its extends
chain are read. Raises ManifestError if the agent or bottle is
invalid. Must be called on a names-only manifest (from resolve).
Backends call this at preflight to upgrade the spec's manifest."""
if self.home_md is None:
# Eager manifest (from_json_obj): already fully loaded; just validate name.
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}"
)
return self
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 = {**home_agents, **cwd_agents}
if agent_name not in merged:
available = ", ".join(sorted(merged.keys())) or "(none)"
raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}"
)
agent_path = merged[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"
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})
return Manifest(
bottles={bottle_name: bottle},
agents={agent_name: agent},
home_md=self.home_md,
cwd_md=self.cwd_md,
)
def has_agent(self, name: str) -> bool: def has_agent(self, name: str) -> bool:
return name in self.agents return name in self.agents
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
if self.home_md is not None: available = ", ".join(self.agents.keys())
# Names-only mode: check file existence without parsing. if available:
home_path = self.home_md / "agents" / f"{name}.md" msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
cwd_path = ( raise ManifestError(msg)
self.cwd_md / "agents" / f"{name}.md"
if self.cwd_md else None
)
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
return
available = ", ".join(self.all_agent_names) or "(none)"
raise ManifestError( raise ManifestError(
f"agent '{name}' not defined. Available: {available}" f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
) )
def has_bottle(self, name: str) -> bool: def has_bottle(self, name: str) -> bool:
@@ -440,7 +356,8 @@ class Manifest:
def bottle_for(self, agent_name: str) -> ManifestBottle: def bottle_for(self, agent_name: str) -> ManifestBottle:
"""Resolve the Bottle the named agent references, with the """Resolve the Bottle the named agent references, with the
agent's git.user overlaid on top. 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 The overlay lives here, the single point both backends call to
resolve an agent's bottle, so the docker / smolmachines git resolve an agent's bottle, so the docker / smolmachines git
+58 -44
View File
@@ -8,19 +8,21 @@ 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 ManifestBottle from .manifest import ManifestAgent, 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(
@@ -32,13 +34,48 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
) )
def scan_agent_names(agents_dir: Path) -> dict[str, Path]: def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
"""Scan `<agents_dir>/*.md` for valid filenames and return `{name: path}`. """Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError
from .manifest_extends import resolve_bottles
No file content is read. Invalid filenames are skipped with a warning.""" raws: dict[str, dict[str, object]] = {}
result: dict[str, Path] = {} if not bottles_dir.is_dir():
return {}
for path in sorted(bottles_dir.glob("*.md")):
name = entity_name_from_path(path)
if name is None:
warn(
f"skipping {path}: filename must match "
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
)
continue
try:
fm, _body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = fm
return resolve_bottles(raws)
def load_agents_from_dir(
agents_dir: Path,
bottle_names: set[str],
*,
source: str, # noqa: F841 — unused, but required by interface
) -> dict[str, ManifestAgent]:
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict."""
from .manifest import ManifestAgent, ManifestError
out: dict[str, ManifestAgent] = {}
if not agents_dir.is_dir(): if not agents_dir.is_dir():
return result return out
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:
@@ -47,45 +84,22 @@ def scan_agent_names(agents_dir: Path) -> dict[str, Path]:
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_bottle_frontmatter_keys(path, fm.keys()) validate_agent_frontmatter_keys(path, fm.keys())
raws[name] = dict(fm) # Build the dict Agent.from_dict expects. The body becomes
parent = fm.get("extends") # prompt; Claude Code passthrough fields stay in fm and get
if isinstance(parent, str): # ignored by Agent.from_dict (reads bottle/skills/git-gate/prompt).
to_load.append(parent) agent_dict: dict[str, object] = {
"bottle": fm.get("bottle"),
return resolve_bottles(raws)[bottle_name] "skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names)
return out
+6 -1
View File
@@ -5,10 +5,15 @@ agent_provider:
egress: egress:
routes: routes:
- host: api.anthropic.com - host: api.anthropic.com
role: claude_code_oauth role: claude_code_oauth # wires Claude Code OAuth; do not change
auth: auth:
scheme: Bearer scheme: Bearer
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
# dlp is omitted → all detectors on by default (token_patterns,
# known_secrets outbound; naive_injection_detection inbound).
# To disable inbound scanning for this route:
# dlp:
# inbound_detectors: false
--- ---
Common Claude provider boundary. Drop this file into Common Claude provider boundary. Drop this file into
-1
View File
@@ -19,7 +19,6 @@ import bot_bottle.cli.tui as tui_mod
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
+3 -10
View File
@@ -217,7 +217,7 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
def test_md_agent_git_user_overlays_bottle(self): def test_md_agent_git_user_overlays_bottle(self):
self._write("bottles/dev.md", _BOTTLE_DEV) self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_GIT) self._write("agents/impl.md", _AGENT_WITH_GIT)
m = Manifest.resolve(str(self.home)).load_for_agent("impl") m = Manifest.resolve(str(self.home))
u = m.bottle_for("impl").git_user u = m.bottle_for("impl").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)
@@ -226,17 +226,10 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
m.git_identity_summary("impl"), m.git_identity_summary("impl"),
) )
def test_md_agent_repos_fails_at_preflight(self): def test_md_agent_repos_dies(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)
from bot_bottle.manifest import ManifestError msg = _error_message(Manifest.resolve, str(self.home))
names = Manifest.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)
+32 -72
View File
@@ -77,12 +77,12 @@ class _ResolveCase(unittest.TestCase):
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 via load_for_agent.""" parses into the expected Bottle shape."""
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().load_for_agent("implementer") m = self.resolve()
self.assertIn("dev", m.bottles) self.assertIn("dev", m.bottles)
routes = m.bottles["dev"].egress.routes routes = m.bottles["dev"].egress.routes
self.assertEqual(2, len(routes)) self.assertEqual(2, len(routes))
@@ -94,13 +94,13 @@ 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 via load_for_agent; the body becomes the prompt, the parses, the body becomes the prompt, the frontmatter fields
frontmatter fields map to Agent 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().load_for_agent("implementer") m = self.resolve()
a = m.agents["implementer"] a = m.agents["implementer"]
self.assertEqual("dev", a.bottle) self.assertEqual("dev", a.bottle)
self.assertEqual(("init-prd",), a.skills) self.assertEqual(("init-prd",), a.skills)
@@ -128,7 +128,7 @@ class TestCwdAgentOverridesHome(_ResolveCase):
CWD-OVERRIDE-PROMPT CWD-OVERRIDE-PROMPT
""", """,
) )
m = self.resolve().load_for_agent("implementer") m = self.resolve()
self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt) self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt)
# Home bottle still present # Home bottle still present
self.assertEqual(2, len(m.bottles["dev"].egress.routes)) self.assertEqual(2, len(m.bottles["dev"].egress.routes))
@@ -155,7 +155,7 @@ class TestCwdBottlesIgnored(_ResolveCase):
--- ---
""", """,
) )
m = self.resolve().load_for_agent("implementer") m = self.resolve()
# 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",
@@ -215,7 +215,7 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase):
Agent prompt body. Agent prompt body.
""", """,
) )
m = self.resolve().load_for_agent("implementer") m = self.resolve()
self.assertEqual("dev", m.agents["implementer"].bottle) self.assertEqual("dev", m.agents["implementer"].bottle)
self.assertEqual(("init-prd",), m.agents["implementer"].skills) self.assertEqual(("init-prd",), m.agents["implementer"].skills)
@@ -228,7 +228,7 @@ 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().load_for_agent("implementer") md_manifest = self.resolve()
json_manifest = Manifest.from_json_obj({ json_manifest = Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
@@ -294,48 +294,34 @@ class TestManifestEntryPointParity(_ResolveCase):
self.assertEqual("dev", manifest.agents["implementer"].bottle) self.assertEqual("dev", manifest.agents["implementer"].bottle)
class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase): class TestUnknownAgentKeyDies(_ResolveCase):
"""A typo'd / unknown frontmatter key on an agent file does NOT crash """A typo'd / unknown frontmatter key on an agent file dies
resolve(). The agent appears in all_agent_names for the selector. rather than silently ignoring."""
The error surfaces only when load_for_agent is called for that agent."""
def test_resolve_succeeds_despite_broken_agent(self): def test_dies(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" / "bad.md", self.home_cb / "agents" / "implementer.md",
""" """
--- ---
bottle: dev bottle: dev
skillz: [init-prd] skillz: [init-prd]
--- ---
""",
)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
# Resolve itself does not raise; broken agent appears in the name list.
self.assertIn("bad", m.all_agent_names)
self.assertIn("implementer", m.all_agent_names)
def test_load_for_agent_raises_for_broken_agent(self): ...
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "bad.md",
"""
---
bottle: dev
skillz: [init-prd]
---
""", """,
) )
m = self.resolve()
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
m.load_for_agent("bad") self.resolve()
def test_broken_bottle_only_fails_at_preflight(self):
"""A broken bottle does not crash resolve; only load_for_agent for class TestUnknownBottleKeyDies(_ResolveCase):
an agent that references it raises. Unrelated agents still work.""" """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" / "bad.md", self.home_cb / "bottles" / "dev.md",
""" """
--- ---
credproxy: credproxy:
@@ -343,26 +329,9 @@ class TestBrokenAgentOnlyFailsAtPreflight(_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.assertIn("implementer", full.agents)
# Broken bottle's agent raises at preflight.
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
m.load_for_agent("broken-agent") self.resolve()
class TestStaleJsonDies(_ResolveCase): class TestStaleJsonDies(_ResolveCase):
@@ -390,11 +359,11 @@ class TestNoManifestDies(_ResolveCase):
self.assertEqual({}, dict(m.agents)) self.assertEqual({}, dict(m.agents))
class TestUnknownBottleReferenceFailsAtPreflight(_ResolveCase): class TestUnknownBottleReferenceDies(_ResolveCase):
"""An agent file naming a non-existent bottle appears in all_agent_names """An agent file naming a bottle that doesn't exist on disk
at resolve time; the error only surfaces when load_for_agent is called.""" dies with the existing "bottle not defined" error."""
def test_stray_bottle_reference_fails_at_preflight(self): def test_dies(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",
@@ -404,17 +373,8 @@ class TestUnknownBottleReferenceFailsAtPreflight(_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.assertIn("implementer", full.agents)
# Stray agent fails at preflight.
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
m.load_for_agent("stray") self.resolve()
class TestFilenameValidation(_ResolveCase): class TestFilenameValidation(_ResolveCase):
@@ -428,9 +388,9 @@ 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.all_agent_names) self.assertIn("implementer", m.agents)
self.assertNotIn("BadName", m.all_agent_names) self.assertNotIn("BadName", m.agents)
self.assertNotIn("badname", m.all_agent_names) self.assertNotIn("badname", m.agents)
if __name__ == "__main__": if __name__ == "__main__":