refactor: prefix all manifest data classes with Manifest
lint / lint (push) Failing after 1m29s
test / unit (pull_request) Successful in 30s
test / integration (pull_request) Successful in 41s

Avoids name collisions with same-named runtime/plugin classes
(e.g. manifest AgentProvider vs plugin AgentProvider ABC,
manifest EgressRoute vs runtime EgressRoute). Renamed:

  AgentProvider        → ManifestAgentProvider   (manifest_agent.py)
  Agent                → ManifestAgent            (manifest_agent.py)
  EgressRoute          → ManifestEgressRoute      (manifest_egress.py)
  PathMatch            → ManifestPathMatch        (manifest_egress.py)
  HeaderMatch          → ManifestHeaderMatch      (manifest_egress.py)
  MatchEntry           → ManifestMatchEntry       (manifest_egress.py)
  EgressConfig         → ManifestEgressConfig     (manifest_egress.py)
  Bottle               → ManifestBottle           (manifest.py)
  ProvisionedKeyConfig → ManifestProvisionedKeyConfig (manifest_git.py)
  GitEntry             → ManifestGitEntry         (manifest_git.py)
  GitUser              → ManifestGitUser          (manifest_git.py)
This commit is contained in:
2026-06-08 06:42:06 +00:00
parent a4e12855df
commit b872985a65
12 changed files with 128 additions and 128 deletions
+2 -2
View File
@@ -43,7 +43,7 @@ from ..agent_provider import AgentProvisionPlan, get_provider
from ..egress import EgressPlan from ..egress import EgressPlan
from ..git_gate import GitGatePlan from ..git_gate import GitGatePlan
from ..log import die, info from ..log import die, info
from ..manifest import GitEntry, Manifest from ..manifest import ManifestGitEntry, Manifest
from ..supervise import SupervisePlan from ..supervise import SupervisePlan
from ..util import expand_tilde from ..util import expand_tilde
from ..workspace import WorkspacePlan from ..workspace import WorkspacePlan
@@ -297,7 +297,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
f"Create it under ~/.claude/skills/, then re-run." f"Create it under ~/.claude/skills/, then re-run."
) )
def _validate_git_entries(self, entries: Sequence[GitEntry]) -> None: def _validate_git_entries(self, entries: Sequence[ManifestGitEntry]) -> None:
"""Each entry's IdentityFile must exist on the host (after """Each entry's IdentityFile must exist on the host (after
expanding leading ~) — the git-gate copies it in at start time expanding leading ~) — the git-gate copies it in at start time
to authenticate the upstream push (PRD 0008). Shape is already to authenticate the upstream push (PRD 0008). Shape is already
+4 -4
View File
@@ -24,7 +24,7 @@ from .egress_addon_core import (
from .log import die from .log import die
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import Bottle from .manifest import ManifestBottle
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
@@ -66,7 +66,7 @@ class EgressPlan:
def egress_manifest_routes( def egress_manifest_routes(
bottle: Bottle, bottle: ManifestBottle,
) -> tuple[EgressRoute, ...]: ) -> tuple[EgressRoute, ...]:
out: list[EgressRoute] = [] out: list[EgressRoute] = []
for r in bottle.egress.routes: for r in bottle.egress.routes:
@@ -98,7 +98,7 @@ def egress_manifest_routes(
def egress_routes_for_bottle( def egress_routes_for_bottle(
bottle: Bottle, bottle: ManifestBottle,
provider_routes: tuple[EgressRoute, ...] = (), provider_routes: tuple[EgressRoute, ...] = (),
) -> tuple[EgressRoute, ...]: ) -> tuple[EgressRoute, ...]:
manifest = egress_manifest_routes(bottle) manifest = egress_manifest_routes(bottle)
@@ -280,7 +280,7 @@ def egress_resolve_token_values(
class Egress(ABC): class Egress(ABC):
def prepare( def prepare(
self, self,
bottle: Bottle, bottle: ManifestBottle,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
provider_routes: tuple[EgressRoute, ...] = (), provider_routes: tuple[EgressRoute, ...] = (),
+7 -7
View File
@@ -37,7 +37,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .log import info from .log import info
from .manifest import Bottle, GitEntry from .manifest import ManifestBottle, ManifestGitEntry
# Short network alias for git-gate inside the sidecar bundle. The # Short network alias for git-gate inside the sidecar bundle. The
@@ -96,9 +96,9 @@ class GitGatePlan:
egress_network: str = "" egress_network: str = ""
def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]: def git_gate_upstreams_for_bottle(bottle: ManifestBottle) -> tuple[GitGateUpstream, ...]:
"""Lift each `bottle.git` entry into a GitGateUpstream. Unique-Name """Lift each `bottle.git` entry into a GitGateUpstream. Unique-Name
validation already ran in `manifest.Bottle.from_dict`.""" validation already ran in `manifest.ManifestBottle.from_dict`."""
return tuple( return tuple(
GitGateUpstream( GitGateUpstream(
name=e.Name, name=e.Name,
@@ -113,7 +113,7 @@ def git_gate_upstreams_for_bottle(bottle: Bottle) -> tuple[GitGateUpstream, ...]
def git_gate_render_gitconfig( def git_gate_render_gitconfig(
entries: tuple[GitEntry, ...], gate_host: str, *, scheme: str = "git", entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
) -> str: ) -> str:
"""Render the agent's ~/.gitconfig content for git-gate """Render the agent's ~/.gitconfig content for git-gate
`insteadOf` rewrites. Pure host-side, no docker / smolvm; `insteadOf` rewrites. Pure host-side, no docker / smolvm;
@@ -361,7 +361,7 @@ exit 0
def _provision_dynamic_key( def _provision_dynamic_key(
entry: GitEntry, entry: ManifestGitEntry,
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
) -> str: ) -> str:
@@ -402,7 +402,7 @@ def _provision_dynamic_key(
return str(key_file) return str(key_file)
def revoke_git_gate_provisioned_keys(bottle: Bottle, stage_dir: Path) -> None: def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) -> None:
"""Revoke all deploy keys provisioned for `bottle` during prepare. """Revoke all deploy keys provisioned for `bottle` during prepare.
Called at teardown after containers stop. Raises if any revocation Called at teardown after containers stop. Raises if any revocation
@@ -440,7 +440,7 @@ class GitGate(ABC):
start/stop lifecycle is backend-specific and lives on concrete start/stop lifecycle is backend-specific and lives on concrete
subclasses.""" subclasses."""
def prepare(self, bottle: Bottle, slug: str, stage_dir: Path) -> GitGatePlan: def prepare(self, bottle: ManifestBottle, slug: str, stage_dir: Path) -> GitGatePlan:
"""Compute the upstream table from `bottle.git` and write the """Compute the upstream table from `bottle.git` and write the
entrypoint, pre-receive hook, and access-hook scripts (mode entrypoint, pre-receive hook, and access-hook scripts (mode
600) under `stage_dir`. Pure host-side, no docker subprocess. 600) under `stage_dir`. Pure host-side, no docker subprocess.
+30 -30
View File
@@ -50,26 +50,26 @@ from pathlib import Path
from typing import Mapping from typing import Mapping
from .manifest_util import ManifestError, as_json_object from .manifest_util import ManifestError, as_json_object
from .manifest_agent import Agent, AgentProvider from .manifest_agent import ManifestAgent, ManifestAgentProvider
from .manifest_egress import ( from .manifest_egress import (
EGRESS_AUTH_SCHEMES, EGRESS_AUTH_SCHEMES,
EgressConfig, ManifestEgressConfig,
EgressRoute, ManifestEgressRoute,
) )
from .manifest_git import GitEntry, GitUser, parse_git_gate_config from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config
from .manifest_schema import BOTTLE_KEYS from .manifest_schema import BOTTLE_KEYS
# Re-export everything that callers currently import from this module. # Re-export everything that callers currently import from this module.
__all__ = [ __all__ = [
"ManifestError", "ManifestError",
"GitEntry", "ManifestGitEntry",
"GitUser", "ManifestGitUser",
"AgentProvider", "ManifestAgentProvider",
"EGRESS_AUTH_SCHEMES", "EGRESS_AUTH_SCHEMES",
"EgressRoute", "ManifestEgressRoute",
"EgressConfig", "ManifestEgressConfig",
"Agent", "ManifestAgent",
"Bottle", "ManifestBottle",
"Manifest", "Manifest",
] ]
@@ -86,16 +86,16 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
@dataclass(frozen=True) @dataclass(frozen=True)
class Bottle: class ManifestBottle:
env: Mapping[str, str] = field(default_factory=_empty_str_dict) env: Mapping[str, str] = field(default_factory=_empty_str_dict)
agent_provider: AgentProvider = field(default_factory=AgentProvider) agent_provider: ManifestAgentProvider = field(default_factory=ManifestAgentProvider)
git: tuple[GitEntry, ...] = () git: tuple[ManifestGitEntry, ...] = ()
# Per-bottle git identity (issue #86). Empty default — bottles # Per-bottle git identity (issue #86). Empty default — bottles
# that don't set `git-gate.user:` in the manifest skip the # that don't set `git-gate.user:` in the manifest skip the
# `git config --global` step entirely. A bottle can declare a user # `git config --global` step entirely. A bottle can declare a user
# identity without any git-gate.repos upstreams, and vice versa. # identity without any git-gate.repos upstreams, and vice versa.
git_user: GitUser = field(default_factory=GitUser) git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
egress: EgressConfig = field(default_factory=EgressConfig) egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true, # Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes MCP # the launch step brings up a supervise sidecar that exposes MCP
# tools to the agent (egress-block, capability-block) plus mounts # tools to the agent (egress-block, capability-block) plus mounts
@@ -105,7 +105,7 @@ class Bottle:
supervise: bool = False supervise: bool = False
@classmethod @classmethod
def from_dict(cls, name: str, raw: object) -> "Bottle": def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
d = as_json_object(raw, f"bottle '{name}'") d = as_json_object(raw, f"bottle '{name}'")
if "runtime" in d: if "runtime" in d:
@@ -157,22 +157,22 @@ class Bottle:
) )
env[var] = value env[var] = value
git: tuple[GitEntry, ...] = () git: tuple[ManifestGitEntry, ...] = ()
git_user = GitUser() git_user = ManifestGitUser()
git_raw = d.get("git-gate") git_raw = d.get("git-gate")
if git_raw is not None: if git_raw is not None:
git, git_user = parse_git_gate_config(name, git_raw) git, git_user = parse_git_gate_config(name, git_raw)
agent_provider = ( agent_provider = (
AgentProvider.from_dict(name, d["agent_provider"]) ManifestAgentProvider.from_dict(name, d["agent_provider"])
if "agent_provider" in d if "agent_provider" in d
else AgentProvider() else ManifestAgentProvider()
) )
egress = ( egress = (
EgressConfig.from_dict(name, d["egress"]) ManifestEgressConfig.from_dict(name, d["egress"])
if "egress" in d if "egress" in d
else EgressConfig() else ManifestEgressConfig()
) )
supervise_raw = d.get("supervise", False) supervise_raw = d.get("supervise", False)
@@ -190,8 +190,8 @@ class Bottle:
@dataclass(frozen=True) @dataclass(frozen=True)
class Manifest: class Manifest:
bottles: Mapping[str, Bottle] bottles: Mapping[str, ManifestBottle]
agents: Mapping[str, Agent] agents: Mapping[str, ManifestAgent]
@classmethod @classmethod
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest": def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
@@ -305,8 +305,8 @@ class Manifest:
bottles = resolve_bottles(raw_bottles) bottles = resolve_bottles(raw_bottles)
bottle_names = set(bottles.keys()) bottle_names = set(bottles.keys())
agents: dict[str, Agent] = { agents: dict[str, ManifestAgent] = {
n: Agent.from_dict(n, a, bottle_names) for n, a in raw_agents.items() n: ManifestAgent.from_dict(n, a, bottle_names) for n, a in raw_agents.items()
} }
return cls(bottles=bottles, agents=agents) return cls(bottles=bottles, agents=agents)
@@ -338,7 +338,7 @@ class Manifest:
) )
raise ManifestError(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).") raise ManifestError(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
def _effective_git_user(self, agent_name: str) -> GitUser: def _effective_git_user(self, agent_name: str) -> ManifestGitUser:
"""Merge the agent's git.user over the referenced bottle's, """Merge the agent's git.user over the referenced bottle's,
per-field, agent-wins-on-non-empty (issue #94). Same overlay per-field, agent-wins-on-non-empty (issue #94). Same overlay
the `extends:` resolver applies between bottles the `extends:` resolver applies between bottles
@@ -348,12 +348,12 @@ class Manifest:
over = agent.git_user over = agent.git_user
if over.is_empty(): if over.is_empty():
return base return base
return GitUser( return ManifestGitUser(
name=over.name or base.name, name=over.name or base.name,
email=over.email or base.email, email=over.email or base.email,
) )
def bottle_for(self, agent_name: str) -> Bottle: 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. The validator guarantees both agent's git.user overlaid on top. The validator guarantees both
lookups succeed for a manifest built via from_json_obj. lookups succeed for a manifest built via from_json_obj.
+8 -8
View File
@@ -7,12 +7,12 @@ from typing import cast
from .agent_provider import PROVIDER_TEMPLATES from .agent_provider import PROVIDER_TEMPLATES
from .manifest_util import ManifestError, as_json_object from .manifest_util import ManifestError, as_json_object
from .manifest_git import GitUser from .manifest_git import ManifestGitUser
from .manifest_schema import AGENT_MODEL_KEYS from .manifest_schema import AGENT_MODEL_KEYS
@dataclass(frozen=True) @dataclass(frozen=True)
class AgentProvider: class ManifestAgentProvider:
"""Provider/template for the agent process inside a bottle. """Provider/template for the agent process inside a bottle.
`template` selects a built-in launch/runtime contract. `dockerfile` `template` selects a built-in launch/runtime contract. `dockerfile`
@@ -35,7 +35,7 @@ class AgentProvider:
forward_host_credentials: bool = False forward_host_credentials: bool = False
@classmethod @classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "AgentProvider": def from_dict(cls, bottle_name: str, raw: object) -> "ManifestAgentProvider":
d = as_json_object(raw, f"bottle '{bottle_name}' agent_provider") d = as_json_object(raw, f"bottle '{bottle_name}' agent_provider")
for k in d: for k in d:
if k not in {"template", "dockerfile", "auth_token", "forward_host_credentials"}: if k not in {"template", "dockerfile", "auth_token", "forward_host_credentials"}:
@@ -98,7 +98,7 @@ class AgentProvider:
@dataclass(frozen=True) @dataclass(frozen=True)
class Agent: class ManifestAgent:
bottle: str bottle: str
skills: tuple[str, ...] = () skills: tuple[str, ...] = ()
prompt: str = "" prompt: str = ""
@@ -106,10 +106,10 @@ class Agent:
# bottle's git-gate.user per-field at `Manifest.bottle_for`. Only # bottle's git-gate.user per-field at `Manifest.bottle_for`. Only
# `user` is allowed at the agent level; `repos` stays bottle-only # `user` is allowed at the agent level; `repos` stays bottle-only
# because it carries credentials and host trust. # because it carries credentials and host trust.
git_user: GitUser = GitUser() git_user: ManifestGitUser = ManifestGitUser()
@classmethod @classmethod
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "Agent": def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent":
d = as_json_object(raw, f"agent '{name}'") d = as_json_object(raw, f"agent '{name}'")
unknown = set(d.keys()) - AGENT_MODEL_KEYS unknown = set(d.keys()) - AGENT_MODEL_KEYS
if unknown: if unknown:
@@ -164,7 +164,7 @@ class Agent:
# git-gate: agents may declare only `git-gate.user` (name/email). # git-gate: agents may declare only `git-gate.user` (name/email).
# `git-gate.repos` is bottle-only — it carries credentials and host trust. # `git-gate.repos` is bottle-only — it carries credentials and host trust.
git_user = GitUser() git_user = ManifestGitUser()
git_raw = d.get("git-gate") git_raw = d.get("git-gate")
if git_raw is not None: if git_raw is not None:
gd = as_json_object(git_raw, f"agent '{name}' git-gate") gd = as_json_object(git_raw, f"agent '{name}' git-gate")
@@ -177,6 +177,6 @@ class Agent:
f"(it carries credentials and host trust)." f"(it carries credentials and host trust)."
) )
if "user" in gd: if "user" in gd:
git_user = GitUser.from_dict(name, gd["user"]) git_user = ManifestGitUser.from_dict(name, gd["user"])
return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user) return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user)
+26 -26
View File
@@ -24,7 +24,7 @@ INBOUND_DETECTOR_NAMES = frozenset({"naive_injection_detection"})
def validate_egress_routes( def validate_egress_routes(
bottle_name: str, bottle_name: str,
routes: tuple[EgressRoute, ...], routes: tuple[ManifestEgressRoute, ...],
) -> None: ) -> None:
seen_hosts: dict[str, None] = {} seen_hosts: dict[str, None] = {}
for r in routes: for r in routes:
@@ -38,29 +38,29 @@ def validate_egress_routes(
@dataclass(frozen=True) @dataclass(frozen=True)
class PathMatch: class ManifestPathMatch:
Type: str = "prefix" Type: str = "prefix"
Value: str = "" Value: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
class HeaderMatch: class ManifestHeaderMatch:
Name: str = "" Name: str = ""
Value: str = "" Value: str = ""
Type: str = "exact" Type: str = "exact"
@dataclass(frozen=True) @dataclass(frozen=True)
class MatchEntry: class ManifestMatchEntry:
Paths: tuple[PathMatch, ...] = () Paths: tuple[ManifestPathMatch, ...] = ()
Methods: tuple[str, ...] = () Methods: tuple[str, ...] = ()
Headers: tuple[HeaderMatch, ...] = () Headers: tuple[ManifestHeaderMatch, ...] = ()
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressRoute: class ManifestEgressRoute:
Host: str Host: str
Matches: tuple[MatchEntry, ...] = () Matches: tuple[ManifestMatchEntry, ...] = ()
AuthScheme: str = "" AuthScheme: str = ""
TokenRef: str = "" TokenRef: str = ""
Role: tuple[str, ...] = () Role: tuple[str, ...] = ()
@@ -68,7 +68,7 @@ class EgressRoute:
InboundDetectors: tuple[str, ...] | None = None InboundDetectors: tuple[str, ...] | None = None
@classmethod @classmethod
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "EgressRoute": def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "ManifestEgressRoute":
label = f"bottle '{bottle_name}' egress.routes[{idx}]" label = f"bottle '{bottle_name}' egress.routes[{idx}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
host = d.get("host") host = d.get("host")
@@ -76,7 +76,7 @@ class EgressRoute:
raise ManifestError(f"{label} missing required string field 'host'") raise ManifestError(f"{label} missing required string field 'host'")
# --- matches --- # --- matches ---
matches: tuple[MatchEntry, ...] = () matches: tuple[ManifestMatchEntry, ...] = ()
matches_raw = d.get("matches") matches_raw = d.get("matches")
if matches_raw is not None: if matches_raw is not None:
if not isinstance(matches_raw, list): if not isinstance(matches_raw, list):
@@ -85,7 +85,7 @@ class EgressRoute:
f"(was {type(matches_raw).__name__})" f"(was {type(matches_raw).__name__})"
) )
matches_list = cast(list[object], matches_raw) matches_list = cast(list[object], matches_raw)
entries: list[MatchEntry] = [] entries: list[ManifestMatchEntry] = []
for k, entry_raw in enumerate(matches_list): for k, entry_raw in enumerate(matches_list):
entries.append( entries.append(
_parse_match_entry(label, k, entry_raw) _parse_match_entry(label, k, entry_raw)
@@ -185,17 +185,17 @@ class EgressRoute:
def _parse_match_entry( def _parse_match_entry(
route_label: str, k: int, raw: object, route_label: str, k: int, raw: object,
) -> MatchEntry: ) -> ManifestMatchEntry:
label = f"{route_label} matches[{k}]" label = f"{route_label} matches[{k}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
paths: tuple[PathMatch, ...] = () paths: tuple[ManifestPathMatch, ...] = ()
paths_raw = d.get("paths") paths_raw = d.get("paths")
if paths_raw is not None: if paths_raw is not None:
if not isinstance(paths_raw, list): if not isinstance(paths_raw, list):
raise ManifestError(f"{label} paths must be an array") raise ManifestError(f"{label} paths must be an array")
paths_list = cast(list[object], paths_raw) paths_list = cast(list[object], paths_raw)
parsed_paths: list[PathMatch] = [] parsed_paths: list[ManifestPathMatch] = []
for j, p_raw in enumerate(paths_list): for j, p_raw in enumerate(paths_list):
parsed_paths.append(_parse_path_match(label, j, p_raw)) parsed_paths.append(_parse_path_match(label, j, p_raw))
paths = tuple(parsed_paths) paths = tuple(parsed_paths)
@@ -220,13 +220,13 @@ def _parse_match_entry(
normalised.append(upper) normalised.append(upper)
methods = tuple(normalised) methods = tuple(normalised)
headers: tuple[HeaderMatch, ...] = () headers: tuple[ManifestHeaderMatch, ...] = ()
headers_raw = d.get("headers") headers_raw = d.get("headers")
if headers_raw is not None: if headers_raw is not None:
if not isinstance(headers_raw, list): if not isinstance(headers_raw, list):
raise ManifestError(f"{label} headers must be an array") raise ManifestError(f"{label} headers must be an array")
headers_list = cast(list[object], headers_raw) headers_list = cast(list[object], headers_raw)
parsed_headers: list[HeaderMatch] = [] parsed_headers: list[ManifestHeaderMatch] = []
for j, h_raw in enumerate(headers_list): for j, h_raw in enumerate(headers_list):
parsed_headers.append(_parse_header_match(label, j, h_raw)) parsed_headers.append(_parse_header_match(label, j, h_raw))
headers = tuple(parsed_headers) headers = tuple(parsed_headers)
@@ -235,12 +235,12 @@ def _parse_match_entry(
if key not in ("paths", "methods", "headers"): if key not in ("paths", "methods", "headers"):
raise ManifestError(f"{label} has unknown key {key!r}") raise ManifestError(f"{label} has unknown key {key!r}")
return MatchEntry(Paths=paths, Methods=methods, Headers=headers) return ManifestMatchEntry(Paths=paths, Methods=methods, Headers=headers)
def _parse_path_match( def _parse_path_match(
entry_label: str, j: int, raw: object, entry_label: str, j: int, raw: object,
) -> PathMatch: ) -> ManifestPathMatch:
label = f"{entry_label} paths[{j}]" label = f"{entry_label} paths[{j}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
ptype = d.get("type", "prefix") ptype = d.get("type", "prefix")
@@ -266,12 +266,12 @@ def _parse_path_match(
for k in d: for k in d:
if k not in ("type", "value"): if k not in ("type", "value"):
raise ManifestError(f"{label} has unknown key {k!r}") raise ManifestError(f"{label} has unknown key {k!r}")
return PathMatch(Type=ptype, Value=value) return ManifestPathMatch(Type=ptype, Value=value)
def _parse_header_match( def _parse_header_match(
entry_label: str, j: int, raw: object, entry_label: str, j: int, raw: object,
) -> HeaderMatch: ) -> ManifestHeaderMatch:
label = f"{entry_label} headers[{j}]" label = f"{entry_label} headers[{j}]"
d = as_json_object(raw, label) d = as_json_object(raw, label)
name = d.get("name") name = d.get("name")
@@ -296,7 +296,7 @@ def _parse_header_match(
for k in d: for k in d:
if k not in ("name", "value", "type"): if k not in ("name", "value", "type"):
raise ManifestError(f"{label} has unknown key {k!r}") raise ManifestError(f"{label} has unknown key {k!r}")
return HeaderMatch(Name=name, Value=value, Type=htype) return ManifestHeaderMatch(Name=name, Value=value, Type=htype)
def _parse_dlp_block( def _parse_dlp_block(
@@ -350,15 +350,15 @@ LOG_LEVELS = frozenset({0, 1, 2})
@dataclass(frozen=True) @dataclass(frozen=True)
class EgressConfig: class ManifestEgressConfig:
routes: tuple[EgressRoute, ...] = () routes: tuple[ManifestEgressRoute, ...] = ()
Log: int = 0 Log: int = 0
@classmethod @classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "EgressConfig": def from_dict(cls, bottle_name: str, raw: object) -> "ManifestEgressConfig":
d = as_json_object(raw, f"bottle '{bottle_name}' egress") d = as_json_object(raw, f"bottle '{bottle_name}' egress")
routes_raw = d.get("routes") routes_raw = d.get("routes")
routes: tuple[EgressRoute, ...] = () routes: tuple[ManifestEgressRoute, ...] = ()
if routes_raw is not None: if routes_raw is not None:
if not isinstance(routes_raw, list): if not isinstance(routes_raw, list):
raise ManifestError( raise ManifestError(
@@ -367,7 +367,7 @@ class EgressConfig:
) )
routes_list = cast(list[object], routes_raw) routes_list = cast(list[object], routes_raw)
routes = tuple( routes = tuple(
EgressRoute.from_dict(bottle_name, i, entry) ManifestEgressRoute.from_dict(bottle_name, i, entry)
for i, entry in enumerate(routes_list) for i, entry in enumerate(routes_list)
) )
validate_egress_routes(bottle_name, routes) validate_egress_routes(bottle_name, routes)
+21 -21
View File
@@ -5,12 +5,12 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import Bottle, GitEntry from .manifest import ManifestBottle, ManifestGitEntry
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, Bottle]: def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
"""Apply `extends:` chains and return resolved Bottle objects.""" """Apply `extends:` chains and return resolved ManifestBottle objects."""
cache: dict[str, Bottle] = {} cache: dict[str, ManifestBottle] = {}
for name in raws: for name in raws:
if name not in cache: if name not in cache:
_resolve_one_bottle(name, raws, cache, ()) _resolve_one_bottle(name, raws, cache, ())
@@ -20,10 +20,10 @@ def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, Bottle]:
def _resolve_one_bottle( def _resolve_one_bottle(
name: str, name: str,
raws: dict[str, dict[str, object]], raws: dict[str, dict[str, object]],
cache: dict[str, Bottle], cache: dict[str, ManifestBottle],
seen: tuple[str, ...], seen: tuple[str, ...],
) -> Bottle: ) -> ManifestBottle:
from .manifest import Bottle, ManifestError from .manifest import ManifestBottle, ManifestError
if name in cache: if name in cache:
return cache[name] return cache[name]
@@ -32,13 +32,13 @@ def _resolve_one_bottle(
raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}") raise ManifestError(f"bottle '{name}' is in an extends cycle: {chain}")
raw = raws[name] raw = raws[name]
parent_name_raw = raw.get("extends") parent_name_raw = raw.get("extends")
# Strip `extends:` before passing to Bottle.from_dict so it # Strip `extends:` before passing to ManifestBottle.from_dict so it
# is not accidentally treated as a real Bottle field by future # is not accidentally treated as a real ManifestBottle field by future
# schema additions. It is only meaningful here. # schema additions. It is only meaningful here.
child_raw = {k: v for k, v in raw.items() if k != "extends"} child_raw = {k: v for k, v in raw.items() if k != "extends"}
if parent_name_raw is None: if parent_name_raw is None:
bottle = Bottle.from_dict(name, child_raw) bottle = ManifestBottle.from_dict(name, child_raw)
cache[name] = bottle cache[name] = bottle
return bottle return bottle
@@ -66,27 +66,27 @@ def _resolve_one_bottle(
def _merge_bottles( def _merge_bottles(
parent: Bottle, parent: ManifestBottle,
child_raw: dict[str, object], child_raw: dict[str, object],
name: str, name: str,
) -> Bottle: ) -> ManifestBottle:
"""Apply PRD 0025 merge rules.""" """Apply PRD 0025 merge rules."""
from .manifest import Bottle, GitUser from .manifest import ManifestBottle, ManifestGitUser
from .manifest_egress import validate_egress_routes from .manifest_egress import validate_egress_routes
# Parse the child's declared fields into a Bottle (with the # Parse the child's declared fields into a ManifestBottle (with the
# usual defaults for anything missing). Validation runs the same # usual defaults for anything missing). Validation runs the same
# way it would for a leaf bottle: typos / wrong types die here. # way it would for a leaf bottle: typos / wrong types die here.
child = Bottle.from_dict(name, child_raw) child = ManifestBottle.from_dict(name, child_raw)
# env: dict merge, child wins on collision. # env: dict merge, child wins on collision.
merged_env = {**parent.env, **child.env} merged_env = {**parent.env, **child.env}
# git-gate.user: per-field overlay. Each non-empty field on child # git-gate.user: per-field overlay. Each non-empty field on child
# wins; empties fall through to parent. The default GitUser() # wins; empties fall through to parent. The default ManifestGitUser()
# is two empty strings, so a child that omits git-gate.user # is two empty strings, so a child that omits git-gate.user
# inherits the parent's user verbatim. # inherits the parent's user verbatim.
merged_git_user = GitUser( merged_git_user = ManifestGitUser(
name=child.git_user.name or parent.git_user.name, name=child.git_user.name or parent.git_user.name,
email=child.git_user.email or parent.git_user.email, email=child.git_user.email or parent.git_user.email,
) )
@@ -112,7 +112,7 @@ def _merge_bottles(
) )
validate_egress_routes(name, merged_egress.routes) validate_egress_routes(name, merged_egress.routes)
return Bottle( return ManifestBottle(
env=merged_env, env=merged_env,
agent_provider=merged_agent_provider, agent_provider=merged_agent_provider,
git=merged_git, git=merged_git,
@@ -133,9 +133,9 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
def _merge_git_remotes( def _merge_git_remotes(
parent: tuple[GitEntry, ...], parent: tuple[ManifestGitEntry, ...],
child: tuple[GitEntry, ...], child: tuple[ManifestGitEntry, ...],
) -> tuple[GitEntry, ...]: ) -> tuple[ManifestGitEntry, ...]:
by_host = {entry.UpstreamHost: entry for entry in parent} by_host = {entry.UpstreamHost: entry for entry in parent}
for entry in child: for entry in child:
by_host[entry.UpstreamHost] = entry by_host[entry.UpstreamHost] = entry
+15 -15
View File
@@ -57,7 +57,7 @@ def parse_git_upstream(url: str, label: str) -> tuple[str, str, str, str]:
return (user, host, port, path) return (user, host, port, path)
def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> None: def validate_unique_git_names(bottle_name: str, git: tuple[ManifestGitEntry, ...]) -> None:
seen: dict[str, None] = {} seen: dict[str, None] = {}
for g in git: for g in git:
if g.Name in seen: if g.Name in seen:
@@ -69,7 +69,7 @@ def validate_unique_git_names(bottle_name: str, git: tuple[GitEntry, ...]) -> No
@dataclass(frozen=True) @dataclass(frozen=True)
class ProvisionedKeyConfig: class ManifestProvisionedKeyConfig:
"""Configuration for automatic deploy-key lifecycle management """Configuration for automatic deploy-key lifecycle management
(PRD 0048). Used when a git-gate.repos entry opts out of a (PRD 0048). Used when a git-gate.repos entry opts out of a
static identity file and instead wants a fresh SSH keypair static identity file and instead wants a fresh SSH keypair
@@ -87,7 +87,7 @@ class ProvisionedKeyConfig:
@dataclass(frozen=True) @dataclass(frozen=True)
class GitEntry: class ManifestGitEntry:
"""One upstream the per-agent git-gate (PRD 0008) is allowed to """One upstream the per-agent git-gate (PRD 0008) is allowed to
talk to. `Upstream` is the real remote URL the agent would push to talk to. `Upstream` is the real remote URL the agent would push to
if there were no gate; the gate hosts a bare repo at /git/<Name>.git if there were no gate; the gate hosts a bare repo at /git/<Name>.git
@@ -107,7 +107,7 @@ class GitEntry:
Upstream: str Upstream: str
IdentityFile: str = "" IdentityFile: str = ""
KnownHostKey: str = "" KnownHostKey: str = ""
ProvisionedKey: Optional[ProvisionedKeyConfig] = None ProvisionedKey: Optional[ManifestProvisionedKeyConfig] = None
RemoteKey: str = "" RemoteKey: str = ""
UpstreamUser: str = "" UpstreamUser: str = ""
UpstreamHost: str = "" UpstreamHost: str = ""
@@ -117,7 +117,7 @@ class GitEntry:
@classmethod @classmethod
def from_repos_entry( def from_repos_entry(
cls, bottle_name: str, repo_name: str, raw: object cls, bottle_name: str, repo_name: str, raw: object
) -> "GitEntry": ) -> "ManifestGitEntry":
"""Parse one entry from `git-gate.repos.<repo_name>`. """Parse one entry from `git-gate.repos.<repo_name>`.
YAML keys: `url` (required), exactly one of `identity` or YAML keys: `url` (required), exactly one of `identity` or
@@ -160,7 +160,7 @@ class GitEntry:
) )
ident = "" ident = ""
provisioned_key: Optional[ProvisionedKeyConfig] = None provisioned_key: Optional[ManifestProvisionedKeyConfig] = None
if has_identity: if has_identity:
raw_ident = d.get("identity") raw_ident = d.get("identity")
if not isinstance(raw_ident, str) or not raw_ident: if not isinstance(raw_ident, str) or not raw_ident:
@@ -196,7 +196,7 @@ class GitEntry:
def _parse_provisioned_key_config( def _parse_provisioned_key_config(
bottle_name: str, label: str, raw: object bottle_name: str, label: str, raw: object
) -> ProvisionedKeyConfig: ) -> ManifestProvisionedKeyConfig:
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key") d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key")
for k in d: for k in d:
if k not in {"provider", "token_env", "api_url"}: if k not in {"provider", "token_env", "api_url"}:
@@ -221,7 +221,7 @@ def _parse_provisioned_key_config(
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string" f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
) )
return ProvisionedKeyConfig( return ManifestProvisionedKeyConfig(
provider=provider, provider=provider,
token_env=token_env, token_env=token_env,
api_url=api_url_raw, api_url=api_url_raw,
@@ -229,7 +229,7 @@ def _parse_provisioned_key_config(
@dataclass(frozen=True) @dataclass(frozen=True)
class GitUser: class ManifestGitUser:
"""Per-bottle `git config --global user.name` / `user.email` """Per-bottle `git config --global user.name` / `user.email`
pair (issue #86). The agent's commits inside the bottle are pair (issue #86). The agent's commits inside the bottle are
attributed to this identity rather than the agent image's attributed to this identity rather than the agent image's
@@ -244,7 +244,7 @@ class GitUser:
email: str = "" email: str = ""
@classmethod @classmethod
def from_dict(cls, bottle_name: str, raw: object) -> "GitUser": def from_dict(cls, bottle_name: str, raw: object) -> "ManifestGitUser":
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user") d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.user")
for k in d: for k in d:
if k not in {"name", "email"}: if k not in {"name", "email"}:
@@ -279,7 +279,7 @@ class GitUser:
def parse_git_gate_config( def parse_git_gate_config(
bottle_name: str, bottle_name: str,
raw: object, raw: object,
) -> tuple[tuple[GitEntry, ...], GitUser]: ) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate") d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
for k in d: for k in d:
if k not in {"user", "repos"}: if k not in {"user", "repos"}:
@@ -289,17 +289,17 @@ def parse_git_gate_config(
) )
git_user = ( git_user = (
GitUser.from_dict(bottle_name, d["user"]) ManifestGitUser.from_dict(bottle_name, d["user"])
if "user" in d if "user" in d
else GitUser() else ManifestGitUser()
) )
git: tuple[GitEntry, ...] = () git: tuple[ManifestGitEntry, ...] = ()
repos_raw = d.get("repos") repos_raw = d.get("repos")
if repos_raw is not None: if repos_raw is not None:
repos = as_json_object(repos_raw, f"bottle '{bottle_name}' git-gate.repos") repos = as_json_object(repos_raw, f"bottle '{bottle_name}' git-gate.repos")
git = tuple( git = tuple(
GitEntry.from_repos_entry(bottle_name, name, entry) ManifestGitEntry.from_repos_entry(bottle_name, name, entry)
for name, entry in repos.items() for name, entry in repos.items()
) )
validate_unique_git_names(bottle_name, git) validate_unique_git_names(bottle_name, git)
+6 -6
View File
@@ -14,7 +14,7 @@ from .manifest_schema import (
from .yaml_subset import YamlSubsetError, parse_frontmatter from .yaml_subset import YamlSubsetError, parse_frontmatter
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import Agent, Bottle 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:
@@ -34,7 +34,7 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
) )
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, Bottle]: def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return """Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict.""" `{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError from .manifest import ManifestError
@@ -67,13 +67,13 @@ def load_agents_from_dir(
bottle_names: set[str], bottle_names: set[str],
*, *,
source: str, # noqa: F841 — unused, but required by interface source: str, # noqa: F841 — unused, but required by interface
) -> dict[str, Agent]: ) -> dict[str, ManifestAgent]:
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return """Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt. `{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict.""" Missing dir returns an empty dict."""
from .manifest import Agent, ManifestError from .manifest import ManifestAgent, ManifestError
out: dict[str, Agent] = {} out: dict[str, ManifestAgent] = {}
if not agents_dir.is_dir(): if not agents_dir.is_dir():
return out return out
for path in sorted(agents_dir.glob("*.md")): for path in sorted(agents_dir.glob("*.md")):
@@ -101,5 +101,5 @@ def load_agents_from_dir(
} }
if "git-gate" in fm: if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"] agent_dict["git-gate"] = fm["git-gate"]
out[name] = Agent.from_dict(name, agent_dict, bottle_names) out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names)
return out return out
+4 -4
View File
@@ -2,7 +2,7 @@
import unittest import unittest
from bot_bottle.manifest import ManifestError, GitUser, Manifest from bot_bottle.manifest import ManifestError, ManifestGitUser, Manifest
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -99,13 +99,13 @@ class TestGitUserDirect(unittest.TestCase):
"""Direct GitUser dataclass exercises (no manifest wrapper).""" """Direct GitUser dataclass exercises (no manifest wrapper)."""
def test_is_empty_default(self): def test_is_empty_default(self):
self.assertTrue(GitUser().is_empty()) self.assertTrue(ManifestGitUser().is_empty())
def test_is_empty_false_when_name_set(self): def test_is_empty_false_when_name_set(self):
self.assertFalse(GitUser(name="x").is_empty()) self.assertFalse(ManifestGitUser(name="x").is_empty())
def test_is_empty_false_when_email_set(self): def test_is_empty_false_when_email_set(self):
self.assertFalse(GitUser(email="x@y").is_empty()) self.assertFalse(ManifestGitUser(email="x@y").is_empty())
if __name__ == "__main__": if __name__ == "__main__":
+2 -2
View File
@@ -7,7 +7,7 @@ silently ignoring."""
import unittest import unittest
from typing import Any from typing import Any
from bot_bottle.manifest import ManifestError, Bottle, Manifest from bot_bottle.manifest import ManifestError, ManifestBottle, Manifest
def _manifest_with_runtime(value: object) -> dict[str, Any]: def _manifest_with_runtime(value: object) -> dict[str, Any]:
@@ -26,7 +26,7 @@ class TestManifestRuntimeRemoved(unittest.TestCase):
self.assertIn("dev", m.bottles) self.assertIn("dev", m.bottles)
def test_bottle_dataclass_has_no_runtime_attribute(self): def test_bottle_dataclass_has_no_runtime_attribute(self):
self.assertFalse(hasattr(Bottle(), "runtime")) self.assertFalse(hasattr(ManifestBottle(), "runtime"))
def test_any_runtime_value_is_rejected(self): def test_any_runtime_value_is_rejected(self):
for value in ("runsc", "runc", "kata-runtime", "", 42, None): for value in ("runsc", "runc", "kata-runtime", "", 42, None):
+3 -3
View File
@@ -33,7 +33,7 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import GitEntry, Manifest from bot_bottle.manifest import ManifestGitEntry, Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
from bot_bottle.workspace import workspace_plan from bot_bottle.workspace import workspace_plan
@@ -85,7 +85,7 @@ def _plan(
*, *,
agent_prompt: str = "", agent_prompt: str = "",
skills: list[str] | None = None, skills: list[str] | None = None,
git: list[GitEntry] = (), # type: ignore git: list[ManifestGitEntry] = (), # type: ignore
git_user: dict | None = None, # type: ignore git_user: dict | None = None, # type: ignore
copy_cwd: bool = False, copy_cwd: bool = False,
user_cwd: str = "/tmp/x", user_cwd: str = "/tmp/x",
@@ -392,7 +392,7 @@ class TestProvisionGit(unittest.TestCase):
# git HTTP port is published on host loopback at launch # git HTTP port is published on host loopback at launch
# time, and the plan carries the discovered host port. # time, and the plan carries the discovered host port.
plan = _plan( plan = _plan(
git=[GitEntry( git=[ManifestGitEntry(
Name="bot-bottle", Name="bot-bottle",
Upstream="ssh://git@host/repo.git", Upstream="ssh://git@host/repo.git",
IdentityFile="~/.ssh/id_ed25519", IdentityFile="~/.ssh/id_ed25519",