Compare commits

...

2 Commits

Author SHA1 Message Date
didericis f787764364 refactor(manifest): break import cycle by extracting ManifestBottle to a leaf module
test / unit (pull_request) Successful in 57s
test / integration (pull_request) Successful in 27s
test / coverage (pull_request) Successful in 1m23s
lint / lint (push) Successful in 2m24s
test / unit (push) Successful in 59s
test / integration (push) Successful in 26s
test / coverage (push) Successful in 1m17s
Update Quality Badges / update-badges (push) Successful in 1m13s
manifest.py imported the extends/loader resolvers, while those resolvers
needed ManifestBottle back from manifest.py — a true bidirectional cycle
papered over with in-function imports and TYPE_CHECKING guards (not clear
dependency inversion).

Extract ManifestBottle into a new leaf module manifest_bottle.py that depends
only on the other leaf modules (manifest_util/agent/egress/git/schema).
manifest.py re-exports ManifestBottle, so `from .manifest import ManifestBottle`
callers are unaffected. With the cycle gone:

- manifest_extends and manifest_loader import ManifestBottle from
  manifest_bottle and their other deps from the real source modules, all at
  top level (TYPE_CHECKING block removed).
- manifest.py imports the extends/loader/schema/yaml_subset/log helpers at
  module top; all per-function lazy imports in the cluster are removed.

