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
- **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.
- **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.
@@ -106,8 +106,15 @@ egress:
routes:
- host: gitea.dideric.is
auth:
scheme: token
scheme: token # Bearer | 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;
@@ -126,6 +133,23 @@ skills:
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`.
## Trademarks
+9 -14
View File
@@ -37,7 +37,7 @@ import shlex
import sys
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
from dataclasses import dataclass, replace
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Generic, Sequence, TypeVar
@@ -289,7 +289,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
write_launch_metadata,
)
spec = self._validate(spec)
self._validate(spec)
self._preflight()
@@ -355,23 +355,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
"""
pass
def _validate(self, spec: BottleSpec) -> BottleSpec:
"""Cross-backend pre-launch checks. Parses the selected agent and
its bottle (raising ManifestError on invalid content), confirms
skills are present on the host, and every git IdentityFile resolves.
Returns a new BottleSpec whose manifest is fully loaded for the
selected agent. Subclasses with additional preconditions should
override and call `super()._validate(spec)` first, using the
returned spec for further checks."""
manifest = spec.manifest.load_for_agent(spec.agent_name)
spec = replace(spec, manifest=manifest)
def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Confirms the agent exists,
the named skills are present on the host, and every git
IdentityFile resolves. Subclasses with additional preconditions
should override and call `super()._validate(spec)` first."""
manifest = spec.manifest
manifest.require_agent(spec.agent_name)
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
self._validate_skills(agent.skills)
self._validate_git_entries(bottle.git)
self._validate_agent_provider_dockerfile(spec)
return spec
def _validate_skills(self, skills: Sequence[str]) -> None:
"""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")
args = parser.parse_args(argv)
names = Manifest.resolve(USER_CWD)
names.require_agent(args.name)
manifest = names.load_for_agent(args.name)
manifest = Manifest.resolve(USER_CWD)
manifest.require_agent(args.name)
agent = manifest.agents[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":
manifest = Manifest.resolve(USER_CWD)
for name in manifest.all_agent_names:
for name in manifest.agents.keys():
print(name)
return 0
+1 -1
View File
@@ -65,7 +65,7 @@ def cmd_start(argv: list[str]) -> int:
agent_name: str | None = args.name
if agent_name is None:
agent_name = tui.filter_select(
manifest.all_agent_names,
sorted(manifest.agents.keys()),
title="Select agent",
)
if agent_name is None:
+29 -112
View File
@@ -193,10 +193,6 @@ class ManifestBottle:
class Manifest:
bottles: Mapping[str, ManifestBottle]
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
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
@@ -253,15 +249,24 @@ class Manifest:
home_dir: Path,
cwd_dir: Path | None,
) -> "Manifest":
"""Return a names-only Manifest. No file content is read; only
filenames are scanned for the agent selector. Full parsing happens
later, per-agent, via `load_for_agent`.
A `bottles/` subdir under `cwd_dir` is logged as a warning and
ignored — the filesystem layout IS the trust boundary.
"""Programmatic entry point. Loads bottles from
`<home_dir>/bottles/`, home agents from `<home_dir>/agents/`,
and (if `cwd_dir` is passed) cwd agents from
`<cwd_dir>/agents/`. Cwd agents override home agents on
name collision. A `bottles/` subdir under `cwd_dir` is
logged as a warning and ignored.
Used by tests to build a Manifest from fixture directories
without touching `os.environ`."""
bottles_dir = home_dir / "bottles"
from .manifest_loader import load_agents_from_dir, load_bottles_from_dir
bottles = load_bottles_from_dir(bottles_dir)
bottle_names = set(bottles.keys())
agents_dir = home_dir / "agents"
agents = load_agents_from_dir(agents_dir, bottle_names, source="$HOME")
if cwd_dir is not None:
stale_bottles = cwd_dir / "bottles"
if stale_bottles.is_dir():
@@ -275,7 +280,13 @@ class Manifest:
f"live under $HOME/.bot-bottle/bottles/ "
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
def from_json_obj(cls, obj: object) -> "Manifest":
@@ -300,113 +311,18 @@ class Manifest:
}
return cls(bottles=bottles, agents=agents)
@property
def all_agent_names(self) -> list[str]:
"""Sorted list of all discoverable agent names.
In names-only mode (from resolve/from_md_dirs) this scans agent
filenames without reading their content. In eager mode (from
from_json_obj) it returns the pre-parsed agents' names."""
if self.home_md is not None:
from .manifest_loader import scan_agent_names
home_names = set(scan_agent_names(self.home_md / "agents").keys())
cwd_names: set[str] = set()
if self.cwd_md is not None:
cwd_names = set(scan_agent_names(self.cwd_md / "agents").keys())
return sorted(home_names | cwd_names)
return sorted(self.agents.keys())
def load_for_agent(self, agent_name: str) -> "Manifest":
"""Parse 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:
return name in self.agents
def require_agent(self, name: str) -> None:
"""Check that `name` is a discoverable agent. In names-only mode
this checks whether the .md file exists; in eager mode it checks
the pre-parsed agents dict. Does NOT parse file content."""
if self.has_agent(name):
return
if self.home_md is not None:
# Names-only mode: check file existence without parsing.
home_path = self.home_md / "agents" / f"{name}.md"
cwd_path = (
self.cwd_md / "agents" / f"{name}.md"
if self.cwd_md else None
)
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
return
available = ", ".join(self.all_agent_names) or "(none)"
available = ", ".join(self.agents.keys())
if available:
msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
raise ManifestError(msg)
raise ManifestError(
f"agent '{name}' not defined. Available: {available}"
f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
)
def has_bottle(self, name: str) -> bool:
@@ -440,7 +356,8 @@ class Manifest:
def bottle_for(self, agent_name: str) -> ManifestBottle:
"""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
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 .manifest_schema import (
entity_name_from_path,
validate_agent_frontmatter_keys,
validate_bottle_frontmatter_keys,
)
from .manifest_util import ManifestError
from .yaml_subset import YamlSubsetError, parse_frontmatter
if TYPE_CHECKING:
from .manifest import ManifestBottle
from .manifest import ManifestAgent, ManifestBottle
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
not. The manifest format changed in PRD 0011 and we do not want
to silently leave the JSON content unused."""
from .manifest import ManifestError
legacy = dir_path / "bot-bottle.json"
if legacy.is_file() and not md_dir.exists():
raise ManifestError(
@@ -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]:
"""Scan `<agents_dir>/*.md` for valid filenames and return `{name: path}`.
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError
from .manifest_extends import resolve_bottles
No file content is read. Invalid filenames are skipped with a warning."""
result: dict[str, Path] = {}
raws: dict[str, dict[str, object]] = {}
if not bottles_dir.is_dir():
return {}
for path in sorted(bottles_dir.glob("*.md")):
name = entity_name_from_path(path)
if name is None:
warn(
f"skipping {path}: filename must match "
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
)
continue
try:
fm, _body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = fm
return resolve_bottles(raws)
def load_agents_from_dir(
agents_dir: Path,
bottle_names: set[str],
*,
source: str, # noqa: F841 — unused, but required by interface
) -> dict[str, ManifestAgent]:
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict."""
from .manifest import ManifestAgent, ManifestError
out: dict[str, ManifestAgent] = {}
if not agents_dir.is_dir():
return result
return out
for path in sorted(agents_dir.glob("*.md")):
name = entity_name_from_path(path)
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})"
)
continue
result[name] = path
return result
def load_bottle_chain_from_dir(
bottle_name: str, bottles_dir: Path
) -> ManifestBottle:
"""Load `bottle_name` and its full `extends:` chain from `bottles_dir`,
returning the resolved ManifestBottle.
Only the files in the extends chain are read — unrelated bottle files
are never touched. Raises ManifestError on parse or validation failure."""
from .manifest_extends import resolve_bottles
raws: dict[str, dict[str, object]] = {}
to_load = [bottle_name]
while to_load:
name = to_load.pop()
if name in raws:
continue
path = bottles_dir / f"{name}.md"
if not path.is_file():
avail = ", ".join(
p.stem for p in sorted(bottles_dir.glob("*.md")) if p.is_file()
) or "(none)"
raise ManifestError(
f"bottle '{name}' not found at {path}. "
f"Available: {avail}"
)
try:
fm, _body = parse_frontmatter(path.read_text())
fm, body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = dict(fm)
parent = fm.get("extends")
if isinstance(parent, str):
to_load.append(parent)
return resolve_bottles(raws)[bottle_name]
validate_agent_frontmatter_keys(path, fm.keys())
# Build the dict Agent.from_dict expects. The body becomes
# prompt; Claude Code passthrough fields stay in fm and get
# ignored by Agent.from_dict (reads bottle/skills/git-gate/prompt).
agent_dict: dict[str, object] = {
"bottle": fm.get("bottle"),
"skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names)
return out
+6 -1
View File
@@ -5,10 +5,15 @@ agent_provider:
egress:
routes:
- host: api.anthropic.com
role: claude_code_oauth
role: claude_code_oauth # wires Claude Code OAuth; do not change
auth:
scheme: Bearer
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
-1
View File
@@ -19,7 +19,6 @@ import bot_bottle.cli.tui as tui_mod
def _make_manifest(agent_names: list[str]):
manifest = MagicMock()
manifest.agents = {name: MagicMock() for name in agent_names}
manifest.all_agent_names = sorted(agent_names)
return manifest
+3 -10
View File
@@ -217,7 +217,7 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
def test_md_agent_git_user_overlays_bottle(self):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_GIT)
m = Manifest.resolve(str(self.home)).load_for_agent("impl")
m = Manifest.resolve(str(self.home))
u = m.bottle_for("impl").git_user
self.assertEqual("agent-name", u.name)
self.assertEqual("bottle@example.com", u.email)
@@ -226,17 +226,10 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
m.git_identity_summary("impl"),
)
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."""
def test_md_agent_repos_dies(self):
self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_REPOS)
from bot_bottle.manifest import ManifestError
names = Manifest.resolve(str(self.home))
self.assertIn("impl", names.all_agent_names)
with self.assertRaises(ManifestError) as ctx:
names.load_for_agent("impl")
msg = str(ctx.exception)
msg = _error_message(Manifest.resolve, str(self.home))
self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg)
+32 -72
View File
@@ -77,12 +77,12 @@ class _ResolveCase(unittest.TestCase):
class TestBottleFileParses(_ResolveCase):
"""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):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve().load_for_agent("implementer")
m = self.resolve()
self.assertIn("dev", m.bottles)
routes = m.bottles["dev"].egress.routes
self.assertEqual(2, len(routes))
@@ -94,13 +94,13 @@ class TestBottleFileParses(_ResolveCase):
class TestAgentFileParses(_ResolveCase):
"""SC #2: an agent file under $HOME/.bot-bottle/agents/
parses via load_for_agent; the body becomes the prompt, the
frontmatter fields map to Agent fields."""
parses, the body becomes the prompt, the frontmatter fields
map to Agent fields."""
def test_loads(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve().load_for_agent("implementer")
m = self.resolve()
a = m.agents["implementer"]
self.assertEqual("dev", a.bottle)
self.assertEqual(("init-prd",), a.skills)
@@ -128,7 +128,7 @@ class TestCwdAgentOverridesHome(_ResolveCase):
CWD-OVERRIDE-PROMPT
""",
)
m = self.resolve().load_for_agent("implementer")
m = self.resolve()
self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt)
# Home bottle still present
self.assertEqual(2, len(m.bottles["dev"].egress.routes))
@@ -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.
self.assertEqual(
"api.anthropic.com",
@@ -215,7 +215,7 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase):
Agent prompt body.
""",
)
m = self.resolve().load_for_agent("implementer")
m = self.resolve()
self.assertEqual("dev", m.agents["implementer"].bottle)
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 / "agents" / "implementer.md", _AGENT_IMPL)
md_manifest = self.resolve().load_for_agent("implementer")
md_manifest = self.resolve()
json_manifest = Manifest.from_json_obj({
"bottles": {
"dev": {
@@ -294,48 +294,34 @@ class TestManifestEntryPointParity(_ResolveCase):
self.assertEqual("dev", manifest.agents["implementer"].bottle)
class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase):
"""A typo'd / unknown frontmatter key on an agent file does NOT crash
resolve(). The agent appears in all_agent_names for the selector.
The error surfaces only when load_for_agent is called for that agent."""
class TestUnknownAgentKeyDies(_ResolveCase):
"""A typo'd / unknown frontmatter key on an agent file dies
rather than silently ignoring."""
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 / "agents" / "bad.md",
self.home_cb / "agents" / "implementer.md",
"""
---
bottle: dev
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):
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
an agent that references it raises. Unrelated agents still work."""
class TestUnknownBottleKeyDies(_ResolveCase):
"""A typo'd / unknown frontmatter key on a bottle file dies
rather than silently ignoring."""
def test_dies(self):
_write(
self.home_cb / "bottles" / "bad.md",
self.home_cb / "bottles" / "dev.md",
"""
---
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" / "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):
m.load_for_agent("broken-agent")
self.resolve()
class TestStaleJsonDies(_ResolveCase):
@@ -390,11 +359,11 @@ class TestNoManifestDies(_ResolveCase):
self.assertEqual({}, dict(m.agents))
class TestUnknownBottleReferenceFailsAtPreflight(_ResolveCase):
"""An agent file naming a non-existent bottle appears in all_agent_names
at resolve time; the error only surfaces when load_for_agent is called."""
class TestUnknownBottleReferenceDies(_ResolveCase):
"""An agent file naming a bottle that doesn't exist on disk
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 / "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):
m.load_for_agent("stray")
self.resolve()
class TestFilenameValidation(_ResolveCase):
@@ -428,9 +388,9 @@ class TestFilenameValidation(_ResolveCase):
# This file should be skipped — capital letters not allowed.
_write(self.home_cb / "agents" / "BadName.md", _AGENT_IMPL)
m = self.resolve()
self.assertIn("implementer", m.all_agent_names)
self.assertNotIn("BadName", m.all_agent_names)
self.assertNotIn("badname", m.all_agent_names)
self.assertIn("implementer", m.agents)
self.assertNotIn("BadName", m.agents)
self.assertNotIn("badname", m.agents)
if __name__ == "__main__":