diff --git a/bot_bottle/manifest.py b/bot_bottle/manifest.py index 7956af5..68c362d 100644 --- a/bot_bottle/manifest.py +++ b/bot_bottle/manifest.py @@ -62,15 +62,25 @@ from dataclasses import dataclass, field, replace from pathlib import Path from typing import Mapping +from .log import warn from .manifest_util import ManifestError, as_json_object from .manifest_agent import ManifestAgent, ManifestAgentProvider +from .manifest_bottle import ManifestBottle from .manifest_egress import ( EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute, ) -from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig, parse_git_gate_config -from .manifest_schema import BOTTLE_KEYS +from .manifest_extends import merge_bottles_runtime, resolve_bottles +from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig +from .manifest_loader import ( + check_stale_json, + load_bottle_chain_from_dir, + scan_agent_names, + scan_bottle_names, +) +from .manifest_schema import validate_agent_frontmatter_keys +from .yaml_subset import YamlSubsetError, parse_frontmatter # Re-export everything that callers currently import from this module. __all__ = [ @@ -89,10 +99,6 @@ __all__ = [ ] -def _empty_str_dict() -> dict[str, str]: - return {} - - def _section_dict(value: object, label: str) -> dict[str, object]: """Like as_json_object but treats absent/null as an empty section.""" if value is None: @@ -100,107 +106,6 @@ def _section_dict(value: object, label: str) -> dict[str, object]: return as_json_object(value, label) -@dataclass(frozen=True) -class ManifestBottle: - env: Mapping[str, str] = field(default_factory=_empty_str_dict) - agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider) - git: tuple[ManifestGitEntry, ...] = () - # Per-bottle git identity (issue #86). Empty default — bottles - # that don't set `git-gate.user:` in the manifest skip the - # `git config --global` step entirely. A bottle can declare a user - # identity without any git-gate.repos upstreams, and vice versa. - git_user: ManifestGitUser = field(default_factory=ManifestGitUser) - egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig) - # Per-bottle stuck-recovery sidecar (PRD 0013). When true (the - # default, issue #249), the launch step brings up a supervise - # sidecar that exposes egress MCP tools to the agent. Set - # `supervise: false` to skip the sidecar. - supervise: bool = True - - @classmethod - def from_dict(cls, name: str, raw: object) -> "ManifestBottle": - d = as_json_object(raw, f"bottle '{name}'") - - if "runtime" in d: - raise ManifestError( - f"bottle '{name}' has a 'runtime' field, which is no longer " - f"supported. gVisor (runsc) is now auto-detected by the " - f"backend; remove the 'runtime' field from the bottle " - f"definition." - ) - - if "ssh" in d: - raise ManifestError( - f"bottle '{name}' has an 'ssh' field, which has been removed " - f"(PRD 0009). Declare upstreams under 'git-gate.repos' with " - f"url + identity + host_key; the git-gate sidecar (PRD 0008) " - f"holds the credential and gitleaks-scans pushes." - ) - - if "git" in d: - raise ManifestError( - f"bottle '{name}' uses 'git' which has been replaced by " - f"'git-gate' (PRD 0047). Move git.user → git-gate.user " - f"and git.remotes → git-gate.repos (fields: url, identity, host_key)." - ) - - if "git_user" in d: - raise ManifestError( - f"bottle '{name}' has a 'git_user' field, which has been " - f"removed. Move it under 'git-gate.user'." - ) - - unknown = set(d.keys()) - BOTTLE_KEYS - if unknown: - allowed = ", ".join(sorted(BOTTLE_KEYS)) - raise ManifestError( - f"bottle '{name}' has unknown key(s) {sorted(unknown)}; " - f"allowed keys are {allowed}." - ) - - env: dict[str, str] = {} - env_raw = d.get("env") - if env_raw is not None: - env_dict = as_json_object(env_raw, f"bottle '{name}' env") - for var, value in env_dict.items(): - if not isinstance(value, str): - raise ManifestError( - f"env entry {var} in bottle '{name}' must be a JSON string " - f"(was {type(value).__name__}). Use \"?\" for prompt-at-runtime." - ) - env[var] = value - - git: tuple[ManifestGitEntry, ...] = () - git_user = ManifestGitUser() - git_raw = d.get("git-gate") - if git_raw is not None: - git, git_user = parse_git_gate_config(name, git_raw) - - agent_provider = ( - ManifestAgentProvider.from_dict(name, d["agent_provider"]) - if "agent_provider" in d - else ManifestAgentProvider() - ) - - egress = ( - ManifestEgressConfig.from_dict(name, d["egress"]) - if "egress" in d - else ManifestEgressConfig() - ) - - supervise_raw = d.get("supervise", True) - if not isinstance(supervise_raw, bool): - raise ManifestError( - f"bottle '{name}' supervise must be a boolean " - f"(was {type(supervise_raw).__name__})" - ) - - return cls( - env=env, agent_provider=agent_provider, git=git, - git_user=git_user, egress=egress, supervise=supervise_raw, - ) - - def _merge_git_user( agent_user: ManifestGitUser, base_user: ManifestGitUser ) -> ManifestGitUser: @@ -237,8 +142,6 @@ def _resolve_effective_bottle_eager( When bottle_names is non-empty they are merged in order. When empty, falls back to agent.bottle. Raises ManifestError when neither is set.""" - from .manifest_extends import merge_bottles_runtime - if bottle_names: resolved: list[ManifestBottle] = [] for bn in bottle_names: @@ -270,9 +173,6 @@ def _resolve_effective_bottle_lazy( When bottle_names is non-empty they are resolved from disk and merged in order. When empty, falls back to agent_bottle. Raises ManifestError when neither is set.""" - from .manifest_extends import merge_bottles_runtime - from .manifest_loader import load_bottle_chain_from_dir - if bottle_names: resolved = [load_bottle_chain_from_dir(bn, bottles_dir) for bn in bottle_names] return merge_bottles_runtime(resolved) @@ -358,8 +258,6 @@ class ManifestIndex: home_md = home_dir / ".bot-bottle" cwd_md = cwd_dir / ".bot-bottle" - from .manifest_loader import check_stale_json - check_stale_json(home_dir, home_md, "$HOME") if cwd_dir.resolve() != home_dir.resolve(): check_stale_json(cwd_dir, cwd_md, "$CWD") @@ -399,7 +297,6 @@ class ManifestIndex: files = sorted(stale_bottles.glob("*.md")) if files: names = ", ".join(p.name for p in files) - from .log import warn warn( f"ignoring bottle file(s) under " f"{stale_bottles}: {names}. Bottles can only " @@ -421,7 +318,6 @@ class ManifestIndex: raw_bottles: dict[str, dict[str, object]] = {} for n, b in raw_bottles_obj.items(): raw_bottles[n] = as_json_object(b, f"bottle '{n}'") - from .manifest_extends import resolve_bottles bottles = resolve_bottles(raw_bottles) @@ -439,7 +335,6 @@ class ManifestIndex: filenames without reading their content. In eager mode (from from_json_obj) it returns the pre-parsed bottles' names.""" if self.home_md is not None: - from .manifest_loader import scan_bottle_names return scan_bottle_names(self.home_md / "bottles") return sorted(self.bottles.keys()) @@ -451,7 +346,6 @@ class ManifestIndex: 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: @@ -509,10 +403,6 @@ class ManifestIndex: """Lazy path (resolve/from_md_dirs): read and parse the agent file and its bottle chain from disk for the first time here.""" assert self.home_md is not None # guaranteed by load_for_agent dispatch - from .manifest_loader import 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] = {} diff --git a/bot_bottle/manifest_bottle.py b/bot_bottle/manifest_bottle.py new file mode 100644 index 0000000..3b0f068 --- /dev/null +++ b/bot_bottle/manifest_bottle.py @@ -0,0 +1,129 @@ +"""The `ManifestBottle` value type. + +Split out of `manifest.py` so the `extends:`/loader resolvers can import it +without a circular dependency: `manifest.py` imports those resolvers, while +they only need this value type. Everything here depends on leaf modules +(`manifest_util`, `manifest_agent`, `manifest_egress`, `manifest_git`, +`manifest_schema`), so this module sits at the bottom of the manifest layer. + +`manifest.py` re-exports `ManifestBottle`, so existing +`from .manifest import ManifestBottle` callers are unaffected. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Mapping + +from .manifest_util import ManifestError, as_json_object +from .manifest_agent import ManifestAgentProvider +from .manifest_egress import ManifestEgressConfig +from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config +from .manifest_schema import BOTTLE_KEYS + +__all__ = ["ManifestBottle"] + + +def _empty_str_dict() -> dict[str, str]: + return {} + + +@dataclass(frozen=True) +class ManifestBottle: + env: Mapping[str, str] = field(default_factory=_empty_str_dict) + agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider) + git: tuple[ManifestGitEntry, ...] = () + # Per-bottle git identity (issue #86). Empty default — bottles + # that don't set `git-gate.user:` in the manifest skip the + # `git config --global` step entirely. A bottle can declare a user + # identity without any git-gate.repos upstreams, and vice versa. + git_user: ManifestGitUser = field(default_factory=ManifestGitUser) + egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig) + # Per-bottle stuck-recovery sidecar (PRD 0013). When true (the + # default, issue #249), the launch step brings up a supervise + # sidecar that exposes egress MCP tools to the agent. Set + # `supervise: false` to skip the sidecar. + supervise: bool = True + + @classmethod + def from_dict(cls, name: str, raw: object) -> "ManifestBottle": + d = as_json_object(raw, f"bottle '{name}'") + + if "runtime" in d: + raise ManifestError( + f"bottle '{name}' has a 'runtime' field, which is no longer " + f"supported. gVisor (runsc) is now auto-detected by the " + f"backend; remove the 'runtime' field from the bottle " + f"definition." + ) + + if "ssh" in d: + raise ManifestError( + f"bottle '{name}' has an 'ssh' field, which has been removed " + f"(PRD 0009). Declare upstreams under 'git-gate.repos' with " + f"url + identity + host_key; the git-gate sidecar (PRD 0008) " + f"holds the credential and gitleaks-scans pushes." + ) + + if "git" in d: + raise ManifestError( + f"bottle '{name}' uses 'git' which has been replaced by " + f"'git-gate' (PRD 0047). Move git.user → git-gate.user " + f"and git.remotes → git-gate.repos (fields: url, identity, host_key)." + ) + + if "git_user" in d: + raise ManifestError( + f"bottle '{name}' has a 'git_user' field, which has been " + f"removed. Move it under 'git-gate.user'." + ) + + unknown = set(d.keys()) - BOTTLE_KEYS + if unknown: + allowed = ", ".join(sorted(BOTTLE_KEYS)) + raise ManifestError( + f"bottle '{name}' has unknown key(s) {sorted(unknown)}; " + f"allowed keys are {allowed}." + ) + + env: dict[str, str] = {} + env_raw = d.get("env") + if env_raw is not None: + env_dict = as_json_object(env_raw, f"bottle '{name}' env") + for var, value in env_dict.items(): + if not isinstance(value, str): + raise ManifestError( + f"env entry {var} in bottle '{name}' must be a JSON string " + f"(was {type(value).__name__}). Use \"?\" for prompt-at-runtime." + ) + env[var] = value + + git: tuple[ManifestGitEntry, ...] = () + git_user = ManifestGitUser() + git_raw = d.get("git-gate") + if git_raw is not None: + git, git_user = parse_git_gate_config(name, git_raw) + + agent_provider = ( + ManifestAgentProvider.from_dict(name, d["agent_provider"]) + if "agent_provider" in d + else ManifestAgentProvider() + ) + + egress = ( + ManifestEgressConfig.from_dict(name, d["egress"]) + if "egress" in d + else ManifestEgressConfig() + ) + + supervise_raw = d.get("supervise", True) + if not isinstance(supervise_raw, bool): + raise ManifestError( + f"bottle '{name}' supervise must be a boolean " + f"(was {type(supervise_raw).__name__})" + ) + + return cls( + env=env, agent_provider=agent_provider, git=git, + git_user=git_user, egress=egress, supervise=supervise_raw, + ) diff --git a/bot_bottle/manifest_extends.py b/bot_bottle/manifest_extends.py index 174003f..28911e0 100644 --- a/bot_bottle/manifest_extends.py +++ b/bot_bottle/manifest_extends.py @@ -2,11 +2,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .manifest import ManifestBottle - from .manifest_egress import ManifestEgressConfig +from .manifest_bottle import ManifestBottle +from .manifest_egress import ManifestEgressConfig, validate_egress_routes +from .manifest_git import ManifestGitUser, parse_git_gate_config +from .manifest_util import ManifestError, as_json_object def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle": @@ -27,9 +26,6 @@ def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle": def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle") -> "ManifestBottle": - from .manifest import ManifestBottle, ManifestGitUser - from .manifest_egress import ManifestEgressConfig - merged_env = {**base.env, **override.env} merged_git_user = ManifestGitUser( @@ -81,8 +77,6 @@ def _resolve_one_bottle( repos_cache: dict[str, dict[str, object]], seen: tuple[str, ...], ) -> ManifestBottle: - from .manifest import ManifestBottle, ManifestError - if name in cache: return cache[name] if name in seen: @@ -174,11 +168,6 @@ def _fold_two_bottles( later_repos_raw: dict[str, object], ) -> tuple[ManifestBottle, dict[str, object]]: """Combine two resolved parent bottles; later wins over earlier.""" - from .manifest import ManifestBottle, ManifestGitUser - from .manifest_egress import ManifestEgressConfig - from .manifest_git import parse_git_gate_config - from .manifest_util import as_json_object - merged_env = {**earlier.env, **later.env} merged_git_user = ManifestGitUser( @@ -227,10 +216,6 @@ def _merge_bottles( name: str, ) -> ManifestBottle: """Apply PRD 0025 merge rules.""" - from .manifest import ManifestBottle, ManifestGitUser - from .manifest_egress import validate_egress_routes - from .manifest_util import as_json_object - # git-gate.repos: when the child declares repos, inject the already # name-merged repo set (computed by _resolve_repos_raw) so the child # parses with the full inherited+overridden list (issue #237). @@ -303,8 +288,6 @@ def _resolve_repos_raw( inherits the parent's set verbatim; an explicit empty dict clears it. Otherwise parent and child unite by name, with same-name entries field-merged (parent fields are defaults, child fields win).""" - from .manifest_util import as_json_object - if not _child_declares_git_gate_repos(child_raw): return parent_repos child_repos = _declared_repos_raw(child_raw) @@ -324,8 +307,6 @@ def _resolve_repos_raw( def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]: """Return the child's explicitly declared git-gate.repos as raw dicts, or an empty dict when none are declared.""" - from .manifest_util import as_json_object - if not _child_declares_git_gate_repos(child_raw): return {} git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate") @@ -333,8 +314,6 @@ def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]: def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool: - from .manifest_util import as_json_object - git_raw = child_raw.get("git-gate") if git_raw is None: return False @@ -347,9 +326,6 @@ def _merge_egress( child: ManifestEgressConfig, child_raw: dict[str, object], ) -> ManifestEgressConfig: - from .manifest_egress import ManifestEgressConfig - from .manifest_util import as_json_object - child_egress_raw = as_json_object(child_raw.get("egress"), "child egress") routes = parent.routes + child.routes log = child.Log if "log" in child_egress_raw else parent.Log diff --git a/bot_bottle/manifest_loader.py b/bot_bottle/manifest_loader.py index 3551a79..c88af32 100644 --- a/bot_bottle/manifest_loader.py +++ b/bot_bottle/manifest_loader.py @@ -3,9 +3,10 @@ from __future__ import annotations from pathlib import Path -from typing import TYPE_CHECKING from .log import warn +from .manifest_bottle import ManifestBottle +from .manifest_extends import resolve_bottles from .manifest_schema import ( entity_name_from_path, validate_bottle_frontmatter_keys, @@ -13,9 +14,6 @@ from .manifest_schema import ( from .manifest_util import ManifestError from .yaml_subset import YamlSubsetError, parse_frontmatter -if TYPE_CHECKING: - from .manifest import ManifestBottle - def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None: """Die if `/bot-bottle.json` exists but `md_dir` does @@ -78,8 +76,6 @@ def load_bottle_chain_from_dir( 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: