Remove the supervise flag; supervise every bottle
lint / lint (push) Successful in 2m2s
test / unit (pull_request) Successful in 46s
test / integration (pull_request) Successful in 22s

Issue #249: in practice the per-bottle `supervise` flag was never
turned off — all bottles should be supervised. Remove the manifest
flag and make the supervise sidecar unconditional, mirroring egress.

- Reject `supervise:` as a removed bottle key with a migration hint.
- Drop the `supervise` field from ManifestBottle and the extends merge.
- prepare_supervise always returns a SupervisePlan; the plan type is
  now non-optional and the per-backend `is None` guards are gone, so
  the supervise daemon, current-config mount, aliases, and MCP
  registration always render.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YcU7nerbg8cVj9R4EkpfLJ
This commit is contained in:
2026-06-23 18:18:29 -04:00
parent 31cde11b0d
commit bdca1c8bea
32 changed files with 170 additions and 239 deletions
+1 -2
View File
@@ -207,8 +207,7 @@ class AgentProvider(ABC):
) -> None: ) -> None:
"""Register the per-bottle supervise sidecar as an MCP server """Register the per-bottle supervise sidecar as an MCP server
in the provider's in-guest config. Called by the backend after in the provider's in-guest config. Called by the backend after
the supervise sidecar is reachable. No-op when the supervise sidecar is reachable."""
`plan.supervise_plan is None`."""
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None: def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
"""Install the egress MITM CA into the agent's trust store. """Install the egress MITM CA into the agent's trust store.
+3 -3
View File
@@ -102,7 +102,7 @@ class BottlePlan(ABC):
over a published host port).""" over a published host port)."""
return "git" return "git"
egress_plan: EgressPlan egress_plan: EgressPlan
supervise_plan: SupervisePlan | None supervise_plan: SupervisePlan
agent_provision: AgentProvisionPlan agent_provision: AgentProvisionPlan
@property @property
@@ -332,7 +332,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
) )
agent_provision_plan = merge_provision_env_vars(agent_provision_plan) agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan) egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
supervise_plan = prepare_supervise(manifest_bottle, slug) supervise_plan = prepare_supervise(slug)
git_gate_plan = prepare_git_gate(manifest_bottle, slug) git_gate_plan = prepare_git_gate(manifest_bottle, slug)
return self._resolve_plan( return self._resolve_plan(
@@ -405,7 +405,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan, egress_plan: EgressPlan,
git_gate_plan: GitGatePlan, git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None, supervise_plan: SupervisePlan,
stage_dir: Path) -> PlanT: stage_dir: Path) -> PlanT:
"""Backend-specific plan resolution: image/container names, """Backend-specific plan resolution: image/container names,
env-file, prompt-file, proxy plan, runtime detection. Called by env-file, prompt-file, proxy plan, runtime detection. Called by
+1 -3
View File
@@ -70,7 +70,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan, egress_plan: EgressPlan,
git_gate_plan: GitGatePlan, git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None, supervise_plan: SupervisePlan,
stage_dir: Path, stage_dir: Path,
) -> DockerBottlePlan: ) -> DockerBottlePlan:
return _resolve_plan.resolve_plan( return _resolve_plan.resolve_plan(
@@ -94,8 +94,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
"""Docker bottles reach the supervise sidecar via the """Docker bottles reach the supervise sidecar via the
compose-network alias `supervise:9100`. No per-bottle URL compose-network alias `supervise:9100`. No per-bottle URL
plumbing needed; the alias resolves inside the bridge.""" plumbing needed; the alias resolves inside the bridge."""
if plan.supervise_plan is None:
return ""
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/" return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
def prepare_cleanup(self) -> DockerBottleCleanupPlan: def prepare_cleanup(self) -> DockerBottleCleanupPlan:
+22 -34
View File
@@ -14,7 +14,7 @@ Conditional services follow the plan content:
- agent + sidecars bundle: always. - agent + sidecars bundle: always.
- git-gate: iff plan.git_gate_plan.upstreams. - git-gate: iff plan.git_gate_plan.upstreams.
- egress: iff plan.egress_plan.routes. - egress: iff plan.egress_plan.routes.
- supervise: iff plan.supervise_plan is not None. - supervise: always (every bottle is supervised, issue #249).
""" """
from __future__ import annotations from __future__ import annotations
@@ -119,13 +119,11 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
image, all daemons under a Python init supervisor. image, all daemons under a Python init supervisor.
Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env. Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env.
egress is always present; git-gate / supervise are conditional. egress and supervise are always present; git-gate is conditional.
""" """
daemons: list[str] = ["egress"] daemons: list[str] = ["egress", "supervise"]
if plan.git_gate_plan.upstreams: if plan.git_gate_plan.upstreams:
daemons.append("git-gate") daemons.append("git-gate")
if plan.supervise_plan is not None:
daemons.append("supervise")
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"] env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
volumes: list[dict[str, Any]] = [] volumes: list[dict[str, Any]] = []
@@ -160,24 +158,21 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
# --- supervise ---------------------------------------------------- # --- supervise ----------------------------------------------------
sp = plan.supervise_plan sp = plan.supervise_plan
if sp is not None: env += [
env += [ f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}", f"SUPERVISE_PORT={SUPERVISE_PORT}",
f"SUPERVISE_PORT={SUPERVISE_PORT}", ]
] volumes.append({
volumes.append({ "type": "bind",
"type": "bind", "source": str(sp.queue_dir),
"source": str(sp.queue_dir), "target": QUEUE_DIR_IN_CONTAINER,
"target": QUEUE_DIR_IN_CONTAINER, "read_only": False,
"read_only": False, })
})
internal_aliases = [EGRESS_HOSTNAME] internal_aliases = [EGRESS_HOSTNAME, SUPERVISE_HOSTNAME]
if gp.upstreams: if gp.upstreams:
internal_aliases.append(GIT_GATE_HOSTNAME) internal_aliases.append(GIT_GATE_HOSTNAME)
if sp is not None:
internal_aliases.append(SUPERVISE_HOSTNAME)
service: dict[str, Any] = { service: dict[str, Any] = {
"image": SIDECAR_BUNDLE_IMAGE, "image": SIDECAR_BUNDLE_IMAGE,
@@ -231,14 +226,10 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
if plan.use_runsc: if plan.use_runsc:
service["runtime"] = "runsc" service["runtime"] = "runsc"
volumes: list[dict[str, Any]] = [] service["volumes"] = [_bind(
if plan.supervise_plan is not None: plan.supervise_plan.current_config_dir,
volumes.append(_bind( CURRENT_CONFIG_DIR_IN_AGENT,
plan.supervise_plan.current_config_dir, )]
CURRENT_CONFIG_DIR_IN_AGENT,
))
if volumes:
service["volumes"] = volumes
# The init supervisor inside the bundle owns intra-bundle # The init supervisor inside the bundle owns intra-bundle
# daemon ordering, so the agent only waits for the bundle # daemon ordering, so the agent only waits for the bundle
@@ -254,12 +245,9 @@ def _agent_proxy_url(plan: DockerBottlePlan) -> str:
def _agent_no_proxy(plan: DockerBottlePlan) -> str: def _agent_no_proxy(plan: DockerBottlePlan) -> str:
"""NO_PROXY for the agent: loopback always; supervise hostname """NO_PROXY for the agent: loopback plus the supervise hostname
when the supervise sidecar is up (MCP long-poll must bypass (MCP long-poll must bypass the egress proxy)."""
the egress proxy).""" hosts = ["localhost", "127.0.0.1", SUPERVISE_HOSTNAME]
hosts = ["localhost", "127.0.0.1"]
if plan.supervise_plan is not None:
hosts.append(SUPERVISE_HOSTNAME)
return ",".join(hosts) return ",".join(hosts)
+4 -6
View File
@@ -130,12 +130,10 @@ def launch(
mitmproxy_ca_host_path=egress_ca_host, mitmproxy_ca_host_path=egress_ca_host,
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only, mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
) )
supervise_plan = plan.supervise_plan supervise_plan = dataclasses.replace(
if supervise_plan is not None: plan.supervise_plan,
supervise_plan = dataclasses.replace( internal_network=internal_network,
supervise_plan, )
internal_network=internal_network,
)
plan = dataclasses.replace( plan = dataclasses.replace(
plan, plan,
git_gate_plan=git_gate_plan, git_gate_plan=git_gate_plan,
+1 -1
View File
@@ -37,7 +37,7 @@ def resolve_plan(
resolved_env: ResolvedEnv, resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan, egress_plan: EgressPlan,
supervise_plan: SupervisePlan | None, supervise_plan: SupervisePlan,
git_gate_plan: GitGatePlan, git_gate_plan: GitGatePlan,
stage_dir: Path, stage_dir: Path,
) -> DockerBottlePlan: ) -> DockerBottlePlan:
@@ -52,7 +52,7 @@ class MacosContainerBottleBackend(
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan, egress_plan: EgressPlan,
git_gate_plan: GitGatePlan, git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None, supervise_plan: SupervisePlan,
stage_dir: Path, stage_dir: Path,
) -> MacosContainerBottlePlan: ) -> MacosContainerBottlePlan:
return _resolve_plan.resolve_plan( return _resolve_plan.resolve_plan(
+8 -14
View File
@@ -222,9 +222,7 @@ def _stamp_agent_urls(
sidecar_ip: str, sidecar_ip: str,
) -> MacosContainerBottlePlan: ) -> MacosContainerBottlePlan:
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}" proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
supervise_url = "" supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
if plan.supervise_plan is not None:
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
git_gate_url = "" git_gate_url = ""
if plan.git_gate_plan.upstreams: if plan.git_gate_plan.upstreams:
git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}" git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}"
@@ -341,11 +339,9 @@ def _sidecar_dns() -> str:
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]: def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
daemons = ["egress"] daemons = ["egress", "supervise"]
if plan.git_gate_plan.upstreams: if plan.git_gate_plan.upstreams:
daemons += ["git-gate", "git-http"] daemons += ["git-gate", "git-http"]
if plan.supervise_plan is not None:
daemons.append("supervise")
return tuple(daemons) return tuple(daemons)
@@ -355,12 +351,11 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
env.extend(sorted(plan.egress_plan.token_env_map.keys())) env.extend(sorted(plan.egress_plan.token_env_map.keys()))
if plan.git_gate_plan.upstreams: if plan.git_gate_plan.upstreams:
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}") env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
if plan.supervise_plan is not None: env += [
env += [ f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}", f"SUPERVISE_PORT={SUPERVISE_PORT}",
f"SUPERVISE_PORT={SUPERVISE_PORT}", ]
]
return tuple(env) return tuple(env)
@@ -383,8 +378,7 @@ def _sidecar_mounts(
)) ))
sp = plan.supervise_plan sp = plan.supervise_plan
if sp is not None: mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
return tuple(mounts) return tuple(mounts)
@@ -30,7 +30,7 @@ def resolve_plan(
resolved_env: ResolvedEnv, resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan, egress_plan: EgressPlan,
supervise_plan: SupervisePlan | None, supervise_plan: SupervisePlan,
git_gate_plan: GitGatePlan, git_gate_plan: GitGatePlan,
stage_dir: Path, stage_dir: Path,
) -> MacosContainerBottlePlan: ) -> MacosContainerBottlePlan:
+3 -5
View File
@@ -92,11 +92,9 @@ def prepare_egress(
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes) return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None: def prepare_supervise(slug: str) -> SupervisePlan:
"""Prepare the supervise sidecar state dir. Returns None when """Prepare the supervise sidecar state dir. Every bottle is
bottle.supervise is falsy.""" supervised (issue #249), so this always returns a plan."""
if not bottle.supervise:
return None
supervise_dir = supervise_state_dir(slug) supervise_dir = supervise_state_dir(slug)
supervise_dir.mkdir(parents=True, exist_ok=True) supervise_dir.mkdir(parents=True, exist_ok=True)
return Supervise().prepare(slug, supervise_dir) return Supervise().prepare(slug, supervise_dir)
+1 -1
View File
@@ -62,7 +62,7 @@ class SmolmachinesBottleBackend(
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan, egress_plan: EgressPlan,
git_gate_plan: GitGatePlan, git_gate_plan: GitGatePlan,
supervise_plan: SupervisePlan | None, supervise_plan: SupervisePlan,
stage_dir: Path, stage_dir: Path,
) -> SmolmachinesBottlePlan: ) -> SmolmachinesBottlePlan:
return _resolve_plan.resolve_plan( return _resolve_plan.resolve_plan(
+13 -20
View File
@@ -206,12 +206,10 @@ def _discover_urls(
) )
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}" agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
agent_supervise_url = "" supervise_host_port = _bundle.bundle_host_port(
if plan.supervise_plan is not None: plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
supervise_host_port = _bundle.bundle_host_port( )
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip, 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}" no_proxy = f"{existing_no_proxy},{loopback_ip}"
@@ -299,15 +297,14 @@ def _bundle_launch_spec(
"""Build a BundleLaunchSpec from the resolved inner Plans. """Build a BundleLaunchSpec from the resolved inner Plans.
Daemons in the CSV: Daemons in the CSV:
- egress is always present. - egress and supervise are always present.
- git-gate + git-http are conditional on plan.git_gate_plan.upstreams. - git-gate + git-http are conditional on plan.git_gate_plan.upstreams.
- supervise is conditional on plan.supervise_plan.
Env + volumes are the union of the sidecar daemons' needs, with Env + volumes are the union of the sidecar daemons' needs, with
daemon-private values only (HTTPS_PROXY is scoped to the daemon-private values only (HTTPS_PROXY is scoped to the
egress process by egress_entrypoint.sh see PRD 0024's bundle egress process by egress_entrypoint.sh see PRD 0024's bundle
bind-address PR).""" bind-address PR)."""
daemons: list[str] = ["egress"] daemons: list[str] = ["egress", "supervise"]
env: list[str] = [] env: list[str] = []
volumes: list[tuple[str, str, bool]] = [] volumes: list[tuple[str, str, bool]] = []
@@ -347,23 +344,19 @@ def _bundle_launch_spec(
# --- supervise -------------------------------------------- # --- supervise --------------------------------------------
sp = plan.supervise_plan sp = plan.supervise_plan
if sp is not None: env += [
daemons.append("supervise") f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
env += [ f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_PORT={SUPERVISE_PORT}",
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}", ]
f"SUPERVISE_PORT={SUPERVISE_PORT}", volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
]
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
# Container ports the agent reaches from the smolvm guest — # Container ports the agent reaches from the smolvm guest —
# published on host loopback so the guest can dial via TSI + # published on host loopback so the guest can dial via TSI +
# macOS networking. Egress is always the agent's HTTP/HTTPS proxy. # macOS networking. Egress is always the agent's HTTP/HTTPS proxy.
ports_to_publish: list[int] = [_EGRESS_PORT] ports_to_publish: list[int] = [_EGRESS_PORT, _SUPERVISE_PORT]
if gp.upstreams: if gp.upstreams:
ports_to_publish.append(_GIT_HTTP_PORT) ports_to_publish.append(_GIT_HTTP_PORT)
if sp is not None:
ports_to_publish.append(_SUPERVISE_PORT)
return _bundle.BundleLaunchSpec( return _bundle.BundleLaunchSpec(
slug=plan.slug, slug=plan.slug,
@@ -52,7 +52,7 @@ def resolve_plan(
resolved_env: ResolvedEnv, resolved_env: ResolvedEnv,
agent_provision_plan: AgentProvisionPlan, agent_provision_plan: AgentProvisionPlan,
egress_plan: EgressPlan, egress_plan: EgressPlan,
supervise_plan: SupervisePlan | None, supervise_plan: SupervisePlan,
git_gate_plan: GitGatePlan, git_gate_plan: GitGatePlan,
stage_dir: Path, stage_dir: Path,
) -> SmolmachinesBottlePlan: ) -> SmolmachinesBottlePlan:
@@ -68,7 +68,8 @@ class BundleLaunchSpec:
image: str = SIDECAR_BUNDLE_IMAGE image: str = SIDECAR_BUNDLE_IMAGE
# Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The # Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The
# supervisor inside the bundle reads it to skip # supervisor inside the bundle reads it to skip
# bottle-irrelevant daemons (e.g. supervise=False bottles). # bottle-irrelevant daemons (e.g. git-gate when a bottle
# declares no upstreams).
daemons_csv: str = "egress" daemons_csv: str = "egress"
# Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name # Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name
# form inherits the value from the docker-run subprocess env, # form inherits the value from the docker-run subprocess env,
@@ -291,8 +291,6 @@ class ClaudeAgentProvider(AgentProvider):
Failure is logged but not fatal the bottle still works without Failure is logged but not fatal the bottle still works without
the entry; the operator can register it manually.""" the entry; the operator can register it manually."""
if plan.supervise_plan is None:
return
info(f"registering supervise MCP server in agent claude config → {supervise_url}") info(f"registering supervise MCP server in agent claude config → {supervise_url}")
r = bottle.exec( r = bottle.exec(
f"claude mcp add --scope user --transport http " f"claude mcp add --scope user --transport http "
@@ -257,8 +257,6 @@ class CodexAgentProvider(AgentProvider):
Mirrors the Claude provider's `claude mcp add` flow — failure Mirrors the Claude provider's `claude mcp add` flow — failure
is logged but not fatal.""" is logged but not fatal."""
if plan.supervise_plan is None:
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 {_SUPERVISE_MCP_NAME} --url "
+8 -16
View File
@@ -19,7 +19,6 @@ Bottle schema (frontmatter):
repos: { <name>: <git-gate-entry>, ... } # optional repos: { <name>: <git-gate-entry>, ... } # optional
egress: { routes: [ <egress-route>, ... ] } egress: { routes: [ <egress-route>, ... ] }
# route keys: host, matches, auth, role, dlp # route keys: host, matches, auth, role, dlp
supervise: <bool> # optional
Agent schema (frontmatter): Agent schema (frontmatter):
bottle: <bottle-name> # required bottle: <bottle-name> # required
@@ -111,13 +110,6 @@ class ManifestBottle:
# identity without any git-gate.repos upstreams, and vice versa. # identity without any git-gate.repos upstreams, and vice versa.
git_user: ManifestGitUser = field(default_factory=ManifestGitUser) git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig) egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
# the launch step brings up a supervise sidecar that exposes MCP
# tools to the agent (egress-block, capability-block) plus mounts
# the current-config dir read-only into the agent at
# /etc/bot-bottle/current-config. False (the default) skips the
# sidecar and mount.
supervise: bool = False
@classmethod @classmethod
def from_dict(cls, name: str, raw: object) -> "ManifestBottle": def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
@@ -152,6 +144,13 @@ class ManifestBottle:
f"removed. Move it under 'git-gate.user'." f"removed. Move it under 'git-gate.user'."
) )
if "supervise" in d:
raise ManifestError(
f"bottle '{name}' has a 'supervise' field, which has been "
f"removed (issue #249). All bottles are now supervised; the "
f"flag was always-on in practice. Delete the field."
)
unknown = set(d.keys()) - BOTTLE_KEYS unknown = set(d.keys()) - BOTTLE_KEYS
if unknown: if unknown:
allowed = ", ".join(sorted(BOTTLE_KEYS)) allowed = ", ".join(sorted(BOTTLE_KEYS))
@@ -190,16 +189,9 @@ class ManifestBottle:
else ManifestEgressConfig() else ManifestEgressConfig()
) )
supervise_raw = d.get("supervise", False)
if not isinstance(supervise_raw, bool):
raise ManifestError(
f"bottle '{name}' supervise must be a boolean "
f"(was {type(supervise_raw).__name__})"
)
return cls( return cls(
env=env, agent_provider=agent_provider, git=git, env=env, agent_provider=agent_provider, git=git,
git_user=git_user, egress=egress, supervise=supervise_raw, git_user=git_user, egress=egress,
) )
-4
View File
@@ -134,9 +134,6 @@ def _merge_bottles(
if "agent_provider" in child_raw if "agent_provider" in child_raw
else parent.agent_provider else parent.agent_provider
) )
merged_supervise = (
child.supervise if "supervise" in child_raw else parent.supervise
)
validate_egress_routes(name, merged_egress.routes) validate_egress_routes(name, merged_egress.routes)
return ManifestBottle( return ManifestBottle(
@@ -145,7 +142,6 @@ def _merge_bottles(
git=merged_git, git=merged_git,
git_user=merged_git_user, git_user=merged_git_user,
egress=merged_egress, egress=merged_egress,
supervise=merged_supervise,
) )
+1 -1
View File
@@ -16,7 +16,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
# sets dies with a "did you mean" pointer: typos should not silently # sets dies with a "did you mean" pointer: typos should not silently
# ghost into an empty config. # ghost into an empty config.
BOTTLE_KEYS = frozenset( BOTTLE_KEYS = frozenset(
{"env", "extends", "agent_provider", "git-gate", "egress", "supervise"} {"env", "extends", "agent_provider", "git-gate", "egress"}
) )
AGENT_KEYS_REQUIRED = frozenset({"bottle"}) AGENT_KEYS_REQUIRED = frozenset({"bottle"})
AGENT_KEYS_OPTIONAL = frozenset({"skills", "git-gate"}) AGENT_KEYS_OPTIONAL = frozenset({"skills", "git-gate"})
@@ -27,14 +27,13 @@ from tests._docker import skip_unless_docker
def _manifest() -> ManifestIndex: def _manifest() -> ManifestIndex:
"""Bottle with supervise on so the bundle exercises egress + """Minimal bottle so the bundle exercises egress + supervise
supervise. Git is off because a meaningful git-gate test needs (every bottle is supervised, issue #249). Git is off because a
a real upstream and SSH keys out of scope for a bundle smoke.""" meaningful git-gate test needs a real upstream and SSH keys
out of scope for a bundle smoke."""
return ManifestIndex.from_json_obj({ return ManifestIndex.from_json_obj({
"bottles": { "bottles": {
"dev": { "dev": {},
"supervise": True,
},
}, },
"agents": { "agents": {
"demo": {"skills": [], "prompt": "", "bottle": "dev"}, "demo": {"skills": [], "prompt": "", "bottle": "dev"},
+27 -33
View File
@@ -40,13 +40,11 @@ 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(*, with_git: bool, with_egress: bool) -> ManifestIndex:
"""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."""
bottle: dict[str, object] = {} bottle: dict[str, object] = {}
if supervise:
bottle["supervise"] = True
if with_git: if with_git:
bottle["git-gate"] = {"repos": { bottle["git-gate"] = {"repos": {
"upstream": { "upstream": {
@@ -111,10 +109,11 @@ def _plan(
*, *,
with_git: bool = False, with_git: bool = False,
with_egress: bool = False, with_egress: bool = False,
supervise: bool = False,
) -> DockerBottlePlan: ) -> DockerBottlePlan:
"""Build a fully-resolved DockerBottlePlan. Toggles cover the """Build a fully-resolved DockerBottlePlan. Toggles cover the
matrix the renderer's conditional-service logic branches on.""" matrix the renderer's conditional-service logic branches on.
Every bottle is supervised (issue #249), so the supervise plan
is always present."""
upstreams: tuple[GitGateUpstream, ...] = () upstreams: tuple[GitGateUpstream, ...] = ()
if with_git: if with_git:
upstreams = (GitGateUpstream( upstreams = (GitGateUpstream(
@@ -136,7 +135,7 @@ def _plan(
roles=(), roles=(),
),) ),)
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress) index = _manifest(with_git=with_git, with_egress=with_egress)
spec = BottleSpec( spec = BottleSpec(
manifest=index, manifest=index,
agent_name="demo", agent_name="demo",
@@ -151,7 +150,7 @@ def _plan(
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"}, forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
git_gate_plan=_git_gate_plan(upstreams), git_gate_plan=_git_gate_plan(upstreams),
egress_plan=_egress_plan(routes), egress_plan=_egress_plan(routes),
supervise_plan=_supervise_plan() if supervise else None, supervise_plan=_supervise_plan(),
use_runsc=False, use_runsc=False,
agent_provision=AgentProvisionPlan( agent_provision=AgentProvisionPlan(
template="claude", template="claude",
@@ -220,10 +219,8 @@ class TestAgentAlwaysPresent(unittest.TestCase):
proxy = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")][0] proxy = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")][0]
self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy) self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy)
def test_agent_no_proxy_adds_supervise_when_enabled(self): def test_agent_no_proxy_includes_supervise(self):
s = bottle_plan_to_compose( s = bottle_plan_to_compose(_plan())["services"]["agent"]
_plan(supervise=True)
)["services"]["agent"]
no_proxy = [e for e in s["environment"] if e.startswith("NO_PROXY=")][0] no_proxy = [e for e in s["environment"] if e.startswith("NO_PROXY=")][0]
self.assertIn("supervise", no_proxy) self.assertIn("supervise", no_proxy)
@@ -259,22 +256,18 @@ class TestAgentAlwaysPresent(unittest.TestCase):
def test_agent_depends_only_on_sidecars(self): def test_agent_depends_only_on_sidecars(self):
# Bundle shape: the init supervisor owns intra-bundle daemon # Bundle shape: the init supervisor owns intra-bundle daemon
# ordering, so the agent waits on the bundle container alone. # ordering, so the agent waits on the bundle container alone.
for kwargs in [{}, {"with_git": True, "with_egress": True, "supervise": True}]: for kwargs in [{}, {"with_git": True, "with_egress": True}]:
with self.subTest(**kwargs): with self.subTest(**kwargs):
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"] s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
self.assertEqual(["sidecars"], s["depends_on"]) self.assertEqual(["sidecars"], s["depends_on"])
def test_agent_current_config_mount_only_with_supervise(self): def test_agent_current_config_always_mounted(self):
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"] # Every bottle is supervised (issue #249), so the read-only
# current-config mount is always present in the agent.
agent = bottle_plan_to_compose(_plan())["services"]["agent"]
self.assertTrue(any( self.assertTrue(any(
v["target"] == "/etc/bot-bottle/current-config" v["target"] == "/etc/bot-bottle/current-config"
for v in with_sv.get("volumes", []) for v in agent.get("volumes", [])
))
without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"]
# Either no volumes key at all, or no current-config target.
self.assertFalse(any(
v["target"] == "/etc/bot-bottle/current-config"
for v in without_sv.get("volumes", [])
)) ))
@@ -292,7 +285,7 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys())) self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
def test_emits_two_services_full_matrix(self): def test_emits_two_services_full_matrix(self):
spec = self._render(with_git=True, with_egress=True, supervise=True) spec = self._render(with_git=True, with_egress=True)
# Still two services — the bundle absorbs git-gate/egress/supervise. # Still two services — the bundle absorbs git-gate/egress/supervise.
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys())) self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
@@ -315,16 +308,16 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertIn("egress", aliases) self.assertIn("egress", aliases)
def test_internal_aliases_omit_inactive_sidecars(self): def test_internal_aliases_omit_inactive_sidecars(self):
# With no git-gate / supervise, those names are NOT aliased # With no git-gate, that name is NOT aliased — keeps the alias
# — keeps the alias list honest about what's actually # list honest about what's actually listening inside the bundle.
# listening inside the bundle. # supervise is always present (issue #249).
sc = self._render()["services"]["sidecars"] sc = self._render()["services"]["sidecars"]
aliases = set(sc["networks"]["internal"]["aliases"]) aliases = set(sc["networks"]["internal"]["aliases"])
self.assertNotIn("git-gate", aliases) self.assertNotIn("git-gate", aliases)
self.assertNotIn("supervise", aliases) self.assertIn("supervise", aliases)
def test_internal_aliases_include_active_sidecars(self): def test_internal_aliases_include_active_sidecars(self):
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] sc = self._render(with_git=True)["services"]["sidecars"]
aliases = set(sc["networks"]["internal"]["aliases"]) aliases = set(sc["networks"]["internal"]["aliases"])
self.assertIn("git-gate", aliases) self.assertIn("git-gate", aliases)
self.assertIn("supervise", aliases) self.assertIn("supervise", aliases)
@@ -336,10 +329,11 @@ class TestSidecarBundleShape(unittest.TestCase):
for line in sc["environment"] for line in sc["environment"]
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=") if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=")
} }
self.assertEqual({"egress"}, daemons) # egress + supervise are always present (issue #249).
self.assertEqual({"egress,supervise"}, daemons)
def test_daemons_csv_expands_with_optional_sidecars(self): def test_daemons_csv_expands_with_optional_sidecars(self):
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"] sc = self._render(with_git=True)["services"]["sidecars"]
for line in sc["environment"]: for line in sc["environment"]:
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS="): if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS="):
csv = line.split("=", 1)[1] csv = line.split("=", 1)[1]
@@ -347,7 +341,7 @@ class TestSidecarBundleShape(unittest.TestCase):
else: else:
self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env") self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env")
self.assertEqual( self.assertEqual(
["egress", "git-gate", "supervise"], ["egress", "supervise", "git-gate"],
csv.split(","), csv.split(","),
) )
@@ -376,7 +370,7 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertNotIn("EGRESS_TOKEN_0", env_strings) self.assertNotIn("EGRESS_TOKEN_0", env_strings)
def test_supervise_env_present_when_active(self): def test_supervise_env_present_when_active(self):
sc = self._render(supervise=True)["services"]["sidecars"] sc = self._render()["services"]["sidecars"]
env_strings = sc["environment"] env_strings = sc["environment"]
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings) self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings)) self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings))
@@ -388,7 +382,7 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets) self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
def test_volumes_union_full_matrix(self): def test_volumes_union_full_matrix(self):
sc = self._render(with_git=True, with_egress=True, supervise=True)[ sc = self._render(with_git=True, with_egress=True)[
"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)
@@ -403,7 +397,7 @@ class TestSidecarBundleShape(unittest.TestCase):
self.assertNotIn("extra_hosts", sc) self.assertNotIn("extra_hosts", sc)
def test_agent_depends_on_bundle_only(self): def test_agent_depends_on_bundle_only(self):
sc = self._render(with_git=True, with_egress=True, supervise=True)[ sc = self._render(with_git=True, with_egress=True)[
"services"]["agent"] "services"]["agent"]
self.assertEqual(["sidecars"], sc["depends_on"]) self.assertEqual(["sidecars"], sc["depends_on"])
+7 -19
View File
@@ -50,11 +50,8 @@ def _plan(
agent_prompt: str = "", agent_prompt: str = "",
skills: list[str] | None = None, skills: list[str] | None = None,
agent_provision: AgentProvisionPlan | None = None, agent_provision: AgentProvisionPlan | None = None,
supervise: bool = False,
) -> DockerBottlePlan: ) -> DockerBottlePlan:
bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore
if supervise:
bottle_json["supervise"] = True
index = ManifestIndex.from_json_obj({ index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": { "agents": {
@@ -70,13 +67,11 @@ def _plan(
manifest=index, agent_name="demo", manifest=index, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x", copy_cwd=False, user_cwd="/tmp/x",
) )
supervise_plan = None supervise_plan = SupervisePlan(
if supervise: slug="demo-abc12",
supervise_plan = SupervisePlan( queue_dir=Path("/tmp/queue"),
slug="demo-abc12", current_config_dir=Path("/tmp/current-config"),
queue_dir=Path("/tmp/queue"), )
current_config_dir=Path("/tmp/current-config"),
)
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=manifest, manifest=manifest,
@@ -314,17 +309,10 @@ class TestClaudeUiProvision(unittest.TestCase):
class TestClaudeSuperviseMcp(unittest.TestCase): class TestClaudeSuperviseMcp(unittest.TestCase):
def test_noop_when_supervise_disabled(self):
bottle = _make_bottle()
ClaudeAgentProvider().provision_supervise_mcp(
_plan(supervise=False), bottle, _URL,
)
bottle.exec.assert_not_called()
def test_runs_claude_mcp_add_as_node(self): def test_runs_claude_mcp_add_as_node(self):
bottle = _make_bottle() bottle = _make_bottle()
ClaudeAgentProvider().provision_supervise_mcp( ClaudeAgentProvider().provision_supervise_mcp(
_plan(supervise=True), bottle, _URL, _plan(), bottle, _URL,
) )
bottle.exec.assert_called_once() bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0] script = bottle.exec.call_args.args[0]
@@ -340,7 +328,7 @@ class TestClaudeSuperviseMcp(unittest.TestCase):
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"), exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
) )
ClaudeAgentProvider().provision_supervise_mcp( ClaudeAgentProvider().provision_supervise_mcp(
_plan(supervise=True), bottle, _URL, _plan(), bottle, _URL,
) )
+7 -19
View File
@@ -50,11 +50,8 @@ def _plan(
agent_prompt: str = "", agent_prompt: str = "",
skills: list[str] | None = None, skills: list[str] | None = None,
agent_provision: AgentProvisionPlan | None = None, agent_provision: AgentProvisionPlan | None = None,
supervise: bool = False,
) -> DockerBottlePlan: ) -> DockerBottlePlan:
bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore
if supervise:
bottle_json["supervise"] = True
index = ManifestIndex.from_json_obj({ index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": { "agents": {
@@ -70,13 +67,11 @@ def _plan(
manifest=index, agent_name="demo", manifest=index, agent_name="demo",
copy_cwd=False, user_cwd="/tmp/x", copy_cwd=False, user_cwd="/tmp/x",
) )
supervise_plan = None supervise_plan = SupervisePlan(
if supervise: slug="demo-abc12",
supervise_plan = SupervisePlan( queue_dir=Path("/tmp/queue"),
slug="demo-abc12", current_config_dir=Path("/tmp/current-config"),
queue_dir=Path("/tmp/queue"), )
current_config_dir=Path("/tmp/current-config"),
)
return DockerBottlePlan( return DockerBottlePlan(
spec=spec, spec=spec,
manifest=manifest, manifest=manifest,
@@ -277,17 +272,10 @@ class TestCodexProvision(unittest.TestCase):
class TestCodexSuperviseMcp(unittest.TestCase): class TestCodexSuperviseMcp(unittest.TestCase):
def test_noop_when_supervise_disabled(self):
bottle = _make_bottle()
CodexAgentProvider().provision_supervise_mcp(
_plan(supervise=False), bottle, _URL,
)
bottle.exec.assert_not_called()
def test_runs_codex_mcp_add_as_node(self): def test_runs_codex_mcp_add_as_node(self):
bottle = _make_bottle() bottle = _make_bottle()
CodexAgentProvider().provision_supervise_mcp( CodexAgentProvider().provision_supervise_mcp(
_plan(supervise=True), bottle, _URL, _plan(), bottle, _URL,
) )
bottle.exec.assert_called_once() bottle.exec.assert_called_once()
script = bottle.exec.call_args.args[0] script = bottle.exec.call_args.args[0]
@@ -302,7 +290,7 @@ class TestCodexSuperviseMcp(unittest.TestCase):
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"), exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
) )
CodexAgentProvider().provision_supervise_mcp( CodexAgentProvider().provision_supervise_mcp(
_plan(supervise=True), bottle, _URL, _plan(), bottle, _URL,
) )
+6 -1
View File
@@ -16,6 +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.supervise import SupervisePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
@@ -77,7 +78,11 @@ def _plan(
routes=(), routes=(),
token_env_map={}, token_env_map={},
), ),
supervise_plan=None, supervise_plan=SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
),
use_runsc=False, use_runsc=False,
agent_provision=agent_provision or AgentProvisionPlan( agent_provision=agent_provision or AgentProvisionPlan(
template="pi", command="pi", prompt_mode="append_system_prompt", template="pi", command="pi", prompt_mode="append_system_prompt",
@@ -16,6 +16,7 @@ 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.supervise import SupervisePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
@@ -55,7 +56,11 @@ def _plan(tmp: str) -> DockerBottlePlan:
routes=(), routes=(),
token_env_map={}, token_env_map={},
), ),
supervise_plan=None, supervise_plan=SupervisePlan(
slug=_SLUG,
queue_dir=stage / "supervise" / "queue",
current_config_dir=stage / "supervise" / "current-config",
),
agent_provision=AgentProvisionPlan( agent_provision=AgentProvisionPlan(
template="claude", template="claude",
command="claude", command="claude",
+6 -1
View File
@@ -21,6 +21,7 @@ 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.supervise import SupervisePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
_INDEX = ManifestIndex.from_json_obj({ _INDEX = ManifestIndex.from_json_obj({
@@ -56,7 +57,11 @@ def _plan(tmp: str) -> DockerBottlePlan:
routes=(), routes=(),
token_env_map={}, token_env_map={},
), ),
supervise_plan=None, supervise_plan=SupervisePlan(
slug="test-teardown-00001",
queue_dir=stage / "supervise" / "queue",
current_config_dir=stage / "supervise" / "current-config",
),
agent_provision=AgentProvisionPlan( agent_provision=AgentProvisionPlan(
template="claude", template="claude",
command="claude", command="claude",
+6 -1
View File
@@ -21,6 +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.supervise import SupervisePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
@@ -79,7 +80,11 @@ def _plan(*, git_user: dict | None = None, # type: ignore
routes=(), routes=(),
token_env_map={}, token_env_map={},
), ),
supervise_plan=None, supervise_plan=SupervisePlan(
slug="demo-abc12",
queue_dir=Path("/tmp/queue"),
current_config_dir=Path("/tmp/current-config"),
),
use_runsc=False, use_runsc=False,
agent_provision=AgentProvisionPlan( agent_provision=AgentProvisionPlan(
template="claude", template="claude",
+10 -9
View File
@@ -15,6 +15,7 @@ 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.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.supervise import SupervisePlan
from bot_bottle.manifest import ManifestIndex from bot_bottle.manifest import ManifestIndex
_MANIFEST = ManifestIndex.from_json_obj({ _MANIFEST = ManifestIndex.from_json_obj({
@@ -27,7 +28,6 @@ def _plan(
*, *,
stage_dir: Path, stage_dir: Path,
git: bool = False, git: bool = False,
supervise: bool = False,
agent_git_gate_url: str = "", agent_git_gate_url: str = "",
agent_supervise_url: str = "", agent_supervise_url: str = "",
) -> MacosContainerBottlePlan: ) -> MacosContainerBottlePlan:
@@ -67,10 +67,8 @@ def _plan(
) )
else: else:
git_gate_plan = SimpleNamespace(upstreams=()) git_gate_plan = SimpleNamespace(upstreams=())
supervise_plan = ( # Every bottle is supervised (issue #249).
SimpleNamespace(queue_dir=Path("/state/supervise/queue")) supervise_plan = SimpleNamespace(queue_dir=Path("/state/supervise/queue"))
if supervise else None
)
agent_provision = SimpleNamespace( agent_provision = SimpleNamespace(
guest_env={"LITERAL": "value"}, guest_env={"LITERAL": "value"},
provisioned_env={"CODEX_HOME": "/run/codex-home"}, provisioned_env={"CODEX_HOME": "/run/codex-home"},
@@ -101,7 +99,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
self._tmp.cleanup() self._tmp.cleanup()
def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self): def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self):
plan = _plan(stage_dir=self.stage_dir, supervise=True) plan = _plan(stage_dir=self.stage_dir)
with patch.object(launch.os, "environ", { with patch.object(launch.os, "environ", {
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9", "BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
}): }):
@@ -172,7 +170,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
def test_git_gate_daemons_are_ready_gated(self): def test_git_gate_daemons_are_ready_gated(self):
plan = _plan(stage_dir=self.stage_dir, git=True) plan = _plan(stage_dir=self.stage_dir, git=True)
self.assertEqual( self.assertEqual(
("egress", "git-gate", "git-http"), ("egress", "supervise", "git-gate", "git-http"),
launch._sidecar_daemons(plan), launch._sidecar_daemons(plan),
) )
self.assertIn( self.assertIn(
@@ -181,7 +179,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
) )
def test_stamp_agent_urls_includes_git_http_when_git_gate_exists(self): def test_stamp_agent_urls_includes_git_http_when_git_gate_exists(self):
plan = _plan(stage_dir=self.stage_dir, git=True, supervise=True) plan = _plan(stage_dir=self.stage_dir, git=True)
with patch.object(launch.dataclasses, "replace") as replace: with patch.object(launch.dataclasses, "replace") as replace:
launch._stamp_agent_urls(plan, "192.168.128.2") launch._stamp_agent_urls(plan, "192.168.128.2")
replace.assert_called_once_with( replace.assert_called_once_with(
@@ -272,7 +270,10 @@ def _build_plan(stage_dir: Path) -> MacosContainerBottlePlan:
stage_dir=stage_dir, stage_dir=stage_dir,
git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())), git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())),
egress_plan=cast(EgressPlan, SimpleNamespace()), egress_plan=cast(EgressPlan, SimpleNamespace()),
supervise_plan=None, supervise_plan=cast(
SupervisePlan,
SimpleNamespace(queue_dir=Path("/state/supervise/queue")),
),
agent_provision=AgentProvisionPlan( agent_provision=AgentProvisionPlan(
template="claude", template="claude",
command="claude", command="claude",
@@ -116,7 +116,6 @@ class TestAgentGitUserOverlay(unittest.TestCase):
idx = ManifestIndex.from_json_obj({ idx = ManifestIndex.from_json_obj({
"bottles": {"dev": { "bottles": {"dev": {
"env": {"FOO": "bar"}, "env": {"FOO": "bar"},
"supervise": True,
"git-gate": {"user": {"name": "B"}}, "git-gate": {"user": {"name": "B"}},
}}, }},
"agents": {"impl": { "agents": {"impl": {
@@ -127,7 +126,6 @@ class TestAgentGitUserOverlay(unittest.TestCase):
b = idx.load_for_agent("impl").bottle b = idx.load_for_agent("impl").bottle
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)
class TestAgentGitUserRejections(unittest.TestCase): class TestAgentGitUserRejections(unittest.TestCase):
+1 -16
View File
@@ -42,38 +42,26 @@ class TestExtendsBasic(unittest.TestCase):
# same way they did before the resolver landed. # same way they did before the resolver landed.
m = _build(dev={ m = _build(dev={
"env": {"FOO": "bar"}, "env": {"FOO": "bar"},
"supervise": True,
}) })
b = m.bottles["dev"] b = m.bottles["dev"]
self.assertEqual({"FOO": "bar"}, dict(b.env)) self.assertEqual({"FOO": "bar"}, dict(b.env))
self.assertTrue(b.supervise)
def test_child_inherits_parent_fields_unchanged(self): def test_child_inherits_parent_fields_unchanged(self):
m = _build( m = _build(
base={ base={
"env": {"BASE": "1"}, "env": {"BASE": "1"},
"supervise": True,
}, },
child={"extends": "base"}, child={"extends": "base"},
) )
c = m.bottles["child"] c = m.bottles["child"]
self.assertEqual({"BASE": "1"}, dict(c.env)) self.assertEqual({"BASE": "1"}, dict(c.env))
self.assertTrue(c.supervise)
def test_child_overrides_supervise_scalar(self):
m = _build(
base={"supervise": True},
off={"extends": "base", "supervise": False},
)
self.assertTrue(m.bottles["base"].supervise)
self.assertFalse(m.bottles["off"].supervise)
def test_parent_resolved_once_for_multiple_children(self): def test_parent_resolved_once_for_multiple_children(self):
# Two children sharing one parent: both inherit; the parent # Two children sharing one parent: both inherit; the parent
# is resolved once + cached. (Cache behavior is internal; we # is resolved once + cached. (Cache behavior is internal; we
# observe correctness on both children.) # observe correctness on both children.)
m = _build( m = _build(
base={"env": {"BASE": "1"}, "supervise": True}, base={"env": {"BASE": "1"}},
a={"extends": "base", "env": {"A": "1"}}, a={"extends": "base", "env": {"A": "1"}},
b={"extends": "base", "env": {"B": "1"}}, b={"extends": "base", "env": {"B": "1"}},
) )
@@ -366,7 +354,6 @@ class TestExtendsChain(unittest.TestCase):
m = _build( m = _build(
grandparent={ grandparent={
"env": {"GP": "1"}, "env": {"GP": "1"},
"supervise": True,
}, },
parent={ parent={
"extends": "grandparent", "extends": "grandparent",
@@ -381,8 +368,6 @@ class TestExtendsChain(unittest.TestCase):
{"GP": "1", "P": "1", "C": "1"}, {"GP": "1", "P": "1", "C": "1"},
dict(m.bottles["child"].env), dict(m.bottles["child"].env),
) )
# supervise threads through unchanged.
self.assertTrue(m.bottles["child"].supervise)
def test_intermediate_can_override(self): def test_intermediate_can_override(self):
m = _build( m = _build(
+12 -2
View File
@@ -19,6 +19,7 @@ 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.supervise import SupervisePlan
from bot_bottle.manifest import Manifest, ManifestIndex from bot_bottle.manifest import Manifest, ManifestIndex
@@ -77,6 +78,15 @@ def _egress_plan(tmp: str) -> EgressPlan:
) )
def _supervise_plan(tmp: str) -> SupervisePlan:
stage = Path(tmp)
return SupervisePlan(
slug="test-00001",
queue_dir=stage / "supervise" / "queue",
current_config_dir=stage / "supervise" / "current-config",
)
def _agent_provision(tmp: str) -> AgentProvisionPlan: def _agent_provision(tmp: str) -> AgentProvisionPlan:
return AgentProvisionPlan( return AgentProvisionPlan(
template="claude", template="claude",
@@ -99,7 +109,7 @@ def _docker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> DockerBottle
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),
supervise_plan=None, supervise_plan=_supervise_plan(tmp),
agent_provision=_agent_provision(tmp), agent_provision=_agent_provision(tmp),
slug="test-00001", slug="test-00001",
forwarded_env={}, forwarded_env={},
@@ -115,7 +125,7 @@ def _smolmachines_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> Smolma
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),
supervise_plan=None, supervise_plan=_supervise_plan(tmp),
agent_provision=_agent_provision(tmp), agent_provision=_agent_provision(tmp),
slug="test-00001", slug="test-00001",
bundle_subnet="10.99.0.0/24", bundle_subnet="10.99.0.0/24",
+6 -11
View File
@@ -86,7 +86,6 @@ def _plan(
stage_dir: Path | None = None, stage_dir: Path | None = None,
egress_routes: tuple[EgressRoute, ...] = (), egress_routes: tuple[EgressRoute, ...] = (),
egress_ca_path: Path = Path(), egress_ca_path: Path = Path(),
supervise: bool = False,
bundle_ip: str = "192.168.50.2", bundle_ip: str = "192.168.50.2",
agent_git_gate_host: str = "127.0.0.1:55555", agent_git_gate_host: str = "127.0.0.1:55555",
agent_supervise_url: str = "http://127.0.0.1:55556/", agent_supervise_url: str = "http://127.0.0.1:55556/",
@@ -108,8 +107,6 @@ def _plan(
git_gate_json["user"] = git_user git_gate_json["user"] = git_user
if git_gate_json: if git_gate_json:
bottle_json["git-gate"] = git_gate_json bottle_json["git-gate"] = git_gate_json
if supervise:
bottle_json["supervise"] = True
index = ManifestIndex.from_json_obj({ index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": { "agents": {
@@ -127,13 +124,11 @@ def _plan(
copy_cwd=copy_cwd, copy_cwd=copy_cwd,
user_cwd=user_cwd, user_cwd=user_cwd,
) )
supervise_plan = None supervise_plan = SupervisePlan(
if supervise: slug="demo-abc12",
supervise_plan = SupervisePlan( queue_dir=Path("/tmp/queue"),
slug="demo-abc12", current_config_dir=Path("/tmp/current-config"),
queue_dir=Path("/tmp/queue"), )
current_config_dir=Path("/tmp/current-config"),
)
return SmolmachinesBottlePlan( return SmolmachinesBottlePlan(
spec=spec, spec=spec,
manifest=manifest, manifest=manifest,
@@ -405,7 +400,7 @@ class TestBundleLaunchSpec(unittest.TestCase):
spec = _bundle_launch_spec(plan, "net", "127.0.0.16") spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
self.assertEqual( self.assertEqual(
"egress,git-gate,git-http", "egress,supervise,git-gate,git-http",
spec.daemons_csv, spec.daemons_csv,
) )
self.assertIn(9420, spec.ports_to_publish) self.assertIn(9420, spec.ports_to_publish)