refactor(manifest): add TypedDict schema and eager validation
test / run tests/run_tests.py (pull_request) Successful in 20s
test / run tests/run_tests.py (pull_request) Successful in 20s
Move schema checks out of per-access getters into a single
manifest_validate pass invoked by manifest_resolve. Getters can now
assume bottles/agents are well-typed dicts and every agent has a
defined bottle, so the .get(...) or {} chains collapse. Behavior
change: a bad runtime / shape error anywhere in the manifest now
fails at load instead of on the N-th read.
Intermediate step toward replacing TypedDict with a dataclass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+192
-87
@@ -28,18 +28,58 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
|
||||
from .log import die
|
||||
|
||||
Manifest = dict[str, Any]
|
||||
|
||||
class SshEntry(TypedDict, total=False):
|
||||
"""One entry from bottle.ssh. Host and IdentityFile are required at
|
||||
runtime (enforced by manifest_validate); the rest configure the
|
||||
generated ~/.ssh/config block inside the container."""
|
||||
|
||||
Host: str
|
||||
IdentityFile: str
|
||||
Hostname: str
|
||||
User: str
|
||||
Port: int | str
|
||||
KnownHostKey: str
|
||||
|
||||
|
||||
class BottleEgress(TypedDict, total=False):
|
||||
allowlist: list[str]
|
||||
|
||||
|
||||
class Bottle(TypedDict, total=False):
|
||||
"""Shared infrastructure referenced by agents. env values are
|
||||
sentinel-prefixed strings (see env_resolve); runtime defaults to
|
||||
"runc" when absent."""
|
||||
|
||||
env: dict[str, str]
|
||||
ssh: list[SshEntry]
|
||||
egress: BottleEgress
|
||||
runtime: Literal["runc", "runsc"]
|
||||
|
||||
|
||||
class Agent(TypedDict, total=False):
|
||||
skills: list[str]
|
||||
prompt: str
|
||||
bottle: str
|
||||
|
||||
|
||||
class Manifest(TypedDict, total=False):
|
||||
bottles: dict[str, Bottle]
|
||||
agents: dict[str, Agent]
|
||||
|
||||
|
||||
_SUPPORTED_RUNTIMES: tuple[str, ...] = ("runc", "runsc")
|
||||
|
||||
|
||||
def manifest_resolve(cwd: str) -> Manifest:
|
||||
"""Look for claude-bottle.json in <cwd> and in $HOME, deep-merge
|
||||
them (cwd entries override home entries on key conflict for both
|
||||
bottles and agents). Dies if neither file is found or either is
|
||||
invalid JSON."""
|
||||
bottles and agents), then validate. Dies if neither file is found,
|
||||
either is invalid JSON, or the merged shape violates the schema."""
|
||||
cwd_file = Path(cwd) / "claude-bottle.json"
|
||||
home_file = Path(os.environ["HOME"]) / "claude-bottle.json"
|
||||
|
||||
@@ -49,18 +89,135 @@ def manifest_resolve(cwd: str) -> Manifest:
|
||||
if cwd_doc is None and home_doc is None:
|
||||
die(f"no claude-bottle.json found in {cwd} or {os.environ['HOME']}")
|
||||
|
||||
if cwd_doc is None:
|
||||
return home_doc # type: ignore[return-value]
|
||||
if home_doc is None:
|
||||
return cwd_doc
|
||||
|
||||
return {
|
||||
"bottles": {**(home_doc.get("bottles") or {}), **(cwd_doc.get("bottles") or {})},
|
||||
"agents": {**(home_doc.get("agents") or {}), **(cwd_doc.get("agents") or {})},
|
||||
h = home_doc if home_doc is not None else cast(Manifest, {})
|
||||
c = cwd_doc if cwd_doc is not None else cast(Manifest, {})
|
||||
manifest: Manifest = {
|
||||
"bottles": {**(h.get("bottles") or {}), **(c.get("bottles") or {})},
|
||||
"agents": {**(h.get("agents") or {}), **(c.get("agents") or {})},
|
||||
}
|
||||
manifest_validate(manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def _load_json_or_die(path: Path) -> Manifest:
|
||||
def manifest_validate(manifest: Manifest) -> None:
|
||||
"""Deep-validate the manifest, dying on the first schema violation.
|
||||
After this returns, getters can trust that bottles and agents are
|
||||
dicts, every agent has a 'bottle' field that references a defined
|
||||
bottle, and optional fields (when present) have correct types."""
|
||||
bottles = manifest.get("bottles") or {}
|
||||
agents = manifest.get("agents") or {}
|
||||
|
||||
for bname, bottle in bottles.items():
|
||||
_validate_bottle(bname, bottle)
|
||||
|
||||
for aname, agent in agents.items():
|
||||
_validate_agent(aname, agent, bottles)
|
||||
|
||||
|
||||
def _validate_bottle(name: str, bottle: Any) -> None:
|
||||
if not isinstance(bottle, dict):
|
||||
die(f"bottle '{name}' must be a JSON object (was {_json_type(bottle)})")
|
||||
bottle = cast(dict[Any, Any], bottle)
|
||||
|
||||
env = bottle.get("env")
|
||||
if env is not None:
|
||||
if not isinstance(env, dict):
|
||||
die(f"bottle '{name}' env must be a JSON object (was {_json_type(env)})")
|
||||
env = cast(dict[Any, Any], env)
|
||||
for var, value in env.items():
|
||||
if not isinstance(value, str):
|
||||
die(
|
||||
f"env entry {var} in bottle '{name}' must be a JSON string "
|
||||
f"(was {_json_type(value)}). Use \"?<message>\" for prompt-at-runtime."
|
||||
)
|
||||
|
||||
ssh = bottle.get("ssh")
|
||||
if ssh is not None:
|
||||
if not isinstance(ssh, list):
|
||||
die(f"bottle '{name}' ssh must be an array (was {_json_type(ssh)})")
|
||||
ssh = cast(list[Any], ssh)
|
||||
for i, entry in enumerate(ssh):
|
||||
if not isinstance(entry, dict):
|
||||
die(
|
||||
f"bottle '{name}' ssh[{i}] must be a JSON object "
|
||||
f"(was {_json_type(entry)})"
|
||||
)
|
||||
entry = cast(dict[Any, Any], entry)
|
||||
host = entry.get("Host")
|
||||
if not isinstance(host, str) or not host:
|
||||
die(f"bottle '{name}' ssh[{i}] missing required string field 'Host'")
|
||||
ident = entry.get("IdentityFile")
|
||||
if not isinstance(ident, str) or not ident:
|
||||
die(
|
||||
f"bottle '{name}' ssh '{host}' missing required string field "
|
||||
f"'IdentityFile'"
|
||||
)
|
||||
|
||||
egress = bottle.get("egress")
|
||||
if egress is not None:
|
||||
if not isinstance(egress, dict):
|
||||
die(f"bottle '{name}' egress must be a JSON object (was {_json_type(egress)})")
|
||||
egress = cast(dict[Any, Any], egress)
|
||||
allowlist = egress.get("allowlist")
|
||||
if allowlist is not None:
|
||||
if not isinstance(allowlist, list):
|
||||
die(
|
||||
f"bottle '{name}' egress.allowlist must be an array "
|
||||
f"(was {_json_type(allowlist)})"
|
||||
)
|
||||
allowlist = cast(list[Any], allowlist)
|
||||
for i, host in enumerate(allowlist):
|
||||
if not isinstance(host, str):
|
||||
die(
|
||||
f"bottle '{name}' egress.allowlist[{i}] must be a string "
|
||||
f"(was {_json_type(host)})"
|
||||
)
|
||||
|
||||
runtime = bottle.get("runtime")
|
||||
if runtime is not None:
|
||||
if not isinstance(runtime, str):
|
||||
die(f"bottle '{name}' runtime must be a string (was {_json_type(runtime)})")
|
||||
if runtime not in _SUPPORTED_RUNTIMES:
|
||||
die(
|
||||
f"bottle '{name}' runtime '{runtime}' is not supported. "
|
||||
f"Use one of: {', '.join(_SUPPORTED_RUNTIMES)}."
|
||||
)
|
||||
|
||||
|
||||
def _validate_agent(name: str, agent: Any, bottles: dict[str, Any]) -> None:
|
||||
if not isinstance(agent, dict):
|
||||
die(f"agent '{name}' must be a JSON object (was {_json_type(agent)})")
|
||||
agent = cast(dict[Any, Any], agent)
|
||||
|
||||
skills = agent.get("skills")
|
||||
if skills is not None:
|
||||
if not isinstance(skills, list):
|
||||
die(f"agent '{name}' skills must be an array (was {_json_type(skills)})")
|
||||
skills = cast(list[Any], skills)
|
||||
for i, skill in enumerate(skills):
|
||||
if not isinstance(skill, str):
|
||||
die(f"agent '{name}' skills[{i}] must be a string (was {_json_type(skill)})")
|
||||
|
||||
prompt = agent.get("prompt")
|
||||
if prompt is not None and not isinstance(prompt, str):
|
||||
die(f"agent '{name}' prompt must be a string (was {_json_type(prompt)})")
|
||||
|
||||
bottle = agent.get("bottle")
|
||||
if not isinstance(bottle, str) or not bottle:
|
||||
die(f"agent '{name}' must declare a 'bottle' field naming a defined bottle")
|
||||
if bottle not in bottles:
|
||||
available = ", ".join(bottles.keys()) or "(none defined)"
|
||||
die(
|
||||
f"agent '{name}' references bottle '{bottle}', which is not defined. "
|
||||
f"Available: {available}"
|
||||
)
|
||||
|
||||
|
||||
def _load_json_or_die(path: Path) -> dict[Any, Any]:
|
||||
"""Load and shallow-check. Confirms bottles/agents (when present)
|
||||
are JSON objects so the merge in manifest_resolve cannot TypeError
|
||||
before manifest_validate runs."""
|
||||
doc: Any = None
|
||||
try:
|
||||
with path.open() as f:
|
||||
doc = json.load(f)
|
||||
@@ -68,18 +225,18 @@ def _load_json_or_die(path: Path) -> Manifest:
|
||||
die(f"claude-bottle.json at {path} is not valid JSON")
|
||||
if not isinstance(doc, dict):
|
||||
die(f"claude-bottle.json at {path} must be a JSON object")
|
||||
return doc
|
||||
return cast(dict[Any, Any], doc)
|
||||
|
||||
|
||||
def manifest_has_agent(manifest: Manifest, name: str) -> bool:
|
||||
return name in (manifest.get("agents") or {})
|
||||
return name in manifest["agents"]
|
||||
|
||||
|
||||
def manifest_require_agent(manifest: Manifest, name: str) -> None:
|
||||
"""Like has_agent but dies with the available agent names listed."""
|
||||
if manifest_has_agent(manifest, name):
|
||||
return
|
||||
available = ", ".join((manifest.get("agents") or {}).keys())
|
||||
available = ", ".join(manifest["agents"].keys())
|
||||
if available:
|
||||
die(f"agent '{name}' not defined in claude-bottle.json. Available: {available}")
|
||||
else:
|
||||
@@ -88,58 +245,38 @@ def manifest_require_agent(manifest: Manifest, name: str) -> None:
|
||||
|
||||
def manifest_env_names(manifest: Manifest, name: str) -> list[str]:
|
||||
"""Names (not values) of bottles[agent.bottle].env, in declaration
|
||||
order. Empty list if the agent has no bottle or the bottle has no env."""
|
||||
agent = (manifest.get("agents") or {}).get(name) or {}
|
||||
bottle_name = agent.get("bottle") or ""
|
||||
if not bottle_name:
|
||||
return []
|
||||
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
|
||||
return list((bottle.get("env") or {}).keys())
|
||||
order. Empty if the agent's bottle has no env."""
|
||||
bottle_name = manifest["agents"][name]["bottle"]
|
||||
return list(manifest["bottles"][bottle_name].get("env", {}).keys())
|
||||
|
||||
|
||||
def manifest_env_entry(manifest: Manifest, agent: str, var: str) -> str:
|
||||
"""Raw string value of one env entry. Used by env_resolve, which
|
||||
classifies the result by sentinel. Dies if the agent has no bottle,
|
||||
or the entry is not a string."""
|
||||
agent_def = (manifest.get("agents") or {}).get(agent) or {}
|
||||
bottle_name = agent_def.get("bottle") or ""
|
||||
if not bottle_name:
|
||||
die(f"env entry {var} for agent {agent}: agent has no 'bottle' field")
|
||||
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
|
||||
env = bottle.get("env") or {}
|
||||
value = env.get(var)
|
||||
if not isinstance(value, str):
|
||||
actual = _json_type(value)
|
||||
die(
|
||||
f"env entry {var} for agent {agent} must be a JSON string "
|
||||
f"(was {actual}). Use \"?<message>\" for prompt-at-runtime."
|
||||
)
|
||||
return value
|
||||
classifies the result by sentinel."""
|
||||
bottle_name = manifest["agents"][agent]["bottle"]
|
||||
return manifest["bottles"][bottle_name]["env"][var]
|
||||
|
||||
|
||||
def manifest_skills(manifest: Manifest, name: str) -> list[str]:
|
||||
agent = (manifest.get("agents") or {}).get(name) or {}
|
||||
return list(agent.get("skills") or [])
|
||||
return list(manifest["agents"][name].get("skills", []))
|
||||
|
||||
|
||||
def manifest_prompt(manifest: Manifest, name: str) -> str:
|
||||
agent = (manifest.get("agents") or {}).get(name) or {}
|
||||
return agent.get("prompt") or ""
|
||||
return manifest["agents"][name].get("prompt", "")
|
||||
|
||||
|
||||
def manifest_agent_bottle(manifest: Manifest, name: str) -> str:
|
||||
agent = (manifest.get("agents") or {}).get(name) or {}
|
||||
return agent.get("bottle") or ""
|
||||
return manifest["agents"][name]["bottle"]
|
||||
|
||||
|
||||
def manifest_has_bottle(manifest: Manifest, bottle_name: str) -> bool:
|
||||
return bottle_name in (manifest.get("bottles") or {})
|
||||
return bottle_name in manifest["bottles"]
|
||||
|
||||
|
||||
def manifest_require_bottle(manifest: Manifest, bottle_name: str) -> None:
|
||||
if manifest_has_bottle(manifest, bottle_name):
|
||||
return
|
||||
available = ", ".join((manifest.get("bottles") or {}).keys())
|
||||
available = ", ".join(manifest["bottles"].keys())
|
||||
if available:
|
||||
die(
|
||||
f"bottle '{bottle_name}' not defined in claude-bottle.json. "
|
||||
@@ -149,56 +286,24 @@ def manifest_require_bottle(manifest: Manifest, bottle_name: str) -> None:
|
||||
die(f"bottle '{bottle_name}' not defined in claude-bottle.json (no bottles defined).")
|
||||
|
||||
|
||||
def manifest_bottle_ssh(manifest: Manifest, bottle_name: str) -> list[dict[str, Any]]:
|
||||
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
|
||||
return list(bottle.get("ssh") or [])
|
||||
|
||||
|
||||
_SUPPORTED_RUNTIMES: tuple[str, ...] = ("runc", "runsc")
|
||||
def manifest_bottle_ssh(manifest: Manifest, bottle_name: str) -> list[SshEntry]:
|
||||
return list(manifest["bottles"][bottle_name].get("ssh", []))
|
||||
|
||||
|
||||
def manifest_bottle_runtime(manifest: Manifest, bottle_name: str) -> str:
|
||||
"""Container runtime for the bottle's agent container. Returns
|
||||
"runc" (Docker default) or "runsc" (gVisor opt-in). Dies if the
|
||||
field is present but not one of the supported values."""
|
||||
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
|
||||
raw = bottle.get("runtime")
|
||||
if raw is None:
|
||||
return "runc"
|
||||
if not isinstance(raw, str):
|
||||
die(
|
||||
f"bottle '{bottle_name}' runtime must be a string "
|
||||
f"(was {_json_type(raw)})."
|
||||
)
|
||||
if raw not in _SUPPORTED_RUNTIMES:
|
||||
die(
|
||||
f"bottle '{bottle_name}' runtime '{raw}' is not supported. "
|
||||
f"Use one of: {', '.join(_SUPPORTED_RUNTIMES)}."
|
||||
)
|
||||
return raw
|
||||
"runc" (Docker default) or "runsc" (gVisor opt-in)."""
|
||||
return manifest["bottles"][bottle_name].get("runtime", "runc")
|
||||
|
||||
|
||||
def manifest_bottle_egress_allowlist(manifest: Manifest, bottle_name: str) -> list[str]:
|
||||
"""Hostnames in bottles[bottle_name].egress.allowlist. Dies if the
|
||||
field is present but not an array. Per-element string typing is
|
||||
re-checked at use-time in pipelock."""
|
||||
bottle = (manifest.get("bottles") or {}).get(bottle_name) or {}
|
||||
allowlist = (bottle.get("egress") or {}).get("allowlist")
|
||||
if allowlist is None:
|
||||
return []
|
||||
if not isinstance(allowlist, list):
|
||||
die(
|
||||
f"bottle '{bottle_name}' egress.allowlist must be an array "
|
||||
f"(was {_json_type(allowlist)})."
|
||||
)
|
||||
return list(allowlist)
|
||||
"""Hostnames in bottles[bottle_name].egress.allowlist."""
|
||||
return list(manifest["bottles"][bottle_name].get("egress", {}).get("allowlist", []))
|
||||
|
||||
|
||||
def manifest_ssh(manifest: Manifest, agent_name: str) -> list[dict[str, Any]]:
|
||||
"""SSH entries resolved via the agent's "bottle" field; empty if no bottle set."""
|
||||
def manifest_ssh(manifest: Manifest, agent_name: str) -> list[SshEntry]:
|
||||
"""SSH entries resolved via the agent's "bottle" field."""
|
||||
bottle_name = manifest_agent_bottle(manifest, agent_name)
|
||||
if not bottle_name:
|
||||
return []
|
||||
return manifest_bottle_ssh(manifest, bottle_name)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Unit: manifest_bottle_runtime — defaults to runc, accepts runsc,
|
||||
rejects unknown values and non-strings."""
|
||||
"""Unit: bottle runtime — manifest_bottle_runtime returns the configured
|
||||
runtime (defaulting to runc); manifest_validate rejects unknown values,
|
||||
non-strings, and empty strings."""
|
||||
|
||||
import unittest
|
||||
|
||||
from claude_bottle.log import Die
|
||||
from claude_bottle.manifest import manifest_bottle_runtime
|
||||
from claude_bottle.manifest import manifest_bottle_runtime, manifest_validate
|
||||
|
||||
|
||||
def _bottle(runtime_value: object | None) -> dict:
|
||||
@@ -34,15 +35,15 @@ class TestManifestBottleRuntime(unittest.TestCase):
|
||||
|
||||
def test_rejects_unknown_runtime(self):
|
||||
with self.assertRaises(Die):
|
||||
manifest_bottle_runtime(_bottle("kata-runtime"), "dev")
|
||||
manifest_validate(_bottle("kata-runtime"))
|
||||
|
||||
def test_rejects_non_string(self):
|
||||
with self.assertRaises(Die):
|
||||
manifest_bottle_runtime(_bottle(42), "dev")
|
||||
manifest_validate(_bottle(42))
|
||||
|
||||
def test_rejects_empty_string(self):
|
||||
with self.assertRaises(Die):
|
||||
manifest_bottle_runtime(_bottle(""), "dev")
|
||||
manifest_validate(_bottle(""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user