Compare commits

..

2 Commits

Author SHA1 Message Date
didericis-claude df469b2f47 docs: add role and git.fetch to egress route fields table
Both fields were missing from the reference table added in the preceding
commit — `role` is visible in examples/bottles/claude.md and `git.fetch`
is documented in PRD 0052 but neither appeared in the README table.
2026-06-22 18:31:32 +00:00
didericis d1d9e7a105 docs: document egress matches, dlp fields, and detector defaults
lint / lint (push) Successful in 1m32s
2026-06-19 21:58:20 -04:00
75 changed files with 738 additions and 1788 deletions
+26 -2
View File
@@ -14,7 +14,7 @@
## Features ## Features
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default. - **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only. - **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential. - **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load. - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
@@ -106,8 +106,15 @@ egress:
routes: routes:
- host: gitea.dideric.is - host: gitea.dideric.is
auth: auth:
scheme: token scheme: token # Bearer | token
token_ref: BOT_BOTTLE_GITEA_TOKEN token_ref: BOT_BOTTLE_GITEA_TOKEN
matches: # optional — restrict to specific paths/methods/headers
- paths:
- {type: prefix, value: /api/v1/}
methods: [GET, POST, PATCH, DELETE]
dlp: # optional — per-route detector overrides (default: all on)
outbound_detectors: [token_patterns, known_secrets]
inbound_detectors: false # disable response scanning for this host
--- ---
The `gitea-dev` bottle. Provider auth via the inherited Claude route; The `gitea-dev` bottle. Provider auth via the inherited Claude route;
@@ -126,6 +133,23 @@ skills:
You help maintain Gitea-hosted projects. You help maintain Gitea-hosted projects.
```` ````
**Egress route fields:**
| Field | Required | Description |
|---|---|---|
| `host` | yes | Hostname to allowlist. One entry per host. |
| `role` | no | Provider-specific role string (e.g. `claude_code_oauth`). Wires built-in auth flows; set by provider templates, not manually. |
| `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. |
| `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. |
| `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. |
| `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. |
| `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. |
| `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. |
| `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). |
| `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). |
| `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). |
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`. More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
## Trademarks ## Trademarks
+1 -1
View File
@@ -240,7 +240,7 @@ class AgentProvider(ABC):
BottleBackend.provision_workspace against the running bottle.""" BottleBackend.provision_workspace against the running bottle."""
from .log import info from .log import info
manifest_bottle = plan.manifest.bottle manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
if manifest_bottle.git: if manifest_bottle.git:
from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME) gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME)
+36 -28
View File
@@ -45,7 +45,7 @@ from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provi
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 Manifest, ManifestIndex from ..manifest import ManifestGitEntry, Manifest
from ..supervise import SupervisePlan from ..supervise import SupervisePlan
from ..util import expand_tilde from ..util import expand_tilde
from ..env import resolve_env, ResolvedEnv from ..env import resolve_env, ResolvedEnv
@@ -61,7 +61,7 @@ class BottleSpec:
Resolved values (image names, container name, scratch paths, runsc Resolved values (image names, container name, scratch paths, runsc
availability) live on the plan, not the spec.""" availability) live on the plan, not the spec."""
manifest: ManifestIndex manifest: Manifest
agent_name: str agent_name: str
copy_cwd: bool copy_cwd: bool
user_cwd: str user_cwd: str
@@ -80,7 +80,6 @@ class BottlePlan(ABC):
(e.g. DockerBottlePlan) add backend-specific resolved fields.""" (e.g. DockerBottlePlan) add backend-specific resolved fields."""
spec: BottleSpec spec: BottleSpec
manifest: Manifest
stage_dir: Path stage_dir: Path
git_gate_plan: GitGatePlan git_gate_plan: GitGatePlan
@@ -113,9 +112,9 @@ class BottlePlan(ABC):
"""Render the y/N preflight summary to stderr.""" """Render the y/N preflight summary to stderr."""
del remote_control del remote_control
spec = self.spec spec = self.spec
manifest = self.manifest manifest = spec.manifest
agent = manifest.agent agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle bottle = manifest.bottle_for(spec.agent_name)
env_names = visible_agent_env_names( env_names = visible_agent_env_names(
sorted( sorted(
@@ -132,7 +131,7 @@ class BottlePlan(ABC):
print_multi("skills ", list(agent.skills)) print_multi("skills ", list(agent.skills))
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary() identity = manifest.git_identity_summary(spec.agent_name)
if identity: if identity:
info(f" git identity : {identity}") info(f" git identity : {identity}")
@@ -290,14 +289,15 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
write_launch_metadata, write_launch_metadata,
) )
manifest = self._validate(spec) self._validate(spec)
self._preflight() self._preflight()
manifest_bottle = manifest.bottle manifest = spec.manifest
manifest_bottle = manifest.bottle_for(spec.agent_name)
manifest_agent_provider = manifest_bottle.agent_provider manifest_agent_provider = manifest_bottle.agent_provider
agent_provider = get_provider(manifest_agent_provider.template) agent_provider = get_provider(manifest_agent_provider.template)
resolved_env = resolve_env(manifest) resolved_env = resolve_env(manifest, spec.agent_name)
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home) workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
slug = mint_slug(spec) slug = mint_slug(spec)
@@ -313,7 +313,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
else: else:
agent_dockerfile_path = str(agent_provider.dockerfile) agent_dockerfile_path = str(agent_provider.dockerfile)
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest) agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
agent_provision_plan = build_agent_provision_plan( agent_provision_plan = build_agent_provision_plan(
template=manifest_agent_provider.template, template=manifest_agent_provider.template,
@@ -337,7 +337,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
return self._resolve_plan( return self._resolve_plan(
spec, spec,
manifest=manifest,
slug=slug, slug=slug,
resolved_env=resolved_env, resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan, agent_provision_plan=agent_provision_plan,
@@ -356,18 +355,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
""" """
pass pass
def _validate(self, spec: BottleSpec) -> Manifest: def _validate(self, spec: BottleSpec) -> None:
"""Cross-backend pre-launch checks. Parses the selected agent and """Cross-backend pre-launch checks. Confirms the agent exists,
its bottle (raising ManifestError on invalid content), confirms the named skills are present on the host, and every git
skills are present on the host, and every git IdentityFile resolves. IdentityFile resolves. Subclasses with additional preconditions
should override and call `super()._validate(spec)` first."""
Returns the loaded Manifest for the selected agent. Subclasses with manifest = spec.manifest
additional preconditions should override and call manifest.require_agent(spec.agent_name)
`super()._validate(spec)` first.""" agent = manifest.agents[spec.agent_name]
manifest = spec.manifest.load_for_agent(spec.agent_name) bottle = manifest.bottle_for(spec.agent_name)
self._validate_skills(manifest.agent.skills) self._validate_skills(agent.skills)
self._validate_agent_provider_dockerfile(spec, manifest) self._validate_git_entries(bottle.git)
return manifest self._validate_agent_provider_dockerfile(spec)
def _validate_skills(self, skills: Sequence[str]) -> None: def _validate_skills(self, skills: Sequence[str]) -> None:
"""Each named skill must be a directory under the host's """Each named skill must be a directory under the host's
@@ -381,8 +380,18 @@ 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_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None: def _validate_git_entries(self, entries: Sequence[ManifestGitEntry]) -> None:
bottle = manifest.bottle """Each entry's IdentityFile must exist on the host (after
expanding leading ~) — the git-gate copies it in at start time
to authenticate the upstream push (PRD 0008). Shape is already
enforced by Manifest validation; this only checks presence."""
for entry in entries:
key = expand_tilde(entry.IdentityFile)
if not os.path.isfile(key):
die(f"git upstream key file not found for '{entry.Name}': {key}")
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
bottle = spec.manifest.bottle_for(spec.agent_name)
dockerfile = bottle.agent_provider.dockerfile dockerfile = bottle.agent_provider.dockerfile
if not dockerfile: if not dockerfile:
return return
@@ -392,14 +401,13 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
if not path.is_file(): if not path.is_file():
die( die(
f"agent_provider.dockerfile for bottle " f"agent_provider.dockerfile for bottle "
f"'{manifest.agent.bottle}' not found: {path}" f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
) )
@abstractmethod @abstractmethod
def _resolve_plan(self, def _resolve_plan(self,
spec: BottleSpec, spec: BottleSpec,
*, *,
manifest: Manifest,
slug: str, slug: str,
resolved_env: ResolvedEnv, resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
-3
View File
@@ -30,7 +30,6 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv from ...env import ResolvedEnv
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
@@ -64,7 +63,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
self, self,
spec: BottleSpec, spec: BottleSpec,
*, *,
manifest: Manifest,
slug: str, slug: str,
resolved_env: ResolvedEnv, resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
@@ -75,7 +73,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
) -> DockerBottlePlan: ) -> DockerBottlePlan:
return _resolve_plan.resolve_plan( return _resolve_plan.resolve_plan(
spec, spec,
manifest=manifest,
slug=slug, slug=slug,
resolved_env=resolved_env, resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan, agent_provision_plan=agent_provision_plan,
+1 -1
View File
@@ -134,7 +134,7 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
ep = plan.egress_plan ep = plan.egress_plan
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER)) volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
if ep.routes: if ep.routes:
volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent))) volumes.append(_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER))
for token_env in sorted(ep.token_env_map.keys()): for token_env in sorted(ep.token_env_map.keys()):
env.append(token_env) env.append(token_env)
+17 -28
View File
@@ -1,21 +1,24 @@
"""Host-side helper for egress sidecar inspection and live updates. """Host-side helper for egress sidecar inspection (issue #198).
The approve path uses this module to validate a proposed routes file, `_merge_single_route`, `add_route`, and `apply_routes_change` were
write it to the bottle's live egress state dir, and signal the sidecar removed when the egress-block MCP tool was dropped. The remaining
bundle so the mitmproxy addon reloads it. helpers support runtime inspection and validation of the routes file
without modifying it at runtime.
""" """
from __future__ import annotations from __future__ import annotations
import os
import subprocess import subprocess
from ...egress import EGRESS_ROUTES_IN_CONTAINER from ...egress import EGRESS_ROUTES_IN_CONTAINER
from ...log import warn from ...egress_addon_core import load_routes
from ..egress_apply import EgressApplicator, EgressApplyError
from .sidecar_bundle import sidecar_bundle_container_name from .sidecar_bundle import sidecar_bundle_container_name
class EgressApplyError(RuntimeError):
pass
def fetch_current_routes(slug: str) -> str: def fetch_current_routes(slug: str) -> str:
container = sidecar_bundle_container_name(slug) container = sidecar_bundle_container_name(slug)
r = subprocess.run( r = subprocess.run(
@@ -30,31 +33,17 @@ def fetch_current_routes(slug: str) -> str:
return r.stdout return r.stdout
class DockerEgressApplicator(EgressApplicator): def validate_routes_content(content: str) -> None:
def _signal_bundle_reload(self, slug: str) -> None: try:
container = sidecar_bundle_container_name(slug) load_routes(content)
result = subprocess.run( except ValueError as e:
["docker", "kill", "--signal", "HUP", container],
capture_output=True, text=True, check=False, env=os.environ,
)
if result.returncode != 0:
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
warn(
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
f"{last_error or 'docker kill failed'}"
)
raise EgressApplyError( raise EgressApplyError(
f"could not reload egress bundle {container}: " f"proposed routes.yaml is not valid: {e}"
f"{last_error or 'docker kill failed'}" ) from e
)
applicator = DockerEgressApplicator()
__all__ = [ __all__ = [
"DockerEgressApplicator",
"EgressApplyError", "EgressApplyError",
"applicator",
"fetch_current_routes", "fetch_current_routes",
"validate_routes_content",
] ]
+2 -2
View File
@@ -75,7 +75,7 @@ def launch(
Teardown on exit.""" Teardown on exit."""
stack = ExitStack() stack = ExitStack()
_bottle_for_revoke = plan.manifest.bottle _bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) _git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
def teardown() -> None: def teardown() -> None:
@@ -176,7 +176,7 @@ def launch(
agent_command=plan.agent_command, agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode, agent_prompt_mode=plan.agent_prompt_mode,
agent_provider_template=plan.agent_provider_template, agent_provider_template=plan.agent_provider_template,
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name, terminal_title=plan.spec.label or plan.spec.agent_name,
terminal_color=plan.spec.color, terminal_color=plan.spec.color,
agent_workdir=plan.workspace_plan.workdir, agent_workdir=plan.workspace_plan.workdir,
) )
@@ -18,7 +18,6 @@ from .. import BottleSpec
from ...env import ResolvedEnv from ...env import ResolvedEnv
from ...agent_provider import AgentProvisionPlan from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan from ...egress import EgressPlan
from ...manifest import Manifest
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
@@ -32,7 +31,6 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
def resolve_plan( def resolve_plan(
spec: BottleSpec, spec: BottleSpec,
manifest: Manifest,
slug: str, slug: str,
resolved_env: ResolvedEnv, resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
@@ -50,7 +48,6 @@ def resolve_plan(
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=stage_dir, stage_dir=stage_dir,
slug=slug, slug=slug,
forwarded_env=dict(resolved_env.forwarded), forwarded_env=dict(resolved_env.forwarded),
-50
View File
@@ -1,50 +0,0 @@
"""Shared base class for host-side egress apply across backends.
Each backend subclasses EgressApplicator and overrides _signal_bundle_reload
with the backend-specific kill command.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from pathlib import Path
from ..bottle_state import egress_state_dir
from ..egress import EGRESS_ROUTES_FILENAME
from ..egress_addon_core import load_routes
class EgressApplyError(RuntimeError):
pass
class EgressApplicator(ABC):
def apply_routes_change(self, slug: str, content: str) -> tuple[str, str]:
"""Persist `content` to the live routes file and reload egress."""
self.validate_routes_content(content)
routes_path = self._routes_path(slug)
routes_path.parent.mkdir(parents=True, exist_ok=True)
before = routes_path.read_text(encoding="utf-8") if routes_path.exists() else ""
routes_path.write_text(content, encoding="utf-8")
routes_path.chmod(0o600)
self._signal_bundle_reload(slug)
return before, content
@staticmethod
def validate_routes_content(content: str) -> None:
try:
load_routes(content)
except ValueError as e:
raise EgressApplyError(
f"proposed routes.yaml is not valid: {e}"
) from e
@staticmethod
def _routes_path(slug: str) -> Path:
return egress_state_dir(slug) / EGRESS_ROUTES_FILENAME
@abstractmethod
def _signal_bundle_reload(self, slug: str) -> None: ...
__all__ = ["EgressApplicator", "EgressApplyError"]
@@ -11,7 +11,6 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv from ...env import ResolvedEnv
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
@@ -46,7 +45,6 @@ class MacosContainerBottleBackend(
self, self,
spec: BottleSpec, spec: BottleSpec,
*, *,
manifest: Manifest,
slug: str, slug: str,
resolved_env: ResolvedEnv, resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
@@ -57,7 +55,6 @@ class MacosContainerBottleBackend(
) -> MacosContainerBottlePlan: ) -> MacosContainerBottlePlan:
return _resolve_plan.resolve_plan( return _resolve_plan.resolve_plan(
spec, spec,
manifest=manifest,
slug=slug, slug=slug,
resolved_env=resolved_env, resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan, agent_provision_plan=agent_provision_plan,
+5 -45
View File
@@ -2,41 +2,12 @@
from __future__ import annotations from __future__ import annotations
import os
import subprocess import subprocess
import sys
from typing import Callable, cast from typing import Callable, cast
from ...agent_provider import PromptMode, prompt_args from ...agent_provider import PromptMode, prompt_args
from .. import Bottle, ExecResult from .. import Bottle, ExecResult
from ..terminal import exec_shell_script from ..terminal import exec_shell_script
from . import pty_forward as _pty_forward
_PTY_FORWARD_SCRIPT = _pty_forward.__file__
_TERMINAL_ENV_NAMES = (
"TERM",
"COLORTERM",
"TERM_PROGRAM",
"TERM_PROGRAM_VERSION",
"KITTY_WINDOW_ID",
"KITTY_PID",
"WEZTERM_PANE",
"WEZTERM_UNIX_SOCKET",
"GHOSTTY_BIN_DIR",
"GHOSTTY_RESOURCES_DIR",
"ITERM_SESSION_ID",
"VTE_VERSION",
"KONSOLE_VERSION",
"ALACRITTY_WINDOW_ID",
)
def _terminal_env_names() -> tuple[str, ...]:
return tuple(
name for name in _TERMINAL_ENV_NAMES
if name == "TERM" or os.environ.get(name)
)
class MacosContainerBottle(Bottle): class MacosContainerBottle(Bottle):
@@ -73,24 +44,13 @@ class MacosContainerBottle(Bottle):
argv=full_argv, argv=full_argv,
) )
) )
container_exec = ["container", "exec"] cmd = ["container", "exec"]
if tty: if tty:
container_exec.extend(["--interactive", "--tty"]) cmd.extend(["--interactive", "--tty"])
# Forward terminal capability hints so TUIs can enable modified-key
# protocols. Use bare env names: values stay in the child env, not
# on argv, and pty_forward supplies a TERM fallback when needed.
for name in _terminal_env_names():
container_exec.extend(["--env", name])
if self.agent_workdir and self.agent_workdir != "/home/node": if self.agent_workdir and self.agent_workdir != "/home/node":
container_exec.extend(["--workdir", self.agent_workdir]) cmd.extend(["--workdir", self.agent_workdir])
container_exec.extend([self.name, self.agent_command, *full_argv]) cmd.extend([self.name, self.agent_command, *full_argv])
if tty: return cmd
# Wrap with the raw-mode forwarder: container exec does not put
# the host terminal into raw mode itself, so the line discipline
# buffers modifier-key sequences until CR. The wrapper sets raw
# mode before exec and restores it on exit.
return [sys.executable, _PTY_FORWARD_SCRIPT, "--", *container_exec]
return container_exec
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int: def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
agent_argv = self.agent_argv(argv, tty=tty) agent_argv = self.agent_argv(argv, tty=tty)
@@ -1,39 +0,0 @@
"""Host-side egress apply for the macos-container backend.
Uses `container kill --signal HUP` (Apple Container framework) instead
of `docker kill` to signal the sidecar bundle.
"""
from __future__ import annotations
import os
import subprocess
from ...log import warn
from ..egress_apply import EgressApplicator, EgressApplyError
from .launch import sidecar_container_name
class MacOSContainerEgressApplicator(EgressApplicator):
def _signal_bundle_reload(self, slug: str) -> None:
container = sidecar_container_name(slug)
result = subprocess.run(
["container", "kill", "--signal", "HUP", container],
capture_output=True, text=True, check=False, env=os.environ,
)
if result.returncode != 0:
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
warn(
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
f"{last_error or 'container kill failed'}"
)
raise EgressApplyError(
f"could not reload egress bundle {container}: "
f"{last_error or 'container kill failed'}"
)
applicator = MacOSContainerEgressApplicator()
__all__ = ["MacOSContainerEgressApplicator", "EgressApplyError", "applicator"]
+15 -3
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import dataclasses import dataclasses
import os import os
import shutil
import subprocess import subprocess
from contextlib import ExitStack, contextmanager from contextlib import ExitStack, contextmanager
from pathlib import Path from pathlib import Path
@@ -67,7 +68,7 @@ def launch(
) -> Generator[MacosContainerBottle, None, None]: ) -> Generator[MacosContainerBottle, None, None]:
"""Build, run, provision, and yield an Apple Container bottle.""" """Build, run, provision, and yield an Apple Container bottle."""
stack = ExitStack() stack = ExitStack()
bottle_for_revoke = plan.manifest.bottle bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug) git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
def teardown() -> None: def teardown() -> None:
@@ -111,7 +112,7 @@ def launch(
agent_command=plan.agent_command, agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode, agent_prompt_mode=plan.agent_prompt_mode,
agent_provider_template=plan.agent_provider_template, agent_provider_template=plan.agent_provider_template,
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name, terminal_title=plan.spec.label or plan.spec.agent_name,
terminal_color=plan.spec.color, terminal_color=plan.spec.color,
agent_workdir=plan.workspace_plan.workdir, agent_workdir=plan.workspace_plan.workdir,
) )
@@ -363,7 +364,7 @@ def _sidecar_mounts(
)) ))
if ep.routes: if ep.routes:
mounts.append(( mounts.append((
str(ep.routes_path.parent), str(_stage_routes_dir(plan)),
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
True, True,
)) ))
@@ -374,6 +375,17 @@ def _sidecar_mounts(
return tuple(mounts) return tuple(mounts)
def _stage_routes_dir(plan: MacosContainerBottlePlan) -> Path:
routes_dir = plan.stage_dir / "macos-container-egress"
routes_dir.mkdir(parents=True, exist_ok=True)
shutil.copyfile(
plan.egress_plan.routes_path,
routes_dir / Path(EGRESS_ROUTES_IN_CONTAINER).name,
)
return routes_dir
def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str: def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str:
spec = f"type=bind,source={host_path},target={container_path}" spec = f"type=bind,source={host_path},target={container_path}"
if read_only: if read_only:
@@ -1,70 +0,0 @@
"""Host-side raw-mode wrapper for `container exec --interactive --tty`.
Apple's `container exec --interactive --tty` does not set the host terminal to
raw mode before starting its I/O relay. Without raw mode the kernel line
discipline buffers modifier-key escape sequences (e.g. Shift+Enter in
modifyOtherKeys mode produces \\x1b[13;2~) until a carriage-return arrives, so
they never reach Claude Code inside the container.
This module sets the host terminal to raw mode, spawns the inner argv (the
container exec command), and restores the original terminal attributes on
exit. When stdin is not a TTY (piped invocations, CI) it falls through to a
bare subprocess.run so callers do not need to special-case non-interactive
contexts.
Usage (the `--` separator is the API contract — everything after it is the
inner command):
python pty_forward.py -- container exec --interactive --tty <name> <cmd>
"""
from __future__ import annotations
import os
import subprocess
import sys
import termios
import tty
def _inner_env() -> dict[str, str]:
env = dict(os.environ)
env.setdefault("TERM", "xterm-256color")
return env
def _run_inner(inner: list[str]) -> int:
return subprocess.run(inner, check=False, env=_inner_env()).returncode
def main(argv: list[str]) -> int:
"""Entry point. ``argv`` shape: ``-- <inner-argv...>``."""
if len(argv) < 2 or argv[0] != "--":
sys.stderr.write(
"usage: python pty_forward.py -- <container-exec-argv...>\n"
)
return 2
inner = argv[1:]
try:
fd = sys.stdin.fileno()
except OSError:
return _run_inner(inner)
if not os.isatty(fd):
return _run_inner(inner)
try:
old = termios.tcgetattr(fd)
except termios.error:
return _run_inner(inner)
try:
tty.setraw(fd)
return _run_inner(inner)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
@@ -9,7 +9,6 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv from ...env import ResolvedEnv
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import BottleSpec from .. import BottleSpec
from . import util as container_mod from . import util as container_mod
from .bottle_plan import MacosContainerBottlePlan from .bottle_plan import MacosContainerBottlePlan
@@ -25,7 +24,6 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
def resolve_plan( def resolve_plan(
spec: BottleSpec, spec: BottleSpec,
manifest: Manifest,
slug: str, slug: str,
resolved_env: ResolvedEnv, resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
@@ -36,7 +34,6 @@ def resolve_plan(
) -> MacosContainerBottlePlan: ) -> MacosContainerBottlePlan:
return MacosContainerBottlePlan( return MacosContainerBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=stage_dir, stage_dir=stage_dir,
slug=slug, slug=slug,
forwarded_env=dict(resolved_env.forwarded), forwarded_env=dict(resolved_env.forwarded),
+6 -15
View File
@@ -26,25 +26,15 @@ from ..bottle_state import (
) )
from ..egress import Egress, EgressPlan from ..egress import Egress, EgressPlan
from ..git_gate import GitGate, GitGatePlan from ..git_gate import GitGate, GitGatePlan
from ..manifest import Manifest, ManifestBottle from ..manifest import ManifestBottle
from ..supervise import Supervise, SupervisePlan from ..supervise import Supervise, SupervisePlan
from . import BottleSpec from . import BottleSpec
def mint_slug(spec: BottleSpec) -> str: def mint_slug(spec: BottleSpec) -> str:
"""Return the bottle identity: the recorded identity for a resume, """Return the bottle identity: the recorded identity for a resume,
or a freshly minted one for a new start. or a freshly minted one for a new start."""
return spec.identity or bottle_identity(spec.agent_name)
When a label is provided it becomes the full slug (no random suffix),
so two launches with the same label collide by design. When no label
is given the identity is minted with a random suffix to avoid
collisions between anonymous launches of the same agent."""
if spec.identity:
return spec.identity
if spec.label:
from .docker import util as docker_mod
return docker_mod.slugify(spec.label)
return bottle_identity(spec.agent_name)
def write_launch_metadata( def write_launch_metadata(
@@ -66,10 +56,11 @@ def write_launch_metadata(
)) ))
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]: def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]:
"""Create the agent state subdir, write the prompt file. """Create the agent state subdir, write the prompt file.
Returns (agent_dir, prompt_file).""" Returns (agent_dir, prompt_file)."""
agent = manifest.agent manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
agent_dir = agent_state_dir(slug) agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True) agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt" prompt_file = agent_dir / "prompt.txt"
@@ -18,7 +18,6 @@ from ...egress import EgressPlan
from ...env import ResolvedEnv from ...env import ResolvedEnv
from ...git_gate import GitGatePlan from ...git_gate import GitGatePlan
from ...supervise import SupervisePlan from ...supervise import SupervisePlan
from ...manifest import Manifest
from .. import ActiveAgent, BottleBackend, BottleSpec from .. import ActiveAgent, BottleBackend, BottleSpec
from . import cleanup as _cleanup from . import cleanup as _cleanup
from . import enumerate as _enumerate from . import enumerate as _enumerate
@@ -56,7 +55,6 @@ class SmolmachinesBottleBackend(
self, self,
spec: BottleSpec, spec: BottleSpec,
*, *,
manifest: Manifest,
slug: str, slug: str,
resolved_env: ResolvedEnv, resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
@@ -67,7 +65,6 @@ class SmolmachinesBottleBackend(
) -> SmolmachinesBottlePlan: ) -> SmolmachinesBottlePlan:
return _resolve_plan.resolve_plan( return _resolve_plan.resolve_plan(
spec, spec,
manifest=manifest,
slug=slug, slug=slug,
resolved_env=resolved_env, resolved_env=resolved_env,
agent_provision_plan=agent_provision_plan, agent_provision_plan=agent_provision_plan,
@@ -1,21 +0,0 @@
"""Egress apply for the smolmachines backend.
The smolmachines sidecar bundle runs as a host-side Docker container,
so egress signalling is identical to the docker backend.
"""
from __future__ import annotations
from ..docker.egress_apply import ( # noqa: F401
DockerEgressApplicator,
EgressApplyError,
applicator,
fetch_current_routes,
)
__all__ = [
"DockerEgressApplicator",
"EgressApplyError",
"applicator",
"fetch_current_routes",
]
+4 -8
View File
@@ -104,7 +104,7 @@ def launch(
agent_command=plan.agent_command, agent_command=plan.agent_command,
agent_prompt_mode=plan.agent_prompt_mode, agent_prompt_mode=plan.agent_prompt_mode,
agent_provider_template=plan.agent_provider_template, agent_provider_template=plan.agent_provider_template,
terminal_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name, terminal_title=plan.spec.label or plan.spec.agent_name,
terminal_color=plan.spec.color, terminal_color=plan.spec.color,
agent_workdir=plan.workspace_plan.workdir, agent_workdir=plan.workspace_plan.workdir,
) )
@@ -130,7 +130,7 @@ def _teardown_smolmachines(
except BaseException as exc: # noqa: W0718 — teardown must not fail except BaseException as exc: # noqa: W0718 — teardown must not fail
teardown_exc = exc teardown_exc = exc
warn(f"smolmachines teardown failed: {exc!r}") warn(f"smolmachines teardown failed: {exc!r}")
bottle = plan.manifest.bottle bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug)) revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
if teardown_exc is not None: if teardown_exc is not None:
raise teardown_exc raise teardown_exc
@@ -217,15 +217,11 @@ def _discover_urls(
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/" agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1") existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
no_proxy = f"{existing_no_proxy},{loopback_ip}"
guest_env = { guest_env = {
**plan.guest_env, **plan.guest_env,
"HTTPS_PROXY": agent_proxy_url, "HTTPS_PROXY": agent_proxy_url,
"HTTP_PROXY": agent_proxy_url, "HTTP_PROXY": agent_proxy_url,
"https_proxy": agent_proxy_url, "NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
"http_proxy": agent_proxy_url,
"NO_PROXY": no_proxy,
"no_proxy": no_proxy,
} }
if agent_git_gate_host: if agent_git_gate_host:
guest_env["GIT_GATE_URL"] = f"http://{agent_git_gate_host}" guest_env["GIT_GATE_URL"] = f"http://{agent_git_gate_host}"
@@ -312,7 +308,7 @@ def _bundle_launch_spec(
ep = plan.egress_plan ep = plan.egress_plan
volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True)) volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True))
if ep.routes: if ep.routes:
volumes.append((str(ep.routes_path.parent), str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True)) volumes.append((str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True))
# Bare-name entries for upstream-token slots. Their values # Bare-name entries for upstream-token slots. Their values
# come from the docker-run subprocess env (inherited from # come from the docker-run subprocess env (inherited from
# the operator's shell), never landing on argv. # the operator's shell), never landing on argv.
@@ -13,7 +13,6 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from .. import BottleSpec from .. import BottleSpec
from ...manifest import Manifest
from ...env import ResolvedEnv from ...env import ResolvedEnv
from ...agent_provider import AgentProvisionPlan from ...agent_provider import AgentProvisionPlan
from ...egress import EgressPlan from ...egress import EgressPlan
@@ -47,7 +46,6 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
def resolve_plan( def resolve_plan(
spec: BottleSpec, spec: BottleSpec,
manifest: Manifest,
slug: str, slug: str,
resolved_env: ResolvedEnv, resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
@@ -69,7 +67,6 @@ def resolve_plan(
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=stage_dir, stage_dir=stage_dir,
slug=slug, slug=slug,
bundle_subnet=subnet, bundle_subnet=subnet,
+16 -5
View File
@@ -12,11 +12,22 @@ import shlex
# uses true/24-bit colors for its own chrome, which would otherwise bypass # uses true/24-bit colors for its own chrome, which would otherwise bypass
# the palette entirely. # the palette entirely.
_COLORS: dict[str, tuple[int, str, int, str, str]] = { _COLORS: dict[str, tuple[int, str, int, str, str]] = {
"red": (9, "#e74c3c", 1, "#c0392b", "#200808"), "black": (0, "#2d2d2d", 8, "#5c5c5c", "#0a0a0a"),
"green": (10, "#2ecc71", 2, "#27ae60", "#082008"), "red": (1, "#c0392b", 9, "#e74c3c", "#1a0707"),
"yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"), "green": (2, "#27ae60", 10, "#2ecc71", "#071a09"),
"blue": (12, "#3498db", 4, "#2471a3", "#080820"), "yellow": (3, "#d4ac0d", 11, "#f1c40f", "#1a1507"),
"magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"), "blue": (4, "#2471a3", 12, "#3498db", "#07071a"),
"magenta": (5, "#7d3c98", 13, "#9b59b6", "#12071a"),
"cyan": (6, "#148f77", 14, "#1abc9c", "#071a1a"),
"white": (7, "#bdc3c7", 15, "#ecf0f1", "#111111"),
"bright-black": (8, "#5c5c5c", 0, "#2d2d2d", "#111111"),
"bright-red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
"bright-green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
"bright-yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
"bright-blue": (12, "#3498db", 4, "#2471a3", "#080820"),
"bright-magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
"bright-cyan": (14, "#1abc9c", 6, "#148f77", "#082020"),
"bright-white": (15, "#ecf0f1", 7, "#bdc3c7", "#151515"),
} }
# OSC 104 resets all indexed palette entries; OSC 111 resets default background. # OSC 104 resets all indexed palette entries; OSC 111 resets default background.
+6 -7
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import argparse import argparse
from ..log import info from ..log import info
from ..manifest import ManifestIndex from ..manifest import Manifest
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
@@ -14,12 +14,11 @@ def cmd_info(argv: list[str]) -> int:
parser.add_argument("name", help="agent name defined in bot-bottle.json") parser.add_argument("name", help="agent name defined in bot-bottle.json")
args = parser.parse_args(argv) args = parser.parse_args(argv)
names = ManifestIndex.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
names.require_agent(args.name) manifest.require_agent(args.name)
manifest = names.load_for_agent(args.name)
agent = manifest.agent agent = manifest.agents[args.name]
bottle = manifest.bottle bottle = manifest.bottle_for(args.name)
env_names = list(bottle.env.keys()) env_names = list(bottle.env.keys())
prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else "" prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else ""
@@ -32,7 +31,7 @@ def cmd_info(argv: list[str]) -> int:
f"first line: {prompt_first_line or '(empty)'}" f"first line: {prompt_first_line or '(empty)'}"
) )
info(f"bottle : {agent.bottle}") info(f"bottle : {agent.bottle}")
identity = manifest.git_identity_summary() identity = manifest.git_identity_summary(args.name)
if identity: if identity:
info(f" git identity : {identity}") info(f" git identity : {identity}")
if bottle.git: if bottle.git:
+20 -9
View File
@@ -7,15 +7,26 @@ import os
import sys import sys
from ..backend import enumerate_active_agents from ..backend import enumerate_active_agents
from ..manifest import ManifestIndex from ..manifest import Manifest
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
_ANSI_COLOR_CODES: dict[str, str] = { _ANSI_COLOR_CODES: dict[str, str] = {
"red": "\033[91m", "black": "\033[30m",
"green": "\033[92m", "red": "\033[31m",
"yellow": "\033[93m", "green": "\033[32m",
"blue": "\033[94m", "yellow": "\033[33m",
"magenta": "\033[95m", "blue": "\033[34m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
"bright-black": "\033[90m",
"bright-red": "\033[91m",
"bright-green": "\033[92m",
"bright-yellow": "\033[93m",
"bright-blue": "\033[94m",
"bright-magenta": "\033[95m",
"bright-cyan": "\033[96m",
"bright-white": "\033[97m",
} }
_ANSI_RESET = "\033[0m" _ANSI_RESET = "\033[0m"
@@ -40,8 +51,8 @@ def cmd_list(argv: list[str]) -> int:
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.scope == "available": if args.scope == "available":
manifest = ManifestIndex.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
for name in manifest.all_agent_names: for name in manifest.agents.keys():
print(name) print(name)
return 0 return 0
@@ -55,7 +66,7 @@ def cmd_list(argv: list[str]) -> int:
# Tab-separated keeps the format stable for shell pipelines. # Tab-separated keeps the format stable for shell pipelines.
for b in active: for b in active:
services = ",".join(b.services) if b.services else "-" services = ",".join(b.services) if b.services else "-"
display_name = f"{b.label} ({b.agent_name})" if b.label else b.agent_name display_name = b.label if b.label else b.agent_name
colored_name = _ansi_label(display_name, b.color) colored_name = _ansi_label(display_name, b.color)
print(f"{b.backend_name}\t{b.slug}\t{colored_name}\t{services}") print(f"{b.backend_name}\t{b.slug}\t{colored_name}\t{services}")
return 0 return 0
+2 -2
View File
@@ -20,7 +20,7 @@ import argparse
from ..backend import BottleSpec from ..backend import BottleSpec
from ..bottle_state import read_metadata from ..bottle_state import read_metadata
from ..log import die from ..log import die
from ..manifest import ManifestIndex from ..manifest import Manifest
from ._common import PROG, USER_CWD from ._common import PROG, USER_CWD
from .start import _launch_bottle from .start import _launch_bottle
@@ -42,7 +42,7 @@ def cmd_resume(argv: list[str]) -> int:
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle" f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
) )
manifest = ManifestIndex.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
manifest.require_agent(metadata.agent_name) manifest.require_agent(metadata.agent_name)
spec = BottleSpec( spec = BottleSpec(
+3 -21
View File
@@ -20,11 +20,9 @@ from ..agent_provider import runtime_for
from ..backend import ( from ..backend import (
Bottle, Bottle,
BottleSpec, BottleSpec,
enumerate_active_agents,
get_bottle_backend, get_bottle_backend,
known_backend_names, known_backend_names,
) )
from ..backend.docker import util as docker_mod
from ..backend.docker.bottle_plan import DockerBottlePlan from ..backend.docker.bottle_plan import DockerBottlePlan
from ..bottle_state import ( from ..bottle_state import (
cleanup_state, cleanup_state,
@@ -33,7 +31,7 @@ from ..bottle_state import (
) )
# from ..backend.docker.capability_apply import snapshot_transcript # from ..backend.docker.capability_apply import snapshot_transcript
from ..log import info from ..log import info
from ..manifest import ManifestIndex from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line from ._common import PROG, USER_CWD, read_tty_line
from . import tui from . import tui
@@ -62,12 +60,12 @@ def cmd_start(argv: list[str]) -> int:
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1" dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
manifest = ManifestIndex.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
agent_name: str | None = args.name agent_name: str | None = args.name
if agent_name is None: if agent_name is None:
agent_name = tui.filter_select( agent_name = tui.filter_select(
manifest.all_agent_names, sorted(manifest.agents.keys()),
title="Select agent", title="Select agent",
) )
if agent_name is None: if agent_name is None:
@@ -76,7 +74,6 @@ def cmd_start(argv: list[str]) -> int:
backend_name: str | None = args.backend backend_name: str | None = args.backend
label, color = tui.name_color_modal(default_label=agent_name) label, color = tui.name_color_modal(default_label=agent_name)
label, color = _resolve_unique_label(label, color)
spec = BottleSpec( spec = BottleSpec(
manifest=manifest, manifest=manifest,
@@ -194,21 +191,6 @@ def _identity_from_plan(plan: object) -> str:
return getattr(plan, "slug", "") return getattr(plan, "slug", "")
def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
"""Re-prompt with a disclaimer until the label's slug is not already
in use among running bottles. Passes through unchanged when no
collision is found on the first check."""
while True:
slug_candidate = docker_mod.slugify(label)
active_slugs = {a.slug for a in enumerate_active_agents()}
if slug_candidate not in active_slugs:
return label, color
label, color = tui.name_color_modal(
default_label=label,
disclaimer=f'"{label}" is already in use',
)
def _text_prompt_yes() -> bool: def _text_prompt_yes() -> bool:
"""Default `prompt_yes` for CLI use: reads y/N from the """Default `prompt_yes` for CLI use: reads y/N from the
controlling tty via stderr prompt + tty-line read.""" controlling tty via stderr prompt + tty-line read."""
+3 -34
View File
@@ -3,8 +3,7 @@ act on them (approve / modify / reject).
Curses-based TUI; modify-then-approve shells out to $EDITOR. The Curses-based TUI; modify-then-approve shells out to $EDITOR. The
approval handler wires to PRD 0016 (capability-block), which rebuilds approval handler wires to PRD 0016 (capability-block), which rebuilds
the bottle Dockerfile. Egress proposals are queued for operator review the bottle Dockerfile. The egress-block tool was removed in issue #198.
as full routes.yaml updates.
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,21 +20,11 @@ from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from .. import supervise as _supervise from .. import supervise as _supervise
from ..bottle_state import read_metadata # from ..bottle_state import read_metadata
# from ..backend.docker.capability_apply import ( # from ..backend.docker.capability_apply import (
# CapabilityApplyError, # CapabilityApplyError,
# apply_capability_change, # apply_capability_change,
# ) # )
from ..backend.docker.egress_apply import (
EgressApplyError,
applicator as _docker_applicator,
)
from ..backend.macos_container.egress_apply import (
applicator as _macos_applicator,
)
from ..backend.smolmachines.egress_apply import (
applicator as _smolmachines_applicator,
)
from ..log import Die, error, info from ..log import Die, error, info
@@ -51,8 +40,6 @@ from ..supervise import (
STATUS_MODIFIED, STATUS_MODIFIED,
STATUS_REJECTED, STATUS_REJECTED,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_ALLOW,
TOOL_EGRESS_BLOCK,
archive_proposal, archive_proposal,
list_pending_proposals, list_pending_proposals,
render_diff, render_diff,
@@ -76,17 +63,7 @@ class QueuedProposal:
# Errors any remediation engine may raise. Caught by the TUI key # Errors any remediation engine may raise. Caught by the TUI key
# handlers and surfaced in the status line so a failed apply keeps # handlers and surfaced in the status line so a failed apply keeps
# the proposal pending rather than crashing curses. # the proposal pending rather than crashing curses.
ApplyError = (CapabilityApplyError, EgressApplyError) ApplyError = (CapabilityApplyError,)
def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
meta = read_metadata(slug)
backend = meta.backend if meta is not None else ""
if backend == "macos-container":
return _macos_applicator.apply_routes_change(slug, content)
if backend == "smolmachines":
return _smolmachines_applicator.apply_routes_change(slug, content)
return _docker_applicator.apply_routes_change(slug, content)
def discover_pending() -> list[QueuedProposal]: def discover_pending() -> list[QueuedProposal]:
@@ -138,8 +115,6 @@ def _detail_lines(
def _suffix_for_tool(tool: str) -> str: def _suffix_for_tool(tool: str) -> str:
if tool == TOOL_CAPABILITY_BLOCK: if tool == TOOL_CAPABILITY_BLOCK:
return ".dockerfile" return ".dockerfile"
if tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
return ".yaml"
return ".txt" return ".txt"
@@ -154,7 +129,6 @@ def approve(
) -> None: ) -> None:
"""Apply the proposal, write the waiting response, and audit it.""" """Apply the proposal, write the waiting response, and audit it."""
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
diff_before, diff_after = "", "" diff_before, diff_after = "", ""
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK: # if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
@@ -168,11 +142,6 @@ def approve(
# diff_before, diff_after = apply_capability_change( # diff_before, diff_after = apply_capability_change(
# qp.proposal.bottle_slug, file_to_apply, # qp.proposal.bottle_slug, file_to_apply,
# ) # )
if qp.proposal.tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
diff_before, diff_after = apply_routes_change(
qp.proposal.bottle_slug,
file_to_apply,
)
response = Response( response = Response(
proposal_id=qp.proposal.id, proposal_id=qp.proposal.id,
+19 -19
View File
@@ -226,15 +226,20 @@ def _addstr_safe(screen: Any, row: int, col: int, text: str, attr: int = curses.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_ANSI_COLORS = [ _ANSI_COLORS = [
"red", "green", "yellow", "blue", "magenta", "red", "green", "blue", "yellow", "magenta", "cyan", "white", "black",
"bright-red", "bright-green", "bright-blue", "bright-yellow",
"bright-magenta", "bright-cyan", "bright-white", "bright-black",
] ]
_CURSES_COLOR_MAP: dict[str, int] = { _CURSES_COLOR_MAP: dict[str, int] = {
"black": curses.COLOR_BLACK,
"red": curses.COLOR_RED, "red": curses.COLOR_RED,
"green": curses.COLOR_GREEN, "green": curses.COLOR_GREEN,
"yellow": curses.COLOR_YELLOW, "yellow": curses.COLOR_YELLOW,
"blue": curses.COLOR_BLUE, "blue": curses.COLOR_BLUE,
"magenta": curses.COLOR_MAGENTA, "magenta": curses.COLOR_MAGENTA,
"cyan": curses.COLOR_CYAN,
"white": curses.COLOR_WHITE,
} }
_COLOR_NONE = "(none)" _COLOR_NONE = "(none)"
@@ -243,15 +248,11 @@ _COLOR_NONE = "(none)"
def name_color_modal( def name_color_modal(
default_label: str, default_label: str,
*, *,
disclaimer: str = "",
tty_path: str = "/dev/tty", tty_path: str = "/dev/tty",
) -> tuple[str, str]: ) -> tuple[str, str]:
"""Present a two-step curses modal: first edit the agent label, """Present a two-step curses modal: first edit the agent label,
then optionally pick a color. then optionally pick a color.
``disclaimer`` is shown below the input field — use it to surface
an error from a previous attempt (e.g. name already in use).
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
color name strings or ``""`` for no color. Falls back to color name strings or ``""`` for no color. Falls back to
``(default_label, "")`` on any error (terminal too small, not a tty). ``(default_label, "")`` on any error (terminal too small, not a tty).
@@ -263,14 +264,14 @@ def name_color_modal(
try: try:
fd_dup = os.dup(tty_fd.fileno()) fd_dup = os.dup(tty_fd.fileno())
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer) return _run_name_color(default_label, tty_fd=fd_dup)
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
return default_label, "" return default_label, ""
finally: finally:
tty_fd.close() tty_fd.close()
def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") -> tuple[str, str]: def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
import io import io
orig_stdin = sys.__stdin__ orig_stdin = sys.__stdin__
orig_stdout = sys.__stdout__ orig_stdout = sys.__stdout__
@@ -285,7 +286,7 @@ def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") ->
curses.cbreak() curses.cbreak()
screen.keypad(True) screen.keypad(True)
try: try:
label = _label_step(screen, default_label, disclaimer=disclaimer) label = _label_step(screen, default_label)
color = _color_step(screen, label) color = _color_step(screen, label)
finally: finally:
screen.keypad(False) screen.keypad(False)
@@ -298,14 +299,14 @@ def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") ->
return label, color return label, color
def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str: def _label_step(screen: Any, default_label: str) -> str:
"""Step 1: edit the label. First printable key replaces the """Step 1: edit the label. First printable key replaces the
pre-fill; subsequent keys append. Enter confirms.""" pre-fill; subsequent keys append. Enter confirms."""
text = default_label text = default_label
replaced = False # True once the user has typed their first char replaced = False # True once the user has typed their first char
while True: while True:
_render_label(screen, text, disclaimer=disclaimer) _render_label(screen, text)
try: try:
key = screen.getch() key = screen.getch()
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -329,7 +330,7 @@ def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str
text += chr(key) text += chr(key)
def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None: def _render_label(screen: Any, text: str) -> None:
screen.erase() screen.erase()
rows, cols = screen.getmaxyx() rows, cols = screen.getmaxyx()
sep = "" * min(cols - 1, 40) sep = "" * min(cols - 1, 40)
@@ -337,12 +338,8 @@ def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None:
_addstr_safe(screen, 1, 0, sep) _addstr_safe(screen, 1, 0, sep)
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE) _addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
_addstr_safe(screen, 3, 0, sep) _addstr_safe(screen, 3, 0, sep)
row = 4 if rows > 5:
if disclaimer and rows > row + 1: _addstr_safe(screen, 5, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
_addstr_safe(screen, row, 0, disclaimer[:cols - 1], curses.A_BOLD)
row += 1
if rows > row + 1:
_addstr_safe(screen, row, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
screen.refresh() screen.refresh()
@@ -382,10 +379,13 @@ def _init_color_pairs() -> dict[str, int]:
curses.use_default_colors() curses.use_default_colors()
pair_idx = 2 # pair 1 reserved for other uses pair_idx = 2 # pair 1 reserved for other uses
for name in _ANSI_COLORS: for name in _ANSI_COLORS:
fg = _CURSES_COLOR_MAP.get(name, curses.COLOR_WHITE) base = name.replace("bright-", "")
fg = _CURSES_COLOR_MAP.get(base, curses.COLOR_WHITE)
try: try:
curses.init_pair(pair_idx, fg, -1) curses.init_pair(pair_idx, fg, -1)
attr = curses.color_pair(pair_idx) | curses.A_BOLD attr = curses.color_pair(pair_idx)
if name.startswith("bright-"):
attr |= curses.A_BOLD
attrs[name] = attr attrs[name] = attr
pair_idx += 1 pair_idx += 1
except curses.error: except curses.error:
+34 -12
View File
@@ -42,19 +42,41 @@ def _prompt_path(guest_home: str) -> str:
_STATUS_LINE_COLORS = { _STATUS_LINE_COLORS = {
"red": "\033[91m", "black": "\033[30m",
"green": "\033[92m", "red": "\033[31m",
"yellow": "\033[93m", "green": "\033[32m",
"blue": "\033[94m", "yellow": "\033[33m",
"magenta": "\033[95m", "blue": "\033[34m",
"magenta": "\033[35m",
"cyan": "\033[36m",
"white": "\033[37m",
"bright-black": "\033[90m",
"bright-red": "\033[91m",
"bright-green": "\033[92m",
"bright-yellow": "\033[93m",
"bright-blue": "\033[94m",
"bright-magenta": "\033[95m",
"bright-cyan": "\033[96m",
"bright-white": "\033[97m",
} }
_CLAUDE_THEME_COLORS = { _CLAUDE_THEME_COLORS = {
"red": "redBright", "black": "black",
"green": "greenBright", "red": "red",
"yellow": "yellowBright", "green": "green",
"blue": "blueBright", "yellow": "yellow",
"magenta": "magentaBright", "blue": "blue",
"magenta": "magenta",
"cyan": "cyan",
"white": "white",
"bright-black": "blackBright",
"bright-red": "redBright",
"bright-green": "greenBright",
"bright-yellow": "yellowBright",
"bright-blue": "blueBright",
"bright-magenta": "magentaBright",
"bright-cyan": "cyanBright",
"bright-white": "whiteBright",
} }
@@ -211,7 +233,7 @@ class ClaudeAgentProvider(AgentProvider):
when the agent has no skills.""" when the agent has no skills."""
from ...backend.util import host_skill_dir from ...backend.util import host_skill_dir
agent = plan.manifest.agent agent = plan.spec.manifest.agents[plan.spec.agent_name]
if not agent.skills: if not agent.skills:
return return
skills_dir = _skills_dir(plan.guest_home) skills_dir = _skills_dir(plan.guest_home)
@@ -240,7 +262,7 @@ class ClaudeAgentProvider(AgentProvider):
f"chown node:node {prompt_path} && chmod 600 {prompt_path}", f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
user="root", user="root",
) )
agent = plan.manifest.agent agent = plan.spec.manifest.agents[plan.spec.agent_name]
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None: def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
+5 -5
View File
@@ -177,7 +177,7 @@ class CodexAgentProvider(AgentProvider):
skills.""" skills."""
from ...backend.util import host_skill_dir from ...backend.util import host_skill_dir
agent = plan.manifest.agent agent = plan.spec.manifest.agents[plan.spec.agent_name]
if not agent.skills: if not agent.skills:
return return
skills_dir = _skills_dir(plan.guest_home) skills_dir = _skills_dir(plan.guest_home)
@@ -206,7 +206,7 @@ class CodexAgentProvider(AgentProvider):
f"chown node:node {prompt_path} && chmod 600 {prompt_path}", f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
user="root", user="root",
) )
agent = plan.manifest.agent agent = plan.spec.manifest.agents[plan.spec.agent_name]
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None: def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
@@ -261,8 +261,8 @@ class CodexAgentProvider(AgentProvider):
return return
info(f"registering supervise MCP server in agent codex config → {supervise_url}") info(f"registering supervise MCP server in agent codex config → {supervise_url}")
r = bottle.exec( r = bottle.exec(
f"codex mcp add {_SUPERVISE_MCP_NAME} --url " f"codex mcp add --transport http "
f"{shlex.quote(supervise_url)}", f"{_SUPERVISE_MCP_NAME} {supervise_url}",
user="node", user="node",
) )
if r.returncode != 0: if r.returncode != 0:
@@ -270,7 +270,7 @@ class CodexAgentProvider(AgentProvider):
f"`codex mcp add supervise` failed (exit {r.returncode}): " f"`codex mcp add supervise` failed (exit {r.returncode}): "
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, " f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
f"register manually with: " f"register manually with: "
f"codex mcp add supervise --url {shlex.quote(supervise_url)}" f"codex mcp add --transport http supervise {supervise_url}"
) )
+1 -1
View File
@@ -232,7 +232,7 @@ class PiAgentProvider(AgentProvider):
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None: def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
from ...backend.util import host_skill_dir from ...backend.util import host_skill_dir
agent = plan.manifest.agent agent = plan.spec.manifest.agents[plan.spec.agent_name]
if not agent.skills: if not agent.skills:
return return
skills_dir = _skills_dir(plan.guest_home) skills_dir = _skills_dir(plan.guest_home)
+1 -3
View File
@@ -31,7 +31,6 @@ CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
EGRESS_HOSTNAME = "egress" EGRESS_HOSTNAME = "egress"
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml" EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
EGRESS_ROUTES_FILENAME = Path(EGRESS_ROUTES_IN_CONTAINER).name
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -296,7 +295,7 @@ class Egress(ABC):
) -> EgressPlan: ) -> EgressPlan:
routes = egress_routes_for_bottle(bottle, provider_routes) routes = egress_routes_for_bottle(bottle, provider_routes)
log = bottle.egress.Log log = bottle.egress.Log
routes_path = stage_dir / EGRESS_ROUTES_FILENAME routes_path = stage_dir / "egress_routes.yaml"
routes_path.write_text(egress_render_routes(routes, log=log)) routes_path.write_text(egress_render_routes(routes, log=log))
routes_path.chmod(0o600) routes_path.chmod(0o600)
return EgressPlan( return EgressPlan(
@@ -310,7 +309,6 @@ class Egress(ABC):
__all__ = [ __all__ = [
"CODEX_HOST_CREDENTIAL_TOKEN_REF", "CODEX_HOST_CREDENTIAL_TOKEN_REF",
"EGRESS_HOSTNAME", "EGRESS_HOSTNAME",
"EGRESS_ROUTES_FILENAME",
"EGRESS_ROUTES_IN_CONTAINER", "EGRESS_ROUTES_IN_CONTAINER",
"Egress", "Egress",
"EgressPlan", "EgressPlan",
+2 -2
View File
@@ -5,6 +5,7 @@ egress container."""
from __future__ import annotations from __future__ import annotations
import dataclasses
import json import json
import os import os
import signal import signal
@@ -26,7 +27,6 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
load_config, load_config,
match_route, match_route,
outbound_scan_headers, outbound_scan_headers,
route_to_yaml_dict,
scan_inbound, scan_inbound,
scan_outbound, scan_outbound,
) )
@@ -82,7 +82,7 @@ class EgressAddon:
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None: def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
if path == "/allowlist": if path == "/allowlist":
payload = json.dumps( payload = json.dumps(
{"routes": [route_to_yaml_dict(r) for r in self.config.routes]}, {"routes": [dataclasses.asdict(r) for r in self.config.routes]},
indent=2, indent=2,
).encode("utf-8") ).encode("utf-8")
flow.response = http.Response.make( flow.response = http.Response.make(
-51
View File
@@ -359,56 +359,6 @@ def _parse_one(idx: int, raw: object) -> Route:
) )
def _path_match_to_dict(pm: PathMatch) -> dict[str, object]:
d: dict[str, object] = {"value": pm.value}
if pm.type != "prefix":
d["type"] = pm.type
return d
def _header_match_to_dict(hm: HeaderMatch) -> dict[str, object]:
d: dict[str, object] = {"name": hm.name, "value": hm.value}
if hm.type != "exact":
d["type"] = hm.type
return d
def _match_entry_to_dict(me: MatchEntry) -> dict[str, object]:
d: dict[str, object] = {}
if me.paths:
d["paths"] = [_path_match_to_dict(p) for p in me.paths]
if me.methods:
d["methods"] = list(me.methods)
if me.headers:
d["headers"] = [_header_match_to_dict(h) for h in me.headers]
return d
def route_to_yaml_dict(r: Route) -> dict[str, object]:
"""Serialize a Route to YAML-schema-compatible dict.
Uses the same field names the YAML parser accepts, so the output
can be round-tripped directly into an `allow` or `egress-block`
proposal without translation. Fields that are empty/default are
omitted so the agent doesn't copy irrelevant keys."""
d: dict[str, object] = {"host": r.host}
if r.auth_scheme:
d["auth_scheme"] = r.auth_scheme
d["token_env"] = r.token_env
if r.matches:
d["matches"] = [_match_entry_to_dict(m) for m in r.matches]
if r.git_fetch:
d["git"] = {"fetch": True}
dlp: dict[str, object] = {}
if r.outbound_detectors is not None:
dlp["outbound_detectors"] = list(r.outbound_detectors)
if r.inbound_detectors is not None:
dlp["inbound_detectors"] = list(r.inbound_detectors)
if dlp:
d["dlp"] = dlp
return d
def load_routes(text: str) -> tuple[Route, ...]: def load_routes(text: str) -> tuple[Route, ...]:
"""Parse YAML text → routes.""" """Parse YAML text → routes."""
try: try:
@@ -748,7 +698,6 @@ def scan_inbound(
__all__ = [ __all__ = [
"LOG_BLOCKS", "LOG_BLOCKS",
"route_to_yaml_dict",
"LOG_FULL", "LOG_FULL",
"LOG_OFF", "LOG_OFF",
"Config", "Config",
+2 -2
View File
@@ -114,7 +114,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
return value return value
def resolve_env(manifest: Manifest) -> ResolvedEnv: def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
"""Iterate the agent's env entries: """Iterate the agent's env entries:
- secret: prompt at runtime; carry value in forwarded - secret: prompt at runtime; carry value in forwarded
- interpolated: read $HOST_VAR from os.environ; carry value in forwarded - interpolated: read $HOST_VAR from os.environ; carry value in forwarded
@@ -124,7 +124,7 @@ def resolve_env(manifest: Manifest) -> ResolvedEnv:
backend injects forwarded values via its launcher's env parameter.""" backend injects forwarded values via its launcher's env parameter."""
forwarded: dict[str, str] = {} forwarded: dict[str, str] = {}
literals: dict[str, str] = {} literals: dict[str, str] = {}
bottle = manifest.bottle bottle = manifest.bottle_for(agent)
for name, raw in bottle.env.items(): for name, raw in bottle.env.items():
if not name: if not name:
continue continue
+97 -192
View File
@@ -36,23 +36,10 @@ Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a
warn at load time and contributes nothing. The trust boundary is warn at load time and contributes nothing. The trust boundary is
expressed as filesystem layout rather than resolver logic. expressed as filesystem layout rather than resolver logic.
Two types are exported: Validation runs once at load. Manifest.from_json_obj is preserved
as a programmatic entry point (used by tests) that takes a dict
ManifestIndex the multi-agent/bottle collection returned by with the same field names useful for building manifests without
resolve() and from_json_obj(). Used for agent on-disk files.
selection (all_agent_names), validation
(require_agent), and lazy loading (load_for_agent).
This is the pre-preflight form.
Manifest a single-agent/bottle value type holding exactly
one agent: ManifestAgent and one bottle:
ManifestBottle (with the agent's git-gate.user
already overlaid). Returned by load_for_agent().
This is the post-preflight form passed to backends.
ManifestIndex.from_json_obj is preserved as a programmatic entry
point (used by tests) that takes a dict with the same field names
useful for building manifests without on-disk files.
""" """
from __future__ import annotations from __future__ import annotations
@@ -84,7 +71,6 @@ __all__ = [
"ManifestEgressConfig", "ManifestEgressConfig",
"ManifestAgent", "ManifestAgent",
"ManifestBottle", "ManifestBottle",
"ManifestIndex",
"Manifest", "Manifest",
] ]
@@ -203,64 +189,14 @@ class ManifestBottle:
) )
def _merge_git_user(
agent_user: ManifestGitUser, base_user: ManifestGitUser
) -> ManifestGitUser:
"""Merge the agent's git.user over the bottle's, agent-wins-on-non-empty."""
if agent_user.is_empty():
return base_user
return ManifestGitUser(
name=agent_user.name or base_user.name,
email=agent_user.email or base_user.email,
)
@dataclass(frozen=True) @dataclass(frozen=True)
class Manifest: class Manifest:
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
`bottle` is the effective bottle with the agent's git-gate.user already
overlaid per-field (agent wins on non-empty). Backends and provisioners
use this directly no agent_name lookup needed."""
agent: ManifestAgent
bottle: ManifestBottle
def git_identity_summary(self) -> str | None:
"""One-line effective git identity with per-field provenance, e.g.
`name=claude (agent), email=eric@dideric.is (bottle)`.
Returns None when neither agent nor bottle sets an identity."""
over = self.agent.git_user # agent's declared git_user (pre-merge)
merged = self.bottle.git_user # effective git_user (post-merge)
if merged.is_empty():
return None
parts: list[str] = []
if merged.name:
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
if merged.email:
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
return ", ".join(parts)
@dataclass(frozen=True)
class ManifestIndex:
"""Multi-agent/bottle collection. The pre-preflight form.
In lazy mode (from resolve()/from_md_dirs()) only filenames are scanned;
no file content is read. In eager mode (from from_json_obj()) all agents
and bottles are pre-parsed. Call load_for_agent() to get a single-value
Manifest ready for backend use."""
bottles: Mapping[str, ManifestBottle] bottles: Mapping[str, ManifestBottle]
agents: Mapping[str, ManifestAgent] agents: Mapping[str, ManifestAgent]
# Set by from_md_dirs; None in from_json_obj (test/programmatic) mode.
# Stores the manifest root dirs so load_for_agent can locate files later.
home_md: Path | None = field(default=None)
cwd_md: Path | None = field(default=None)
@classmethod @classmethod
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex": def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
"""Walk the per-file manifest tree and build a ManifestIndex. """Walk the per-file manifest tree and build a Manifest.
Layout (PRD 0011): Layout (PRD 0011):
$HOME/.bot-bottle/bottles/<name>.md bottles (home-only) $HOME/.bot-bottle/bottles/<name>.md bottles (home-only)
@@ -273,7 +209,7 @@ class ManifestIndex:
boundary. boundary.
If `missing_ok` is true, a missing `$HOME/.bot-bottle/` If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
returns an empty index instead of dying. This is for returns an empty manifest instead of dying. This is for
passive UI surfaces like the dashboard, which can still passive UI surfaces like the dashboard, which can still
monitor already-running agents without launch config. monitor already-running agents without launch config.
@@ -312,16 +248,25 @@ class ManifestIndex:
cls, cls,
home_dir: Path, home_dir: Path,
cwd_dir: Path | None, cwd_dir: Path | None,
) -> "ManifestIndex": ) -> "Manifest":
"""Return a names-only ManifestIndex. No file content is read; only """Programmatic entry point. Loads bottles from
filenames are scanned for the agent selector. Full parsing happens `<home_dir>/bottles/`, home agents from `<home_dir>/agents/`,
later, per-agent, via `load_for_agent`. and (if `cwd_dir` is passed) cwd agents from
`<cwd_dir>/agents/`. Cwd agents override home agents on
name collision. A `bottles/` subdir under `cwd_dir` is
logged as a warning and ignored.
A `bottles/` subdir under `cwd_dir` is logged as a warning and Used by tests to build a Manifest from fixture directories
ignored the filesystem layout IS the trust boundary.
Used by tests to build a ManifestIndex from fixture directories
without touching `os.environ`.""" without touching `os.environ`."""
bottles_dir = home_dir / "bottles"
from .manifest_loader import load_agents_from_dir, load_bottles_from_dir
bottles = load_bottles_from_dir(bottles_dir)
bottle_names = set(bottles.keys())
agents_dir = home_dir / "agents"
agents = load_agents_from_dir(agents_dir, bottle_names, source="$HOME")
if cwd_dir is not None: if cwd_dir is not None:
stale_bottles = cwd_dir / "bottles" stale_bottles = cwd_dir / "bottles"
if stale_bottles.is_dir(): if stale_bottles.is_dir():
@@ -335,11 +280,17 @@ class ManifestIndex:
f"live under $HOME/.bot-bottle/bottles/ " f"live under $HOME/.bot-bottle/bottles/ "
f"(PRD 0011). Move them or delete." f"(PRD 0011). Move them or delete."
) )
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir) cwd_agents_dir = cwd_dir / "agents"
cwd_agents = load_agents_from_dir(
cwd_agents_dir, bottle_names, source="$CWD"
)
agents = {**agents, **cwd_agents}
return cls(bottles=bottles, agents=agents)
@classmethod @classmethod
def from_json_obj(cls, obj: object) -> "ManifestIndex": def from_json_obj(cls, obj: object) -> "Manifest":
"""Validate and build a ManifestIndex from a raw JSON-like dict.""" """Validate and build a Manifest from a raw JSON-like dict."""
d = as_json_object(obj, "manifest") d = as_json_object(obj, "manifest")
raw_bottles_obj = _section_dict(d.get("bottles"), "manifest 'bottles'") raw_bottles_obj = _section_dict(d.get("bottles"), "manifest 'bottles'")
raw_agents = _section_dict(d.get("agents"), "manifest 'agents'") raw_agents = _section_dict(d.get("agents"), "manifest 'agents'")
@@ -360,121 +311,75 @@ class ManifestIndex:
} }
return cls(bottles=bottles, agents=agents) return cls(bottles=bottles, agents=agents)
@property
def all_agent_names(self) -> list[str]:
"""Sorted list of all discoverable agent names.
In names-only mode (from resolve/from_md_dirs) this scans agent
filenames without reading their content. In eager mode (from
from_json_obj) it returns the pre-parsed agents' names."""
if self.home_md is not None:
from .manifest_loader import scan_agent_names
home_names = set(scan_agent_names(self.home_md / "agents").keys())
cwd_names: set[str] = set()
if self.cwd_md is not None:
cwd_names = set(scan_agent_names(self.cwd_md / "agents").keys())
return sorted(home_names | cwd_names)
return sorted(self.agents.keys())
def load_for_agent(self, agent_name: str) -> "Manifest":
"""Parse the named agent and its bottle; return a single-value Manifest.
In lazy mode (from resolve/from_md_dirs) the agent file and its
bottle chain are read from disk for the first time here. In eager
mode (from_json_obj) the data is already parsed; this just filters
down to the requested agent and its bottle.
The returned Manifest.bottle has the agent's git-gate.user already
overlaid (agent wins on non-empty, per-field).
Always raises ManifestError if the agent is unknown or invalid.
Backends call this at preflight inside _validate."""
if self.home_md is None:
# Eager manifest (from_json_obj): data already parsed; filter to
# the one requested agent and its bottle so the returned Manifest
# always holds exactly one agent and one bottle regardless of path.
if agent_name not in self.agents:
available = ", ".join(sorted(self.agents.keys())) or "(none)"
raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}"
)
agent = self.agents[agent_name]
raw_bottle = self.bottles[agent.bottle]
merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
bottle = raw_bottle if merged == raw_bottle.git_user else replace(raw_bottle, git_user=merged)
return Manifest(agent=agent, bottle=bottle)
from .manifest_loader import load_bottle_chain_from_dir, scan_agent_names
from .manifest_schema import validate_agent_frontmatter_keys
from .yaml_subset import YamlSubsetError, parse_frontmatter
# Locate the agent file; cwd wins over home on name collision.
home_agents = scan_agent_names(self.home_md / "agents")
cwd_agents: dict[str, Path] = {}
if self.cwd_md is not None:
cwd_agents = scan_agent_names(self.cwd_md / "agents")
merged_agents = {**home_agents, **cwd_agents}
if agent_name not in merged_agents:
available = ", ".join(sorted(merged_agents.keys())) or "(none)"
raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}"
)
agent_path = merged_agents[agent_name]
try:
fm, body = parse_frontmatter(agent_path.read_text())
except OSError as e:
raise ManifestError(f"could not read {agent_path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{agent_path}: {e}") from e
validate_agent_frontmatter_keys(agent_path, fm.keys())
bottle_name = fm.get("bottle")
if not isinstance(bottle_name, str) or not bottle_name:
raise ManifestError(
f"agent '{agent_name}' must declare a 'bottle' field "
f"naming a defined bottle"
)
# Load the bottle chain (may raise ManifestError).
bottles_dir = self.home_md / "bottles"
raw_bottle = load_bottle_chain_from_dir(bottle_name, bottles_dir)
# Build and validate the full ManifestAgent.
agent_dict: dict[str, object] = {
"bottle": bottle_name,
"skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
agent = ManifestAgent.from_dict(agent_name, agent_dict, {bottle_name})
merged_user = _merge_git_user(agent.git_user, raw_bottle.git_user)
bottle = raw_bottle if merged_user == raw_bottle.git_user else replace(raw_bottle, git_user=merged_user)
return Manifest(agent=agent, bottle=bottle)
def has_agent(self, name: str) -> bool: def has_agent(self, name: str) -> bool:
return name in self.agents return name in self.agents
def require_agent(self, name: str) -> None: def require_agent(self, name: str) -> None:
"""Check that `name` is a discoverable agent. In names-only mode
this checks whether the .md file exists; in eager mode it checks
the pre-parsed agents dict. Does NOT parse file content."""
if self.has_agent(name): if self.has_agent(name):
return return
if self.home_md is not None: available = ", ".join(self.agents.keys())
# Names-only mode: check file existence without parsing. if available:
home_path = self.home_md / "agents" / f"{name}.md" msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
cwd_path = ( raise ManifestError(msg)
self.cwd_md / "agents" / f"{name}.md"
if self.cwd_md else None
)
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
return
available = ", ".join(self.all_agent_names) or "(none)"
raise ManifestError( raise ManifestError(
f"agent '{name}' not defined. Available: {available}" f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
) )
def has_bottle(self, name: str) -> bool:
return name in self.bottles
def require_bottle(self, name: str) -> None:
if self.has_bottle(name):
return
available = ", ".join(self.bottles.keys())
if available:
raise ManifestError(
f"bottle '{name}' not defined in bot-bottle.json. "
f"Available bottles: {available}"
)
raise ManifestError(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
def _effective_git_user(self, agent_name: str) -> ManifestGitUser:
"""Merge the agent's git.user over the referenced bottle's,
per-field, agent-wins-on-non-empty (issue #94). Same overlay
the `extends:` resolver applies between bottles
(`_merge_bottles`)."""
agent = self.agents[agent_name]
base = self.bottles[agent.bottle].git_user
over = agent.git_user
if over.is_empty():
return base
return ManifestGitUser(
name=over.name or base.name,
email=over.email or base.email,
)
def bottle_for(self, agent_name: str) -> ManifestBottle:
"""Resolve the Bottle the named agent references, with the
agent's git.user overlaid on top. The validator guarantees both
lookups succeed for a manifest built via from_json_obj.
The overlay lives here, the single point both backends call to
resolve an agent's bottle, so the docker / smolmachines git
provisioners pick up the merged identity unchanged."""
bottle = self.bottles[self.agents[agent_name].bottle]
merged = self._effective_git_user(agent_name)
if merged == bottle.git_user:
return bottle
return replace(bottle, git_user=merged)
def git_identity_summary(self, agent_name: str) -> str | None:
"""One-line effective git identity with per-field provenance
for launch summaries, e.g.
`name=claude (agent), email=eric@dideric.is (bottle)`.
Returns None when neither agent nor bottle sets an identity."""
over = self.agents[agent_name].git_user
merged = self._effective_git_user(agent_name)
if merged.is_empty():
return None
parts: list[str] = []
if merged.name:
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
if merged.email:
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
return ", ".join(parts)
+18 -66
View File
@@ -5,20 +5,16 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import ManifestBottle from .manifest import ManifestBottle, ManifestGitEntry
from .manifest_egress import ManifestEgressConfig from .manifest_egress import ManifestEgressConfig
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]: def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
"""Apply `extends:` chains and return resolved ManifestBottle objects.""" """Apply `extends:` chains and return resolved ManifestBottle objects."""
cache: dict[str, ManifestBottle] = {} cache: dict[str, ManifestBottle] = {}
# Per-bottle effective git-gate.repos, as raw dicts keyed by repo name.
# Threaded alongside `cache` so a child can field-merge against its
# parent's repos without reconstructing them from parsed entries.
repos_cache: dict[str, dict[str, object]] = {}
for name in raws: for name in raws:
if name not in cache: if name not in cache:
_resolve_one_bottle(name, raws, cache, repos_cache, ()) _resolve_one_bottle(name, raws, cache, ())
return cache return cache
@@ -26,7 +22,6 @@ def _resolve_one_bottle(
name: str, name: str,
raws: dict[str, dict[str, object]], raws: dict[str, dict[str, object]],
cache: dict[str, ManifestBottle], cache: dict[str, ManifestBottle],
repos_cache: dict[str, dict[str, object]],
seen: tuple[str, ...], seen: tuple[str, ...],
) -> ManifestBottle: ) -> ManifestBottle:
from .manifest import ManifestBottle, ManifestError from .manifest import ManifestBottle, ManifestError
@@ -46,7 +41,6 @@ def _resolve_one_bottle(
if parent_name_raw is None: if parent_name_raw is None:
bottle = ManifestBottle.from_dict(name, child_raw) bottle = ManifestBottle.from_dict(name, child_raw)
cache[name] = bottle cache[name] = bottle
repos_cache[name] = _resolve_repos_raw({}, child_raw)
return bottle return bottle
if not isinstance(parent_name_raw, str): if not isinstance(parent_name_raw, str):
@@ -66,33 +60,20 @@ def _resolve_one_bottle(
f"bottle '{name}' extends '{parent_name}' which is not " f"bottle '{name}' extends '{parent_name}' which is not "
f"defined. Available bottles: {avail}" f"defined. Available bottles: {avail}"
) )
parent = _resolve_one_bottle( parent = _resolve_one_bottle(parent_name, raws, cache, seen + (name,))
parent_name, raws, cache, repos_cache, seen + (name,) bottle = _merge_bottles(parent, child_raw, name)
)
merged_repos_raw = _resolve_repos_raw(repos_cache[parent_name], child_raw)
bottle = _merge_bottles(parent, child_raw, merged_repos_raw, name)
cache[name] = bottle cache[name] = bottle
repos_cache[name] = merged_repos_raw
return bottle return bottle
def _merge_bottles( def _merge_bottles(
parent: ManifestBottle, parent: ManifestBottle,
child_raw: dict[str, object], child_raw: dict[str, object],
merged_repos_raw: dict[str, object],
name: str, name: str,
) -> ManifestBottle: ) -> ManifestBottle:
"""Apply PRD 0025 merge rules.""" """Apply PRD 0025 merge rules."""
from .manifest import ManifestBottle, ManifestGitUser from .manifest import ManifestBottle, ManifestGitUser
from .manifest_egress import validate_egress_routes 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).
if _child_declares_git_gate_repos(child_raw):
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
child_raw = {**child_raw, "git-gate": {**git_raw, "repos": merged_repos_raw}}
# Parse the child's declared fields into a ManifestBottle (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
@@ -111,11 +92,11 @@ def _merge_bottles(
email=child.git_user.email or parent.git_user.email, email=child.git_user.email or parent.git_user.email,
) )
# git-gate.repos: when declared, child.git already holds the merged # git-gate.repos: missing means inherit; an explicit empty object
# set (an explicit empty dict clears parent, leaving child.git empty). # clears; otherwise parent and child merge by UpstreamHost with
# When omitted, the parent's entries are inherited verbatim. # child entries replacing duplicate hosts.
if _child_declares_git_gate_repos(child_raw): if _child_declares_git_gate_repos(child_raw):
merged_git = child.git merged_git = _merge_git_remotes(parent.git, child.git) if child.git else ()
else: else:
merged_git = parent.git merged_git = parent.git
@@ -149,45 +130,6 @@ def _merge_bottles(
) )
def _resolve_repos_raw(
parent_repos: dict[str, object],
child_raw: dict[str, object],
) -> dict[str, object]:
"""Compute a bottle's effective git-gate.repos as raw dicts.
Repos are keyed by name. When the child omits git-gate.repos it
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)
if not child_repos:
return {}
# Parent entries keep their order; child-only names are appended.
names = list(parent_repos) + [n for n in child_repos if n not in parent_repos]
return {
name: {
**as_json_object(parent_repos.get(name, {}), "parent git-gate repo"),
**as_json_object(child_repos.get(name, {}), "child git-gate repo"),
}
for name in names
}
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")
return as_json_object(git_raw.get("repos", {}), "child git-gate.repos")
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 from .manifest_util import as_json_object
@@ -198,6 +140,16 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
return "repos" in git_obj return "repos" in git_obj
def _merge_git_remotes(
parent: tuple[ManifestGitEntry, ...],
child: tuple[ManifestGitEntry, ...],
) -> tuple[ManifestGitEntry, ...]:
by_host = {entry.UpstreamHost: entry for entry in parent}
for entry in child:
by_host[entry.UpstreamHost] = entry
return tuple(by_host.values())
def _merge_egress( def _merge_egress(
parent: ManifestEgressConfig, parent: ManifestEgressConfig,
child: ManifestEgressConfig, child: ManifestEgressConfig,
+58 -44
View File
@@ -8,19 +8,21 @@ from typing import TYPE_CHECKING
from .log import warn from .log import warn
from .manifest_schema import ( from .manifest_schema import (
entity_name_from_path, entity_name_from_path,
validate_agent_frontmatter_keys,
validate_bottle_frontmatter_keys, validate_bottle_frontmatter_keys,
) )
from .manifest_util import ManifestError
from .yaml_subset import YamlSubsetError, parse_frontmatter from .yaml_subset import YamlSubsetError, parse_frontmatter
if TYPE_CHECKING: if TYPE_CHECKING:
from .manifest import ManifestBottle from .manifest import ManifestAgent, ManifestBottle
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None: 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
not. The manifest format changed in PRD 0011 and we do not want not. The manifest format changed in PRD 0011 and we do not want
to silently leave the JSON content unused.""" to silently leave the JSON content unused."""
from .manifest import ManifestError
legacy = dir_path / "bot-bottle.json" legacy = dir_path / "bot-bottle.json"
if legacy.is_file() and not md_dir.exists(): if legacy.is_file() and not md_dir.exists():
raise ManifestError( raise ManifestError(
@@ -32,13 +34,48 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
) )
def scan_agent_names(agents_dir: Path) -> dict[str, Path]: def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
"""Scan `<agents_dir>/*.md` for valid filenames and return `{name: path}`. """Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
`{name: Bottle}`. Missing dir returns an empty dict."""
from .manifest import ManifestError
from .manifest_extends import resolve_bottles
No file content is read. Invalid filenames are skipped with a warning.""" raws: dict[str, dict[str, object]] = {}
result: dict[str, Path] = {} if not bottles_dir.is_dir():
return {}
for path in sorted(bottles_dir.glob("*.md")):
name = entity_name_from_path(path)
if name is None:
warn(
f"skipping {path}: filename must match "
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
)
continue
try:
fm, _body = parse_frontmatter(path.read_text())
except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e
except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e
validate_bottle_frontmatter_keys(path, fm.keys())
raws[name] = fm
return resolve_bottles(raws)
def load_agents_from_dir(
agents_dir: Path,
bottle_names: set[str],
*,
source: str, # noqa: F841 — unused, but required by interface
) -> dict[str, ManifestAgent]:
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
`{name: Agent}`. The Markdown body becomes the agent's prompt.
Missing dir returns an empty dict."""
from .manifest import ManifestAgent, ManifestError
out: dict[str, ManifestAgent] = {}
if not agents_dir.is_dir(): if not agents_dir.is_dir():
return result return out
for path in sorted(agents_dir.glob("*.md")): for path in sorted(agents_dir.glob("*.md")):
name = entity_name_from_path(path) name = entity_name_from_path(path)
if name is None: if name is None:
@@ -47,45 +84,22 @@ def scan_agent_names(agents_dir: Path) -> dict[str, Path]:
f"[a-z][a-z0-9-]*.md (got {path.name!r})" f"[a-z][a-z0-9-]*.md (got {path.name!r})"
) )
continue continue
result[name] = path
return result
def load_bottle_chain_from_dir(
bottle_name: str, bottles_dir: Path
) -> ManifestBottle:
"""Load `bottle_name` and its full `extends:` chain from `bottles_dir`,
returning the resolved ManifestBottle.
Only the files in the extends chain are read unrelated bottle files
are never touched. Raises ManifestError on parse or validation failure."""
from .manifest_extends import resolve_bottles
raws: dict[str, dict[str, object]] = {}
to_load = [bottle_name]
while to_load:
name = to_load.pop()
if name in raws:
continue
path = bottles_dir / f"{name}.md"
if not path.is_file():
avail = ", ".join(
p.stem for p in sorted(bottles_dir.glob("*.md")) if p.is_file()
) or "(none)"
raise ManifestError(
f"bottle '{name}' not found at {path}. "
f"Available: {avail}"
)
try: try:
fm, _body = parse_frontmatter(path.read_text()) fm, body = parse_frontmatter(path.read_text())
except OSError as e: except OSError as e:
raise ManifestError(f"could not read {path}: {e}") from e raise ManifestError(f"could not read {path}: {e}") from e
except YamlSubsetError as e: except YamlSubsetError as e:
raise ManifestError(f"{path}: {e}") from e raise ManifestError(f"{path}: {e}") from e
validate_bottle_frontmatter_keys(path, fm.keys()) validate_agent_frontmatter_keys(path, fm.keys())
raws[name] = dict(fm) # Build the dict Agent.from_dict expects. The body becomes
parent = fm.get("extends") # prompt; Claude Code passthrough fields stay in fm and get
if isinstance(parent, str): # ignored by Agent.from_dict (reads bottle/skills/git-gate/prompt).
to_load.append(parent) agent_dict: dict[str, object] = {
"bottle": fm.get("bottle"),
return resolve_bottles(raws)[bottle_name] "skills": fm.get("skills", []),
"prompt": body.strip(),
}
if "git-gate" in fm:
agent_dict["git-gate"] = fm["git-gate"]
out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names)
return out
+12 -19
View File
@@ -5,7 +5,7 @@ queue/audit support. The sidecar (bot_bottle.supervise_server)
sits on the bottle's internal network and exposes three MCP tools the sits on the bottle's internal network and exposes three MCP tools the
agent calls when it hits a stuck-recovery category: agent calls when it hits a stuck-recovery category:
* egress-block / allow agent proposes a new routes.yaml * egress-block agent proposes a new routes.yaml
* capability-block agent proposes a new agent Dockerfile * capability-block agent proposes a new agent Dockerfile
Each tool call: the agent passes the full proposed file plus a Each tool call: the agent passes the full proposed file plus a
@@ -49,34 +49,27 @@ SUPERVISE_HOSTNAME = "supervise"
SUPERVISE_PORT = 9100 SUPERVISE_PORT = 9100
TOOL_CAPABILITY_BLOCK = "capability-block" TOOL_CAPABILITY_BLOCK = "capability-block"
TOOL_EGRESS_BLOCK = "egress-block"
TOOL_ALLOW = "allow"
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes" TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
TOOLS: tuple[str, ...] = ( TOOLS: tuple[str, ...] = (
TOOL_ALLOW,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
TOOL_EGRESS_BLOCK,
TOOL_LIST_EGRESS_ROUTES, TOOL_LIST_EGRESS_ROUTES,
) )
# The supervise sidecar uses these to query egress's # The supervise sidecar uses these to query egress's
# introspection endpoint for the `list-egress-routes` MCP # introspection endpoint for the `list-egress-routes` MCP
# tool. The hostname + port match egress's docker network # tool. The hostname + port match egress's docker network
# listen port (see backend.docker.egress.EGRESS_PORT). The supervise # alias + listen port (see bot_bottle.egress.EGRESS_HOSTNAME
# daemon runs inside the sidecar bundle alongside egress, so loopback # and backend.docker.egress.EGRESS_PORT — the values
# is the stable address across docker, smolmachines, and Apple # are inlined here so the in-container supervise_server doesn't
# Container backends. # need to import the egress package).
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099" EGRESS_FORWARD_PROXY = "http://egress:9099"
EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist" EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
# capability-block has no on-disk config the operator edits in place # capability-block has no on-disk config the operator edits in place
# (the Dockerfile is rebuilt, not patched), so it has no audit log # (the Dockerfile is rebuilt, not patched), so it has no audit log
# here — those changes are captured by git history + the rebuild record # here — those changes are captured by git history + the rebuild
# laid down in PRD 0016. # record laid down in PRD 0016. egress-block was removed in issue #198.
COMPONENT_FOR_TOOL: dict[str, str] = { COMPONENT_FOR_TOOL: dict[str, str] = {}
TOOL_ALLOW: "egress",
TOOL_EGRESS_BLOCK: "egress",
}
STATUS_APPROVED = "approved" STATUS_APPROVED = "approved"
STATUS_MODIFIED = "modified" STATUS_MODIFIED = "modified"
@@ -438,9 +431,9 @@ def sha256_hex(content: str) -> str:
# Dockerfile and propose modifications. # Dockerfile and propose modifications.
# #
# routes.yaml + allowlist used to live here too; PRD 0017 chunk 3 # routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
# moved them behind the `list-egress-routes` MCP tool (live state # moved them behind the `list-egress-routes` MCP tool (live
# from egress's introspection endpoint) so the agent always sees # state from egress's introspection endpoint) so the agent
# current data rather than a launch-time snapshot. # always sees current data rather than a launch-time snapshot.
CURRENT_CONFIG_DOCKERFILE = "Dockerfile" CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
+10 -108
View File
@@ -1,8 +1,8 @@
"""Supervise sidecar HTTP server (PRD 0013). """Supervise sidecar HTTP server (PRD 0013).
Per-bottle MCP server exposing tools the agent calls to propose config Per-bottle MCP server exposing tools the agent calls to propose config
changes when stuck. The tools are `allow`, `egress-block`, changes when stuck. The egress-block tool was removed in issue #198;
`capability-block`, and `list-egress-routes`. the remaining tools are `capability-block` and `list-egress-routes`.
Each queued tool call: Each queued tool call:
@@ -44,15 +44,9 @@ import urllib.request
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
try: # Same-directory import inside the bundle container; `supervise.py`
# Same-directory imports inside the bundle container; these files are # is COPYed alongside this file by Dockerfile.sidecars.
# COPYed flat under /app by Dockerfile.sidecars. import supervise as _sv
from egress_addon_core import load_routes
import supervise as _sv
except ModuleNotFoundError:
# Package imports for host-side tests and tooling.
from .egress_addon_core import load_routes
from . import supervise as _sv
# --- JSON-RPC / MCP plumbing ---------------------------------------------- # --- JSON-RPC / MCP plumbing ----------------------------------------------
@@ -148,9 +142,8 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"allowlist. Returns JSON with one entry per allowed host, " "allowlist. Returns JSON with one entry per allowed host, "
"each carrying its matches rules (if any) and whether " "each carrying its matches rules (if any) and whether "
"the proxy injects Authorization for the route. Use this " "the proxy injects Authorization for the route. Use this "
"before composing an `allow` or `egress-block` proposal so " "before composing an `egress-block` proposal so the new "
"the new routes file extends the live one rather than " "routes file extends the live one rather than replacing it."
"replacing it."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
@@ -158,88 +151,6 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
"additionalProperties": False, "additionalProperties": False,
}, },
}, },
{
"name": _sv.TOOL_ALLOW,
"description": (
"Request operator approval to change the bottle's egress "
"allowlist. Pass the full proposed routes.yaml content, not "
"just the new host, plus a justification. Use "
"`list-egress-routes` first so the proposal preserves existing "
"routes."
),
"inputSchema": {
"type": "object",
"properties": {
"routes_yaml": {
"type": "string",
"description": (
"Full proposed /etc/egress/routes.yaml content. "
"Each route entry accepts these keys:\n"
" host: <hostname> (required)\n"
" auth_scheme: Bearer|token (must pair with token_env)\n"
" token_env: <ENV_VAR_NAME> (must pair with auth_scheme)\n"
" matches: (optional list of match entries)\n"
" - paths: [{type: prefix|exact|regex, value: /...}]\n"
" methods: [GET, POST, ...]\n"
" headers: [{name: X-Hdr, value: val, type: exact|regex}]\n"
" git: (optional; omit to block git clone/fetch)\n"
" fetch: true\n"
" dlp: (optional DLP scanner overrides)\n"
" outbound_detectors: [token_patterns, known_secrets]\n"
" inbound_detectors: [naive_injection_detection]\n"
"Omit any key that should use its default. "
"`list-egress-routes` returns routes in this same format."
),
},
"justification": {
"type": "string",
"description": "Why this egress route is needed.",
},
},
"required": ["routes_yaml", "justification"],
},
},
{
"name": _sv.TOOL_EGRESS_BLOCK,
"description": (
"Request operator approval to change the bottle's egress "
"allowlist after a blocked outbound request. Pass the full "
"proposed routes.yaml content plus a justification. Use "
"`list-egress-routes` first so the proposal preserves existing "
"routes."
),
"inputSchema": {
"type": "object",
"properties": {
"routes_yaml": {
"type": "string",
"description": (
"Full proposed /etc/egress/routes.yaml content. "
"Each route entry accepts these keys:\n"
" host: <hostname> (required)\n"
" auth_scheme: Bearer|token (must pair with token_env)\n"
" token_env: <ENV_VAR_NAME> (must pair with auth_scheme)\n"
" matches: (optional list of match entries)\n"
" - paths: [{type: prefix|exact|regex, value: /...}]\n"
" methods: [GET, POST, ...]\n"
" headers: [{name: X-Hdr, value: val, type: exact|regex}]\n"
" git: (optional; omit to block git clone/fetch)\n"
" fetch: true\n"
" dlp: (optional DLP scanner overrides)\n"
" outbound_detectors: [token_patterns, known_secrets]\n"
" inbound_detectors: [naive_injection_detection]\n"
"Omit any key that should use its default. "
"`list-egress-routes` returns routes in this same format."
),
},
"justification": {
"type": "string",
"description": "Why this egress route is needed.",
},
},
"required": ["routes_yaml", "justification"],
},
},
{ {
"name": _sv.TOOL_CAPABILITY_BLOCK, "name": _sv.TOOL_CAPABILITY_BLOCK,
"description": ( "description": (
@@ -271,12 +182,11 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
] ]
# Map each proposal tool to the input field that carries the agent's # Map each non-egress tool to the input field that carries the agent's
# payload (stored in Proposal.proposed_file). # payload (stored in Proposal.proposed_file). egress-block builds its
# payload from structured input fields in `handle_egress_block`.
PROPOSED_FILE_FIELD: dict[str, str] = { PROPOSED_FILE_FIELD: dict[str, str] = {
_sv.TOOL_ALLOW: "routes_yaml",
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile", _sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
_sv.TOOL_EGRESS_BLOCK: "routes_yaml",
} }
@@ -293,14 +203,6 @@ def validate_proposed_file(tool: str, content: str) -> None:
# Dockerfiles are too varied to validate syntactically beyond # Dockerfiles are too varied to validate syntactically beyond
# non-empty. The operator reads the diff in the TUI. # non-empty. The operator reads the diff in the TUI.
pass pass
elif tool in (_sv.TOOL_ALLOW, _sv.TOOL_EGRESS_BLOCK):
try:
load_routes(content)
except ValueError as e:
raise _RpcError(
ERR_INVALID_PARAMS,
f"{tool}: proposed routes.yaml is not valid: {e}",
) from e
else: else:
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}") raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}")
+6 -1
View File
@@ -5,10 +5,15 @@ agent_provider:
egress: egress:
routes: routes:
- host: api.anthropic.com - host: api.anthropic.com
role: claude_code_oauth role: claude_code_oauth # wires Claude Code OAuth; do not change
auth: auth:
scheme: Bearer scheme: Bearer
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
# dlp is omitted → all detectors on by default (token_patterns,
# known_secrets outbound; naive_injection_detection inbound).
# To disable inbound scanning for this route:
# dlp:
# inbound_detectors: false
--- ---
Common Claude provider boundary. Drop this file into Common Claude provider boundary. Drop this file into
+7 -7
View File
@@ -10,7 +10,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
def fixture_minimal_dict() -> dict[str, Any]: def fixture_minimal_dict() -> dict[str, Any]:
@@ -62,16 +62,16 @@ def fixture_with_git_dict() -> dict[str, Any]:
} }
def fixture_minimal() -> ManifestIndex: def fixture_minimal() -> Manifest:
return ManifestIndex.from_json_obj(fixture_minimal_dict()) return Manifest.from_json_obj(fixture_minimal_dict())
def fixture_with_egress() -> ManifestIndex: def fixture_with_egress() -> Manifest:
return ManifestIndex.from_json_obj(fixture_with_egress_dict()) return Manifest.from_json_obj(fixture_with_egress_dict())
def fixture_with_git() -> ManifestIndex: def fixture_with_git() -> Manifest:
return ManifestIndex.from_json_obj(fixture_with_git_dict()) return Manifest.from_json_obj(fixture_with_git_dict())
def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path: def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path:
@@ -29,7 +29,7 @@ from bot_bottle.backend.macos_container.util import (
dns_server as _container_dns_server, dns_server as _container_dns_server,
is_available as _container_available, is_available as _container_available,
) )
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief." _AGENT_PROMPT = "You are a launch smoke-test agent. Be brief."
@@ -52,8 +52,8 @@ def _minimal_agent_dockerfile(path: Path) -> None:
) )
def _minimal_manifest(dockerfile: Path) -> ManifestIndex: def _minimal_manifest(dockerfile: Path) -> Manifest:
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
"agent_provider": { "agent_provider": {
+6 -5
View File
@@ -31,7 +31,7 @@ from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.bottle_state import cleanup_state from bot_bottle.bottle_state import cleanup_state
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker
@@ -92,16 +92,17 @@ class TestSandboxEscape(unittest.TestCase):
"on PATH: curl -sSL https://smolmachines.com/install.sh | sh" "on PATH: curl -sSL https://smolmachines.com/install.sh | sh"
) )
# Throwaway "identity file" for the git-gate's `identity` field. # Throwaway "identity file" so the manifest's _validate_git_entries
# It need not be a real SSH key: test 5 reaches gitleaks before # passes (it only checks `os.path.isfile`, not that the content is
# any SSH attempt anyway. # a real SSH key). Test 5 reaches gitleaks before any SSH attempt
# anyway.
fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.") fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.")
os.close(fd) os.close(fd)
cls._key_path = Path(kp) cls._key_path = Path(kp)
cls._key_path.write_text("placeholder\n") cls._key_path.write_text("placeholder\n")
cls._key_path.chmod(0o600) cls._key_path.chmod(0o600)
manifest = ManifestIndex.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
# Three fake secrets — different shapes — land # Three fake secrets — different shapes — land
@@ -22,15 +22,15 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from bot_bottle.backend import BottleSpec, get_bottle_backend from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker
def _manifest() -> ManifestIndex: def _manifest() -> Manifest:
"""Bottle with supervise on so the bundle exercises egress + """Bottle with supervise on so the bundle exercises egress +
supervise. Git is off because a meaningful git-gate test needs supervise. Git is off because a meaningful git-gate test needs
a real upstream and SSH keys out of scope for a bundle smoke.""" a real upstream and SSH keys out of scope for a bundle smoke."""
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
"supervise": True, "supervise": True,
@@ -35,15 +35,15 @@ from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.backend.smolmachines.smolvm import is_available as _smolvm_available from bot_bottle.backend.smolmachines.smolvm import is_available as _smolvm_available
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker
_AGENT_PROMPT = "You are demo. Be brief." _AGENT_PROMPT = "You are demo. Be brief."
def _minimal_manifest() -> ManifestIndex: def _minimal_manifest() -> Manifest:
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
"egress": { "egress": {
+1 -1
View File
@@ -74,7 +74,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
instance_name="bot-bottle-test", instance_name="bot-bottle-test",
prompt_file=prompt_file, prompt_file=prompt_file,
label="review-api", label="review-api",
color="cyan", color="bright-cyan",
) )
prompt = prompt_file.read_text() prompt = prompt_file.read_text()
config = Path(tmp, "codex-config.toml").read_text() config = Path(tmp, "codex-config.toml").read_text()
+3 -35
View File
@@ -16,13 +16,12 @@ from bot_bottle import bottle_state
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.backend import BottleSpec from bot_bottle.backend import BottleSpec
from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.resolve_common import mint_slug
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
def _manifest() -> ManifestIndex: def _manifest() -> Manifest:
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
"env": { "env": {
@@ -116,36 +115,5 @@ class TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase):
) )
class TestMintSlug(unittest.TestCase):
def _spec(self, *, label: str = "", identity: str = "") -> BottleSpec:
manifest = _manifest()
return BottleSpec(
manifest=manifest,
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp",
label=label,
identity=identity,
)
def test_no_label_uses_agent_name_with_random_suffix(self) -> None:
slug = mint_slug(self._spec(label=""))
self.assertTrue(slug.startswith("demo-"), slug)
# random suffix present — slug is longer than just "demo"
self.assertGreater(len(slug), len("demo-"))
def test_label_becomes_exact_slug(self) -> None:
slug = mint_slug(self._spec(label="my-run"))
self.assertEqual("my-run", slug)
def test_label_with_spaces_slugified_no_suffix(self) -> None:
slug = mint_slug(self._spec(label="My Feature Run"))
self.assertEqual("my-feature-run", slug)
def test_identity_takes_precedence_over_label(self) -> None:
slug = mint_slug(self._spec(label="my-run", identity="fixed-id"))
self.assertEqual("fixed-id", slug)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+11 -8
View File
@@ -11,14 +11,14 @@ class TestPalettePrintf(unittest.TestCase):
def test_known_color_returns_printf(self): def test_known_color_returns_printf(self):
cmd = palette_printf("red") cmd = palette_printf("red")
self.assertTrue(cmd.startswith("printf '")) self.assertTrue(cmd.startswith("printf '"))
self.assertIn("\\033]4;9;", cmd) # bright-red slot self.assertIn("\\033]4;1;", cmd) # normal red
self.assertIn("\\033]4;1;", cmd) # normal-red slot self.assertIn("\\033]4;9;", cmd) # bright red
self.assertIn("\\033]11;", cmd) # default background tint self.assertIn("\\033]11;", cmd) # default background tint
def test_color_sets_both_palette_slots(self): def test_bright_variant_sets_both_slots(self):
cmd = palette_printf("blue") cmd = palette_printf("bright-blue")
self.assertIn("\\033]4;12;", cmd) # bright-blue slot self.assertIn("\\033]4;12;", cmd) # bright-blue
self.assertIn("\\033]4;4;", cmd) # normal-blue slot self.assertIn("\\033]4;4;", cmd) # blue
def test_unknown_color_returns_empty(self): def test_unknown_color_returns_empty(self):
self.assertEqual("", palette_printf("")) self.assertEqual("", palette_printf(""))
@@ -26,7 +26,10 @@ class TestPalettePrintf(unittest.TestCase):
def test_all_named_colors_produce_output(self): def test_all_named_colors_produce_output(self):
colors = [ colors = [
"red", "green", "yellow", "blue", "magenta", "black", "red", "green", "yellow",
"blue", "magenta", "cyan", "white",
"bright-black", "bright-red", "bright-green", "bright-yellow",
"bright-blue", "bright-magenta", "bright-cyan", "bright-white",
] ]
for color in colors: for color in colors:
with self.subTest(color=color): with self.subTest(color=color):
@@ -62,7 +65,7 @@ class TestExecShellScript(unittest.TestCase):
self.assertFalse(agent_part.startswith("exec ")) self.assertFalse(agent_part.startswith("exec "))
def test_title_and_color_both_appear(self): def test_title_and_color_both_appear(self):
script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="magenta") script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="cyan")
assert script is not None assert script is not None
self.assertIn("bot", script) self.assertIn("bot", script)
self.assertIn("\\033]4;", script) self.assertIn("\\033]4;", script)
+3 -3
View File
@@ -17,11 +17,11 @@ from bot_bottle import supervise
from bot_bottle.backend import Bottle, BottleSpec, ExecResult from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker import DockerBottleBackend from bot_bottle.backend.docker import DockerBottleBackend
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
def _manifest() -> ManifestIndex: def _manifest() -> Manifest:
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": { "agents": {
"demo": { "demo": {
+1 -61
View File
@@ -14,13 +14,11 @@ from unittest.mock import MagicMock, patch
import bot_bottle.cli.start as start_mod import bot_bottle.cli.start as start_mod
import bot_bottle.cli.tui as tui_mod import bot_bottle.cli.tui as tui_mod
from bot_bottle.backend import ActiveAgent
def _make_manifest(agent_names: list[str]): def _make_manifest(agent_names: list[str]):
manifest = MagicMock() manifest = MagicMock()
manifest.agents = {name: MagicMock() for name in agent_names} manifest.agents = {name: MagicMock() for name in agent_names}
manifest.all_agent_names = sorted(agent_names)
return manifest return manifest
@@ -31,7 +29,7 @@ class TestCmdStartSelector(unittest.TestCase):
# Stub Manifest.resolve so no on-disk manifest is needed. # Stub Manifest.resolve so no on-disk manifest is needed.
self._manifest = _make_manifest(["researcher", "implementer"]) self._manifest = _make_manifest(["researcher", "implementer"])
self._resolve_patch = patch( self._resolve_patch = patch(
"bot_bottle.cli.start.ManifestIndex.resolve", "bot_bottle.cli.start.Manifest.resolve",
return_value=self._manifest, return_value=self._manifest,
) )
self._resolve_patch.start() self._resolve_patch.start()
@@ -135,63 +133,5 @@ class TestCmdStartSelector(unittest.TestCase):
self._launch_mock.assert_not_called() self._launch_mock.assert_not_called()
def _active_agent(slug: str) -> ActiveAgent:
return ActiveAgent(
backend_name="docker",
slug=slug,
agent_name="demo",
started_at="2026-01-01T00:00:00+00:00",
services=(),
)
class TestCmdStartLabelCollision(unittest.TestCase):
"""cmd_start re-prompts when the label's slug is already running."""
def setUp(self):
self._manifest = _make_manifest(["researcher"])
patch("bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest).start()
self._launch_mock = patch(
"bot_bottle.cli.start._launch_bottle", return_value=0,
).start()
self.addCleanup(patch.stopall)
def test_no_collision_proceeds_without_reprompt(self):
with (
patch.object(tui_mod, "name_color_modal", return_value=("researcher", "")) as modal,
patch("bot_bottle.cli.start.enumerate_active_agents", return_value=[]),
):
rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc)
modal.assert_called_once()
self._launch_mock.assert_called_once()
def test_collision_reprompts_with_disclaimer(self):
collision_agent = _active_agent("researcher")
call_count = 0
def _modal(default_label: str, *, disclaimer: str = "", **_kw: object) -> tuple[str, str]:
nonlocal call_count
call_count += 1
if call_count == 1:
return "researcher", ""
return "researcher-2", ""
with (
patch.object(tui_mod, "name_color_modal", side_effect=_modal) as modal,
patch(
"bot_bottle.cli.start.enumerate_active_agents",
side_effect=[[collision_agent], []],
),
):
rc = start_mod.cmd_start(["researcher"])
self.assertEqual(0, rc)
self.assertEqual(2, modal.call_count)
second_call_kwargs = modal.call_args_list[1][1]
self.assertIn("researcher", second_call_kwargs.get("disclaimer", ""))
self.assertIn("already in use", second_call_kwargs.get("disclaimer", ""))
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+15 -12
View File
@@ -31,7 +31,7 @@ from bot_bottle.egress import (
EgressRoute, EgressRoute,
) )
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
@@ -40,7 +40,7 @@ STAGE = Path("/tmp/cb-stage")
STATE = Path("/tmp/cb-state") STATE = Path("/tmp/cb-state")
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex: def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest:
"""Minimal manifest with the toggles the chunk-1 matrix needs. """Minimal manifest with the toggles the chunk-1 matrix needs.
The renderer only reads from the plan, not the manifest, so this The renderer only reads from the plan, not the manifest, so this
is just here to back BottleSpec.""" is just here to back BottleSpec."""
@@ -61,12 +61,22 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
"auth": {"scheme": "Bearer", "token_ref": "TOK"}, "auth": {"scheme": "Bearer", "token_ref": "TOK"},
}], }],
} }
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": {"dev": bottle}, "bottles": {"dev": bottle},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
def _spec(*, supervise: bool, with_git: bool, with_egress: bool) -> BottleSpec:
return BottleSpec(
manifest=_manifest(
supervise=supervise, with_git=with_git, with_egress=with_egress,
),
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp/x",
)
def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan: def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
return GitGatePlan( return GitGatePlan(
@@ -136,16 +146,9 @@ def _plan(
roles=(), roles=(),
),) ),)
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress) spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
spec = BottleSpec(
manifest=index,
agent_name="demo",
copy_cwd=False,
user_cwd="/tmp/x",
)
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=index.load_for_agent("demo"),
stage_dir=STAGE, stage_dir=STAGE,
slug=SLUG, slug=SLUG,
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
@@ -392,7 +395,7 @@ class TestSidecarBundleShape(unittest.TestCase):
"services"]["sidecars"] "services"]["sidecars"]
targets = {v["target"] for v in sc["volumes"]} targets = {v["target"] for v in sc["volumes"]}
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets) self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
self.assertIn("/etc/egress", targets) self.assertIn("/etc/egress/routes.yaml", targets)
self.assertIn("/git-gate-entrypoint.sh", targets) self.assertIn("/git-gate-entrypoint.sh", targets)
self.assertIn("/git-gate/creds/upstream-known_hosts", targets) self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise") self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
+6 -8
View File
@@ -24,7 +24,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider from bot_bottle.contrib.claude.agent_provider import ClaudeAgentProvider
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
@@ -55,7 +55,7 @@ def _plan(
bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore
if supervise: if supervise:
bottle_json["supervise"] = True bottle_json["supervise"] = True
index = ManifestIndex.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": { "agents": {
"demo": { "demo": {
@@ -65,9 +65,8 @@ def _plan(
}, },
}, },
}) })
manifest = index.load_for_agent("demo")
spec = BottleSpec( spec = BottleSpec(
manifest=index, agent_name="demo", manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x", copy_cwd=False, user_cwd="/tmp/x",
) )
supervise_plan = None supervise_plan = None
@@ -79,7 +78,6 @@ def _plan(
) )
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=Path("/tmp/stage"), stage_dir=Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
forwarded_env={}, forwarded_env={},
@@ -278,7 +276,7 @@ class TestClaudeUiProvision(unittest.TestCase):
instance_name="bot-bottle-demo-abc12", instance_name="bot-bottle-demo-abc12",
prompt_file=prompt_file, prompt_file=prompt_file,
label="research-ui", label="research-ui",
color="blue", color="bright-cyan",
) )
settings = json.loads((state_dir / "claude-settings.json").read_text()) settings = json.loads((state_dir / "claude-settings.json").read_text())
statusline = (state_dir / "claude-statusline.sh").read_text() statusline = (state_dir / "claude-statusline.sh").read_text()
@@ -290,9 +288,9 @@ class TestClaudeUiProvision(unittest.TestCase):
self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"]) self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"])
self.assertEqual("custom:bot-bottle-research-ui", settings["theme"]) self.assertEqual("custom:bot-bottle-research-ui", settings["theme"])
self.assertIn("research-ui", statusline) self.assertIn("research-ui", statusline)
self.assertIn("\x1b[94m", statusline) self.assertIn("\x1b[96m", statusline)
self.assertEqual("dark", theme["base"]) self.assertEqual("dark", theme["base"])
self.assertEqual("ansi:blueBright", theme["overrides"]["claude"]) self.assertEqual("ansi:cyanBright", theme["overrides"]["claude"])
def test_runs_verify_commands(self): def test_runs_verify_commands(self):
provision = AgentProvisionPlan( provision = AgentProvisionPlan(
+8 -10
View File
@@ -24,7 +24,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider from bot_bottle.contrib.codex.agent_provider import CodexAgentProvider
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
@@ -55,7 +55,7 @@ def _plan(
bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore
if supervise: if supervise:
bottle_json["supervise"] = True bottle_json["supervise"] = True
index = ManifestIndex.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": { "agents": {
"demo": { "demo": {
@@ -65,9 +65,8 @@ def _plan(
}, },
}, },
}) })
manifest = index.load_for_agent("demo")
spec = BottleSpec( spec = BottleSpec(
manifest=index, agent_name="demo", manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x", copy_cwd=False, user_cwd="/tmp/x",
) )
supervise_plan = None supervise_plan = None
@@ -79,7 +78,6 @@ def _plan(
) )
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=Path("/tmp/stage"), stage_dir=Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
forwarded_env={}, forwarded_env={},
@@ -160,7 +158,7 @@ class TestCodexProvisionPrompt(unittest.TestCase):
instance_name="bot-bottle-demo-abc12", instance_name="bot-bottle-demo-abc12",
prompt_file=prompt_file, prompt_file=prompt_file,
label="research-ui", label="research-ui",
color="cyan", color="bright-cyan",
) )
config = (state_dir / "codex-config.toml").read_text() config = (state_dir / "codex-config.toml").read_text()
prompt_text = prompt_file.read_text() prompt_text = prompt_file.read_text()
@@ -292,10 +290,10 @@ class TestCodexSuperviseMcp(unittest.TestCase):
bottle.exec.assert_called_once() bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0] script = bottle.exec.call_args.args[0]
self.assertEqual("node", bottle.exec.call_args.kwargs.get("user")) self.assertEqual("node", bottle.exec.call_args.kwargs.get("user"))
self.assertEqual( self.assertIn("codex mcp add", script)
f"codex mcp add supervise --url {_URL}", self.assertIn("--transport http", script)
script, self.assertIn("supervise", script)
) self.assertIn(_URL, script)
def test_logs_warning_on_failure_but_does_not_raise(self): def test_logs_warning_on_failure_but_does_not_raise(self):
bottle = _make_bottle( bottle = _make_bottle(
+3 -5
View File
@@ -16,7 +16,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.contrib.pi.agent_provider import PiAgentProvider from bot_bottle.contrib.pi.agent_provider import PiAgentProvider
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
_URL = "http://supervise:9100/" _URL = "http://supervise:9100/"
@@ -43,7 +43,7 @@ def _plan(
skills: list[str] | None = None, skills: list[str] | None = None,
agent_provision: AgentProvisionPlan | None = None, agent_provision: AgentProvisionPlan | None = None,
) -> DockerBottlePlan: ) -> DockerBottlePlan:
index = ManifestIndex.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": {"dev": {"agent_provider": {"template": "pi"}}}, "bottles": {"dev": {"agent_provider": {"template": "pi"}}},
"agents": { "agents": {
"demo": { "demo": {
@@ -53,14 +53,12 @@ def _plan(
}, },
}, },
}) })
manifest = index.load_for_agent("demo")
spec = BottleSpec( spec = BottleSpec(
manifest=index, agent_name="demo", manifest=manifest, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x", copy_cwd=False, user_cwd="/tmp/x",
) )
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=Path("/tmp/stage"), stage_dir=Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
forwarded_env={}, forwarded_env={},
+7 -6
View File
@@ -21,19 +21,21 @@ from bot_bottle.backend.docker import launch as launch_mod
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
_INDEX = ManifestIndex.from_json_obj({
def _manifest() -> Manifest:
return Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
def _plan(tmp: str) -> DockerBottlePlan: def _plan(tmp: str) -> DockerBottlePlan:
stage = Path(tmp) stage = Path(tmp)
manifest = _INDEX.load_for_agent("demo") manifest = _manifest()
spec = BottleSpec( spec = BottleSpec(
manifest=_INDEX, manifest=manifest,
agent_name="demo", agent_name="demo",
copy_cwd=False, copy_cwd=False,
user_cwd=tmp, user_cwd=tmp,
@@ -41,7 +43,6 @@ def _plan(tmp: str) -> DockerBottlePlan:
) )
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=stage, stage_dir=stage,
git_gate_plan=GitGatePlan( git_gate_plan=GitGatePlan(
slug="test-teardown-00001", slug="test-teardown-00001",
+3 -5
View File
@@ -21,7 +21,7 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
class _Provider(AgentProvider): class _Provider(AgentProvider):
@@ -51,18 +51,16 @@ def _plan(*, git_user: dict | None = None, # type: ignore
bottle_json: dict = {} # type: ignore bottle_json: dict = {} # type: ignore
if git_user is not None: if git_user is not None:
bottle_json["git-gate"] = {"user": git_user} bottle_json["git-gate"] = {"user": git_user}
index = ManifestIndex.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
manifest = index.load_for_agent("demo")
spec = BottleSpec( spec = BottleSpec(
manifest=index, agent_name="demo", manifest=manifest, agent_name="demo",
copy_cwd=copy_cwd, user_cwd=user_cwd, copy_cwd=copy_cwd, user_cwd=user_cwd,
) )
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=stage_dir or Path("/tmp/stage"), stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
forwarded_env={}, forwarded_env={},
+4 -4
View File
@@ -13,12 +13,12 @@ from bot_bottle.egress import (
egress_token_env_map, egress_token_env_map,
) )
from bot_bottle.log import Die from bot_bottle.log import Die
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
from bot_bottle.yaml_subset import parse_yaml_subset from bot_bottle.yaml_subset import parse_yaml_subset
def _bottle(routes): # type: ignore def _bottle(routes): # type: ignore
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"routes": routes}}}, "bottles": {"dev": {"egress": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"] }).bottles["dev"]
@@ -362,9 +362,9 @@ class TestRenderRoutes(unittest.TestCase):
self.assertEqual("x.example", cfg.routes[0].host) self.assertEqual("x.example", cfg.routes[0].host)
def test_log_via_manifest_flows_to_render(self): def test_log_via_manifest_flows_to_render(self):
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
from bot_bottle.egress_addon_core import load_config, LOG_BLOCKS from bot_bottle.egress_addon_core import load_config, LOG_BLOCKS
m = ManifestIndex.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {"egress": { "bottles": {"dev": {"egress": {
"log": 1, "log": 1,
"routes": [{"host": "x.example"}], "routes": [{"host": "x.example"}],
+11 -54
View File
@@ -2,15 +2,12 @@
add_route removed; docker exec / cp / kill paths are covered by the add_route removed; docker exec / cp / kill paths are covered by the
integration test).""" integration test)."""
import tempfile
import unittest import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from bot_bottle import supervise from bot_bottle.backend.docker.egress_apply import (
from bot_bottle.backend.egress_apply import EgressApplyError EgressApplyError,
from bot_bottle.backend.docker.egress_apply import applicator validate_routes_content,
)
_ROUTES_EMPTY = "routes: []\n" _ROUTES_EMPTY = "routes: []\n"
@@ -19,11 +16,11 @@ _ROUTES_ONE = 'routes:\n - host: "api.anthropic.com"\n'
class TestValidateRoutesContent(unittest.TestCase): class TestValidateRoutesContent(unittest.TestCase):
def test_accepts_minimal_route_table(self): def test_accepts_minimal_route_table(self):
applicator.validate_routes_content(_ROUTES_EMPTY) validate_routes_content(_ROUTES_EMPTY)
applicator.validate_routes_content(_ROUTES_ONE) validate_routes_content(_ROUTES_ONE)
def test_accepts_full_route_with_matches(self): def test_accepts_full_route_with_matches(self):
applicator.validate_routes_content( validate_routes_content(
'routes:\n' 'routes:\n'
' - host: "api.github.com"\n' ' - host: "api.github.com"\n'
' auth_scheme: "Bearer"\n' ' auth_scheme: "Bearer"\n'
@@ -35,65 +32,25 @@ class TestValidateRoutesContent(unittest.TestCase):
def test_rejects_bad_yaml(self): def test_rejects_bad_yaml(self):
with self.assertRaises(EgressApplyError) as cm: with self.assertRaises(EgressApplyError) as cm:
applicator.validate_routes_content("routes:\n\t- host: x\n") validate_routes_content("routes:\n\t- host: x\n")
self.assertIn("not valid", str(cm.exception)) self.assertIn("not valid", str(cm.exception))
def test_rejects_missing_routes_key(self): def test_rejects_missing_routes_key(self):
with self.assertRaises(EgressApplyError): with self.assertRaises(EgressApplyError):
applicator.validate_routes_content("other: []\n") validate_routes_content("other: []\n")
def test_rejects_non_list_routes(self): def test_rejects_non_list_routes(self):
with self.assertRaises(EgressApplyError): with self.assertRaises(EgressApplyError):
applicator.validate_routes_content('routes: "not a list"\n') validate_routes_content('routes: "not a list"\n')
def test_rejects_partial_auth_pair(self): def test_rejects_partial_auth_pair(self):
with self.assertRaises(EgressApplyError): with self.assertRaises(EgressApplyError):
applicator.validate_routes_content( validate_routes_content(
'routes:\n' 'routes:\n'
' - host: "x.example"\n' ' - host: "x.example"\n'
' auth_scheme: "Bearer"\n' ' auth_scheme: "Bearer"\n'
) )
class TestApplyRoutesChange(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory(prefix="egress-apply-test.")
original = supervise.bot_bottle_root
def fake_root() -> Path:
return Path(self._tmp.name) / ".bot-bottle"
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
self.addCleanup(lambda: setattr(supervise, "bot_bottle_root", original))
self.addCleanup(self._tmp.cleanup)
def test_writes_live_routes_and_signals_reload(self):
calls: list[list[str]] = []
def fake_run(argv: list[str], **kwargs: object) -> SimpleNamespace:
calls.append(list(argv))
return SimpleNamespace(returncode=0, stdout="", stderr="")
with patch(
"bot_bottle.backend.docker.egress_apply.subprocess.run",
side_effect=fake_run,
):
before, after = applicator.apply_routes_change(
"dev",
"routes:\n - host: google.com\n",
)
self.assertEqual("", before)
self.assertEqual("routes:\n - host: google.com\n", after)
self.assertEqual(
"routes:\n - host: google.com\n",
(Path(self._tmp.name) / ".bot-bottle/state/dev/egress/routes.yaml").read_text(encoding="utf-8"),
)
self.assertEqual(
["docker", "kill", "--signal", "HUP", "bot-bottle-sidecars-dev"],
calls[0],
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+2 -2
View File
@@ -15,7 +15,7 @@ from bot_bottle.git_gate import (
git_gate_render_hook, git_gate_render_hook,
git_gate_upstreams_for_bottle, git_gate_upstreams_for_bottle,
) )
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
from tests.fixtures import fixture_minimal, fixture_with_git from tests.fixtures import fixture_minimal, fixture_with_git
@@ -280,7 +280,7 @@ class TestPrepare(unittest.TestCase):
self.assertEqual(0o600, os.stat(upstream.known_hosts_file).st_mode & 0o777) self.assertEqual(0o600, os.stat(upstream.known_hosts_file).st_mode & 0o777)
def test_prepare_skips_known_hosts_file_when_key_missing(self): def test_prepare_skips_known_hosts_file_when_key_missing(self):
manifest = ManifestIndex.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": {"dev": {"git-gate": {"repos": { "bottles": {"dev": {"git-gate": {"repos": {
"foo": { "foo": {
"url": "ssh://git@github.com/didericis/foo.git", "url": "ssh://git@github.com/didericis/foo.git",
+4 -49
View File
@@ -2,32 +2,26 @@
from __future__ import annotations from __future__ import annotations
import sys
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from bot_bottle.backend.macos_container import bottle as bottle_mod from bot_bottle.backend.macos_container.bottle import MacosContainerBottle
from bot_bottle.backend.macos_container.bottle import MacosContainerBottle, _PTY_FORWARD_SCRIPT
class TestMacosContainerBottle(unittest.TestCase): class TestMacosContainerBottle(unittest.TestCase):
def test_agent_argv_uses_pty_forward_and_container_exec(self): def test_agent_argv_uses_container_exec(self):
bottle = MacosContainerBottle( bottle = MacosContainerBottle(
"bot-bottle-dev-abc", "bot-bottle-dev-abc",
lambda: None, lambda: None,
None, None,
agent_command="codex", agent_command="codex",
) )
with patch.dict(bottle_mod.os.environ, {}, clear=True):
argv = bottle.agent_argv(["run"])
self.assertEqual( self.assertEqual(
[ [
sys.executable, _PTY_FORWARD_SCRIPT, "--",
"container", "exec", "--interactive", "--tty", "container", "exec", "--interactive", "--tty",
"--env", "TERM",
"bot-bottle-dev-abc", "codex", "run", "bot-bottle-dev-abc", "codex", "run",
], ],
argv, bottle.agent_argv(["run"]),
) )
def test_agent_argv_includes_workdir(self): def test_agent_argv_includes_workdir(self):
@@ -37,54 +31,15 @@ class TestMacosContainerBottle(unittest.TestCase):
None, None,
agent_workdir="/home/node/workspace", agent_workdir="/home/node/workspace",
) )
with patch.dict(bottle_mod.os.environ, {}, clear=True):
argv = bottle.agent_argv([])
self.assertEqual( self.assertEqual(
[ [
sys.executable, _PTY_FORWARD_SCRIPT, "--",
"container", "exec", "--interactive", "--tty", "container", "exec", "--interactive", "--tty",
"--env", "TERM",
"--workdir", "/home/node/workspace", "--workdir", "/home/node/workspace",
"bot-bottle-dev-abc", "claude", "bot-bottle-dev-abc", "claude",
], ],
argv, bottle.agent_argv([]),
) )
def test_agent_argv_forwards_terminal_env_names_without_values(self):
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
with patch.dict(
bottle_mod.os.environ,
{
"TERM": "screen-256color",
"TERM_PROGRAM": "WezTerm",
"WEZTERM_PANE": "pane-id",
"SHELL": "/bin/zsh",
},
clear=True,
):
argv = bottle.agent_argv([])
self.assertIn("TERM", argv)
self.assertIn("TERM_PROGRAM", argv)
self.assertIn("WEZTERM_PANE", argv)
self.assertNotIn("SHELL", argv)
self.assertNotIn("TERM=screen-256color", argv)
self.assertNotIn("TERM_PROGRAM=WezTerm", argv)
self.assertNotIn("WEZTERM_PANE=pane-id", argv)
def test_agent_argv_always_forwards_term_name(self):
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
with patch.dict(bottle_mod.os.environ, {}, clear=True):
argv = bottle.agent_argv([])
self.assertIn("TERM", argv)
def test_agent_argv_no_tty_omits_wrapper_and_tty_flags(self):
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
argv = bottle.agent_argv([], tty=False)
self.assertNotIn("--tty", argv)
self.assertNotIn("--env", argv)
self.assertNotIn(_PTY_FORWARD_SCRIPT, argv)
self.assertEqual(["container", "exec", "bot-bottle-dev-abc", "claude"], argv)
def test_exec_pipes_script_to_shell(self): def test_exec_pipes_script_to_shell(self):
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None) bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run: with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run:
+7 -10
View File
@@ -11,12 +11,6 @@ from unittest.mock import patch
from bot_bottle.backend.macos_container import launch from bot_bottle.backend.macos_container import launch
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
from bot_bottle.manifest import ManifestIndex
_MANIFEST = ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).load_for_agent("demo")
def _plan( def _plan(
@@ -27,7 +21,7 @@ def _plan(
agent_git_gate_url: str = "", agent_git_gate_url: str = "",
agent_supervise_url: str = "", agent_supervise_url: str = "",
) -> MacosContainerBottlePlan: ) -> MacosContainerBottlePlan:
routes_path = stage_dir / "routes.yaml" routes_path = stage_dir / "source-routes.yaml"
routes_path.write_text("routes: []\n", encoding="utf-8") routes_path.write_text("routes: []\n", encoding="utf-8")
ca_dir = stage_dir / "egress-ca" ca_dir = stage_dir / "egress-ca"
ca_dir.mkdir(exist_ok=True) ca_dir.mkdir(exist_ok=True)
@@ -73,7 +67,6 @@ def _plan(
) )
return cast(MacosContainerBottlePlan, SimpleNamespace( return cast(MacosContainerBottlePlan, SimpleNamespace(
spec=SimpleNamespace(), spec=SimpleNamespace(),
manifest=_MANIFEST,
stage_dir=stage_dir, stage_dir=stage_dir,
slug="dev-abc", slug="dev-abc",
container_name="bot-bottle-dev-abc", container_name="bot-bottle-dev-abc",
@@ -125,10 +118,15 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy", f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy",
argv, argv,
) )
routes_dir = self.stage_dir / "macos-container-egress"
self.assertIn( self.assertIn(
f"type=bind,source={self.stage_dir},target=/etc/egress,readonly", f"type=bind,source={routes_dir},target=/etc/egress,readonly",
argv, argv,
) )
self.assertEqual(
"routes: []\n",
(routes_dir / "routes.yaml").read_text(encoding="utf-8"),
)
self.assertIn( self.assertIn(
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue", "type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
argv, argv,
@@ -195,7 +193,6 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
) )
plan = MacosContainerBottlePlan( plan = MacosContainerBottlePlan(
spec=base.spec, spec=base.spec,
manifest=base.manifest,
stage_dir=base.stage_dir, stage_dir=base.stage_dir,
git_gate_plan=base.git_gate_plan, git_gate_plan=base.git_gate_plan,
egress_plan=base.egress_plan, egress_plan=base.egress_plan,
@@ -1,159 +0,0 @@
"""Unit: macos-container pty_forward raw-mode wrapper (issue #245).
Tests argument parsing, non-TTY fallback, and the raw-mode
setup/restore sequence without requiring a real terminal.
"""
from __future__ import annotations
import io
import termios
import unittest
from unittest.mock import ANY, MagicMock, patch
from bot_bottle.backend.macos_container import pty_forward
def _fake_stdin(fd: int = 0) -> MagicMock:
"""Return a mock stdin whose fileno() returns *fd*."""
m = MagicMock()
m.fileno.return_value = fd
return m
class TestArgvParsing(unittest.TestCase):
def test_missing_separator_returns_error_exit_code(self):
with patch.object(pty_forward.sys, "stderr", new=io.StringIO()) as err:
rc = pty_forward.main(["container", "exec"])
self.assertEqual(2, rc)
self.assertIn("usage:", err.getvalue())
def test_too_few_args_returns_error_exit_code(self):
with patch.object(pty_forward.sys, "stderr", new=io.StringIO()):
self.assertEqual(2, pty_forward.main([]))
self.assertEqual(2, pty_forward.main(["--"]))
def test_separator_at_start_with_inner_is_valid(self):
with (
patch.object(pty_forward.sys, "stdin", _fake_stdin()),
patch.object(pty_forward.os, "isatty", return_value=False),
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 0
rc = pty_forward.main(["--", "container", "exec"])
self.assertEqual(0, rc)
run.assert_called_once()
self.assertEqual(["container", "exec"], run.call_args.args[0])
self.assertFalse(run.call_args.kwargs["check"])
class TestNonTtyFallback(unittest.TestCase):
def test_non_tty_stdin_runs_inner_directly(self):
with (
patch.object(pty_forward.sys, "stdin", _fake_stdin()),
patch.object(pty_forward.os, "isatty", return_value=False),
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 42
rc = pty_forward.main(
["--", "container", "exec", "--interactive", "--tty", "c", "claude"]
)
self.assertEqual(42, rc)
run.assert_called_once()
self.assertEqual(
["container", "exec", "--interactive", "--tty", "c", "claude"],
run.call_args.args[0],
)
self.assertFalse(run.call_args.kwargs["check"])
def test_fileno_error_runs_inner_directly(self):
bad_stdin = MagicMock()
bad_stdin.fileno.side_effect = OSError("pseudofile")
with (
patch.object(pty_forward.sys, "stdin", bad_stdin),
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 0
rc = pty_forward.main(["--", "container", "exec"])
run.assert_called_once()
self.assertEqual(["container", "exec"], run.call_args.args[0])
self.assertFalse(run.call_args.kwargs["check"])
self.assertEqual(0, rc)
class TestRawModeSetupAndRestore(unittest.TestCase):
def test_tty_stdin_sets_raw_mode_and_restores_on_exit(self):
saved_attrs = object()
with (
patch.object(pty_forward.sys, "stdin", _fake_stdin()),
patch.object(pty_forward.os, "isatty", return_value=True),
patch.object(pty_forward.termios, "tcgetattr", return_value=saved_attrs),
patch.object(pty_forward.tty, "setraw") as setraw,
patch.object(pty_forward.termios, "tcsetattr") as tcsetattr,
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 0
rc = pty_forward.main(["--", "container", "exec"])
self.assertEqual(0, rc)
setraw.assert_called_once()
tcsetattr.assert_called_once_with(
ANY, termios.TCSADRAIN, saved_attrs,
)
def test_tty_restores_on_subprocess_nonzero_exit(self):
saved_attrs = object()
with (
patch.object(pty_forward.sys, "stdin", _fake_stdin()),
patch.object(pty_forward.os, "isatty", return_value=True),
patch.object(pty_forward.termios, "tcgetattr", return_value=saved_attrs),
patch.object(pty_forward.tty, "setraw"),
patch.object(pty_forward.termios, "tcsetattr") as tcsetattr,
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 1
rc = pty_forward.main(["--", "container", "exec"])
self.assertEqual(1, rc)
tcsetattr.assert_called_once_with(
ANY, termios.TCSADRAIN, saved_attrs,
)
def test_tcgetattr_error_falls_back_to_bare_run(self):
with (
patch.object(pty_forward.sys, "stdin", _fake_stdin()),
patch.object(pty_forward.os, "isatty", return_value=True),
patch.object(
pty_forward.termios, "tcgetattr",
side_effect=termios.error("not a tty"),
),
patch.object(pty_forward.tty, "setraw") as setraw,
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 0
rc = pty_forward.main(["--", "container", "exec"])
setraw.assert_not_called()
run.assert_called_once()
self.assertEqual(["container", "exec"], run.call_args.args[0])
self.assertFalse(run.call_args.kwargs["check"])
self.assertEqual(0, rc)
def test_inner_run_sets_term_default_without_mutating_process_env(self):
with (
patch.dict(pty_forward.os.environ, {}, clear=True),
patch.object(pty_forward.subprocess, "run") as run,
):
run.return_value.returncode = 0
rc = pty_forward._run_inner(["container", "exec"])
self.assertNotIn("TERM", pty_forward.os.environ)
self.assertEqual(0, rc)
child_env = run.call_args.kwargs["env"]
self.assertEqual(["TERM"], sorted(child_env.keys()))
self.assertEqual("xterm-256color", child_env["TERM"])
if __name__ == "__main__":
unittest.main()
+30 -58
View File
@@ -1,14 +1,14 @@
"""Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047). """Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047).
An agent file may declare `git-gate.user` (name/email). At An agent file may declare `git-gate.user` (name/email). At
`ManifestIndex.load_for_agent()` it overlays the referenced bottle's `Manifest.bottle_for()` it overlays the referenced bottle's
`git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is `git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is
rejected on agents. `Manifest.git_identity_summary()` reports the rejected on agents. `Manifest.git_identity_summary()` reports the
effective identity with per-field `(agent)`/`(bottle)` provenance. effective identity with per-field `(agent)`/`(bottle)` provenance.
The `from_json_obj` path drives `Agent.from_dict` + the overlay in The `from_json_obj` path drives `Agent.from_dict` + `bottle_for`;
load_for_agent; a temp-dir case locks the md loader (the `_AGENT_KEYS` a temp-dir case locks the md loader (the `_AGENT_KEYS` allow + the
allow + the `git-gate` threading into `agent_dict`).""" `git-gate` threading into `agent_dict`)."""
from __future__ import annotations from __future__ import annotations
@@ -19,7 +19,7 @@ import textwrap
import unittest import unittest
from pathlib import Path from pathlib import Path
from bot_bottle.manifest import ManifestError, Manifest, ManifestIndex from bot_bottle.manifest import ManifestError, Manifest
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -32,28 +32,13 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
"""Build an index with one agent 'impl' and load it, returning a Manifest."""
bottle: dict = {} # type: ignore bottle: dict = {} # type: ignore
if bottle_user is not None: if bottle_user is not None:
bottle = {"git-gate": {"user": bottle_user}} bottle = {"git-gate": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
if agent_git is not None: if agent_git is not None:
agent["git-gate"] = agent_git agent["git-gate"] = agent_git
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": {"dev": bottle},
"agents": {"impl": agent},
}).load_for_agent("impl")
def _index(*, bottle_user: dict[str, object] | None = None, agent_git: dict[str, object] | None = None) -> ManifestIndex:
"""Build an index with one agent 'impl' without loading it."""
bottle: dict = {} # type: ignore
if bottle_user is not None:
bottle = {"git-gate": {"user": bottle_user}}
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
if agent_git is not None:
agent["git-gate"] = agent_git
return ManifestIndex.from_json_obj({
"bottles": {"dev": bottle}, "bottles": {"dev": bottle},
"agents": {"impl": agent}, "agents": {"impl": agent},
}) })
@@ -62,7 +47,7 @@ def _index(*, bottle_user: dict[str, object] | None = None, agent_git: dict[str,
class TestAgentGitUserOverlay(unittest.TestCase): class TestAgentGitUserOverlay(unittest.TestCase):
def test_agent_supplies_both_fields(self): def test_agent_supplies_both_fields(self):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}}) m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
u = m.bottle.git_user u = m.bottle_for("impl").git_user
self.assertEqual("a", u.name) self.assertEqual("a", u.name)
self.assertEqual("a@b", u.email) self.assertEqual("a@b", u.email)
@@ -71,7 +56,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
bottle_user={"name": "B", "email": "b@c"}, bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "a"}}, agent_git={"user": {"name": "a"}},
) )
u = m.bottle.git_user u = m.bottle_for("impl").git_user
self.assertEqual("a", u.name) # agent wins self.assertEqual("a", u.name) # agent wins
self.assertEqual("b@c", u.email) # bottle falls through self.assertEqual("b@c", u.email) # bottle falls through
@@ -80,40 +65,34 @@ class TestAgentGitUserOverlay(unittest.TestCase):
bottle_user={"name": "B", "email": "b@c"}, bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"email": "a@b"}}, agent_git={"user": {"email": "a@b"}},
) )
u = m.bottle.git_user u = m.bottle_for("impl").git_user
self.assertEqual("B", u.name) self.assertEqual("B", u.name)
self.assertEqual("a@b", u.email) self.assertEqual("a@b", u.email)
def test_agent_identity_with_bottle_declaring_none(self): def test_agent_identity_with_bottle_declaring_none(self):
idx = _index(agent_git={"user": {"name": "a", "email": "a@b"}}) m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
# Raw bottle has no git_user; loaded manifest has merged git_user from agent self.assertTrue(m.bottles["dev"].git_user.is_empty())
self.assertTrue(idx.bottles["dev"].git_user.is_empty()) self.assertFalse(m.bottle_for("impl").git_user.is_empty())
m = idx.load_for_agent("impl")
self.assertFalse(m.bottle.git_user.is_empty())
def test_bottle_only_identity_preserved_when_agent_silent(self): def test_bottle_only_identity_preserved_when_agent_silent(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"}) m = _manifest(bottle_user={"name": "B", "email": "b@c"})
u = m.bottle.git_user u = m.bottle_for("impl").git_user
self.assertEqual("B", u.name) self.assertEqual("B", u.name)
self.assertEqual("b@c", u.email) self.assertEqual("b@c", u.email)
def test_no_overlay_uses_bottle_instance_directly(self): def test_bottle_for_returns_same_instance_when_no_overlay(self):
idx = _index(bottle_user={"name": "B"}) m = _manifest(bottle_user={"name": "B"})
m = idx.load_for_agent("impl") self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
# Agent has no git_user — bottle instance should be the same object
self.assertIs(idx.bottles["dev"], m.bottle)
def test_noop_overlay_uses_bottle_instance_directly(self): def test_bottle_for_returns_same_instance_when_overlay_is_noop(self):
idx = _index( m = _manifest(
bottle_user={"name": "B", "email": "b@c"}, bottle_user={"name": "B", "email": "b@c"},
agent_git={"user": {"name": "B", "email": "b@c"}}, agent_git={"user": {"name": "B", "email": "b@c"}},
) )
m = idx.load_for_agent("impl") self.assertIs(m.bottles["dev"], m.bottle_for("impl"))
# Agent git_user == bottle git_user — no replace needed
self.assertEqual(idx.bottles["dev"].git_user, m.bottle.git_user)
def test_other_bottle_fields_untouched_by_overlay(self): def test_other_bottle_fields_untouched_by_overlay(self):
idx = ManifestIndex.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": { "bottles": {"dev": {
"env": {"FOO": "bar"}, "env": {"FOO": "bar"},
"supervise": True, "supervise": True,
@@ -124,7 +103,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
"git-gate": {"user": {"name": "a"}}, "git-gate": {"user": {"name": "a"}},
}}, }},
}) })
b = idx.load_for_agent("impl").bottle b = m.bottle_for("impl")
self.assertEqual("a", b.git_user.name) self.assertEqual("a", b.git_user.name)
self.assertEqual({"FOO": "bar"}, dict(b.env)) self.assertEqual({"FOO": "bar"}, dict(b.env))
self.assertTrue(b.supervise) self.assertTrue(b.supervise)
@@ -152,7 +131,7 @@ class TestGitIdentitySummary(unittest.TestCase):
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}}) m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
self.assertEqual( self.assertEqual(
"name=a (agent), email=a@b (agent)", "name=a (agent), email=a@b (agent)",
m.git_identity_summary(), m.git_identity_summary("impl"),
) )
def test_mixed_provenance(self): def test_mixed_provenance(self):
@@ -162,19 +141,19 @@ class TestGitIdentitySummary(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
"name=a (agent), email=b@c (bottle)", "name=a (agent), email=b@c (bottle)",
m.git_identity_summary(), m.git_identity_summary("impl"),
) )
def test_bottle_only(self): def test_bottle_only(self):
m = _manifest(bottle_user={"name": "B", "email": "b@c"}) m = _manifest(bottle_user={"name": "B", "email": "b@c"})
self.assertEqual( self.assertEqual(
"name=B (bottle), email=b@c (bottle)", "name=B (bottle), email=b@c (bottle)",
m.git_identity_summary(), m.git_identity_summary("impl"),
) )
def test_none_when_unset_anywhere(self): def test_none_when_unset_anywhere(self):
m = _manifest() m = _manifest()
self.assertIsNone(m.git_identity_summary()) self.assertIsNone(m.git_identity_summary("impl"))
_BOTTLE_DEV = """ _BOTTLE_DEV = """
@@ -238,26 +217,19 @@ class TestAgentGitUserMdLoader(unittest.TestCase):
def test_md_agent_git_user_overlays_bottle(self): def test_md_agent_git_user_overlays_bottle(self):
self._write("bottles/dev.md", _BOTTLE_DEV) self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_GIT) self._write("agents/impl.md", _AGENT_WITH_GIT)
m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl") m = Manifest.resolve(str(self.home))
u = m.bottle.git_user u = m.bottle_for("impl").git_user
self.assertEqual("agent-name", u.name) self.assertEqual("agent-name", u.name)
self.assertEqual("bottle@example.com", u.email) self.assertEqual("bottle@example.com", u.email)
self.assertEqual( self.assertEqual(
"name=agent-name (agent), email=bottle@example.com (bottle)", "name=agent-name (agent), email=bottle@example.com (bottle)",
m.git_identity_summary(), m.git_identity_summary("impl"),
) )
def test_md_agent_repos_fails_at_preflight(self): def test_md_agent_repos_dies(self):
"""git-gate.repos on an agent is an error; resolve() still succeeds
so other agents remain accessible, but load_for_agent raises."""
self._write("bottles/dev.md", _BOTTLE_DEV) self._write("bottles/dev.md", _BOTTLE_DEV)
self._write("agents/impl.md", _AGENT_WITH_REPOS) self._write("agents/impl.md", _AGENT_WITH_REPOS)
from bot_bottle.manifest import ManifestError msg = _error_message(Manifest.resolve, str(self.home))
names = ManifestIndex.resolve(str(self.home))
self.assertIn("impl", names.all_agent_names)
with self.assertRaises(ManifestError) as ctx:
names.load_for_agent("impl")
msg = str(ctx.exception)
self.assertIn("git-gate.repos", msg) self.assertIn("git-gate.repos", msg)
self.assertIn("bottle-only", msg) self.assertIn("bottle-only", msg)
+11 -11
View File
@@ -9,18 +9,18 @@ partial `auth` is an error, auth omission means unauthenticated."""
import unittest import unittest
from bot_bottle.manifest import ManifestError, ManifestIndex from bot_bottle.manifest import ManifestError, Manifest
def _bottle(routes): # type: ignore def _bottle(routes): # type: ignore
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"routes": routes}}}, "bottles": {"dev": {"egress": {"routes": routes}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"] }).bottles["dev"]
def _provider_bottle(provider, routes): # type: ignore def _provider_bottle(provider, routes): # type: ignore
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
"agent_provider": {"template": provider}, "agent_provider": {"template": provider},
@@ -32,7 +32,7 @@ def _provider_bottle(provider, routes): # type: ignore
def _provider_config_bottle(agent_provider): # type: ignore def _provider_config_bottle(agent_provider): # type: ignore
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": {"dev": {"agent_provider": agent_provider}}, "bottles": {"dev": {"agent_provider": agent_provider}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"] }).bottles["dev"]
@@ -433,7 +433,7 @@ class TestRouteValidation(unittest.TestCase):
self.assertEqual((), b.egress.routes) self.assertEqual((), b.egress.routes)
def test_no_egress_block_means_empty(self): def test_no_egress_block_means_empty(self):
b = ManifestIndex.from_json_obj({ b = Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"] }).bottles["dev"]
@@ -443,7 +443,7 @@ class TestRouteValidation(unittest.TestCase):
class TestConfigShape(unittest.TestCase): class TestConfigShape(unittest.TestCase):
def test_unknown_egress_key_rejected(self): def test_unknown_egress_key_rejected(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"wat": []}}}, "bottles": {"dev": {"egress": {"wat": []}}},
"agents": {"demo": {"skills": [], "prompt": "", "agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}}, "bottle": "dev"}},
@@ -454,14 +454,14 @@ class TestConfigShape(unittest.TestCase):
self.assertEqual(0, b.egress.Log) self.assertEqual(0, b.egress.Log)
def test_log_level_1_accepted(self): def test_log_level_1_accepted(self):
b = ManifestIndex.from_json_obj({ b = Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"log": 1, "routes": []}}}, "bottles": {"dev": {"egress": {"log": 1, "routes": []}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"] }).bottles["dev"]
self.assertEqual(1, b.egress.Log) self.assertEqual(1, b.egress.Log)
def test_log_level_2_accepted(self): def test_log_level_2_accepted(self):
b = ManifestIndex.from_json_obj({ b = Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"log": 2, "routes": []}}}, "bottles": {"dev": {"egress": {"log": 2, "routes": []}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}).bottles["dev"] }).bottles["dev"]
@@ -469,7 +469,7 @@ class TestConfigShape(unittest.TestCase):
def test_log_invalid_level_rejected(self): def test_log_invalid_level_rejected(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"log": 3}}}, "bottles": {"dev": {"egress": {"log": 3}}},
"agents": {"demo": {"skills": [], "prompt": "", "agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}}, "bottle": "dev"}},
@@ -477,7 +477,7 @@ class TestConfigShape(unittest.TestCase):
def test_log_bool_rejected(self): def test_log_bool_rejected(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"log": True}}}, "bottles": {"dev": {"egress": {"log": True}}},
"agents": {"demo": {"skills": [], "prompt": "", "agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}}, "bottle": "dev"}},
@@ -485,7 +485,7 @@ class TestConfigShape(unittest.TestCase):
def test_log_string_rejected(self): def test_log_string_rejected(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {"egress": {"log": "full"}}}, "bottles": {"dev": {"egress": {"log": "full"}}},
"agents": {"demo": {"skills": [], "prompt": "", "agents": {"demo": {"skills": [], "prompt": "",
"bottle": "dev"}}, "bottle": "dev"}},
+10 -83
View File
@@ -12,7 +12,7 @@ from __future__ import annotations
import unittest import unittest
from bot_bottle.manifest import ManifestError, ManifestIndex from bot_bottle.manifest import ManifestError, Manifest
def _error_message(callable_, *args, **kwargs) -> str: # type: ignore def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
@@ -28,7 +28,7 @@ def _build(**bottles) -> Manifest: # type: ignore
"""Build a manifest with the given bottles and one trivial agent """Build a manifest with the given bottles and one trivial agent
referencing the first bottle (so the manifest is valid).""" referencing the first bottle (so the manifest is valid)."""
first = next(iter(bottles)) first = next(iter(bottles))
return ManifestIndex.from_json_obj({ return Manifest.from_json_obj({
"bottles": bottles, "bottles": bottles,
"agents": { "agents": {
"demo": {"skills": [], "prompt": "", "bottle": first}, "demo": {"skills": [], "prompt": "", "bottle": first},
@@ -113,8 +113,8 @@ class TestExtendsEnvMerge(unittest.TestCase):
class TestExtendsGitMerge(unittest.TestCase): class TestExtendsGitMerge(unittest.TestCase):
"""git-gate.user overlays by field; git-gate.repos merges by name, """git-gate.user overlays by field; git-gate.repos merges by upstream
with same-name child entries merging field-by-field (child wins).""" host, with child entries replacing duplicate hosts."""
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}} _GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}} _GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
@@ -130,21 +130,19 @@ class TestExtendsGitMerge(unittest.TestCase):
names = [e.Name for e in m.bottles["child"].git] names = [e.Name for e in m.bottles["child"].git]
self.assertEqual(["a", "b"], names) self.assertEqual(["a", "b"], names)
def test_child_git_repo_different_name_same_host_coexists(self): def test_child_git_repo_replaces_same_host(self):
# Repos are keyed by Name, not UpstreamHost: two repos with replacement = {"url": "ssh://git@host-a/replacement.git", "key": {"provider": "static", "path": "/dev/null"}}
# different names on the same host both survive the merge.
same_host_b = {"url": "ssh://git@host-a/b.git", "key": {"provider": "static", "path": "/dev/null"}}
m = _build( m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
child={ child={
"extends": "base", "extends": "base",
"git-gate": {"repos": {"a2": same_host_b}}, "git-gate": {"repos": {"a2": replacement}},
}, },
) )
entries = m.bottles["child"].git entries = m.bottles["child"].git
self.assertEqual(2, len(entries)) self.assertEqual(1, len(entries))
names = {e.Name for e in entries} self.assertEqual("a2", entries[0].Name)
self.assertEqual({"a", "a2"}, names) self.assertEqual("replacement.git", entries[0].UpstreamPath)
def test_child_omits_git_gate_inherits_full_list(self): def test_child_omits_git_gate_inherits_full_list(self):
m = _build( m = _build(
@@ -166,77 +164,6 @@ class TestExtendsGitMerge(unittest.TestCase):
) )
self.assertEqual((), m.bottles["child"].git) self.assertEqual((), m.bottles["child"].git)
def test_child_same_name_repo_merges_key_field(self):
# Issue #237: child repo with same name as parent should merge
# field-by-field. Child overrides only `key`; parent's url and
# host_key are preserved.
parent_entry = {
"url": "ssh://git@host-a/repo.git",
"host_key": "ecdsa-sha2-nistp256 AAAA",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {"repo": {
"key": {"provider": "gitea", "forge_token_env": "GITEA_TOKEN"},
}}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
e = entries[0]
self.assertEqual("repo", e.Name)
self.assertEqual("ssh://git@host-a/repo.git", e.Upstream)
self.assertEqual("ecdsa-sha2-nistp256 AAAA", e.KnownHostKey)
self.assertEqual("gitea", e.Key.provider)
self.assertEqual("GITEA_TOKEN", e.Key.forge_token_env)
def test_child_same_name_repo_overrides_url(self):
# Child can override url on a same-name repo; other parent fields
# fall through.
parent_entry = {
"url": "ssh://git@host-a/old.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {"repo": {
"url": "ssh://git@host-b/new.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}}},
},
)
entries = m.bottles["child"].git
self.assertEqual(1, len(entries))
self.assertEqual("ssh://git@host-b/new.git", entries[0].Upstream)
def test_child_same_name_plus_new_repo(self):
# Same-name repo is field-merged; a distinct new name in child
# is appended.
parent_entry = {
"url": "ssh://git@host-a/repo.git",
"key": {"provider": "static", "path": "/keys/id_rsa"},
}
m = _build(
base={"git-gate": {"repos": {"repo": parent_entry}}},
child={
"extends": "base",
"git-gate": {"repos": {
"repo": {"key": {"provider": "gitea", "forge_token_env": "TOK"}},
"other": self._GIT_ENTRY_B,
}},
},
)
child = m.bottles["child"]
names = {e.Name for e in child.git}
self.assertEqual({"repo", "other"}, names)
repo_entry = next(e for e in child.git if e.Name == "repo")
self.assertEqual("gitea", repo_entry.Key.provider)
def test_child_git_user_inherits_parent_repos(self): def test_child_git_user_inherits_parent_repos(self):
m = _build( m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
+38 -38
View File
@@ -2,7 +2,7 @@
import unittest import unittest
from bot_bottle.manifest import ManifestError, ManifestIndex from bot_bottle.manifest import ManifestError, Manifest
def _manifest(repos: dict) -> dict: # type: ignore def _manifest(repos: dict) -> dict: # type: ignore
@@ -14,7 +14,7 @@ def _manifest(repos: dict) -> dict: # type: ignore
class TestGitEntryParsing(unittest.TestCase): class TestGitEntryParsing(unittest.TestCase):
def test_parses_minimal_entry(self): def test_parses_minimal_entry(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"bot-bottle": { "bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -30,7 +30,7 @@ class TestGitEntryParsing(unittest.TestCase):
self.assertEqual("didericis/bot-bottle.git", e.UpstreamPath) self.assertEqual("didericis/bot-bottle.git", e.UpstreamPath)
def test_default_port_is_22(self): def test_default_port_is_22(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/didericis/foo.git", "url": "ssh://git@github.com/didericis/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -41,7 +41,7 @@ class TestGitEntryParsing(unittest.TestCase):
self.assertEqual("github.com", e.UpstreamHost) self.assertEqual("github.com", e.UpstreamHost)
def test_host_key_optional(self): def test_host_key_optional(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -50,7 +50,7 @@ class TestGitEntryParsing(unittest.TestCase):
self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey) self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey)
def test_host_key_stored(self): def test_host_key_stored(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -60,7 +60,7 @@ class TestGitEntryParsing(unittest.TestCase):
self.assertEqual("ssh-ed25519 AAAA", m.bottles["dev"].git[0].KnownHostKey) self.assertEqual("ssh-ed25519 AAAA", m.bottles["dev"].git[0].KnownHostKey)
def test_repo_name_becomes_Name(self): def test_repo_name_becomes_Name(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"my-repo": { "my-repo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -70,19 +70,19 @@ class TestGitEntryParsing(unittest.TestCase):
def test_missing_url_dies(self): def test_missing_url_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": {"key": {"provider": "static", "path": "/dev/null"}}, "foo": {"key": {"provider": "static", "path": "/dev/null"}},
})) }))
def test_missing_key_block_dies(self): def test_missing_key_block_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": {"url": "ssh://git@github.com/foo.git"}, "foo": {"url": "ssh://git@github.com/foo.git"},
})) }))
def test_unknown_key_in_entry_dies(self): def test_unknown_key_in_entry_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -92,7 +92,7 @@ class TestGitEntryParsing(unittest.TestCase):
def test_non_ssh_url_dies(self): def test_non_ssh_url_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "https://github.com/didericis/foo.git", "url": "https://github.com/didericis/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -101,7 +101,7 @@ class TestGitEntryParsing(unittest.TestCase):
def test_scp_style_url_dies(self): def test_scp_style_url_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "git@github.com:didericis/foo.git", "url": "git@github.com:didericis/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -110,7 +110,7 @@ class TestGitEntryParsing(unittest.TestCase):
def test_url_without_user_dies(self): def test_url_without_user_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://github.com/foo.git", "url": "ssh://github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -119,7 +119,7 @@ class TestGitEntryParsing(unittest.TestCase):
def test_url_without_path_dies(self): def test_url_without_path_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com", "url": "ssh://git@github.com",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -128,7 +128,7 @@ class TestGitEntryParsing(unittest.TestCase):
def test_non_numeric_port_dies(self): def test_non_numeric_port_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com:notaport/foo.git", "url": "ssh://git@github.com:notaport/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -136,7 +136,7 @@ class TestGitEntryParsing(unittest.TestCase):
})) }))
def test_ip_literal_upstream(self): def test_ip_literal_upstream(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"bot-bottle": { "bot-bottle": {
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", "url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -152,7 +152,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_two_repos_different_hosts_both_parsed(self): def test_two_repos_different_hosts_both_parsed(self):
# Repo names come from dict keys; two distinct keys always produce # Repo names come from dict keys; two distinct keys always produce
# two distinct entries (uniqueness is guaranteed at the YAML/dict level). # two distinct entries (uniqueness is guaranteed at the YAML/dict level).
m = ManifestIndex.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {"git-gate": {"repos": { "bottles": {"dev": {"git-gate": {"repos": {
"foo": { "foo": {
"url": "ssh://git@a.example/x.git", "url": "ssh://git@a.example/x.git",
@@ -170,7 +170,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_legacy_ssh_field_dies_with_hint(self): def test_legacy_ssh_field_dies_with_hint(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj({ Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
"ssh": [{ "ssh": [{
@@ -187,7 +187,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_name_with_single_quote_dies(self): def test_name_with_single_quote_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"o'reilly": { "o'reilly": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -196,7 +196,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_name_with_space_dies(self): def test_name_with_space_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"my repo": { "my repo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -205,7 +205,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_name_with_semicolon_dies(self): def test_name_with_semicolon_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo;bar": { "foo;bar": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -214,7 +214,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_name_with_dollar_dies(self): def test_name_with_dollar_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo$bar": { "foo$bar": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -222,7 +222,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
})) }))
def test_valid_name_with_dots_and_hyphens_accepted(self): def test_valid_name_with_dots_and_hyphens_accepted(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"my.repo-name_1": { "my.repo-name_1": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -233,7 +233,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
def test_legacy_git_key_dies_with_hint(self): def test_legacy_git_key_dies_with_hint(self):
msg = "" msg = ""
try: try:
ManifestIndex.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {"git": {"remotes": {}}}}, "bottles": {"dev": {"git": {"remotes": {}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
@@ -247,7 +247,7 @@ class TestStaticKey(unittest.TestCase):
"""git-gate.repos entries with key.provider = "static".""" """git-gate.repos entries with key.provider = "static"."""
def test_static_key_minimal(self): def test_static_key_minimal(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"bot-bottle": { "bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"key": {"provider": "static", "path": "/home/user/.ssh/id_ed25519"}, "key": {"provider": "static", "path": "/home/user/.ssh/id_ed25519"},
@@ -260,7 +260,7 @@ class TestStaticKey(unittest.TestCase):
self.assertEqual("/home/user/.ssh/id_ed25519", e.IdentityFile) self.assertEqual("/home/user/.ssh/id_ed25519", e.IdentityFile)
def test_static_key_sets_identity_file_at_parse_time(self): def test_static_key_sets_identity_file_at_parse_time(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null"}, "key": {"provider": "static", "path": "/dev/null"},
@@ -270,7 +270,7 @@ class TestStaticKey(unittest.TestCase):
def test_static_key_missing_path_dies(self): def test_static_key_missing_path_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static"}, "key": {"provider": "static"},
@@ -279,7 +279,7 @@ class TestStaticKey(unittest.TestCase):
def test_static_key_unknown_field_dies(self): def test_static_key_unknown_field_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "static", "path": "/dev/null", "api_url": "x"}, "key": {"provider": "static", "path": "/dev/null", "api_url": "x"},
@@ -291,7 +291,7 @@ class TestGiteaKey(unittest.TestCase):
"""git-gate.repos entries with key.provider = "gitea".""" """git-gate.repos entries with key.provider = "gitea"."""
def test_gitea_key_minimal(self): def test_gitea_key_minimal(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"bot-bottle": { "bot-bottle": {
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
"key": { "key": {
@@ -308,7 +308,7 @@ class TestGiteaKey(unittest.TestCase):
self.assertEqual("", e.IdentityFile) self.assertEqual("", e.IdentityFile)
def test_gitea_key_with_api_url(self): def test_gitea_key_with_api_url(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"repo": { "repo": {
"url": "ssh://git@gitea.example.com/org/repo.git", "url": "ssh://git@gitea.example.com/org/repo.git",
"key": { "key": {
@@ -321,7 +321,7 @@ class TestGiteaKey(unittest.TestCase):
self.assertEqual("https://gitea.example.com", m.bottles["dev"].git[0].Key.api_url) self.assertEqual("https://gitea.example.com", m.bottles["dev"].git[0].Key.api_url)
def test_gitea_key_has_no_identity_file_at_parse_time(self): def test_gitea_key_has_no_identity_file_at_parse_time(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/didericis/foo.git", "url": "ssh://git@github.com/didericis/foo.git",
"key": {"provider": "gitea", "forge_token_env": "T"}, "key": {"provider": "gitea", "forge_token_env": "T"},
@@ -331,7 +331,7 @@ class TestGiteaKey(unittest.TestCase):
def test_gitea_key_missing_forge_token_env_dies(self): def test_gitea_key_missing_forge_token_env_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "gitea"}, "key": {"provider": "gitea"},
@@ -340,7 +340,7 @@ class TestGiteaKey(unittest.TestCase):
def test_gitea_key_unknown_field_dies(self): def test_gitea_key_unknown_field_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": { "key": {
@@ -357,7 +357,7 @@ class TestKeyBlockValidation(unittest.TestCase):
def test_missing_provider_dies(self): def test_missing_provider_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"path": "/dev/null"}, "key": {"path": "/dev/null"},
@@ -366,7 +366,7 @@ class TestKeyBlockValidation(unittest.TestCase):
def test_unknown_provider_dies(self): def test_unknown_provider_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "github"}, "key": {"provider": "github"},
@@ -375,14 +375,14 @@ class TestKeyBlockValidation(unittest.TestCase):
def test_missing_key_block_dies(self): def test_missing_key_block_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": {"url": "ssh://git@github.com/foo.git"}, "foo": {"url": "ssh://git@github.com/foo.git"},
})) }))
class TestEmptyGitGateField(unittest.TestCase): class TestEmptyGitGateField(unittest.TestCase):
def test_no_git_gate_field_yields_empty_tuple(self): def test_no_git_gate_field_yields_empty_tuple(self):
m = ManifestIndex.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
@@ -390,13 +390,13 @@ class TestEmptyGitGateField(unittest.TestCase):
def test_git_gate_object_type_required(self): def test_git_gate_object_type_required(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {"git-gate": "not-a-dict"}}, "bottles": {"dev": {"git-gate": "not-a-dict"}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
def test_empty_repos_yields_empty_tuple(self): def test_empty_repos_yields_empty_tuple(self):
m = ManifestIndex.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {"git-gate": {"repos": {}}}}, "bottles": {"dev": {"git-gate": {"repos": {}}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
+10 -10
View File
@@ -2,7 +2,7 @@
import unittest import unittest
from bot_bottle.manifest import ManifestError, ManifestGitUser, ManifestIndex 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
@@ -23,7 +23,7 @@ def _manifest(git_user): # type: ignore
class TestGitUserParsing(unittest.TestCase): class TestGitUserParsing(unittest.TestCase):
def test_parses_both_fields(self): def test_parses_both_fields(self):
m = ManifestIndex.from_json_obj(_manifest({ m = Manifest.from_json_obj(_manifest({
"name": "Eric Bauerfeld", "name": "Eric Bauerfeld",
"email": "eric+claude@dideric.is", "email": "eric+claude@dideric.is",
})) }))
@@ -33,13 +33,13 @@ class TestGitUserParsing(unittest.TestCase):
self.assertFalse(u.is_empty()) self.assertFalse(u.is_empty())
def test_name_only(self): def test_name_only(self):
m = ManifestIndex.from_json_obj(_manifest({"name": "Bot"})) m = Manifest.from_json_obj(_manifest({"name": "Bot"}))
u = m.bottles["dev"].git_user u = m.bottles["dev"].git_user
self.assertEqual("Bot", u.name) self.assertEqual("Bot", u.name)
self.assertEqual("", u.email) self.assertEqual("", u.email)
def test_email_only(self): def test_email_only(self):
m = ManifestIndex.from_json_obj(_manifest({"email": "bot@example.com"})) m = Manifest.from_json_obj(_manifest({"email": "bot@example.com"}))
u = m.bottles["dev"].git_user u = m.bottles["dev"].git_user
self.assertEqual("", u.name) self.assertEqual("", u.name)
self.assertEqual("bot@example.com", u.email) self.assertEqual("bot@example.com", u.email)
@@ -47,7 +47,7 @@ class TestGitUserParsing(unittest.TestCase):
def test_omitted_defaults_to_empty(self): def test_omitted_defaults_to_empty(self):
# No git.user block at all → empty GitUser, is_empty True → # No git.user block at all → empty GitUser, is_empty True →
# provisioner skips the `git config` step entirely. # provisioner skips the `git config` step entirely.
m = ManifestIndex.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
@@ -59,13 +59,13 @@ class TestGitUserParsing(unittest.TestCase):
# / half-finished edit; fail loudly rather than silently # / half-finished edit; fail loudly rather than silently
# no-op (the operator clearly meant to configure something). # no-op (the operator clearly meant to configure something).
msg = _error_message( msg = _error_message(
ManifestIndex.from_json_obj, _manifest({"name": "", "email": ""}), Manifest.from_json_obj, _manifest({"name": "", "email": ""}),
) )
self.assertIn("neither name nor email", msg) self.assertIn("neither name nor email", msg)
def test_unknown_key_dies(self): def test_unknown_key_dies(self):
msg = _error_message( msg = _error_message(
ManifestIndex.from_json_obj, Manifest.from_json_obj,
_manifest({"name": "Bot", "username": "bot"}), _manifest({"name": "Bot", "username": "bot"}),
) )
self.assertIn("unknown key", msg) self.assertIn("unknown key", msg)
@@ -73,19 +73,19 @@ class TestGitUserParsing(unittest.TestCase):
def test_non_string_name_dies(self): def test_non_string_name_dies(self):
msg = _error_message( msg = _error_message(
ManifestIndex.from_json_obj, _manifest({"name": 42}), Manifest.from_json_obj, _manifest({"name": 42}),
) )
self.assertIn("git-gate.user.name must be a string", msg) self.assertIn("git-gate.user.name must be a string", msg)
def test_non_string_email_dies(self): def test_non_string_email_dies(self):
msg = _error_message( msg = _error_message(
ManifestIndex.from_json_obj, _manifest({"email": ["x@y.z"]}), Manifest.from_json_obj, _manifest({"email": ["x@y.z"]}),
) )
self.assertIn("git-gate.user.email must be a string", msg) self.assertIn("git-gate.user.email must be a string", msg)
def test_legacy_top_level_git_user_dies(self): def test_legacy_top_level_git_user_dies(self):
msg = _error_message( msg = _error_message(
ManifestIndex.from_json_obj, Manifest.from_json_obj,
{ {
"bottles": {"dev": {"git_user": {"name": "Bot"}}}, "bottles": {"dev": {"git_user": {"name": "Bot"}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
+60 -95
View File
@@ -11,7 +11,7 @@ import textwrap
import unittest import unittest
from pathlib import Path from pathlib import Path
from bot_bottle.manifest import ManifestError, ManifestIndex from bot_bottle.manifest import ManifestError, Manifest
def _write(p: Path, text: str) -> None: def _write(p: Path, text: str) -> None:
@@ -45,7 +45,7 @@ _AGENT_IMPL = """
class _ResolveCase(unittest.TestCase): class _ResolveCase(unittest.TestCase):
"""Drives `ManifestIndex.resolve(cwd)` against a temp $HOME and a """Drives `Manifest.resolve(cwd)` against a temp $HOME and a
temp cwd. Subclasses lay down fixture files in setUp.""" temp cwd. Subclasses lay down fixture files in setUp."""
def setUp(self) -> None: def setUp(self) -> None:
@@ -71,19 +71,20 @@ class _ResolveCase(unittest.TestCase):
def cwd_cb(self) -> Path: def cwd_cb(self) -> Path:
return self.cwd_root / ".bot-bottle" return self.cwd_root / ".bot-bottle"
def resolve(self) -> ManifestIndex: def resolve(self) -> Manifest:
return ManifestIndex.resolve(str(self.cwd_root)) return Manifest.resolve(str(self.cwd_root))
class TestBottleFileParses(_ResolveCase): class TestBottleFileParses(_ResolveCase):
"""SC #1: a bottle file under $HOME/.bot-bottle/bottles/ """SC #1: a bottle file under $HOME/.bot-bottle/bottles/
parses into the expected Bottle shape via load_for_agent.""" parses into the expected Bottle shape."""
def test_loads(self): def test_loads(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL) _write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve().load_for_agent("implementer") m = self.resolve()
routes = m.bottle.egress.routes self.assertIn("dev", m.bottles)
routes = m.bottles["dev"].egress.routes
self.assertEqual(2, len(routes)) self.assertEqual(2, len(routes))
self.assertEqual("api.anthropic.com", routes[0].Host) self.assertEqual("api.anthropic.com", routes[0].Host)
self.assertEqual("Bearer", routes[0].AuthScheme) self.assertEqual("Bearer", routes[0].AuthScheme)
@@ -93,14 +94,14 @@ class TestBottleFileParses(_ResolveCase):
class TestAgentFileParses(_ResolveCase): class TestAgentFileParses(_ResolveCase):
"""SC #2: an agent file under $HOME/.bot-bottle/agents/ """SC #2: an agent file under $HOME/.bot-bottle/agents/
parses via load_for_agent; the body becomes the prompt, the parses, the body becomes the prompt, the frontmatter fields
frontmatter fields map to Agent fields.""" map to Agent fields."""
def test_loads(self): def test_loads(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL) _write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve().load_for_agent("implementer") m = self.resolve()
a = m.agent a = m.agents["implementer"]
self.assertEqual("dev", a.bottle) self.assertEqual("dev", a.bottle)
self.assertEqual(("init-prd",), a.skills) self.assertEqual(("init-prd",), a.skills)
# Body became the prompt; whitespace stripped. # Body became the prompt; whitespace stripped.
@@ -127,10 +128,10 @@ class TestCwdAgentOverridesHome(_ResolveCase):
CWD-OVERRIDE-PROMPT CWD-OVERRIDE-PROMPT
""", """,
) )
m = self.resolve().load_for_agent("implementer") m = self.resolve()
self.assertIn("CWD-OVERRIDE-PROMPT", m.agent.prompt) self.assertIn("CWD-OVERRIDE-PROMPT", m.agents["implementer"].prompt)
# Home bottle still present with its two egress routes # Home bottle still present
self.assertEqual(2, len(m.bottle.egress.routes)) self.assertEqual(2, len(m.bottles["dev"].egress.routes))
class TestCwdBottlesIgnored(_ResolveCase): class TestCwdBottlesIgnored(_ResolveCase):
@@ -154,11 +155,11 @@ class TestCwdBottlesIgnored(_ResolveCase):
--- ---
""", """,
) )
m = self.resolve().load_for_agent("implementer") m = self.resolve()
# Home value wins because cwd bottles are ignored entirely. # Home value wins because cwd bottles are ignored entirely.
self.assertEqual( self.assertEqual(
"api.anthropic.com", "api.anthropic.com",
m.bottle.egress.routes[0].Host, m.bottles["dev"].egress.routes[0].Host,
) )
@@ -175,12 +176,12 @@ class TestStdlibOnly(unittest.TestCase):
class TestExistingFromJsonObjStillWorks(unittest.TestCase): class TestExistingFromJsonObjStillWorks(unittest.TestCase):
"""SC #6: `ManifestIndex.from_json_obj` continues to work as a """SC #6: `Manifest.from_json_obj` continues to work as a
programmatic entry point even though disk loading moved to the programmatic entry point even though disk loading moved to the
MD layout.""" MD layout."""
def test_from_json_obj(self): def test_from_json_obj(self):
m = ManifestIndex.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "hi", "agents": {"demo": {"skills": [], "prompt": "hi",
"bottle": "dev"}}, "bottle": "dev"}},
@@ -214,9 +215,9 @@ class TestAgentFileDoublesAsClaudeCodeSubagent(_ResolveCase):
Agent prompt body. Agent prompt body.
""", """,
) )
m = self.resolve().load_for_agent("implementer") m = self.resolve()
self.assertEqual("dev", m.agent.bottle) self.assertEqual("dev", m.agents["implementer"].bottle)
self.assertEqual(("init-prd",), m.agent.skills) self.assertEqual(("init-prd",), m.agents["implementer"].skills)
class TestManifestEntryPointParity(_ResolveCase): class TestManifestEntryPointParity(_ResolveCase):
@@ -227,8 +228,8 @@ class TestManifestEntryPointParity(_ResolveCase):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL) _write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
md_manifest = self.resolve().load_for_agent("implementer") md_manifest = self.resolve()
json_index = ManifestIndex.from_json_obj({ json_manifest = Manifest.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {
"egress": { "egress": {
@@ -255,17 +256,17 @@ class TestManifestEntryPointParity(_ResolveCase):
}) })
self.assertEqual( self.assertEqual(
md_manifest.agent, md_manifest.agents["implementer"],
json_index.agents["implementer"], json_manifest.agents["implementer"],
) )
self.assertEqual( self.assertEqual(
md_manifest.bottle.egress.routes, md_manifest.bottles["dev"].egress.routes,
json_index.bottles["dev"].egress.routes, json_manifest.bottles["dev"].egress.routes,
) )
def test_json_agent_rejects_unknown_keys(self): def test_json_agent_rejects_unknown_keys(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj({ Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": { "agents": {
"implementer": { "implementer": {
@@ -276,7 +277,7 @@ class TestManifestEntryPointParity(_ResolveCase):
}) })
def test_json_agent_accepts_claude_code_passthrough_keys(self): def test_json_agent_accepts_claude_code_passthrough_keys(self):
index = ManifestIndex.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": { "agents": {
"implementer": { "implementer": {
@@ -290,51 +291,37 @@ class TestManifestEntryPointParity(_ResolveCase):
}, },
}) })
self.assertEqual("dev", index.agents["implementer"].bottle) self.assertEqual("dev", manifest.agents["implementer"].bottle)
class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase): class TestUnknownAgentKeyDies(_ResolveCase):
"""A typo'd / unknown frontmatter key on an agent file does NOT crash """A typo'd / unknown frontmatter key on an agent file dies
resolve(). The agent appears in all_agent_names for the selector. rather than silently ignoring."""
The error surfaces only when load_for_agent is called for that agent."""
def test_resolve_succeeds_despite_broken_agent(self): def test_dies(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write( _write(
self.home_cb / "agents" / "bad.md", self.home_cb / "agents" / "implementer.md",
""" """
--- ---
bottle: dev bottle: dev
skillz: [init-prd] skillz: [init-prd]
--- ---
""",
)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
# Resolve itself does not raise; broken agent appears in the name list.
self.assertIn("bad", m.all_agent_names)
self.assertIn("implementer", m.all_agent_names)
def test_load_for_agent_raises_for_broken_agent(self): ...
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(
self.home_cb / "agents" / "bad.md",
"""
---
bottle: dev
skillz: [init-prd]
---
""", """,
) )
m = self.resolve()
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
m.load_for_agent("bad") self.resolve()
def test_broken_bottle_only_fails_at_preflight(self):
"""A broken bottle does not crash resolve; only load_for_agent for class TestUnknownBottleKeyDies(_ResolveCase):
an agent that references it raises. Unrelated agents still work.""" """A typo'd / unknown frontmatter key on a bottle file dies
rather than silently ignoring."""
def test_dies(self):
_write( _write(
self.home_cb / "bottles" / "bad.md", self.home_cb / "bottles" / "dev.md",
""" """
--- ---
credproxy: credproxy:
@@ -342,26 +329,9 @@ class TestBrokenAgentOnlyFailsAtPreflight(_ResolveCase):
--- ---
""", """,
) )
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL) _write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
_write(
self.home_cb / "agents" / "broken-agent.md",
"""
---
bottle: bad
---
""",
)
m = self.resolve()
# Both agents appear in the name list at resolve time.
self.assertIn("implementer", m.all_agent_names)
self.assertIn("broken-agent", m.all_agent_names)
# Valid agent loads fine.
full = m.load_for_agent("implementer")
self.assertEqual("dev", full.agent.bottle)
# Broken bottle's agent raises at preflight.
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
m.load_for_agent("broken-agent") self.resolve()
class TestStaleJsonDies(_ResolveCase): class TestStaleJsonDies(_ResolveCase):
@@ -384,16 +354,16 @@ class TestNoManifestDies(_ResolveCase):
self.resolve() self.resolve()
def test_missing_ok_returns_empty_manifest(self): def test_missing_ok_returns_empty_manifest(self):
m = ManifestIndex.resolve(str(self.cwd_root), missing_ok=True) m = Manifest.resolve(str(self.cwd_root), missing_ok=True)
self.assertEqual({}, dict(m.bottles)) self.assertEqual({}, dict(m.bottles))
self.assertEqual({}, dict(m.agents)) self.assertEqual({}, dict(m.agents))
class TestUnknownBottleReferenceFailsAtPreflight(_ResolveCase): class TestUnknownBottleReferenceDies(_ResolveCase):
"""An agent file naming a non-existent bottle appears in all_agent_names """An agent file naming a bottle that doesn't exist on disk
at resolve time; the error only surfaces when load_for_agent is called.""" dies with the existing "bottle not defined" error."""
def test_stray_bottle_reference_fails_at_preflight(self): def test_dies(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write( _write(
self.home_cb / "agents" / "stray.md", self.home_cb / "agents" / "stray.md",
@@ -403,17 +373,8 @@ class TestUnknownBottleReferenceFailsAtPreflight(_ResolveCase):
--- ---
""", """,
) )
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
m = self.resolve()
# Both names visible at resolve time.
self.assertIn("stray", m.all_agent_names)
self.assertIn("implementer", m.all_agent_names)
# Valid agent loads fine.
full = m.load_for_agent("implementer")
self.assertEqual("dev", full.agent.bottle)
# Stray agent fails at preflight.
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
m.load_for_agent("stray") self.resolve()
class TestFilenameValidation(_ResolveCase): class TestFilenameValidation(_ResolveCase):
@@ -427,6 +388,10 @@ class TestFilenameValidation(_ResolveCase):
# This file should be skipped — capital letters not allowed. # This file should be skipped — capital letters not allowed.
_write(self.home_cb / "agents" / "BadName.md", _AGENT_IMPL) _write(self.home_cb / "agents" / "BadName.md", _AGENT_IMPL)
m = self.resolve() m = self.resolve()
self.assertIn("implementer", m.all_agent_names) self.assertIn("implementer", m.agents)
self.assertNotIn("BadName", m.all_agent_names) self.assertNotIn("BadName", m.agents)
self.assertNotIn("badname", m.all_agent_names) self.assertNotIn("badname", m.agents)
if __name__ == "__main__":
unittest.main()
+3 -3
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, ManifestBottle, ManifestIndex 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]:
@@ -19,7 +19,7 @@ def _manifest_with_runtime(value: object) -> dict[str, Any]:
class TestManifestRuntimeRemoved(unittest.TestCase): class TestManifestRuntimeRemoved(unittest.TestCase):
def test_loads_when_runtime_absent(self): def test_loads_when_runtime_absent(self):
m = ManifestIndex.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
@@ -32,7 +32,7 @@ class TestManifestRuntimeRemoved(unittest.TestCase):
for value in ("runsc", "runc", "kata-runtime", "", 42, None): for value in ("runsc", "runc", "kata-runtime", "", 42, None):
with self.subTest(value=value): with self.subTest(value=value):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestIndex.from_json_obj(_manifest_with_runtime(value)) Manifest.from_json_obj(_manifest_with_runtime(value))
if __name__ == "__main__": if __name__ == "__main__":
+16 -17
View File
@@ -19,18 +19,19 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
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 Manifest, ManifestIndex from bot_bottle.manifest import Manifest
_INDEX = ManifestIndex.from_json_obj({ def _manifest() -> Manifest:
return Manifest.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
def _spec(index: ManifestIndex, tmp: str) -> BottleSpec: def _spec(manifest: Manifest, tmp: str) -> BottleSpec:
return BottleSpec( return BottleSpec(
manifest=index, manifest=manifest,
agent_name="demo", agent_name="demo",
copy_cwd=False, copy_cwd=False,
user_cwd=tmp, user_cwd=tmp,
@@ -91,11 +92,10 @@ def _agent_provision(tmp: str) -> AgentProvisionPlan:
) )
def _docker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> DockerBottlePlan: def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
stage = Path(tmp) stage = Path(tmp)
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=stage, stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp), git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp), egress_plan=_egress_plan(tmp),
@@ -107,11 +107,10 @@ def _docker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> DockerBottle
) )
def _smolmachines_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> SmolmachinesBottlePlan: def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
stage = Path(tmp) stage = Path(tmp)
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=stage, stage_dir=stage,
git_gate_plan=_git_gate_plan(tmp), git_gate_plan=_git_gate_plan(tmp),
egress_plan=_egress_plan(tmp), egress_plan=_egress_plan(tmp),
@@ -141,10 +140,10 @@ class TestGitGatePrintParity(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-") self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
manifest = _INDEX.load_for_agent("demo") manifest = _manifest()
spec = _spec(_INDEX, self._tmp) spec = _spec(manifest, self._tmp)
self._docker_lines = _capture_print(_docker_plan(spec, manifest, self._tmp)) self._docker_lines = _capture_print(_docker_plan(spec, self._tmp))
self._smol_lines = _capture_print(_smolmachines_plan(spec, manifest, self._tmp)) self._smol_lines = _capture_print(_smolmachines_plan(spec, self._tmp))
def _git_gate_lines(self, lines: list[str]) -> list[str]: def _git_gate_lines(self, lines: list[str]) -> list[str]:
return [ln for ln in lines if "git gate" in ln] return [ln for ln in lines if "git gate" in ln]
@@ -171,10 +170,10 @@ class TestEgressPrintParity(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-") self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
manifest = _INDEX.load_for_agent("demo") manifest = _manifest()
spec = _spec(_INDEX, self._tmp) spec = _spec(manifest, self._tmp)
self._docker_lines = _capture_print(_docker_plan(spec, manifest, self._tmp)) self._docker_lines = _capture_print(_docker_plan(spec, self._tmp))
self._smol_lines = _capture_print(_smolmachines_plan(spec, manifest, self._tmp)) self._smol_lines = _capture_print(_smolmachines_plan(spec, self._tmp))
def _egress_section(self, lines: list[str]) -> list[str]: def _egress_section(self, lines: list[str]) -> list[str]:
"""Return lines from the egress label through the last route entry. """Return lines from the egress label through the last route entry.
+2 -2
View File
@@ -10,7 +10,7 @@ from bot_bottle.git_gate import (
GIT_GATE_HOSTNAME, GIT_GATE_HOSTNAME,
git_gate_render_gitconfig, git_gate_render_gitconfig,
) )
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import Manifest
from tests.fixtures import fixture_minimal, fixture_with_git from tests.fixtures import fixture_minimal, fixture_with_git
@@ -72,7 +72,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
def test_ip_upstream_emits_single_insteadof(self): def test_ip_upstream_emits_single_insteadof(self):
# In the new format the dict key is the repo name, not a host # In the new format the dict key is the repo name, not a host
# alias, so there is only one insteadOf rule — for the IP URL. # alias, so there is only one insteadOf rule — for the IP URL.
m = ManifestIndex.from_json_obj({ m = Manifest.from_json_obj({
"bottles": {"dev": {"git-gate": {"repos": { "bottles": {"dev": {"git-gate": {"repos": {
"bot-bottle": { "bot-bottle": {
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git", "url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
+3 -5
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 ManifestGitEntry, ManifestKeyConfig, ManifestIndex from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, Manifest
from bot_bottle.supervise import SupervisePlan from bot_bottle.supervise import SupervisePlan
@@ -110,7 +110,7 @@ def _plan(
bottle_json["git-gate"] = git_gate_json bottle_json["git-gate"] = git_gate_json
if supervise: if supervise:
bottle_json["supervise"] = True bottle_json["supervise"] = True
index = ManifestIndex.from_json_obj({ manifest = Manifest.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": { "agents": {
"demo": { "demo": {
@@ -120,9 +120,8 @@ def _plan(
}, },
}, },
}) })
manifest = index.load_for_agent("demo")
spec = BottleSpec( spec = BottleSpec(
manifest=index, manifest=manifest,
agent_name="demo", agent_name="demo",
copy_cwd=copy_cwd, copy_cwd=copy_cwd,
user_cwd=user_cwd, user_cwd=user_cwd,
@@ -136,7 +135,6 @@ def _plan(
) )
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
spec=spec, spec=spec,
manifest=manifest,
stage_dir=stage_dir or Path("/tmp/stage"), stage_dir=stage_dir or Path("/tmp/stage"),
slug="demo-abc12", slug="demo-abc12",
bundle_subnet="192.168.50.0/24", bundle_subnet="192.168.50.0/24",
+3 -10
View File
@@ -317,22 +317,15 @@ class TestToolConstants(unittest.TestCase):
def test_tools_tuple_matches_individual_constants(self): def test_tools_tuple_matches_individual_constants(self):
self.assertEqual( self.assertEqual(
( (
supervise.TOOL_ALLOW,
TOOL_CAPABILITY_BLOCK, TOOL_CAPABILITY_BLOCK,
supervise.TOOL_EGRESS_BLOCK,
supervise.TOOL_LIST_EGRESS_ROUTES, supervise.TOOL_LIST_EGRESS_ROUTES,
), ),
supervise.TOOLS, supervise.TOOLS,
) )
def test_component_map_has_egress_entries(self): def test_component_map_has_no_entries(self):
self.assertEqual( # egress-block removed in issue #198; capability-block never had one.
{ self.assertEqual({}, supervise.COMPONENT_FOR_TOOL)
supervise.TOOL_ALLOW: "egress",
supervise.TOOL_EGRESS_BLOCK: "egress",
},
supervise.COMPONENT_FOR_TOOL,
)
class _StubSupervise(supervise.Supervise): class _StubSupervise(supervise.Supervise):
+3 -19
View File
@@ -2,6 +2,9 @@
The curses TUI itself isn't exercised here — these tests cover the The curses TUI itself isn't exercised here — these tests cover the
discovery + approve/reject paths that the TUI's key handlers call into. discovery + approve/reject paths that the TUI's key handlers call into.
egress-block (add_route) was removed in issue #198; the TestEgressApplyWiring
class and all stubs for add_route have been dropped accordingly.
""" """
import os import os
@@ -9,7 +12,6 @@ import tempfile
import unittest import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from bot_bottle import supervise from bot_bottle import supervise
from bot_bottle.cli import supervise as supervise_cli from bot_bottle.cli import supervise as supervise_cli
@@ -31,8 +33,6 @@ FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal: def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal:
payloads = { payloads = {
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n", TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
supervise.TOOL_ALLOW: "routes:\n - host: example.com\n",
supervise.TOOL_EGRESS_BLOCK: "routes:\n - host: example.com\n",
} }
payload = payloads.get(tool, "") payload = payloads.get(tool, "")
return Proposal.new( return Proposal.new(
@@ -154,22 +154,6 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
supervise_cli.approve(qp) supervise_cli.approve(qp)
self.assertEqual([], read_audit_entries("egress", "dev")) self.assertEqual([], read_audit_entries("egress", "dev"))
def test_approve_egress_block_writes_audit_log(self):
qp = self._enqueue(tool=supervise.TOOL_EGRESS_BLOCK)
with patch(
"bot_bottle.cli.supervise.apply_routes_change",
return_value=("routes: []\n", "routes:\n - host: example.com\n"),
) as apply_routes_change:
supervise_cli.approve(qp)
apply_routes_change.assert_called_once_with(
"dev",
"routes:\n - host: example.com\n",
)
entries = read_audit_entries("egress", "dev")
self.assertEqual(1, len(entries))
self.assertEqual(STATUS_APPROVED, entries[0].operator_action)
self.assertEqual("needed for dev", entries[0].justification)
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase): # class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
# # DISABLED — capability_apply functionality is currently commented out. # # DISABLED — capability_apply functionality is currently commented out.
+5 -45
View File
@@ -54,19 +54,13 @@ class TestValidation(unittest.TestCase):
) )
def test_empty_proposed_file_rejected_for_tools_with_file_field(self): def test_empty_proposed_file_rejected_for_tools_with_file_field(self):
# egress-block has structured input (validated in
# _validate_and_bundle_egress_route, not here) and
# list-egress-routes takes no input. Only capability-block
# goes through `validate_proposed_file`.
with self.assertRaises(_RpcError): with self.assertRaises(_RpcError):
validate_proposed_file(_sv.TOOL_CAPABILITY_BLOCK, " \n\t") validate_proposed_file(_sv.TOOL_CAPABILITY_BLOCK, " \n\t")
def test_egress_routes_yaml_is_validated(self):
validate_proposed_file(
_sv.TOOL_ALLOW,
"routes:\n - host: example.com\n",
)
def test_invalid_egress_routes_yaml_rejected(self):
with self.assertRaises(_RpcError):
validate_proposed_file(_sv.TOOL_EGRESS_BLOCK, "routes: nope\n")
# --- JSON-RPC parsing ------------------------------------------------------ # --- JSON-RPC parsing ------------------------------------------------------
@@ -147,9 +141,7 @@ class TestHandleToolsList(unittest.TestCase):
names = [t["name"] for t in result["tools"]] # type: ignore[index] names = [t["name"] for t in result["tools"]] # type: ignore[index]
self.assertEqual( self.assertEqual(
sorted([ sorted([
_sv.TOOL_ALLOW,
_sv.TOOL_CAPABILITY_BLOCK, _sv.TOOL_CAPABILITY_BLOCK,
_sv.TOOL_EGRESS_BLOCK,
_sv.TOOL_LIST_EGRESS_ROUTES, _sv.TOOL_LIST_EGRESS_ROUTES,
]), ]),
sorted(names), sorted(names),
@@ -180,17 +172,6 @@ class TestHandleToolsList(unittest.TestCase):
# No `required` array because no inputs are required. # No `required` array because no inputs are required.
self.assertNotIn("required", schema) # type: ignore[operator] self.assertNotIn("required", schema) # type: ignore[operator]
def test_egress_tools_take_routes_yaml_and_justification(self):
for tool_name in (_sv.TOOL_ALLOW, _sv.TOOL_EGRESS_BLOCK):
with self.subTest(tool_name=tool_name):
tool = next(t for t in TOOL_DEFINITIONS if t["name"] == tool_name)
schema = tool["inputSchema"]
self.assertEqual("object", schema["type"]) # type: ignore[index]
self.assertEqual(
["routes_yaml", "justification"],
schema["required"], # type: ignore[index]
)
class TestHandleToolsCall(unittest.TestCase): class TestHandleToolsCall(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -239,26 +220,6 @@ class TestHandleToolsCall(unittest.TestCase):
self.assertIn("status: approved", text) self.assertIn("status: approved", text)
self.assertIn("notes: lgtm", text) self.assertIn("notes: lgtm", text)
def test_allow_round_trips_through_queue(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_APPROVED, notes="ok")
try:
result = handle_tools_call(
{
"name": _sv.TOOL_ALLOW,
"arguments": {
"routes_yaml": "routes:\n - host: example.com\n",
"justification": "need example.com",
},
},
self.config,
)
finally:
responder.join()
self.assertFalse(result["isError"]) # type: ignore[index]
text = result["content"][0]["text"] # type: ignore[index]
self.assertIn("status: approved", text)
self.assertIn("notes: ok", text)
def test_rejected_response_sets_isError(self): def test_rejected_response_sets_isError(self):
responder = self._respond_when_proposal_appears(_sv.STATUS_REJECTED, notes="nope") responder = self._respond_when_proposal_appears(_sv.STATUS_REJECTED, notes="nope")
try: try:
@@ -451,8 +412,7 @@ class TestHttpEndToEnd(unittest.TestCase):
self.assertEqual(1, result["id"]) self.assertEqual(1, result["id"])
names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index] names = [t["name"] for t in result["result"]["tools"]] # type: ignore[index]
self.assertIn(_sv.TOOL_CAPABILITY_BLOCK, names) self.assertIn(_sv.TOOL_CAPABILITY_BLOCK, names)
self.assertIn(_sv.TOOL_ALLOW, names) self.assertNotIn("egress-block", names)
self.assertIn(_sv.TOOL_EGRESS_BLOCK, names)
def test_unknown_method_returns_jsonrpc_error(self): def test_unknown_method_returns_jsonrpc_error(self):
result = self._post_jsonrpc( result = self._post_jsonrpc(