No behavior change; full unit suite green, pyright clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
2026-06-26 23:42:03 -04:00
didericis-claude a256e5762a Merge pull request 'DLP injection-check perf, bounded variant cache, dedup supervise schema' (#312) from dlp-supervise-quality-fixes into main
lint / lint (push) Successful in 2m22s
test / unit (push) Successful in 50s
test / integration (push) Successful in 18s
test / coverage (push) Successful in 1m2s
Update Quality Badges / update-badges (push) Successful in 1m9s
2026-06-26 23:30:16 -04:00
4 changed files with 147 additions and 156 deletions
+12 -122
View File
@@ -62,15 +62,25 @@ from dataclasses import dataclass, field, replace
from pathlib import Path from pathlib import Path
from typing import Mapping from typing import Mapping
from .log import warn
from .manifest_util import ManifestError, as_json_object from .manifest_util import ManifestError, as_json_object
from .manifest_agent import ManifestAgent, ManifestAgentProvider from .manifest_agent import ManifestAgent, ManifestAgentProvider
from .manifest_bottle import ManifestBottle
from .manifest_egress import ( from .manifest_egress import (
EGRESS_AUTH_SCHEMES, EGRESS_AUTH_SCHEMES,
ManifestEgressConfig, ManifestEgressConfig,
ManifestEgressRoute, ManifestEgressRoute,
) )
from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig, parse_git_gate_config from .manifest_extends import merge_bottles_runtime, resolve_bottles
from .manifest_schema import BOTTLE_KEYS 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. # Re-export everything that callers currently import from this module.
__all__ = [ __all__ = [
@@ -89,10 +99,6 @@ __all__ = [
] ]
def _empty_str_dict() -> dict[str, str]:
return {}
def _section_dict(value: object, label: str) -> dict[str, object]: def _section_dict(value: object, label: str) -> dict[str, object]:
"""Like as_json_object but treats absent/null as an empty section.""" """Like as_json_object but treats absent/null as an empty section."""
if value is None: if value is None:
@@ -100,107 +106,6 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
return as_json_object(value, label) 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 \"?<message>\" 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( def _merge_git_user(
agent_user: ManifestGitUser, base_user: ManifestGitUser agent_user: ManifestGitUser, base_user: ManifestGitUser
) -> 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 When bottle_names is non-empty they are merged in order. When empty, falls
back to agent.bottle. Raises ManifestError when neither is set.""" back to agent.bottle. Raises ManifestError when neither is set."""
from .manifest_extends import merge_bottles_runtime
if bottle_names: if bottle_names:
resolved: list[ManifestBottle] = [] resolved: list[ManifestBottle] = []
for bn in bottle_names: 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 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 order. When empty, falls back to agent_bottle. Raises ManifestError when
neither is set.""" neither is set."""
from .manifest_extends import merge_bottles_runtime
from .manifest_loader import load_bottle_chain_from_dir
if bottle_names: if bottle_names:
resolved = [load_bottle_chain_from_dir(bn, bottles_dir) for bn in bottle_names] resolved = [load_bottle_chain_from_dir(bn, bottles_dir) for bn in bottle_names]
return merge_bottles_runtime(resolved) return merge_bottles_runtime(resolved)
@@ -358,8 +258,6 @@ class ManifestIndex:
home_md = home_dir / ".bot-bottle" home_md = home_dir / ".bot-bottle"
cwd_md = cwd_dir / ".bot-bottle" cwd_md = cwd_dir / ".bot-bottle"
from .manifest_loader import check_stale_json
check_stale_json(home_dir, home_md, "$HOME") check_stale_json(home_dir, home_md, "$HOME")
if cwd_dir.resolve() != home_dir.resolve(): if cwd_dir.resolve() != home_dir.resolve():
check_stale_json(cwd_dir, cwd_md, "$CWD") check_stale_json(cwd_dir, cwd_md, "$CWD")
@@ -399,7 +297,6 @@ class ManifestIndex:
files = sorted(stale_bottles.glob("*.md")) files = sorted(stale_bottles.glob("*.md"))
if files: if files:
names = ", ".join(p.name for p in files) names = ", ".join(p.name for p in files)
from .log import warn
warn( warn(
f"ignoring bottle file(s) under " f"ignoring bottle file(s) under "
f"{stale_bottles}: {names}. Bottles can only " f"{stale_bottles}: {names}. Bottles can only "
@@ -421,7 +318,6 @@ class ManifestIndex:
raw_bottles: dict[str, dict[str, object]] = {} raw_bottles: dict[str, dict[str, object]] = {}
for n, b in raw_bottles_obj.items(): for n, b in raw_bottles_obj.items():
raw_bottles[n] = as_json_object(b, f"bottle '{n}'") raw_bottles[n] = as_json_object(b, f"bottle '{n}'")
from .manifest_extends import resolve_bottles
bottles = resolve_bottles(raw_bottles) bottles = resolve_bottles(raw_bottles)
@@ -439,7 +335,6 @@ class ManifestIndex:
filenames without reading their content. In eager mode (from filenames without reading their content. In eager mode (from
from_json_obj) it returns the pre-parsed bottles' names.""" from_json_obj) it returns the pre-parsed bottles' names."""
if self.home_md is not None: if self.home_md is not None:
from .manifest_loader import scan_bottle_names
return scan_bottle_names(self.home_md / "bottles") return scan_bottle_names(self.home_md / "bottles")
return sorted(self.bottles.keys()) return sorted(self.bottles.keys())
@@ -451,7 +346,6 @@ class ManifestIndex:
filenames without reading their content. In eager mode (from filenames without reading their content. In eager mode (from
from_json_obj) it returns the pre-parsed agents' names.""" from_json_obj) it returns the pre-parsed agents' names."""
if self.home_md is not None: 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()) home_names = set(scan_agent_names(self.home_md / "agents").keys())
cwd_names: set[str] = set() cwd_names: set[str] = set()
if self.cwd_md is not None: 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 """Lazy path (resolve/from_md_dirs): read and parse the agent file and
its bottle chain from disk for the first time here.""" its bottle chain from disk for the first time here."""
assert self.home_md is not None # guaranteed by load_for_agent dispatch 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. # Locate the agent file; cwd wins over home on name collision.
home_agents = scan_agent_names(self.home_md / "agents") home_agents = scan_agent_names(self.home_md / "agents")
cwd_agents: dict[str, Path] = {} cwd_agents: dict[str, Path] = {}
+129
View File
@@ -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 \"?<message>\" 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,
)
+4 -28
View File
@@ -2,11 +2,10 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from .manifest_bottle import ManifestBottle
from .manifest_egress import ManifestEgressConfig, validate_egress_routes
if TYPE_CHECKING: from .manifest_git import ManifestGitUser, parse_git_gate_config
from .manifest import ManifestBottle from .manifest_util import ManifestError, as_json_object
from .manifest_egress import ManifestEgressConfig
def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle": 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": 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_env = {**base.env, **override.env}
merged_git_user = ManifestGitUser( merged_git_user = ManifestGitUser(
@@ -81,8 +77,6 @@ def _resolve_one_bottle(
repos_cache: dict[str, dict[str, object]], repos_cache: dict[str, dict[str, object]],
seen: tuple[str, ...], seen: tuple[str, ...],
) -> ManifestBottle: ) -> ManifestBottle:
from .manifest import ManifestBottle, ManifestError
if name in cache: if name in cache:
return cache[name] return cache[name]
if name in seen: if name in seen:
@@ -174,11 +168,6 @@ def _fold_two_bottles(
later_repos_raw: dict[str, object], later_repos_raw: dict[str, object],
) -> tuple[ManifestBottle, dict[str, object]]: ) -> tuple[ManifestBottle, dict[str, object]]:
"""Combine two resolved parent bottles; later wins over earlier.""" """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_env = {**earlier.env, **later.env}
merged_git_user = ManifestGitUser( merged_git_user = ManifestGitUser(
@@ -227,10 +216,6 @@ def _merge_bottles(
name: str, name: str,
) -> ManifestBottle: ) -> ManifestBottle:
"""Apply PRD 0025 merge rules.""" """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 # git-gate.repos: when the child declares repos, inject the already
# name-merged repo set (computed by _resolve_repos_raw) so the child # name-merged repo set (computed by _resolve_repos_raw) so the child
# parses with the full inherited+overridden list (issue #237). # 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. inherits the parent's set verbatim; an explicit empty dict clears it.
Otherwise parent and child unite by name, with same-name entries Otherwise parent and child unite by name, with same-name entries
field-merged (parent fields are defaults, child fields win).""" 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): if not _child_declares_git_gate_repos(child_raw):
return parent_repos return parent_repos
child_repos = _declared_repos_raw(child_raw) 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]: def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
"""Return the child's explicitly declared git-gate.repos as raw dicts, """Return the child's explicitly declared git-gate.repos as raw dicts,
or an empty dict when none are declared.""" or an empty dict when none are declared."""
from .manifest_util import as_json_object
if not _child_declares_git_gate_repos(child_raw): if not _child_declares_git_gate_repos(child_raw):
return {} return {}
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate") 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: 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") git_raw = child_raw.get("git-gate")
if git_raw is None: if git_raw is None:
return False return False
@@ -347,9 +326,6 @@ def _merge_egress(
child: ManifestEgressConfig, child: ManifestEgressConfig,
child_raw: dict[str, object], child_raw: dict[str, object],
) -> ManifestEgressConfig: ) -> 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") child_egress_raw = as_json_object(child_raw.get("egress"), "child egress")
routes = parent.routes + child.routes routes = parent.routes + child.routes
log = child.Log if "log" in child_egress_raw else parent.Log log = child.Log if "log" in child_egress_raw else parent.Log
+2 -6
View File
@@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
from .log import warn from .log import warn
from .manifest_bottle import ManifestBottle
from .manifest_extends import resolve_bottles
from .manifest_schema import ( from .manifest_schema import (
entity_name_from_path, entity_name_from_path,
validate_bottle_frontmatter_keys, validate_bottle_frontmatter_keys,
@@ -13,9 +14,6 @@ from .manifest_schema import (
from .manifest_util import ManifestError from .manifest_util import ManifestError
from .yaml_subset import YamlSubsetError, parse_frontmatter 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: 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
@@ -78,8 +76,6 @@ def load_bottle_chain_from_dir(
Only the files in the extends chain are read unrelated bottle files Only the files in the extends chain are read unrelated bottle files
are never touched. Raises ManifestError on parse or validation failure.""" are never touched. Raises ManifestError on parse or validation failure."""
from .manifest_extends import resolve_bottles
raws: dict[str, dict[str, object]] = {} raws: dict[str, dict[str, object]] = {}
to_load = [bottle_name] to_load = [bottle_name]
while to_load: while to_load: