Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e823d2aff | |||
| 5498f20547 | |||
| 2e467d236a | |||
| d123b99347 | |||
| f7f9892b53 | |||
| d923871fd2 | |||
| 7350494944 | |||
| 4abad499b6 |
@@ -5,7 +5,7 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://github.com/PyCQA/pylint)
|
[](https://github.com/PyCQA/pylint)
|
||||||
[](https://github.com/microsoft/pyright)
|
[](https://github.com/microsoft/pyright)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -526,11 +534,6 @@ from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-i
|
|||||||
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
|
|
||||||
# Freezer is imported after the backend classes for the same reason:
|
|
||||||
# Freezer.commit_slug constructs ActiveAgent, which must be fully
|
|
||||||
# defined first.
|
|
||||||
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
|
|
||||||
|
|
||||||
|
|
||||||
# The dict is heterogeneous: each value is a BottleBackend specialized
|
# The dict is heterogeneous: each value is a BottleBackend specialized
|
||||||
# over its own plan type. Concrete plan types are erased here because
|
# over its own plan type. Concrete plan types are erased here because
|
||||||
@@ -618,12 +621,9 @@ __all__ = [
|
|||||||
"BottleCleanupPlan",
|
"BottleCleanupPlan",
|
||||||
"BottlePlan",
|
"BottlePlan",
|
||||||
"BottleSpec",
|
"BottleSpec",
|
||||||
"CommitCancelled",
|
|
||||||
"ExecResult",
|
"ExecResult",
|
||||||
"Freezer",
|
|
||||||
"enumerate_active_agents",
|
"enumerate_active_agents",
|
||||||
"get_bottle_backend",
|
"get_bottle_backend",
|
||||||
"get_freezer",
|
|
||||||
"has_backend",
|
"has_backend",
|
||||||
"known_backend_names",
|
"known_backend_names",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
raise EgressApplyError(
|
||||||
capture_output=True, text=True, check=False, env=os.environ,
|
f"proposed routes.yaml is not valid: {e}"
|
||||||
)
|
) from e
|
||||||
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(
|
|
||||||
f"could not reload egress bundle {container}: "
|
|
||||||
f"{last_error or 'docker kill failed'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
applicator = DockerEgressApplicator()
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DockerEgressApplicator",
|
|
||||||
"EgressApplyError",
|
"EgressApplyError",
|
||||||
"applicator",
|
|
||||||
"fetch_current_routes",
|
"fetch_current_routes",
|
||||||
|
"validate_routes_content",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
"""DockerFreezer — snapshot a Docker bottle via `docker commit`."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ..freeze import Freezer
|
|
||||||
from .util import commit_container
|
|
||||||
from ...log import info
|
|
||||||
|
|
||||||
|
|
||||||
class DockerFreezer(Freezer):
|
|
||||||
"""Freezes a Docker bottle by running `docker commit`."""
|
|
||||||
|
|
||||||
backend_name = "docker"
|
|
||||||
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
container = f"bot-bottle-{agent.slug}"
|
|
||||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
|
||||||
commit_container(container, image_tag)
|
|
||||||
return image_tag
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
info(f"to export for migration: docker save {image_ref} -o {slug}.tar")
|
|
||||||
@@ -47,7 +47,6 @@ from ...bottle_state import (
|
|||||||
bottle_state_dir,
|
bottle_state_dir,
|
||||||
egress_state_dir,
|
egress_state_dir,
|
||||||
git_gate_state_dir,
|
git_gate_state_dir,
|
||||||
read_committed_image,
|
|
||||||
)
|
)
|
||||||
from .compose import (
|
from .compose import (
|
||||||
bottle_plan_to_compose,
|
bottle_plan_to_compose,
|
||||||
@@ -76,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:
|
||||||
@@ -92,22 +91,12 @@ def launch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: agent image. Use a committed snapshot when one exists
|
# Step 1: agent image build. Sidecar images get built lazily by
|
||||||
# and is present in the local daemon; otherwise build from the
|
# `docker compose up` via the renderer's `build:` directives.
|
||||||
# Dockerfile. Sidecar images get built lazily by `docker compose
|
docker_mod.build_image(
|
||||||
# up` via the renderer's `build:` directives.
|
plan.image, _REPO_DIR,
|
||||||
committed = read_committed_image(plan.slug)
|
dockerfile=plan.dockerfile_path,
|
||||||
if committed and docker_mod.image_exists(committed):
|
)
|
||||||
info(f"using committed image {committed!r}")
|
|
||||||
plan = dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
docker_mod.build_image(
|
|
||||||
plan.image, _REPO_DIR,
|
|
||||||
dockerfile=plan.dockerfile_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal_network = network_mod.network_name_for_slug(plan.slug)
|
internal_network = network_mod.network_name_for_slug(plan.slug)
|
||||||
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
||||||
@@ -187,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),
|
||||||
|
|||||||
@@ -152,21 +152,6 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|||||||
# )
|
# )
|
||||||
|
|
||||||
|
|
||||||
def commit_container(container_name: str, image_tag: str) -> None:
|
|
||||||
"""Run `docker commit <container_name> <image_tag>` to snapshot the
|
|
||||||
running container's filesystem state as a local Docker image."""
|
|
||||||
result = subprocess.run(
|
|
||||||
["docker", "commit", container_name, image_tag],
|
|
||||||
capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"docker commit {container_name!r} → {image_tag!r} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
info(f"committed {container_name!r} → {image_tag!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def image_id(ref: str) -> str:
|
def image_id(ref: str) -> str:
|
||||||
"""Return the content-addressed image ID (e.g.
|
"""Return the content-addressed image ID (e.g.
|
||||||
`sha256:abcd...`) for `ref`. The smolmachines backend keys its
|
`sha256:abcd...`) for `ref`. The smolmachines backend keys its
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
"""Freezer — snapshot a running bottle to a resumable artifact.
|
|
||||||
|
|
||||||
Follows the same pattern as BottleBackend: a shared base class with
|
|
||||||
common post-freeze steps (write committed-image path, mark preserved,
|
|
||||||
print resume hint) and backend-specific subclasses in their respective
|
|
||||||
backend directories.
|
|
||||||
|
|
||||||
Entry points:
|
|
||||||
Freezer.commit(agent) — freeze by ActiveAgent
|
|
||||||
Freezer.commit_slug(slug) — convenience wrapper for cmd_commit
|
|
||||||
get_freezer(backend_name) — factory
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
from . import ActiveAgent
|
|
||||||
from ..bottle_state import mark_preserved, write_committed_image
|
|
||||||
from ..log import die, info
|
|
||||||
|
|
||||||
|
|
||||||
class CommitCancelled(Exception):
|
|
||||||
"""Raised by Freezer._freeze when the user declines a confirmation prompt."""
|
|
||||||
|
|
||||||
|
|
||||||
class Freezer(ABC):
|
|
||||||
"""Freezes a running bottle to a resumable artifact.
|
|
||||||
|
|
||||||
The base class owns the shared post-commit steps:
|
|
||||||
- write_committed_image — records the artifact path in per-bottle state
|
|
||||||
- mark_preserved — prevents teardown from removing the state dir
|
|
||||||
- resume hint — printed to stderr after the snapshot
|
|
||||||
|
|
||||||
Subclasses implement _freeze with the backend-specific snapshot
|
|
||||||
operation and optionally override _export_hint for migration hints.
|
|
||||||
"""
|
|
||||||
|
|
||||||
backend_name: str
|
|
||||||
|
|
||||||
def commit(self, agent: ActiveAgent) -> None:
|
|
||||||
"""Freeze the bottle for `agent` to a resumable artifact.
|
|
||||||
|
|
||||||
Calls _freeze for the backend-specific snapshot, then writes the
|
|
||||||
committed image reference to per-bottle state and marks the bottle
|
|
||||||
preserved so the next `./cli.py resume` boots from the snapshot.
|
|
||||||
|
|
||||||
Raises CommitCancelled if the user declines an interactive
|
|
||||||
confirmation prompt (e.g. the macos-container stop prompt).
|
|
||||||
"""
|
|
||||||
image_ref = self._freeze(agent)
|
|
||||||
write_committed_image(agent.slug, image_ref)
|
|
||||||
mark_preserved(agent.slug)
|
|
||||||
info(f"to resume from this snapshot: ./cli.py resume {agent.slug}")
|
|
||||||
self._export_hint(agent.slug, image_ref)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
"""Backend-specific snapshot. Returns the image tag or artifact path
|
|
||||||
stored by write_committed_image. Raises CommitCancelled if the user
|
|
||||||
declines a stop-confirmation prompt."""
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
"""Optionally print an export-for-migration hint after committing.
|
|
||||||
Overridden by backends that provide a meaningful export command."""
|
|
||||||
|
|
||||||
def commit_slug(self, slug: str) -> None:
|
|
||||||
"""Convenience entry for cmd_commit when only a slug is available."""
|
|
||||||
from ..bottle_state import read_metadata
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
agent = ActiveAgent(
|
|
||||||
backend_name=self.backend_name,
|
|
||||||
slug=slug,
|
|
||||||
agent_name=metadata.agent_name if metadata else "",
|
|
||||||
started_at=metadata.started_at if metadata else "",
|
|
||||||
services=(),
|
|
||||||
)
|
|
||||||
self.commit(agent)
|
|
||||||
|
|
||||||
|
|
||||||
def get_freezer(backend_name: str) -> Freezer:
|
|
||||||
"""Return the Freezer for the named backend.
|
|
||||||
|
|
||||||
backend_name "" is treated as "docker" for backward compatibility
|
|
||||||
with state dirs written before the backend field was added."""
|
|
||||||
resolved = backend_name or "docker"
|
|
||||||
if resolved == "docker":
|
|
||||||
from .docker.freezer import DockerFreezer
|
|
||||||
return DockerFreezer()
|
|
||||||
if resolved == "macos-container":
|
|
||||||
from .macos_container.freezer import MacosContainerFreezer
|
|
||||||
return MacosContainerFreezer()
|
|
||||||
if resolved == "smolmachines":
|
|
||||||
from .smolmachines.freezer import SmolmachinesFreezer
|
|
||||||
return SmolmachinesFreezer()
|
|
||||||
die(
|
|
||||||
f"commit is only supported for docker, macos-container, and "
|
|
||||||
f"smolmachines; backend {backend_name!r} has no freezer"
|
|
||||||
)
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"]
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""MacosContainerFreezer — snapshot a macOS container bottle.
|
|
||||||
|
|
||||||
Apple Container removes containers when they stop, making stop-then-export
|
|
||||||
impossible. Instead, commit_container execs into the running container and
|
|
||||||
streams the root filesystem via tar. The bottle continues running after commit.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ..freeze import Freezer
|
|
||||||
from .util import commit_container
|
|
||||||
from ...log import info
|
|
||||||
|
|
||||||
|
|
||||||
class MacosContainerFreezer(Freezer):
|
|
||||||
"""Freezes a macOS-container bottle via exec-tar + image rebuild."""
|
|
||||||
|
|
||||||
backend_name = "macos-container"
|
|
||||||
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
container = f"bot-bottle-{agent.slug}"
|
|
||||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
|
||||||
commit_container(container, image_tag)
|
|
||||||
return image_tag
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
info(
|
|
||||||
f"to export for migration: "
|
|
||||||
f"container image save {image_ref} -o {slug}.tar"
|
|
||||||
)
|
|
||||||
@@ -12,16 +12,13 @@ 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
|
||||||
from typing import Callable, Generator
|
from typing import Callable, Generator
|
||||||
|
|
||||||
from ...bottle_state import (
|
from ...bottle_state import egress_state_dir, git_gate_state_dir
|
||||||
egress_state_dir,
|
|
||||||
git_gate_state_dir,
|
|
||||||
read_committed_image,
|
|
||||||
)
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
|
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
|
||||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||||
from ...log import die, info, warn
|
from ...log import die, info, warn
|
||||||
@@ -71,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:
|
||||||
@@ -87,7 +84,7 @@ def launch(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
plan = _mint_certs(plan)
|
plan = _mint_certs(plan)
|
||||||
plan = _build_images(plan)
|
_build_images(plan)
|
||||||
|
|
||||||
internal_network = internal_network_name(plan.slug)
|
internal_network = internal_network_name(plan.slug)
|
||||||
egress_network = egress_network_name(plan.slug)
|
egress_network = egress_network_name(plan.slug)
|
||||||
@@ -115,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,
|
||||||
)
|
)
|
||||||
@@ -138,28 +135,17 @@ def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
|||||||
return dataclasses.replace(plan, egress_plan=egress_plan)
|
return dataclasses.replace(plan, egress_plan=egress_plan)
|
||||||
|
|
||||||
|
|
||||||
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
def _build_images(plan: MacosContainerBottlePlan) -> None:
|
||||||
container_mod.build_image(
|
container_mod.build_image(
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
SIDECAR_BUNDLE_IMAGE,
|
||||||
_REPO_DIR,
|
_REPO_DIR,
|
||||||
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
||||||
)
|
)
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed and container_mod.image_exists(committed):
|
|
||||||
info(f"using committed image {committed!r}")
|
|
||||||
return dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_provision=dataclasses.replace(
|
|
||||||
plan.agent_provision,
|
|
||||||
image=committed,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
container_mod.build_image(
|
container_mod.build_image(
|
||||||
plan.image,
|
plan.image,
|
||||||
_REPO_DIR,
|
_REPO_DIR,
|
||||||
dockerfile=plan.dockerfile_path,
|
dockerfile=plan.dockerfile_path,
|
||||||
)
|
)
|
||||||
return plan
|
|
||||||
|
|
||||||
|
|
||||||
def _create_networks(
|
def _create_networks(
|
||||||
@@ -328,6 +314,7 @@ def _agent_run_argv(
|
|||||||
"container", "run",
|
"container", "run",
|
||||||
"--name", plan.container_name,
|
"--name", plan.container_name,
|
||||||
"--detach",
|
"--detach",
|
||||||
|
"--rm",
|
||||||
"--network", internal_network,
|
"--network", internal_network,
|
||||||
]
|
]
|
||||||
for entry in _agent_env_entries(plan, sidecar_ip):
|
for entry in _agent_env_entries(plan, sidecar_ip):
|
||||||
@@ -377,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,
|
||||||
))
|
))
|
||||||
@@ -388,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),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import ipaddress
|
|||||||
import platform
|
import platform
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
|
||||||
import time
|
import time
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
@@ -36,20 +35,6 @@ def require_container() -> None:
|
|||||||
info("Apple Container is required but was not found on PATH.")
|
info("Apple Container is required but was not found on PATH.")
|
||||||
info("Install: https://github.com/apple/container/releases")
|
info("Install: https://github.com/apple/container/releases")
|
||||||
die("container not found")
|
die("container not found")
|
||||||
_require_container_service()
|
|
||||||
|
|
||||||
|
|
||||||
def _require_container_service() -> None:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "system", "status"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
info("Apple Container system service is not running.")
|
|
||||||
info("Start it with: container system start")
|
|
||||||
die("container system service not running")
|
|
||||||
|
|
||||||
|
|
||||||
def dns_server() -> str:
|
def dns_server() -> str:
|
||||||
@@ -73,53 +58,6 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
|||||||
subprocess.run(args, check=True)
|
subprocess.run(args, check=True)
|
||||||
|
|
||||||
|
|
||||||
def commit_container(container_name: str, image_tag: str) -> None:
|
|
||||||
"""Snapshot a running Apple Container as a local image.
|
|
||||||
|
|
||||||
`container export` requires a stopped container, but Apple Container
|
|
||||||
removes containers when they stop, making stop-then-export impossible.
|
|
||||||
Instead, exec into the running container as root and stream the root
|
|
||||||
filesystem out via tar, then build a new image from that archive.
|
|
||||||
The bottle continues running after commit.
|
|
||||||
"""
|
|
||||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-container-commit.") as tmp:
|
|
||||||
rootfs_tar = os.path.join(tmp, "rootfs.tar")
|
|
||||||
dockerfile = os.path.join(tmp, "Dockerfile")
|
|
||||||
with open(rootfs_tar, "wb") as tar_out:
|
|
||||||
result = subprocess.run(
|
|
||||||
[
|
|
||||||
_CONTAINER, "exec",
|
|
||||||
"--user", "root",
|
|
||||||
container_name,
|
|
||||||
"tar", "--create",
|
|
||||||
"--exclude=./proc",
|
|
||||||
"--exclude=./sys",
|
|
||||||
"--exclude=./dev",
|
|
||||||
"--exclude=./run",
|
|
||||||
"--file=-",
|
|
||||||
"--directory=/",
|
|
||||||
".",
|
|
||||||
],
|
|
||||||
stdout=tar_out,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container exec tar {container_name!r} failed: "
|
|
||||||
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
with open(dockerfile, "w", encoding="utf-8") as f:
|
|
||||||
f.write(
|
|
||||||
"FROM scratch\n"
|
|
||||||
"ADD rootfs.tar /\n"
|
|
||||||
"USER node\n"
|
|
||||||
"WORKDIR /home/node\n"
|
|
||||||
)
|
|
||||||
build_image(image_tag, tmp, dockerfile=dockerfile)
|
|
||||||
info(f"committed {container_name!r} → {image_tag!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_builder_dns() -> None:
|
def _ensure_builder_dns() -> None:
|
||||||
dns = dns_server()
|
dns = dns_server()
|
||||||
status = _builder_status()
|
status = _builder_status()
|
||||||
@@ -266,36 +204,6 @@ def container_exists(name: str) -> bool:
|
|||||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
return name in {line.strip() for line in result.stdout.splitlines()}
|
||||||
|
|
||||||
|
|
||||||
def container_is_running(name: str) -> bool:
|
|
||||||
"""Return True if the named container is currently running.
|
|
||||||
|
|
||||||
`container list` without `--all` lists only running containers."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "list", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return False
|
|
||||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
|
||||||
|
|
||||||
|
|
||||||
def stop_container(name: str) -> None:
|
|
||||||
"""Stop the named container without deleting it."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "stop", name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container stop {name!r} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def force_remove_container(name: str) -> None:
|
def force_remove_container(name: str) -> None:
|
||||||
if container_exists(name):
|
if container_exists(name):
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -145,12 +145,7 @@ class SmolmachinesBottle(Bottle):
|
|||||||
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
script = exec_shell_script(agent_argv, self.terminal_title, self.terminal_color) if tty else None
|
||||||
if script is None:
|
if script is None:
|
||||||
return subprocess.run(agent_argv, check=False).returncode
|
return subprocess.run(agent_argv, check=False).returncode
|
||||||
# Use sh -c (not -lc) so the script inherits PATH from the calling
|
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
||||||
# process. sh -l sources login-shell init files (e.g. /etc/profile)
|
|
||||||
# which may NOT include smolvm's location when it was installed via
|
|
||||||
# homebrew. The calling process (./cli.py) already has smolvm on PATH
|
|
||||||
# (provision steps succeed), so -c is sufficient.
|
|
||||||
return subprocess.run(["sh", "-c", script], check=False).returncode
|
|
||||||
|
|
||||||
# smolvm/libkrun can SIGKILL an otherwise-normal exec during
|
# smolvm/libkrun can SIGKILL an otherwise-normal exec during
|
||||||
# early-VM provisioning. Retry once after a short settle so
|
# early-VM provisioning. Retry once after a short settle so
|
||||||
|
|||||||
@@ -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",
|
|
||||||
]
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
"""SmolmachinesFreezer — snapshot a smolmachines bottle.
|
|
||||||
|
|
||||||
`smolvm pack create --from-vm` requires the VM to be stopped, and smolvm
|
|
||||||
removes VMs when stopped (same issue as Apple Container). Instead, exec
|
|
||||||
into the running VM as root to write a gzip-compressed tar of the root
|
|
||||||
filesystem to /var/tmp, then copy it to the host with `smolvm machine cp`,
|
|
||||||
build a Docker image from the archive, convert it to a smolmachine artifact
|
|
||||||
via the existing registry pipeline, and record the sidecar path. The VM
|
|
||||||
stays running throughout."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from .. import ActiveAgent
|
|
||||||
from ..freeze import Freezer
|
|
||||||
from ..docker import util as docker_mod
|
|
||||||
from .local_registry import crane_push_tarball, ephemeral_registry
|
|
||||||
from .smolvm import machine_cp, machine_exec, pack_create
|
|
||||||
from ...bottle_state import bottle_state_dir
|
|
||||||
from ...log import die, info
|
|
||||||
|
|
||||||
|
|
||||||
# Temp file written inside the VM during commit. Lives in /var/tmp
|
|
||||||
# (on-disk, unlike tmpfs /tmp) to survive for machine_cp.
|
|
||||||
_VM_COMMIT_TAR = "/var/tmp/.bot-bottle-commit.tar.gz"
|
|
||||||
|
|
||||||
|
|
||||||
class SmolmachinesFreezer(Freezer):
|
|
||||||
"""Freezes a smolmachines bottle via exec-tar + Docker image + smolmachine pack.
|
|
||||||
|
|
||||||
The VM is NOT stopped. We exec into the running VM to write a compressed
|
|
||||||
tar of the root filesystem to /var/tmp, copy it to the host with
|
|
||||||
machine_cp, build a Docker image (Docker's ADD decompresses .tar.gz
|
|
||||||
automatically), then run the same image→registry→pack_create pipeline
|
|
||||||
that _ensure_smolmachine uses for fresh builds."""
|
|
||||||
|
|
||||||
backend_name = "smolmachines"
|
|
||||||
|
|
||||||
def _freeze(self, agent: ActiveAgent) -> str:
|
|
||||||
machine = f"bot-bottle-{agent.slug}"
|
|
||||||
image_ref = f"bot-bottle-committed-{agent.slug}:latest"
|
|
||||||
output_dir = bottle_state_dir(agent.slug)
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
binary = output_dir / "committed-smolmachine"
|
|
||||||
sidecar = output_dir / "committed-smolmachine.smolmachine"
|
|
||||||
_snapshot_running_vm(machine, image_ref, binary)
|
|
||||||
return str(sidecar)
|
|
||||||
|
|
||||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
|
||||||
info(f"to export for migration: cp {image_ref} {slug}.smolmachine")
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_running_vm(machine: str, image_ref: str, binary: Path) -> None:
|
|
||||||
"""Exec-tar the running VM, build a Docker image, and pack to a smolmachine.
|
|
||||||
|
|
||||||
binary: destination for the launcher (sibling .smolmachine is the artifact
|
|
||||||
that machine_create --from consumes, same convention as pack_create).
|
|
||||||
"""
|
|
||||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-vm-commit.") as tmp:
|
|
||||||
tmp_path = Path(tmp)
|
|
||||||
# Use .tar.gz — Docker ADD decompresses automatically and the
|
|
||||||
# compressed archive fits in the VM's /var/tmp more easily.
|
|
||||||
rootfs_tar_gz = tmp_path / "rootfs.tar.gz"
|
|
||||||
dockerfile = tmp_path / "Dockerfile"
|
|
||||||
|
|
||||||
_exec_tar_to_file(machine, rootfs_tar_gz)
|
|
||||||
|
|
||||||
dockerfile.write_text(
|
|
||||||
"FROM scratch\n"
|
|
||||||
"ADD rootfs.tar.gz /\n"
|
|
||||||
"USER node\n"
|
|
||||||
"WORKDIR /home/node\n"
|
|
||||||
)
|
|
||||||
docker_mod.build_image(image_ref, str(tmp_path), dockerfile=str(dockerfile))
|
|
||||||
|
|
||||||
image_tarball = binary.parent / "committed.image.tar"
|
|
||||||
docker_mod.save(image_ref, str(image_tarball))
|
|
||||||
try:
|
|
||||||
with ephemeral_registry() as handle:
|
|
||||||
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
|
|
||||||
push_ref = f"{handle.push_endpoint}/bot-bottle-committed:{digest}"
|
|
||||||
pack_ref = f"{handle.pull_endpoint}/bot-bottle-committed:{digest}"
|
|
||||||
crane_push_tarball(handle, str(image_tarball), push_ref)
|
|
||||||
pack_create(pack_ref, binary)
|
|
||||||
finally:
|
|
||||||
image_tarball.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _exec_tar_to_file(machine: str, dest: Path) -> None:
|
|
||||||
"""Snapshot the running VM's root filesystem to dest (.tar.gz).
|
|
||||||
|
|
||||||
Writes a gzip-compressed tar to _VM_COMMIT_TAR inside the VM via
|
|
||||||
machine_exec (same mechanism as provisioning), then copies it to the
|
|
||||||
host with machine_cp. This avoids binary-stdout piping through the
|
|
||||||
smolvm exec channel, which does not reliably handle large binary output.
|
|
||||||
|
|
||||||
A connectivity probe (machine_exec true) runs first so a concurrent-exec
|
|
||||||
limitation (smolvm may reject a second exec while -i -t is active) is
|
|
||||||
reported clearly rather than as a silent failure."""
|
|
||||||
# Connectivity probe — if smolvm rejects concurrent exec while an
|
|
||||||
# interactive session is running, fail clearly here.
|
|
||||||
probe = machine_exec(machine, ["true"])
|
|
||||||
if probe.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"smolvm exec is not available for {machine!r} "
|
|
||||||
f"(exit {probe.returncode}: {probe.stderr.strip() or probe.stdout.strip() or '<no output>'}). "
|
|
||||||
f"If an interactive session is active, smolvm may not support concurrent exec."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create the compressed tar inside the VM.
|
|
||||||
# tar exits 1 when files change during archiving (normal for a live
|
|
||||||
# filesystem); only treat exit > 1 as fatal.
|
|
||||||
tar_result = machine_exec(
|
|
||||||
machine,
|
|
||||||
[
|
|
||||||
"tar", "--create", "--gzip",
|
|
||||||
"--exclude=./proc",
|
|
||||||
"--exclude=./sys",
|
|
||||||
"--exclude=./dev",
|
|
||||||
"--exclude=./run",
|
|
||||||
# /tmp and /var/tmp are ephemeral. Their stale contents
|
|
||||||
# (e.g. /tmp/claude-<uid>) have uid remapped by smolvm's
|
|
||||||
# pack process, causing Claude Code to refuse to use them
|
|
||||||
# on resume. Exclude both; _init_vm recreates them with
|
|
||||||
# mkdir -p + correct ownership on every boot.
|
|
||||||
"--exclude=./tmp",
|
|
||||||
"--exclude=./var/tmp",
|
|
||||||
f"--file={_VM_COMMIT_TAR}",
|
|
||||||
"--directory=/",
|
|
||||||
".",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
if tar_result.returncode > 1:
|
|
||||||
die(
|
|
||||||
f"smolvm exec tar {machine!r} failed (exit {tar_result.returncode}): "
|
|
||||||
f"{tar_result.stderr.strip() or tar_result.stdout.strip() or '<no output>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy from VM to host, then clean up.
|
|
||||||
try:
|
|
||||||
machine_cp(f"{machine}:{_VM_COMMIT_TAR}", str(dest))
|
|
||||||
finally:
|
|
||||||
machine_exec(machine, ["rm", "-f", _VM_COMMIT_TAR])
|
|
||||||
@@ -40,12 +40,8 @@ from ..docker.git_gate import (
|
|||||||
GIT_GATE_HOOK_IN_CONTAINER,
|
GIT_GATE_HOOK_IN_CONTAINER,
|
||||||
)
|
)
|
||||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||||
from ...log import info, warn
|
from ...log import warn
|
||||||
from ...bottle_state import (
|
from ...bottle_state import egress_state_dir, git_gate_state_dir
|
||||||
egress_state_dir,
|
|
||||||
git_gate_state_dir,
|
|
||||||
read_committed_image,
|
|
||||||
)
|
|
||||||
from . import loopback_alias as _loopback
|
from . import loopback_alias as _loopback
|
||||||
from . import sidecar_bundle as _bundle
|
from . import sidecar_bundle as _bundle
|
||||||
from . import smolvm as _smolvm
|
from . import smolvm as _smolvm
|
||||||
@@ -89,7 +85,14 @@ def launch(
|
|||||||
plan = _start_bundle(plan, network, loopback_ip, stack)
|
plan = _start_bundle(plan, network, loopback_ip, stack)
|
||||||
plan = _discover_urls(plan, loopback_ip)
|
plan = _discover_urls(plan, loopback_ip)
|
||||||
|
|
||||||
agent_from_path = _agent_from_path(plan)
|
# Build the agent image and pack it into a `.smolmachine`
|
||||||
|
# artifact (or hit the per-Dockerfile-digest cache). Runs
|
||||||
|
# here, not in prepare, so the docker-build output doesn't
|
||||||
|
# garble the dashboard's preflight modal.
|
||||||
|
agent_from_path = _ensure_smolmachine(
|
||||||
|
plan.agent_image,
|
||||||
|
dockerfile=plan.agent_dockerfile_path,
|
||||||
|
)
|
||||||
|
|
||||||
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
||||||
_init_vm(plan)
|
_init_vm(plan)
|
||||||
@@ -101,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,
|
||||||
)
|
)
|
||||||
@@ -127,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
|
||||||
@@ -214,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}"
|
||||||
@@ -276,16 +275,10 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
|
|||||||
All folded into one sh -c to avoid back-to-back exec calls
|
All folded into one sh -c to avoid back-to-back exec calls
|
||||||
immediately after machine_start (libkrun exec-channel race).
|
immediately after machine_start (libkrun exec-channel race).
|
||||||
|
|
||||||
mkdir -p guards: when booting from a committed snapshot, /tmp and
|
|
||||||
/var/tmp are excluded from the archive (they're ephemeral and their
|
|
||||||
stale contents would have wrong uid after smolvm's uid remap). The
|
|
||||||
directories must be created before chown/chmod can set permissions.
|
|
||||||
|
|
||||||
wait_exec_ready polls until the exec channel is ready for the
|
wait_exec_ready polls until the exec channel is ready for the
|
||||||
subsequent provision calls, replacing the empirical sleep."""
|
subsequent provision calls, replacing the empirical sleep."""
|
||||||
_smolvm.machine_exec(plan.machine_name, [
|
_smolvm.machine_exec(plan.machine_name, [
|
||||||
"sh", "-c",
|
"sh", "-c",
|
||||||
"mkdir -p /tmp /var/tmp && "
|
|
||||||
"chown -R node:node /home/node && "
|
"chown -R node:node /home/node && "
|
||||||
"chown root:root /tmp /var/tmp && "
|
"chown root:root /tmp /var/tmp && "
|
||||||
"chmod 1777 /tmp /var/tmp",
|
"chmod 1777 /tmp /var/tmp",
|
||||||
@@ -315,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.
|
||||||
@@ -389,30 +382,6 @@ def _resolve_token_env(
|
|||||||
return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env)
|
return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env)
|
||||||
|
|
||||||
|
|
||||||
def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
|
||||||
"""Return the `.smolmachine` artifact used for `machine create --from`.
|
|
||||||
|
|
||||||
Prefer a committed VM artifact when one is recorded and still
|
|
||||||
present. If the file was removed, fall back to the normal image
|
|
||||||
build + pack cache path.
|
|
||||||
"""
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed:
|
|
||||||
committed_path = Path(committed)
|
|
||||||
if committed_path.is_file():
|
|
||||||
info(f"using committed smolmachine {str(committed_path)!r}")
|
|
||||||
return committed_path
|
|
||||||
|
|
||||||
# Build the agent image and pack it into a `.smolmachine`
|
|
||||||
# artifact (or hit the per-Dockerfile-digest cache). Runs here,
|
|
||||||
# not in prepare, so the docker-build output doesn't garble the
|
|
||||||
# dashboard's preflight modal.
|
|
||||||
return _ensure_smolmachine(
|
|
||||||
plan.agent_image,
|
|
||||||
dockerfile=plan.agent_dockerfile_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||||
"""Build the agent docker image and convert it into a
|
"""Build the agent docker image and convert it into a
|
||||||
`.smolmachine` artifact, caching the result under
|
`.smolmachine` artifact, caching the result under
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ smolvm binary."""
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
@@ -95,16 +94,6 @@ def pack_create(image: str, output: Path) -> None:
|
|||||||
_smolvm("pack", "create", "--image", image, "-o", str(output))
|
_smolvm("pack", "create", "--image", image, "-o", str(output))
|
||||||
|
|
||||||
|
|
||||||
def pack_create_from_vm(name: str, output: Path) -> None:
|
|
||||||
"""`smolvm pack create --from-vm <name> -o <output>`.
|
|
||||||
|
|
||||||
Snapshots an existing persistent VM into a pack artifact. As
|
|
||||||
with `pack_create`, smolvm writes a launcher at `output` and the
|
|
||||||
bootable sidecar at `output.smolmachine`.
|
|
||||||
"""
|
|
||||||
_smolvm("pack", "create", "--from-vm", name, "-o", str(output))
|
|
||||||
|
|
||||||
|
|
||||||
# --- Machine lifecycle ---------------------------------------------------
|
# --- Machine lifecycle ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -154,21 +143,6 @@ def machine_create(
|
|||||||
_smolvm(*args)
|
_smolvm(*args)
|
||||||
|
|
||||||
|
|
||||||
def machine_is_running(name: str) -> bool:
|
|
||||||
"""Return True if the named VM is in the 'running' state."""
|
|
||||||
result = _smolvm("machine", "ls", "--json", check=False)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
machines = json.loads(result.stdout or "[]")
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return any(
|
|
||||||
isinstance(m, dict) and m.get("name") == name and m.get("state") == "running"
|
|
||||||
for m in machines
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def machine_start(name: str) -> None:
|
def machine_start(name: str) -> None:
|
||||||
"""`smolvm machine start --name NAME`."""
|
"""`smolvm machine start --name NAME`."""
|
||||||
_smolvm("machine", "start", "--name", name)
|
_smolvm("machine", "start", "--name", name)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ from . import supervise as _supervise
|
|||||||
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
||||||
_STATE_SUBDIR = "state"
|
_STATE_SUBDIR = "state"
|
||||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||||
_COMMITTED_IMAGE_NAME = "committed-image"
|
|
||||||
_TRANSCRIPT_SUBDIR = "transcript"
|
_TRANSCRIPT_SUBDIR = "transcript"
|
||||||
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
||||||
# live here so chunk 3's `docker compose up` can find them at stable
|
# live here so chunk 3's `docker compose up` can find them at stable
|
||||||
@@ -180,32 +179,6 @@ def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
|
|||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
def committed_image_path(identity: str) -> Path:
|
|
||||||
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def write_committed_image(identity: str, image_tag: str) -> Path:
|
|
||||||
"""Persist the committed image tag for `identity`. The next
|
|
||||||
`cli.py resume <identity>` will boot from this image instead of
|
|
||||||
rebuilding from the Dockerfile."""
|
|
||||||
path = committed_image_path(identity)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(image_tag.strip() + "\n")
|
|
||||||
path.chmod(0o644)
|
|
||||||
return path
|
|
||||||
|
|
||||||
|
|
||||||
def read_committed_image(identity: str) -> str | None:
|
|
||||||
"""Return the committed image tag for `identity`, or None if no
|
|
||||||
commit has been recorded. Used by the Docker launch step to skip
|
|
||||||
the Dockerfile build when a committed snapshot exists."""
|
|
||||||
path = committed_image_path(identity)
|
|
||||||
if not path.is_file():
|
|
||||||
return None
|
|
||||||
tag = path.read_text().strip()
|
|
||||||
return tag or None
|
|
||||||
|
|
||||||
|
|
||||||
def per_bottle_image_tag(identity: str) -> str:
|
def per_bottle_image_tag(identity: str) -> str:
|
||||||
"""Image tag for a rebuilt bottle. Distinct from the base
|
"""Image tag for a rebuilt bottle. Distinct from the base
|
||||||
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
|
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
|
||||||
@@ -341,7 +314,6 @@ __all__ = [
|
|||||||
"bottle_state_dir",
|
"bottle_state_dir",
|
||||||
"cleanup_state",
|
"cleanup_state",
|
||||||
"clear_preserve_marker",
|
"clear_preserve_marker",
|
||||||
"committed_image_path",
|
|
||||||
"egress_state_dir",
|
"egress_state_dir",
|
||||||
"git_gate_state_dir",
|
"git_gate_state_dir",
|
||||||
"is_preserved",
|
"is_preserved",
|
||||||
@@ -351,11 +323,9 @@ __all__ = [
|
|||||||
"per_bottle_dockerfile_path",
|
"per_bottle_dockerfile_path",
|
||||||
"per_bottle_image_tag",
|
"per_bottle_image_tag",
|
||||||
"preserve_marker_path",
|
"preserve_marker_path",
|
||||||
"read_committed_image",
|
|
||||||
"read_metadata",
|
"read_metadata",
|
||||||
"supervise_state_dir",
|
"supervise_state_dir",
|
||||||
"transcript_snapshot_dir",
|
"transcript_snapshot_dir",
|
||||||
"write_committed_image",
|
|
||||||
"write_metadata",
|
"write_metadata",
|
||||||
"write_per_bottle_dockerfile",
|
"write_per_bottle_dockerfile",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Main CLI dispatcher.
|
"""Main CLI dispatcher.
|
||||||
|
|
||||||
Commands: cleanup, commit, edit, info, init, list, resume, start, supervise
|
Commands: cleanup, edit, info, init, list, resume, start, supervise
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,7 +12,6 @@ from ..manifest import ManifestError
|
|||||||
from ._common import PROG
|
from ._common import PROG
|
||||||
from . import list as _list_mod
|
from . import list as _list_mod
|
||||||
from .cleanup import cmd_cleanup
|
from .cleanup import cmd_cleanup
|
||||||
from .commit import cmd_commit
|
|
||||||
from .edit import cmd_edit
|
from .edit import cmd_edit
|
||||||
from .info import cmd_info
|
from .info import cmd_info
|
||||||
from .init import cmd_init
|
from .init import cmd_init
|
||||||
@@ -24,7 +23,6 @@ cmd_list = _list_mod.cmd_list
|
|||||||
|
|
||||||
COMMANDS = {
|
COMMANDS = {
|
||||||
"cleanup": cmd_cleanup,
|
"cleanup": cmd_cleanup,
|
||||||
"commit": cmd_commit,
|
|
||||||
"edit": cmd_edit,
|
"edit": cmd_edit,
|
||||||
"info": cmd_info,
|
"info": cmd_info,
|
||||||
"init": cmd_init,
|
"init": cmd_init,
|
||||||
@@ -39,7 +37,6 @@ def usage() -> None:
|
|||||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||||
sys.stderr.write("Commands:\n")
|
sys.stderr.write("Commands:\n")
|
||||||
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
||||||
sys.stderr.write(" commit snapshot a running bottle's container state to a Docker image\n")
|
|
||||||
sys.stderr.write(" edit open an agent in vim for editing\n")
|
sys.stderr.write(" edit open an agent in vim for editing\n")
|
||||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||||
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
"""commit: freeze a running bottle's state to a resumable artifact.
|
|
||||||
|
|
||||||
Docker bottles are committed to a local Docker image. Macos-container
|
|
||||||
bottles are exported and rebuilt as a local Apple Container image.
|
|
||||||
Smolmachines bottles are packed from the running VM into a
|
|
||||||
`.smolmachine` artifact. The resulting reference is stored in
|
|
||||||
per-bottle state so the next `./cli.py resume <slug>` boots from the
|
|
||||||
snapshot instead of rebuilding from the Dockerfile.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
|
|
||||||
from ..backend import enumerate_active_agents
|
|
||||||
from ..backend.freeze import CommitCancelled, get_freezer
|
|
||||||
from ..bottle_state import read_metadata
|
|
||||||
from ..log import die
|
|
||||||
from ._common import PROG
|
|
||||||
from . import tui
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_commit(argv: list[str]) -> int:
|
|
||||||
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
|
|
||||||
parser.add_argument(
|
|
||||||
"slug",
|
|
||||||
nargs="?",
|
|
||||||
default=None,
|
|
||||||
help=(
|
|
||||||
"bottle slug from `cli.py list active` "
|
|
||||||
"(omit to pick interactively)"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
args = parser.parse_args(argv)
|
|
||||||
|
|
||||||
slug = args.slug
|
|
||||||
if slug is None:
|
|
||||||
active = enumerate_active_agents()
|
|
||||||
if not active:
|
|
||||||
die("no active bottles; start one with `./cli.py start`")
|
|
||||||
choices = [a.slug for a in active]
|
|
||||||
slug = tui.filter_select(choices, title="Select bottle to commit")
|
|
||||||
if slug is None:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
backend = metadata.backend if metadata else ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
get_freezer(backend).commit_slug(slug)
|
|
||||||
except CommitCancelled:
|
|
||||||
return 0
|
|
||||||
return 0
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ RUN apt-get update \
|
|||||||
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
||||||
# rolling forward; an unpinned install would mean rebuilds silently pick
|
# rolling forward; an unpinned install would mean rebuilds silently pick
|
||||||
# up new behavior.
|
# up new behavior.
|
||||||
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
|
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.126 \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
# Run as a non-root user. The node image already provides a `node` user
|
# Run as a non-root user. The node image already provides a `node` user
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,7 @@
|
|||||||
|
|
||||||
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
|
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
|
||||||
them using the Gitea deploy-key HTTP API. No new Python dependencies —
|
them using the Gitea deploy-key HTTP API. No new Python dependencies —
|
||||||
only stdlib `urllib.request` and `subprocess`.
|
only stdlib `urllib.request` and `subprocess`."""
|
||||||
|
|
||||||
Required token permissions (Gitea "Applications" → "Generate Token"):
|
|
||||||
- Repository: Read & Write
|
|
||||||
Grants POST /api/v1/repos/{owner}/{repo}/keys (create deploy key)
|
|
||||||
and DELETE /api/v1/repos/{owner}/{repo}/keys/{id} (revoke deploy key).
|
|
||||||
No other scopes are needed."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
+16
-24
@@ -300,8 +300,6 @@ while IFS=' ' read -r old new ref; do
|
|||||||
[ -z "$ref" ] && continue
|
[ -z "$ref" ] && continue
|
||||||
if [ "$new" = "$zero" ]; then
|
if [ "$new" = "$zero" ]; then
|
||||||
refspec=":$ref"
|
refspec=":$ref"
|
||||||
elif [ "$old" != "$zero" ] && ! git merge-base --is-ancestor "$old" "$new" 2>/dev/null; then
|
|
||||||
refspec="+$new:$ref"
|
|
||||||
else
|
else
|
||||||
refspec="$new:$ref"
|
refspec="$new:$ref"
|
||||||
fi
|
fi
|
||||||
@@ -389,12 +387,13 @@ def _provision_dynamic_key(
|
|||||||
Returns the host-side path to the private key file so the caller
|
Returns the host-side path to the private key file so the caller
|
||||||
can inject it into the GitGateUpstream as `identity_file`."""
|
can inject it into the GitGateUpstream as `identity_file`."""
|
||||||
from .deploy_key_provisioner import get_provisioner
|
from .deploy_key_provisioner import get_provisioner
|
||||||
pk = entry.Key
|
pk = entry.ProvisionedKey
|
||||||
token = os.environ.get(pk.forge_token_env)
|
assert pk is not None
|
||||||
|
token = os.environ.get(pk.token_env)
|
||||||
if token is None:
|
if token is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
|
||||||
f" = {pk.forge_token_env!r}: env var is not set"
|
f" = {pk.token_env!r}: env var is not set"
|
||||||
)
|
)
|
||||||
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||||
provisioner = get_provisioner(pk.provider, token, api_url)
|
provisioner = get_provisioner(pk.provider, token, api_url)
|
||||||
@@ -427,18 +426,18 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
|
|||||||
address manually."""
|
address manually."""
|
||||||
from .deploy_key_provisioner import get_provisioner
|
from .deploy_key_provisioner import get_provisioner
|
||||||
for entry in bottle.git:
|
for entry in bottle.git:
|
||||||
if entry.Key.provider != "gitea":
|
if entry.ProvisionedKey is None:
|
||||||
continue
|
continue
|
||||||
pk = entry.Key
|
pk = entry.ProvisionedKey
|
||||||
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
|
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
|
||||||
if not id_file.exists():
|
if not id_file.exists():
|
||||||
continue
|
continue
|
||||||
key_id = id_file.read_text().strip()
|
key_id = id_file.read_text().strip()
|
||||||
token = os.environ.get(pk.forge_token_env)
|
token = os.environ.get(pk.token_env)
|
||||||
if token is None:
|
if token is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
|
||||||
f" = {pk.forge_token_env!r}: env var is not set;"
|
f" = {pk.token_env!r}: env var is not set;"
|
||||||
f" cannot revoke deploy key {key_id}"
|
f" cannot revoke deploy key {key_id}"
|
||||||
)
|
)
|
||||||
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||||
@@ -451,14 +450,6 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
|
|||||||
info(f"revoked deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
|
info(f"revoked deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_identity_file(entry: ManifestGitEntry, slug: str, stage_dir: Path) -> str:
|
|
||||||
"""Return the host-side SSH identity file path for this entry.
|
|
||||||
For gitea entries, provisions a fresh deploy key first."""
|
|
||||||
if entry.Key.provider == "gitea":
|
|
||||||
return _provision_dynamic_key(entry, slug, stage_dir)
|
|
||||||
return entry.IdentityFile
|
|
||||||
|
|
||||||
|
|
||||||
class GitGate(ABC):
|
class GitGate(ABC):
|
||||||
"""The per-agent git-gate. Encapsulates the host-side prepare
|
"""The per-agent git-gate. Encapsulates the host-side prepare
|
||||||
(upstream lift + entrypoint/hook render); the sidecar's
|
(upstream lift + entrypoint/hook render); the sidecar's
|
||||||
@@ -470,7 +461,7 @@ class GitGate(ABC):
|
|||||||
entrypoint, pre-receive hook, and access-hook scripts (mode
|
entrypoint, pre-receive hook, and access-hook scripts (mode
|
||||||
600) under `stage_dir`. Pure host-side, no docker subprocess.
|
600) under `stage_dir`. Pure host-side, no docker subprocess.
|
||||||
|
|
||||||
For `gitea` key entries, also generates and registers
|
For `provisioned_key` entries, also generates and registers
|
||||||
a fresh deploy key via the forge API and writes the private key
|
a fresh deploy key via the forge API and writes the private key
|
||||||
+ key ID to `stage_dir`.
|
+ key ID to `stage_dir`.
|
||||||
|
|
||||||
@@ -479,10 +470,11 @@ class GitGate(ABC):
|
|||||||
before passing the plan to `.start`."""
|
before passing the plan to `.start`."""
|
||||||
upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
|
upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
|
||||||
for i, entry in enumerate(bottle.git):
|
for i, entry in enumerate(bottle.git):
|
||||||
upstreams_list[i] = dataclasses.replace(
|
if entry.ProvisionedKey is not None:
|
||||||
upstreams_list[i],
|
key_file = _provision_dynamic_key(entry, slug, stage_dir)
|
||||||
identity_file=_resolve_identity_file(entry, slug, stage_dir),
|
upstreams_list[i] = dataclasses.replace(
|
||||||
)
|
upstreams_list[i], identity_file=key_file
|
||||||
|
)
|
||||||
upstreams = tuple(upstreams_list)
|
upstreams = tuple(upstreams_list)
|
||||||
entrypoint = stage_dir / "git_gate_entrypoint.sh"
|
entrypoint = stage_dir / "git_gate_entrypoint.sh"
|
||||||
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
|
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
|
||||||
|
|||||||
+98
-194
@@ -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
|
||||||
@@ -69,7 +56,7 @@ from .manifest_egress import (
|
|||||||
ManifestEgressConfig,
|
ManifestEgressConfig,
|
||||||
ManifestEgressRoute,
|
ManifestEgressRoute,
|
||||||
)
|
)
|
||||||
from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig, parse_git_gate_config
|
from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config
|
||||||
from .manifest_schema import BOTTLE_KEYS
|
from .manifest_schema import BOTTLE_KEYS
|
||||||
|
|
||||||
# Re-export everything that callers currently import from this module.
|
# Re-export everything that callers currently import from this module.
|
||||||
@@ -77,14 +64,12 @@ __all__ = [
|
|||||||
"ManifestError",
|
"ManifestError",
|
||||||
"ManifestGitEntry",
|
"ManifestGitEntry",
|
||||||
"ManifestGitUser",
|
"ManifestGitUser",
|
||||||
"ManifestKeyConfig",
|
|
||||||
"ManifestAgentProvider",
|
"ManifestAgentProvider",
|
||||||
"EGRESS_AUTH_SCHEMES",
|
"EGRESS_AUTH_SCHEMES",
|
||||||
"ManifestEgressRoute",
|
"ManifestEgressRoute",
|
||||||
"ManifestEgressConfig",
|
"ManifestEgressConfig",
|
||||||
"ManifestAgent",
|
"ManifestAgent",
|
||||||
"ManifestBottle",
|
"ManifestBottle",
|
||||||
"ManifestIndex",
|
|
||||||
"Manifest",
|
"Manifest",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -203,64 +188,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 +208,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 +247,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 +279,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 +310,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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+66
-73
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from .manifest_util import ManifestError, as_json_object
|
from .manifest_util import ManifestError, as_json_object
|
||||||
|
|
||||||
@@ -12,8 +13,6 @@ from .manifest_util import ManifestError, as_json_object
|
|||||||
# defence; this regex is belt-and-suspenders and documents intent).
|
# defence; this regex is belt-and-suspenders and documents intent).
|
||||||
_GIT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
_GIT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||||
|
|
||||||
_KEY_PROVIDERS = {"static", "gitea"}
|
|
||||||
|
|
||||||
|
|
||||||
def _opt_str(value: object, label: str) -> str:
|
def _opt_str(value: object, label: str) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -70,22 +69,20 @@ def validate_unique_git_names(bottle_name: str, git: tuple[ManifestGitEntry, ...
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ManifestKeyConfig:
|
class ManifestProvisionedKeyConfig:
|
||||||
"""Configuration for a repo's SSH key in git-gate.repos.
|
"""Configuration for automatic deploy-key lifecycle management
|
||||||
|
(PRD 0048). Used when a git-gate.repos entry opts out of a
|
||||||
|
static identity file and instead wants a fresh SSH keypair
|
||||||
|
generated at spin-up and revoked at teardown.
|
||||||
|
|
||||||
`provider` is either `"static"` (a pre-existing key on the host) or
|
`provider` names the contrib sub-package to load (e.g. `gitea`).
|
||||||
`"gitea"` (automatic deploy-key lifecycle via the Gitea API).
|
`token_env` is the name of a host-side env var carrying the API
|
||||||
|
token; the value is read at provision time, never stored on the
|
||||||
For `static`: `path` is the host-side absolute path to the SSH private key.
|
plan. `api_url` is the forge's HTTP API root; if empty, it is
|
||||||
|
derived from the upstream URL's host at provision time."""
|
||||||
For `gitea`: `forge_token_env` is the name of a host-side env var
|
|
||||||
carrying the Gitea API token; the value is read at provision time,
|
|
||||||
never stored on the plan. `api_url` is the forge's HTTP API root; if
|
|
||||||
empty, it is derived from the upstream URL's host at provision time."""
|
|
||||||
|
|
||||||
provider: str
|
provider: str
|
||||||
path: str = ""
|
token_env: str
|
||||||
forge_token_env: str = ""
|
|
||||||
api_url: str = ""
|
api_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -102,16 +99,15 @@ class ManifestGitEntry:
|
|||||||
stashed in the `Upstream*` fields so the git-gate render step
|
stashed in the `Upstream*` fields so the git-gate render step
|
||||||
doesn't have to re-parse.
|
doesn't have to re-parse.
|
||||||
|
|
||||||
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). A `key`
|
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). Exactly
|
||||||
block is required; `key.provider` is `"static"` or `"gitea"`. For
|
one of `identity` (static key path) or `provisioned_key` (automatic
|
||||||
`static`, `IdentityFile` is populated at parse time from `key.path`.
|
lifecycle) must be present. The internal field names are stable."""
|
||||||
For `gitea`, `IdentityFile` is populated at provision time."""
|
|
||||||
|
|
||||||
Name: str
|
Name: str
|
||||||
Upstream: str
|
Upstream: str
|
||||||
Key: ManifestKeyConfig = ManifestKeyConfig(provider="")
|
|
||||||
IdentityFile: str = ""
|
IdentityFile: str = ""
|
||||||
KnownHostKey: str = ""
|
KnownHostKey: str = ""
|
||||||
|
ProvisionedKey: Optional[ManifestProvisionedKeyConfig] = None
|
||||||
RemoteKey: str = ""
|
RemoteKey: str = ""
|
||||||
UpstreamUser: str = ""
|
UpstreamUser: str = ""
|
||||||
UpstreamHost: str = ""
|
UpstreamHost: str = ""
|
||||||
@@ -124,8 +120,8 @@ class ManifestGitEntry:
|
|||||||
) -> "ManifestGitEntry":
|
) -> "ManifestGitEntry":
|
||||||
"""Parse one entry from `git-gate.repos.<repo_name>`.
|
"""Parse one entry from `git-gate.repos.<repo_name>`.
|
||||||
|
|
||||||
YAML keys: `url` (required), `key` (required object with
|
YAML keys: `url` (required), exactly one of `identity` or
|
||||||
`provider`, and provider-specific fields), `host_key` (optional).
|
`provisioned_key` (required), `host_key` (optional).
|
||||||
The repo_name becomes `Name`."""
|
The repo_name becomes `Name`."""
|
||||||
if not repo_name:
|
if not repo_name:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
@@ -139,10 +135,10 @@ class ManifestGitEntry:
|
|||||||
label = f"git-gate.repos[{repo_name!r}]"
|
label = f"git-gate.repos[{repo_name!r}]"
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
|
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
|
||||||
for k in d:
|
for k in d:
|
||||||
if k not in {"url", "key", "host_key"}:
|
if k not in {"url", "identity", "provisioned_key", "host_key"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
|
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
|
||||||
f"allowed: url, key, host_key"
|
f"allowed: url, identity, provisioned_key, host_key"
|
||||||
)
|
)
|
||||||
upstream = d.get("url")
|
upstream = d.get("url")
|
||||||
if not isinstance(upstream, str) or not upstream:
|
if not isinstance(upstream, str) or not upstream:
|
||||||
@@ -150,13 +146,32 @@ class ManifestGitEntry:
|
|||||||
f"bottle '{bottle_name}' {label} missing required string field 'url'"
|
f"bottle '{bottle_name}' {label} missing required string field 'url'"
|
||||||
)
|
)
|
||||||
|
|
||||||
if "key" not in d:
|
has_identity = "identity" in d
|
||||||
|
has_provisioned = "provisioned_key" in d
|
||||||
|
if has_identity and has_provisioned:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label} missing required 'key' block"
|
f"bottle '{bottle_name}' {label} must set exactly one of "
|
||||||
|
f"'identity' or 'provisioned_key'; got both."
|
||||||
|
)
|
||||||
|
if not has_identity and not has_provisioned:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' {label} must set exactly one of "
|
||||||
|
f"'identity' or 'provisioned_key'; got neither."
|
||||||
)
|
)
|
||||||
key_config = _parse_key_config(bottle_name, label, d["key"])
|
|
||||||
|
|
||||||
ident = key_config.path if key_config.provider == "static" else ""
|
ident = ""
|
||||||
|
provisioned_key: Optional[ManifestProvisionedKeyConfig] = None
|
||||||
|
if has_identity:
|
||||||
|
raw_ident = d.get("identity")
|
||||||
|
if not isinstance(raw_ident, str) or not raw_ident:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' {label} 'identity' must be a non-empty string"
|
||||||
|
)
|
||||||
|
ident = raw_ident
|
||||||
|
else:
|
||||||
|
provisioned_key = _parse_provisioned_key_config(
|
||||||
|
bottle_name, label, d["provisioned_key"]
|
||||||
|
)
|
||||||
|
|
||||||
khk = _opt_str(
|
khk = _opt_str(
|
||||||
d.get("host_key"),
|
d.get("host_key"),
|
||||||
@@ -168,9 +183,9 @@ class ManifestGitEntry:
|
|||||||
return cls(
|
return cls(
|
||||||
Name=repo_name,
|
Name=repo_name,
|
||||||
Upstream=upstream,
|
Upstream=upstream,
|
||||||
Key=key_config,
|
|
||||||
IdentityFile=ident,
|
IdentityFile=ident,
|
||||||
KnownHostKey=khk,
|
KnownHostKey=khk,
|
||||||
|
ProvisionedKey=provisioned_key,
|
||||||
RemoteKey=host,
|
RemoteKey=host,
|
||||||
UpstreamUser=user,
|
UpstreamUser=user,
|
||||||
UpstreamHost=host,
|
UpstreamHost=host,
|
||||||
@@ -179,60 +194,38 @@ class ManifestGitEntry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_key_config(
|
def _parse_provisioned_key_config(
|
||||||
bottle_name: str, label: str, raw: object
|
bottle_name: str, label: str, raw: object
|
||||||
) -> ManifestKeyConfig:
|
) -> ManifestProvisionedKeyConfig:
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.key")
|
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key")
|
||||||
|
for k in d:
|
||||||
|
if k not in {"provider", "token_env", "api_url"}:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{bottle_name}' {label}.provisioned_key has unknown key {k!r}; "
|
||||||
|
f"allowed: provider, token_env, api_url"
|
||||||
|
)
|
||||||
provider = d.get("provider")
|
provider = d.get("provider")
|
||||||
if not isinstance(provider, str) or not provider:
|
if not isinstance(provider, str) or not provider:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label}.key missing required "
|
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
|
||||||
f"string field 'provider'"
|
f"string field 'provider'"
|
||||||
)
|
)
|
||||||
if provider not in _KEY_PROVIDERS:
|
token_env = d.get("token_env")
|
||||||
|
if not isinstance(token_env, str) or not token_env:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label}.key provider {provider!r} is unknown; "
|
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
|
||||||
f"allowed: {', '.join(sorted(_KEY_PROVIDERS))}"
|
f"string field 'token_env'"
|
||||||
)
|
)
|
||||||
|
api_url_raw = d.get("api_url", "")
|
||||||
if provider == "gitea":
|
if not isinstance(api_url_raw, str):
|
||||||
for k in d:
|
|
||||||
if k not in {"provider", "forge_token_env", "api_url"}:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{bottle_name}' {label}.key has unknown key {k!r} "
|
|
||||||
f"for provider 'gitea'; allowed: provider, forge_token_env, api_url"
|
|
||||||
)
|
|
||||||
forge_token_env = d.get("forge_token_env")
|
|
||||||
if not isinstance(forge_token_env, str) or not forge_token_env:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{bottle_name}' {label}.key missing required "
|
|
||||||
f"string field 'forge_token_env' for provider 'gitea'"
|
|
||||||
)
|
|
||||||
api_url_raw = d.get("api_url", "")
|
|
||||||
if not isinstance(api_url_raw, str):
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{bottle_name}' {label}.key 'api_url' must be a string"
|
|
||||||
)
|
|
||||||
return ManifestKeyConfig(
|
|
||||||
provider=provider,
|
|
||||||
forge_token_env=forge_token_env,
|
|
||||||
api_url=api_url_raw,
|
|
||||||
)
|
|
||||||
|
|
||||||
# provider == "static"
|
|
||||||
for k in d:
|
|
||||||
if k not in {"provider", "path"}:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{bottle_name}' {label}.key has unknown key {k!r} "
|
|
||||||
f"for provider 'static'; allowed: provider, path"
|
|
||||||
)
|
|
||||||
path = d.get("path")
|
|
||||||
if not isinstance(path, str) or not path:
|
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label}.key missing required "
|
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
|
||||||
f"string field 'path' for provider 'static'"
|
|
||||||
)
|
)
|
||||||
return ManifestKeyConfig(provider=provider, path=path)
|
return ManifestProvisionedKeyConfig(
|
||||||
|
provider=provider,
|
||||||
|
token_env=token_env,
|
||||||
|
api_url=api_url_raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
# PRD prd-new: Commit bottle state to an image
|
|
||||||
|
|
||||||
- **Status:** Active
|
|
||||||
- **Author:** Claude
|
|
||||||
- **Created:** 2026-06-20
|
|
||||||
- **Issue:** #194
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Add a `commit` CLI command that freezes a running bottle's state to a
|
|
||||||
resumable local artifact. Docker bottles are stored as Docker images;
|
|
||||||
smolmachines bottles are stored as `.smolmachine` artifacts. Operators
|
|
||||||
can then resume the bottle from that exact filesystem snapshot, or
|
|
||||||
export the artifact to migrate work to a different host.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
When a long-running agent session is interrupted — by a host reboot, a
|
|
||||||
network failure, or a planned infrastructure migration — the in-progress
|
|
||||||
container state is lost. `cli.py resume` rebuilds the agent image from
|
|
||||||
the Dockerfile and reprovi-sions the bottle, but that returns the guest
|
|
||||||
to its initial state, not to wherever the agent was mid-task.
|
|
||||||
|
|
||||||
There is no mechanism today to capture "what's installed / configured
|
|
||||||
inside the running container right now" and make it reproducible. The
|
|
||||||
`capability-block` flow writes a new Dockerfile and marks the bottle for
|
|
||||||
resume, but that only applies when the agent itself has requested a
|
|
||||||
capability change; it doesn't help the operator who wants to take a
|
|
||||||
snapshot before a planned host reboot or hardware migration.
|
|
||||||
|
|
||||||
## Goals / Success Criteria
|
|
||||||
|
|
||||||
- `./cli.py commit [<slug>]` takes a snapshot of the running agent and
|
|
||||||
stores it as a local artifact.
|
|
||||||
- Without a slug argument the command shows the same interactive picker
|
|
||||||
as `start` (the list of active slugs).
|
|
||||||
- The committed artifact reference is stored in per-bottle state so
|
|
||||||
that the next `./cli.py resume <slug>` automatically uses the
|
|
||||||
snapshot instead of rebuilding from the Dockerfile.
|
|
||||||
- `mark_preserved` is called so the state dir survives the normal
|
|
||||||
session-end cleanup.
|
|
||||||
- A backend-specific export hint is printed so operators know how to
|
|
||||||
migrate the snapshot.
|
|
||||||
- The command errors clearly on unsupported backends.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- macOS-container backend support.
|
|
||||||
- Automatic commit on agent exit.
|
|
||||||
- Image push to a remote registry.
|
|
||||||
- Storing the image tag in the manifest or sharing it between operators.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Docker image tag
|
|
||||||
|
|
||||||
`bot-bottle-committed-<slug>:latest` — namespaced under `bot-bottle-`
|
|
||||||
to match existing image naming conventions; `committed` distinguishes it
|
|
||||||
from the build-time image (`bot-bottle-claude:latest`) and the
|
|
||||||
capability-block rebuild image (`bot-bottle-rebuilt-<identity>:latest`).
|
|
||||||
|
|
||||||
### State storage
|
|
||||||
|
|
||||||
A new plain-text file `committed-image` is added to the per-bottle state
|
|
||||||
directory:
|
|
||||||
|
|
||||||
```
|
|
||||||
~/.bot-bottle/state/<identity>/
|
|
||||||
metadata.json
|
|
||||||
Dockerfile (capability-block override; optional)
|
|
||||||
committed-image (committed artifact reference; optional)
|
|
||||||
transcript/
|
|
||||||
```
|
|
||||||
|
|
||||||
`bottle_state.committed_image_path(identity)` returns the path.
|
|
||||||
`write_committed_image` / `read_committed_image` are the read/write
|
|
||||||
helpers, matching the existing `per_bottle_dockerfile` pattern. Docker
|
|
||||||
stores a Docker tag in this file; smolmachines stores the absolute path
|
|
||||||
to the committed `.smolmachine` artifact.
|
|
||||||
|
|
||||||
### `commit` command
|
|
||||||
|
|
||||||
```
|
|
||||||
./cli.py commit [<slug>]
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Resolve slug (arg or interactive picker from `enumerate_active_agents`).
|
|
||||||
2. Check metadata and branch by backend.
|
|
||||||
3. For Docker, derive container name `bot-bottle-<slug>` and run
|
|
||||||
`docker commit <container> bot-bottle-committed-<slug>:latest`.
|
|
||||||
4. For smolmachines, derive machine name `bot-bottle-<slug>` and run
|
|
||||||
`smolvm pack create --from-vm <machine> -o ~/.bot-bottle/state/<slug>/committed-smolmachine`.
|
|
||||||
5. Write the Docker image tag or smolmachine artifact path to
|
|
||||||
`~/.bot-bottle/state/<slug>/committed-image`.
|
|
||||||
6. Call `mark_preserved(<slug>)` so the state dir survives session-end.
|
|
||||||
7. Print the resume hint and a backend-specific export example.
|
|
||||||
|
|
||||||
### Resume from committed image
|
|
||||||
|
|
||||||
`bot_bottle/backend/docker/launch.py` already rebuilds the agent image
|
|
||||||
at the top of the `launch` context manager. The change is a check
|
|
||||||
immediately before that step:
|
|
||||||
|
|
||||||
```python
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed and docker_mod.image_exists(committed):
|
|
||||||
info(f"using committed image {committed!r}")
|
|
||||||
plan = dataclasses.replace(
|
|
||||||
plan,
|
|
||||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
|
||||||
```
|
|
||||||
|
|
||||||
Replacing `agent_provision.image` propagates to `plan.image` (a
|
|
||||||
property) and from there to the Compose spec renderer's `_agent_service`
|
|
||||||
→ `image:` field, so the container boots from the committed snapshot.
|
|
||||||
The build step is skipped entirely when a committed image is found and
|
|
||||||
exists locally.
|
|
||||||
|
|
||||||
If the committed image has been deleted from the local daemon (e.g.
|
|
||||||
after `docker rmi` or a `docker system prune`), the launch falls back
|
|
||||||
to a normal Dockerfile build, matching the pre-commit behavior.
|
|
||||||
|
|
||||||
### Resume from committed smolmachine
|
|
||||||
|
|
||||||
`bot_bottle/backend/smolmachines/launch.py` checks the committed
|
|
||||||
reference before the normal Docker build -> pack cache path:
|
|
||||||
|
|
||||||
```python
|
|
||||||
committed = read_committed_image(plan.slug)
|
|
||||||
if committed and Path(committed).is_file():
|
|
||||||
return Path(committed)
|
|
||||||
return _ensure_smolmachine(plan.agent_image, dockerfile=plan.agent_dockerfile_path)
|
|
||||||
```
|
|
||||||
|
|
||||||
The returned path is passed to `smolvm machine create --from`, so the
|
|
||||||
resumed VM boots from the committed snapshot. If the artifact has been
|
|
||||||
deleted, launch falls back to the normal build and pack flow.
|
|
||||||
|
|
||||||
## Testing strategy
|
|
||||||
|
|
||||||
- Unit tests for `write_committed_image` / `read_committed_image` in
|
|
||||||
`tests/unit/test_bottle_state.py`, using the existing `_FakeHomeMixin`
|
|
||||||
pattern.
|
|
||||||
- Unit tests for `commit_container` in `tests/unit/test_docker_util_image.py`,
|
|
||||||
mocking `subprocess.run` and asserting on the `docker commit` argv.
|
|
||||||
- Unit tests for `cmd_commit` argument parsing, Docker commit,
|
|
||||||
smolmachines pack, and the unsupported backend error path, mocking
|
|
||||||
`enumerate_active_agents`, `commit_container`, and
|
|
||||||
`pack_create_from_vm`.
|
|
||||||
- Unit tests for the launch-step committed-image branch: patch
|
|
||||||
`read_committed_image` to return a tag, patch `image_exists` to return
|
|
||||||
True, and assert that `build_image` is not called and `plan.image` is
|
|
||||||
overridden.
|
|
||||||
- Unit tests for the smolmachines launch-step committed-artifact branch:
|
|
||||||
patch `read_committed_image` to return an existing path and assert the
|
|
||||||
normal `_ensure_smolmachine` path is skipped.
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# PRD 0059: macOS Container backend
|
# PRD prd-new: macOS Container backend
|
||||||
|
|
||||||
- **Status:** Active
|
- **Status:** Draft
|
||||||
- **Author:** Codex
|
- **Author:** Codex
|
||||||
- **Created:** 2026-06-10
|
- **Created:** 2026-06-10
|
||||||
- **Issue:** #220
|
- **Issue:** #220
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
# Apple Container networking spike
|
|
||||||
|
|
||||||
Issue: https://gitea.dideric.is/didericis/bot-bottle/issues/230
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Apple Container 1.0.0 on macOS 26 can support the core two-network
|
|
||||||
sidecar shape, but not as a drop-in Docker Compose clone.
|
|
||||||
|
|
||||||
The viable shape is:
|
|
||||||
|
|
||||||
- agent container on one `--internal` host-only network;
|
|
||||||
- sidecar bundle container on both the NAT egress network and the
|
|
||||||
host-only agent network;
|
|
||||||
- sidecar network flags ordered with the NAT network first, because
|
|
||||||
Apple Container chooses the first network as the default route;
|
|
||||||
- explicit DNS on the sidecar, because the tested NAT gateway routed
|
|
||||||
packets but did not resolve DNS;
|
|
||||||
- agent talks to sidecar by the sidecar's host-only-network IP, not by
|
|
||||||
container name or host-published loopback alias.
|
|
||||||
|
|
||||||
This is enough to unblock a cautious `macos-container` launch spike if
|
|
||||||
the backend records inspect-derived IPs and avoids depending on Docker
|
|
||||||
Compose-style aliases. It is not enough to reuse the Docker backend's
|
|
||||||
service-name assumptions unchanged.
|
|
||||||
|
|
||||||
## Local Environment
|
|
||||||
|
|
||||||
Tested on 2026-06-10:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ sw_vers
|
|
||||||
ProductName: macOS
|
|
||||||
ProductVersion: 26.5.1
|
|
||||||
BuildVersion: 25F80
|
|
||||||
|
|
||||||
$ uname -m
|
|
||||||
arm64
|
|
||||||
|
|
||||||
$ container --version
|
|
||||||
container CLI version 1.0.0 (build: release, commit: ee848e3)
|
|
||||||
|
|
||||||
$ container system version --format json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"appName": "container",
|
|
||||||
"buildType": "release",
|
|
||||||
"commit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
|
|
||||||
"version": "1.0.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"appName": "container-apiserver",
|
|
||||||
"buildType": "release",
|
|
||||||
"commit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
|
|
||||||
"version": "container-apiserver version 1.0.0 (build: release, commit: ee848e3)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
$ container system status --format json
|
|
||||||
{
|
|
||||||
"apiServerAppName": "container-apiserver",
|
|
||||||
"apiServerBuild": "release",
|
|
||||||
"apiServerCommit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
|
|
||||||
"apiServerVersion": "container-apiserver version 1.0.0 (build: release, commit: ee848e3)",
|
|
||||||
"appRoot": "/Users/didericis/Library/Application Support/com.apple.container/",
|
|
||||||
"installRoot": "/usr/local/",
|
|
||||||
"status": "running"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Apple Container was installed from the official signed 1.0.0 GitHub
|
|
||||||
release package, `container-1.0.0-installer-signed.pkg`. The package was
|
|
||||||
signed by `Developer ID Installer: Apple Inc. - Containerization
|
|
||||||
(UPBK2H6LZM)` and notarized by Apple.
|
|
||||||
|
|
||||||
## Commands Run
|
|
||||||
|
|
||||||
Create the networks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container network create bb-spike-230-agent \
|
|
||||||
--internal \
|
|
||||||
--label bot-bottle.spike=apple-container-networking
|
|
||||||
|
|
||||||
container network create bb-spike-230-egress \
|
|
||||||
--label bot-bottle.spike=apple-container-networking
|
|
||||||
```
|
|
||||||
|
|
||||||
`container network inspect bb-spike-230-agent bb-spike-230-egress`
|
|
||||||
showed:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"configuration": {
|
|
||||||
"labels": {"bot-bottle.spike": "apple-container-networking"},
|
|
||||||
"mode": "hostOnly",
|
|
||||||
"name": "bb-spike-230-agent",
|
|
||||||
"plugin": "container-network-vmnet"
|
|
||||||
},
|
|
||||||
"id": "bb-spike-230-agent",
|
|
||||||
"status": {
|
|
||||||
"ipv4Gateway": "192.168.128.1",
|
|
||||||
"ipv4Subnet": "192.168.128.0/24"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"configuration": {
|
|
||||||
"labels": {"bot-bottle.spike": "apple-container-networking"},
|
|
||||||
"mode": "nat",
|
|
||||||
"name": "bb-spike-230-egress",
|
|
||||||
"plugin": "container-network-vmnet"
|
|
||||||
},
|
|
||||||
"id": "bb-spike-230-egress",
|
|
||||||
"status": {
|
|
||||||
"ipv4Gateway": "192.168.66.1",
|
|
||||||
"ipv4Subnet": "192.168.66.0/24"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Repeated `--network` flags are accepted. With the agent network first,
|
|
||||||
the sidecar got two interfaces but the default route pointed at the
|
|
||||||
host-only gateway, so egress failed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container run --name bb-spike-230-sidecar \
|
|
||||||
--label bot-bottle.spike=apple-container-networking \
|
|
||||||
--network bb-spike-230-agent \
|
|
||||||
--network bb-spike-230-egress \
|
|
||||||
--detach --rm docker.io/python:alpine \
|
|
||||||
sh -c 'mkdir -p /srv && printf ok >/srv/index.html && cd /srv && python3 -m http.server 80 --bind 0.0.0.0'
|
|
||||||
|
|
||||||
container exec bb-spike-230-sidecar sh -c 'ip route && cat /etc/resolv.conf'
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed:
|
|
||||||
|
|
||||||
```console
|
|
||||||
default via 192.168.128.1 dev eth0
|
|
||||||
192.168.66.0/24 dev eth1 scope link src 192.168.66.3
|
|
||||||
192.168.128.0/24 dev eth0 scope link src 192.168.128.3
|
|
||||||
nameserver 192.168.128.1
|
|
||||||
```
|
|
||||||
|
|
||||||
With the NAT network first and explicit DNS, the sidecar can egress:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container run --name bb-spike-230-sidecar \
|
|
||||||
--label bot-bottle.spike=apple-container-networking \
|
|
||||||
--network bb-spike-230-egress \
|
|
||||||
--network bb-spike-230-agent \
|
|
||||||
--dns 1.1.1.1 \
|
|
||||||
--detach docker.io/python:alpine \
|
|
||||||
sh -c 'mkdir -p /srv && printf ok >/srv/index.html && cd /srv && python3 -m http.server 80 --bind 0.0.0.0'
|
|
||||||
|
|
||||||
container exec bb-spike-230-sidecar sh -c 'ip route; cat /etc/resolv.conf; wget -T 8 -O- https://example.com'
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed:
|
|
||||||
|
|
||||||
```console
|
|
||||||
default via 192.168.66.1 dev eth0
|
|
||||||
192.168.66.0/24 dev eth0 scope link src 192.168.66.5
|
|
||||||
192.168.128.0/24 dev eth1 scope link src 192.168.128.7
|
|
||||||
nameserver 1.1.1.1
|
|
||||||
Connecting to example.com (172.66.147.243:443)
|
|
||||||
... 100%
|
|
||||||
```
|
|
||||||
|
|
||||||
Start an agent only on the host-only network:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container run --name bb-spike-230-agent \
|
|
||||||
--label bot-bottle.spike=apple-container-networking \
|
|
||||||
--network bb-spike-230-agent \
|
|
||||||
--detach docker.io/alpine:latest sleep 600
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent network probes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container exec bb-spike-230-agent sh -c '
|
|
||||||
ip route
|
|
||||||
cat /etc/resolv.conf
|
|
||||||
wget -T 5 -O- http://192.168.128.7
|
|
||||||
wget -T 5 -O- http://bb-spike-230-sidecar || true
|
|
||||||
ping -c 2 1.1.1.1 || true
|
|
||||||
wget -T 5 -O- https://example.com || true
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed:
|
|
||||||
|
|
||||||
```console
|
|
||||||
default via 192.168.128.1 dev eth0
|
|
||||||
192.168.128.0/24 dev eth0 scope link src 192.168.128.8
|
|
||||||
nameserver 192.168.128.1
|
|
||||||
Connecting to 192.168.128.7 (192.168.128.7:80)
|
|
||||||
ok
|
|
||||||
wget: bad address 'bb-spike-230-sidecar'
|
|
||||||
2 packets transmitted, 0 packets received, 100% packet loss
|
|
||||||
wget: bad address 'example.com'
|
|
||||||
```
|
|
||||||
|
|
||||||
Host-published loopback aliases work and are constrained to the bound
|
|
||||||
alias on the host:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container run --name bb-spike-230-sidecar-alias \
|
|
||||||
--label bot-bottle.spike=apple-container-networking \
|
|
||||||
--network bb-spike-230-egress \
|
|
||||||
--network bb-spike-230-agent \
|
|
||||||
--dns 1.1.1.1 \
|
|
||||||
--publish 127.0.0.31:18080:80 \
|
|
||||||
--detach docker.io/python:alpine \
|
|
||||||
sh -c 'mkdir -p /srv && printf ok >/srv/index.html && cd /srv && python3 -m http.server 80 --bind 0.0.0.0'
|
|
||||||
|
|
||||||
curl -fsS --max-time 5 http://127.0.0.31:18080
|
|
||||||
curl -fsS --max-time 5 http://127.0.0.1:18080
|
|
||||||
lsof -nP -iTCP:18080 -sTCP:LISTEN
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ curl -fsS --max-time 5 http://127.0.0.31:18080
|
|
||||||
ok
|
|
||||||
|
|
||||||
$ curl -fsS --max-time 5 http://127.0.0.1:18080
|
|
||||||
curl: (7) Failed to connect to 127.0.0.1 port 18080 after 0 ms: Couldn't connect to server
|
|
||||||
|
|
||||||
$ lsof -nP -iTCP:18080 -sTCP:LISTEN
|
|
||||||
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
|
|
||||||
container 17908 didericis 25u IPv4 ... 0t0 TCP 127.0.0.31:18080 (LISTEN)
|
|
||||||
```
|
|
||||||
|
|
||||||
The guest cannot reach that host loopback-published listener through
|
|
||||||
the host-only gateway or through its own loopback address:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container exec bb-spike-230-agent sh -c '
|
|
||||||
wget -T 5 -O- http://192.168.128.10
|
|
||||||
wget -T 5 -O- http://192.168.128.1:18080 || true
|
|
||||||
wget -T 5 -O- http://127.0.0.31:18080 || true
|
|
||||||
wget -T 5 -O- http://bb-spike-230-sidecar-alias || true
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed:
|
|
||||||
|
|
||||||
```console
|
|
||||||
Connecting to 192.168.128.10 (192.168.128.10:80)
|
|
||||||
ok
|
|
||||||
Connecting to 192.168.128.1:18080 (192.168.128.1:18080)
|
|
||||||
wget: can't connect to remote host (192.168.128.1): Connection refused
|
|
||||||
Connecting to 127.0.0.31:18080 (127.0.0.31:18080)
|
|
||||||
wget: can't connect to remote host (127.0.0.31): Connection refused
|
|
||||||
wget: bad address 'bb-spike-230-sidecar-alias'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Answers
|
|
||||||
|
|
||||||
### 1. Does `container network create --internal` prevent outbound internet access?
|
|
||||||
|
|
||||||
Yes in this run. `--internal` produced a `hostOnly` network. An
|
|
||||||
internal-only agent had a default route to the host-only gateway, but
|
|
||||||
could not ping `1.1.1.1` and could not resolve or fetch
|
|
||||||
`https://example.com`.
|
|
||||||
|
|
||||||
### 2. Can `container run` attach one container to multiple networks?
|
|
||||||
|
|
||||||
Yes. Repeated `--network` flags produced multiple interfaces and the
|
|
||||||
inspect JSON preserved both network attachments.
|
|
||||||
|
|
||||||
Important caveat: network order matters. The first network became
|
|
||||||
`eth0`, supplied the default route, and supplied `/etc/resolv.conf`.
|
|
||||||
For a sidecar that needs internet egress, put the NAT network first and
|
|
||||||
the internal agent network second.
|
|
||||||
|
|
||||||
### 3. Can the sidecar bundle sit on both an internal agent network and an egress-capable network?
|
|
||||||
|
|
||||||
Yes. The sidecar had a NAT interface and a host-only interface. With the
|
|
||||||
NAT network first and explicit DNS, it could fetch `https://example.com`
|
|
||||||
while the agent on only the host-only network could not.
|
|
||||||
|
|
||||||
### 4. Can Apple Container provide stable network aliases or service discovery equivalent to Docker Compose aliases?
|
|
||||||
|
|
||||||
Not by default in this run. The agent could not resolve
|
|
||||||
`bb-spike-230-sidecar` or `bb-spike-230-sidecar-alias`, even though
|
|
||||||
those were the container names and hostnames in inspect output. The
|
|
||||||
agent could reach the sidecar by the sidecar's host-only-network IP.
|
|
||||||
|
|
||||||
The backend should not assume Docker Compose-style aliases. It should
|
|
||||||
read the sidecar's host-only IP from `container inspect` and inject
|
|
||||||
that concrete endpoint into the agent environment/config, or run a
|
|
||||||
small internal DNS/hosts-file setup as an explicit backend feature.
|
|
||||||
|
|
||||||
### 5. Can a published sidecar port bound to a per-bottle loopback alias be reached from another Apple Container guest and constrained to that alias?
|
|
||||||
|
|
||||||
Host-side alias binding works and is constrained on the host:
|
|
||||||
`127.0.0.31:18080` reached the sidecar, while `127.0.0.1:18080` failed.
|
|
||||||
|
|
||||||
Guest-to-host-published-loopback did not work. From the agent,
|
|
||||||
`192.168.128.1:18080` and `127.0.0.31:18080` both failed. For
|
|
||||||
agent-to-sidecar traffic, use the sidecar's internal network IP rather
|
|
||||||
than a host-published loopback alias.
|
|
||||||
|
|
||||||
### 6. What structured output is available for robust enumeration and cleanup?
|
|
||||||
|
|
||||||
Confirmed structured output:
|
|
||||||
|
|
||||||
- `container list --all --format json`
|
|
||||||
- `container inspect <container...>` as JSON
|
|
||||||
- `container image inspect <image...>` as JSON
|
|
||||||
- `container network list --format json`
|
|
||||||
- `container network inspect <network...>` as JSON
|
|
||||||
- `container system status --format json`
|
|
||||||
- `container system version --format json`
|
|
||||||
|
|
||||||
Useful fields observed:
|
|
||||||
|
|
||||||
- containers: `id`, `configuration.labels`,
|
|
||||||
`configuration.networks`, `configuration.publishedPorts`,
|
|
||||||
`status.state`, `status.networks[].network`,
|
|
||||||
`status.networks[].ipv4Address`, `status.networks[].ipv4Gateway`;
|
|
||||||
- networks: `id`, `configuration.name`, `configuration.labels`,
|
|
||||||
`configuration.mode`, `status.ipv4Gateway`, `status.ipv4Subnet`;
|
|
||||||
- images: `id`, `configuration.name`, `configuration.descriptor`,
|
|
||||||
`variants[].platform`, `variants[].size`.
|
|
||||||
|
|
||||||
### 7. Are labels supported on containers and networks enough to replace prefix-only discovery?
|
|
||||||
|
|
||||||
Labels are present in container and network inspect/list JSON, so they
|
|
||||||
are sufficient as metadata if the backend lists resources and filters
|
|
||||||
client-side. I did not find or validate a server-side label filter for
|
|
||||||
`container list` or `container network list`.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
Proceed with a narrow `macos-container` launch prototype, but encode
|
|
||||||
the Apple Container-specific constraints directly:
|
|
||||||
|
|
||||||
- create one host-only agent network and one NAT egress network per
|
|
||||||
bottle;
|
|
||||||
- start the sidecar bundle with `--network <egress>` before
|
|
||||||
`--network <agent>`;
|
|
||||||
- set sidecar DNS explicitly, ideally from the bottle/host policy
|
|
||||||
rather than hardcoding a public resolver;
|
|
||||||
- start the agent only on the host-only network;
|
|
||||||
- discover the sidecar's host-only IP from `container inspect` and pass
|
|
||||||
concrete URLs to the agent;
|
|
||||||
- use host loopback publishing only for host-to-sidecar access, not
|
|
||||||
guest-to-sidecar access;
|
|
||||||
- enumerate and clean up by labels plus name prefixes until/unless the
|
|
||||||
CLI adds label filters.
|
|
||||||
|
|
||||||
Do not implement the backend as a direct clone of Docker Compose
|
|
||||||
service aliases. That assumption failed in this run.
|
|
||||||
@@ -1,476 +0,0 @@
|
|||||||
# Apple Container transparent egress spike
|
|
||||||
|
|
||||||
Issue: https://gitea.dideric.is/didericis/bot-bottle/issues/230#issuecomment-1994
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Transparent egress is mechanically possible on Apple Container 1.0.0,
|
|
||||||
but it is not a free property of the platform and it is not a drop-in
|
|
||||||
replacement for `HTTP_PROXY` yet.
|
|
||||||
|
|
||||||
The spike proved two separate things:
|
|
||||||
|
|
||||||
- Plain routing/NAT works if the sidecar has `CAP_NET_ADMIN`, IP
|
|
||||||
forwarding, and masquerade rules, and if the agent default route is
|
|
||||||
changed to the sidecar's host-only-network IP.
|
|
||||||
- Transparent mitmproxy interception works if the sidecar redirects
|
|
||||||
agent-facing TCP 80/443 traffic to `mitmdump --mode transparent`.
|
|
||||||
Direct HTTP was logged by mitmproxy. Direct HTTPS reached mitmproxy;
|
|
||||||
it failed with normal certificate verification until the client
|
|
||||||
skipped verification, which is consistent with bot-bottle's existing
|
|
||||||
requirement that agents trust the sidecar CA.
|
|
||||||
- Running DNS on the sidecar and pointing the agent at the sidecar's
|
|
||||||
host-only IP also works. This is cleaner than relying on forwarded
|
|
||||||
UDP DNS to a public resolver and gives the backend a natural place to
|
|
||||||
enforce or observe DNS policy.
|
|
||||||
|
|
||||||
The hard blocker is agent routing. Apple Container 1.0.0 exposes no
|
|
||||||
documented `--network` gateway option. An ordinary agent container
|
|
||||||
cannot replace its default route:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ container exec bb-spike-230t-agent sh -c \
|
|
||||||
'ip route replace default via 192.168.128.2 dev eth0; ip route'
|
|
||||||
default via 192.168.128.1 dev eth0
|
|
||||||
192.168.128.0/24 dev eth0 scope link src 192.168.128.3
|
|
||||||
ip: RTNETLINK answers: Operation not permitted
|
|
||||||
```
|
|
||||||
|
|
||||||
The successful route-through-sidecar tests used `--cap-add
|
|
||||||
CAP_NET_ADMIN` on the agent so the route could be changed after start.
|
|
||||||
That is not an acceptable final design by itself: it expands the
|
|
||||||
agent's kernel-facing privilege and lets the agent mutate its own
|
|
||||||
network namespace. A production design needs either a backend-owned
|
|
||||||
init/shim that sets the route then drops privilege in a way the agent
|
|
||||||
cannot regain, a platform-supported gateway option, or a different
|
|
||||||
network attachment layer.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
Tested on 2026-06-10:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ sw_vers
|
|
||||||
ProductName: macOS
|
|
||||||
ProductVersion: 26.5.1
|
|
||||||
BuildVersion: 25F80
|
|
||||||
|
|
||||||
$ uname -m
|
|
||||||
arm64
|
|
||||||
|
|
||||||
$ container --version
|
|
||||||
container CLI version 1.0.0 (build: release, commit: ee848e3)
|
|
||||||
```
|
|
||||||
|
|
||||||
Apple Container system status:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"apiServerAppName": "container-apiserver",
|
|
||||||
"apiServerBuild": "release",
|
|
||||||
"apiServerCommit": "ee848e3ebfd7c73b04dd419683be54fb450b8779",
|
|
||||||
"apiServerVersion": "container-apiserver version 1.0.0 (build: release, commit: ee848e3)",
|
|
||||||
"appRoot": "/Users/didericis/Library/Application Support/com.apple.container/",
|
|
||||||
"installRoot": "/usr/local/",
|
|
||||||
"status": "running"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Baseline
|
|
||||||
|
|
||||||
Networks:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container network create bb-spike-230t-agent \
|
|
||||||
--internal \
|
|
||||||
--label bot-bottle.spike=transparent-egress
|
|
||||||
|
|
||||||
container network create bb-spike-230t-egress \
|
|
||||||
--label bot-bottle.spike=transparent-egress
|
|
||||||
```
|
|
||||||
|
|
||||||
Sidecar, dual-homed with NAT first:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container run --name bb-spike-230t-sidecar \
|
|
||||||
--label bot-bottle.spike=transparent-egress \
|
|
||||||
--network bb-spike-230t-egress \
|
|
||||||
--network bb-spike-230t-agent \
|
|
||||||
--dns 1.1.1.1 \
|
|
||||||
--detach docker.io/alpine:latest sleep 1800
|
|
||||||
```
|
|
||||||
|
|
||||||
Agent, host-only network:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container run --name bb-spike-230t-agent \
|
|
||||||
--label bot-bottle.spike=transparent-egress \
|
|
||||||
--network bb-spike-230t-agent \
|
|
||||||
--detach docker.io/alpine:latest sleep 1800
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed sidecar addresses:
|
|
||||||
|
|
||||||
```console
|
|
||||||
eth0 192.168.66.2/24 # NAT egress network
|
|
||||||
eth1 192.168.128.2/24 # host-only agent network
|
|
||||||
default via 192.168.66.1 dev eth0
|
|
||||||
nameserver 1.1.1.1
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed agent baseline:
|
|
||||||
|
|
||||||
```console
|
|
||||||
eth0 192.168.128.3/24
|
|
||||||
default via 192.168.128.1 dev eth0
|
|
||||||
nameserver 192.168.128.1
|
|
||||||
wget: bad address 'pypi.org'
|
|
||||||
```
|
|
||||||
|
|
||||||
That confirms the previous spike's baseline: sidecar can egress, agent
|
|
||||||
cannot egress directly.
|
|
||||||
|
|
||||||
## Plain NAT Test
|
|
||||||
|
|
||||||
Relaunch sidecar and agent with `CAP_NET_ADMIN`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container run --name bb-spike-230t-sidecar \
|
|
||||||
--label bot-bottle.spike=transparent-egress \
|
|
||||||
--network bb-spike-230t-egress \
|
|
||||||
--network bb-spike-230t-agent \
|
|
||||||
--dns 1.1.1.1 \
|
|
||||||
--cap-add CAP_NET_ADMIN \
|
|
||||||
--detach docker.io/alpine:latest sleep 1800
|
|
||||||
|
|
||||||
container run --name bb-spike-230t-agent \
|
|
||||||
--label bot-bottle.spike=transparent-egress \
|
|
||||||
--network bb-spike-230t-agent \
|
|
||||||
--cap-add CAP_NET_ADMIN \
|
|
||||||
--detach docker.io/alpine:latest sleep 1800
|
|
||||||
```
|
|
||||||
|
|
||||||
Configure sidecar forwarding:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container exec bb-spike-230t-sidecar sh -c '
|
|
||||||
apk add --no-cache iptables iproute2
|
|
||||||
sysctl -w net.ipv4.ip_forward=1
|
|
||||||
iptables -t nat -A POSTROUTING -s 192.168.128.0/24 -o eth0 -j MASQUERADE
|
|
||||||
iptables -A FORWARD -i eth1 -o eth0 -j ACCEPT
|
|
||||||
iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Point the agent at the sidecar:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container exec bb-spike-230t-agent sh -c '
|
|
||||||
ip route replace default via 192.168.128.4 dev eth0
|
|
||||||
printf "nameserver 1.1.1.1\n" > /etc/resolv.conf
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Normal direct PyPI fetch from the agent, with no proxy variables set:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container exec bb-spike-230t-agent sh -c '
|
|
||||||
for v in HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy; do
|
|
||||||
if [ -n "$(printenv "$v")" ]; then echo "$v=SET"; fi
|
|
||||||
done
|
|
||||||
wget -T 10 -O- https://pypi.org/simple/pip/ | head -c 120
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed:
|
|
||||||
|
|
||||||
```console
|
|
||||||
Connecting to pypi.org (151.101.0.223:443)
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta name="pypi:repository-version" content="1.4">
|
|
||||||
```
|
|
||||||
|
|
||||||
Sidecar NAT counters increased:
|
|
||||||
|
|
||||||
```console
|
|
||||||
POSTROUTING MASQUERADE 3 packets / 168 bytes
|
|
||||||
FORWARD eth1 -> eth0 22 packets / 2806 bytes
|
|
||||||
FORWARD eth0 -> eth1 29 packets / 54781 bytes
|
|
||||||
```
|
|
||||||
|
|
||||||
Verdict: plain transparent routing through the sidecar works, but this
|
|
||||||
is only NAT. It does not apply bot-bottle's existing route allowlist,
|
|
||||||
authorization stripping/injection, or DLP logic.
|
|
||||||
|
|
||||||
## Transparent Mitmproxy Test
|
|
||||||
|
|
||||||
The current sidecar launcher uses explicit proxy mode:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
MODE="--mode regular@9099"
|
|
||||||
exec mitmdump $CONFDIR_FLAG $MODE $LISTEN_HOST_FLAG $TRUST_FLAG -s /app/egress_addon.py
|
|
||||||
```
|
|
||||||
|
|
||||||
So transparent egress needs a launcher mode change plus iptables
|
|
||||||
redirects.
|
|
||||||
|
|
||||||
Run a test mitmproxy container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container run --name bb-spike-230t-mitm \
|
|
||||||
--label bot-bottle.spike=transparent-egress \
|
|
||||||
--network bb-spike-230t-egress \
|
|
||||||
--network bb-spike-230t-agent \
|
|
||||||
--dns 1.1.1.1 \
|
|
||||||
--cap-add CAP_NET_ADMIN \
|
|
||||||
--detach mitmproxy/mitmproxy:11.1.3 \
|
|
||||||
sh -c 'apt-get update >/tmp/apt.log &&
|
|
||||||
apt-get install -y --no-install-recommends iptables iproute2 >>/tmp/apt.log &&
|
|
||||||
echo 1 > /proc/sys/net/ipv4/ip_forward &&
|
|
||||||
iptables -t nat -A PREROUTING -i eth1 -p tcp --dport 80 -j REDIRECT --to-port 8080 &&
|
|
||||||
iptables -t nat -A PREROUTING -i eth1 -p tcp --dport 443 -j REDIRECT --to-port 8080 &&
|
|
||||||
mitmdump --mode transparent@8080 --set showhost=true --set ssl_insecure=true --set confdir=/tmp/mitm -v'
|
|
||||||
```
|
|
||||||
|
|
||||||
The container listened successfully:
|
|
||||||
|
|
||||||
```console
|
|
||||||
Transparent Proxy listening at *:8080.
|
|
||||||
```
|
|
||||||
|
|
||||||
It had an agent-facing address of `192.168.128.7`. Point the agent at
|
|
||||||
it and set DNS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container exec bb-spike-230t-agent sh -c '
|
|
||||||
ip route replace default via 192.168.128.7 dev eth0
|
|
||||||
printf "nameserver 1.1.1.1\n" > /etc/resolv.conf
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
DNS also needs NAT/forwarding because only TCP 80/443 is redirected:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container exec bb-spike-230t-mitm sh -c '
|
|
||||||
iptables -t nat -A POSTROUTING -s 192.168.128.0/24 -o eth0 -j MASQUERADE
|
|
||||||
iptables -A FORWARD -i eth1 -o eth0 -j ACCEPT
|
|
||||||
iptables -A FORWARD -i eth0 -o eth1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
An alternative, and likely better, DNS shape is to run a DNS forwarder on
|
|
||||||
the sidecar's host-only IP and point the agent at it. This was tested
|
|
||||||
with `dnsmasq`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container exec bb-spike-230t-mitm sh -c '
|
|
||||||
apt-get install -y --no-install-recommends dnsmasq
|
|
||||||
cat >/tmp/dnsmasq.conf <<EOF
|
|
||||||
no-daemon
|
|
||||||
listen-address=192.168.128.7
|
|
||||||
bind-interfaces
|
|
||||||
server=1.1.1.1
|
|
||||||
log-queries
|
|
||||||
log-facility=-
|
|
||||||
EOF
|
|
||||||
(dnsmasq --conf-file=/tmp/dnsmasq.conf >/tmp/dnsmasq.log 2>&1 &)
|
|
||||||
sleep 1
|
|
||||||
ss -lunp | grep :53
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed:
|
|
||||||
|
|
||||||
```console
|
|
||||||
UNCONN 0 0 192.168.128.7:53 0.0.0.0:* users:(("dnsmasq",pid=515,fd=4))
|
|
||||||
```
|
|
||||||
|
|
||||||
Point the agent to sidecar DNS:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
container exec bb-spike-230t-agent sh -c '
|
|
||||||
printf "nameserver 192.168.128.7\n" > /etc/resolv.conf
|
|
||||||
nslookup pypi.org
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
Observed:
|
|
||||||
|
|
||||||
```console
|
|
||||||
Server: 192.168.128.7
|
|
||||||
Address: 192.168.128.7:53
|
|
||||||
|
|
||||||
Non-authoritative answer:
|
|
||||||
Name: pypi.org
|
|
||||||
Address: 151.101.128.223
|
|
||||||
Name: pypi.org
|
|
||||||
Address: 151.101.192.223
|
|
||||||
Name: pypi.org
|
|
||||||
Address: 151.101.64.223
|
|
||||||
Name: pypi.org
|
|
||||||
Address: 151.101.0.223
|
|
||||||
```
|
|
||||||
|
|
||||||
Direct HTTP from the agent worked and mitmproxy logged the request:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ container exec bb-spike-230t-agent sh -c \
|
|
||||||
'wget -T 10 -O- http://example.com | head -c 100'
|
|
||||||
Connecting to example.com (172.66.147.243:80)
|
|
||||||
<!doctype html><html lang="en"><head><title>Example Domain</title>
|
|
||||||
```
|
|
||||||
|
|
||||||
Mitmproxy log:
|
|
||||||
|
|
||||||
```console
|
|
||||||
192.168.128.5:39742: GET http://example.com/
|
|
||||||
Host: example.com
|
|
||||||
User-Agent: Wget
|
|
||||||
<< 200 OK 559b
|
|
||||||
```
|
|
||||||
|
|
||||||
After switching the agent to sidecar DNS, direct HTTP still hit
|
|
||||||
mitmproxy:
|
|
||||||
|
|
||||||
```console
|
|
||||||
192.168.128.5:50784: GET http://example.com/
|
|
||||||
Host: example.com
|
|
||||||
User-Agent: Wget
|
|
||||||
<< 200 OK 559b
|
|
||||||
```
|
|
||||||
|
|
||||||
Direct HTTPS from the agent reached mitmproxy but failed certificate
|
|
||||||
verification, as expected when the client does not trust the mitmproxy
|
|
||||||
CA:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ container exec bb-spike-230t-agent sh -c \
|
|
||||||
'wget -T 10 -O- https://pypi.org/simple/pip/ | head -c 100'
|
|
||||||
Connecting to pypi.org (151.101.128.223:443)
|
|
||||||
... certificate verify failed ...
|
|
||||||
```
|
|
||||||
|
|
||||||
Mitmproxy log:
|
|
||||||
|
|
||||||
```console
|
|
||||||
Client TLS handshake failed. The client does not trust the proxy's
|
|
||||||
certificate for pypi.org (tlsv1 alert unknown ca)
|
|
||||||
```
|
|
||||||
|
|
||||||
With verification disabled, the same direct URL succeeded and mitmproxy
|
|
||||||
logged the full HTTPS request:
|
|
||||||
|
|
||||||
```console
|
|
||||||
$ container exec bb-spike-230t-agent sh -c \
|
|
||||||
'wget --no-check-certificate -T 10 -O- https://pypi.org/simple/pip/ | head -c 100'
|
|
||||||
Connecting to pypi.org (151.101.128.223:443)
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta name="pypi:repository-version" content="1.4">
|
|
||||||
```
|
|
||||||
|
|
||||||
Mitmproxy log:
|
|
||||||
|
|
||||||
```console
|
|
||||||
192.168.128.5:32802: GET https://pypi.org/simple/pip/
|
|
||||||
Host: pypi.org
|
|
||||||
User-Agent: Wget
|
|
||||||
<< 200 OK 103k
|
|
||||||
```
|
|
||||||
|
|
||||||
After switching the agent to sidecar DNS, direct HTTPS still hit
|
|
||||||
mitmproxy:
|
|
||||||
|
|
||||||
```console
|
|
||||||
192.168.128.5:50254: GET https://pypi.org/simple/pip/
|
|
||||||
Host: pypi.org
|
|
||||||
User-Agent: Wget
|
|
||||||
<< 200 OK 103k
|
|
||||||
```
|
|
||||||
|
|
||||||
Verdict: transparent mitmproxy mode works in this topology. The bot
|
|
||||||
agent would still need the egress CA installed, which bot-bottle already
|
|
||||||
does for explicit proxy mode.
|
|
||||||
|
|
||||||
## Answers
|
|
||||||
|
|
||||||
### Can the sidecar become the agent network's default gateway?
|
|
||||||
|
|
||||||
Not directly through Apple Container's documented CLI. The installed
|
|
||||||
`container run --help` documents `--network
|
|
||||||
<name>[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE]`; it does not document a
|
|
||||||
gateway option.
|
|
||||||
|
|
||||||
The route can be changed after container start only if the agent has
|
|
||||||
`CAP_NET_ADMIN`. Without it, `ip route replace default via <sidecar>`
|
|
||||||
fails with `Operation not permitted`.
|
|
||||||
|
|
||||||
### Can Apple Container support sidecar forwarding/NAT/transparent proxying?
|
|
||||||
|
|
||||||
Yes. A dual-homed sidecar with `CAP_NET_ADMIN` can enable IP forwarding,
|
|
||||||
set iptables NAT/forwarding rules, and route agent traffic out through
|
|
||||||
the NAT network.
|
|
||||||
|
|
||||||
Transparent mitmproxy interception also works with `PREROUTING`
|
|
||||||
redirects to `mitmdump --mode transparent`.
|
|
||||||
|
|
||||||
### What capabilities/custom image are required?
|
|
||||||
|
|
||||||
At minimum:
|
|
||||||
|
|
||||||
- sidecar needs `CAP_NET_ADMIN`;
|
|
||||||
- sidecar image needs `iptables`/`iproute2` or equivalent nftables
|
|
||||||
tooling;
|
|
||||||
- sidecar should run a DNS listener on its host-only IP, or otherwise
|
|
||||||
provide a controlled resolver path for the agent;
|
|
||||||
- sidecar launcher needs a transparent mode variant;
|
|
||||||
- agent route must be changed to the sidecar's host-only IP;
|
|
||||||
- agent DNS should point to the sidecar DNS listener;
|
|
||||||
- agent must trust the sidecar CA for HTTPS interception.
|
|
||||||
|
|
||||||
The tested agent route mutation required agent `CAP_NET_ADMIN`, which
|
|
||||||
should not be accepted as the final design without a privilege-dropping
|
|
||||||
init/shim story.
|
|
||||||
|
|
||||||
### Can host-level `pf` or vmnet rules replace agent route mutation?
|
|
||||||
|
|
||||||
Not tested. The successful transparent paths did not use host `pf`;
|
|
||||||
they used container-local routing and iptables. Host-level `pf` remains
|
|
||||||
a possible escape hatch if Apple Container cannot set a custom gateway
|
|
||||||
and we reject agent `CAP_NET_ADMIN`.
|
|
||||||
|
|
||||||
### Can existing route policy and DLP semantics be preserved?
|
|
||||||
|
|
||||||
Likely, but not fully validated in this spike. Mitmproxy transparent
|
|
||||||
mode produced normal HTTP flows with correct `Host` values for both
|
|
||||||
HTTP and HTTPS. The existing `egress_addon.py` hooks should still see
|
|
||||||
`flow.request.pretty_host`, method, path, headers, and response bodies.
|
|
||||||
|
|
||||||
But the current sidecar entrypoint only starts `mitmdump` in regular
|
|
||||||
explicit-proxy mode. A real implementation must add a transparent mode
|
|
||||||
launcher and then run the existing egress addon test suite against
|
|
||||||
transparent flows.
|
|
||||||
|
|
||||||
## Recommendation
|
|
||||||
|
|
||||||
Do not switch `macos-container` to transparent egress yet, but keep it
|
|
||||||
as a plausible implementation path.
|
|
||||||
|
|
||||||
The next implementation spike should focus on removing the agent
|
|
||||||
`CAP_NET_ADMIN` requirement. Acceptable options:
|
|
||||||
|
|
||||||
- find or add an Apple Container-supported default-gateway setting;
|
|
||||||
- start the agent through a tiny root init that sets route/DNS, drops
|
|
||||||
capabilities, and then execs the agent as the normal user;
|
|
||||||
- include a sidecar DNS service and set the agent resolver to the
|
|
||||||
sidecar's host-only IP as part of that init/setup path;
|
|
||||||
- avoid routing mutation by using host/vmnet-level packet redirection;
|
|
||||||
- explicitly decide that route mutation is only a convenience layer and
|
|
||||||
keep explicit proxy env vars for v1.
|
|
||||||
|
|
||||||
Bluntly: transparent egress is feasible, but not production-ready until
|
|
||||||
the agent route can be controlled without leaving network-admin power in
|
|
||||||
the agent runtime.
|
|
||||||
+9
-9
@@ -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]:
|
||||||
@@ -46,12 +46,12 @@ def fixture_with_git_dict() -> dict[str, Any]:
|
|||||||
"repos": {
|
"repos": {
|
||||||
"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"},
|
"identity": "/dev/null",
|
||||||
"host_key": "ssh-ed25519 AAAA...",
|
"host_key": "ssh-ed25519 AAAA...",
|
||||||
},
|
},
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/didericis/foo.git",
|
"url": "ssh://git@github.com/didericis/foo.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"identity": "/dev/null",
|
||||||
"host_key": "ssh-ed25519 BBBB...",
|
"host_key": "ssh-ed25519 BBBB...",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,216 +0,0 @@
|
|||||||
"""Unit: Freezer class hierarchy."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle import supervise, bottle_state
|
|
||||||
from bot_bottle.backend import ActiveAgent
|
|
||||||
from bot_bottle.backend.freeze import get_freezer
|
|
||||||
from bot_bottle.backend.docker.freezer import DockerFreezer
|
|
||||||
from bot_bottle.backend.macos_container.freezer import MacosContainerFreezer
|
|
||||||
from bot_bottle.backend.smolmachines.freezer import SmolmachinesFreezer
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeHomeMixin:
|
|
||||||
def _setup_fake_home(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="freezer-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._restore = lambda: setattr(supervise, "bot_bottle_root", original)
|
|
||||||
|
|
||||||
def _teardown_fake_home(self):
|
|
||||||
self._restore()
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
def _make_agent(slug: str, backend: str = "docker") -> ActiveAgent:
|
|
||||||
return ActiveAgent(
|
|
||||||
backend_name=backend,
|
|
||||||
slug=slug,
|
|
||||||
agent_name="dev",
|
|
||||||
started_at="t",
|
|
||||||
services=(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetFreezer(unittest.TestCase):
|
|
||||||
def test_docker(self):
|
|
||||||
self.assertIsInstance(get_freezer("docker"), DockerFreezer)
|
|
||||||
|
|
||||||
def test_empty_backend_gives_docker(self):
|
|
||||||
self.assertIsInstance(get_freezer(""), DockerFreezer)
|
|
||||||
|
|
||||||
def test_macos_container(self):
|
|
||||||
self.assertIsInstance(get_freezer("macos-container"), MacosContainerFreezer)
|
|
||||||
|
|
||||||
def test_smolmachines(self):
|
|
||||||
self.assertIsInstance(get_freezer("smolmachines"), SmolmachinesFreezer)
|
|
||||||
|
|
||||||
def test_unknown_backend_dies(self):
|
|
||||||
with patch("bot_bottle.backend.freeze.die", side_effect=SystemExit("die")):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
get_freezer("unknown-backend")
|
|
||||||
|
|
||||||
|
|
||||||
class TestFreezerBaseCommit(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
"""The base Freezer.commit() owns the shared post-freeze steps."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def test_writes_committed_image_and_marks_preserved(self):
|
|
||||||
slug = "dev-abc12"
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend="docker",
|
|
||||||
))
|
|
||||||
freezer = get_freezer("docker")
|
|
||||||
agent = _make_agent(slug)
|
|
||||||
|
|
||||||
with patch.object(freezer, "_freeze", return_value="bot-bottle-committed-dev-abc12:latest"), \
|
|
||||||
patch("bot_bottle.backend.freeze.info"):
|
|
||||||
freezer.commit(agent)
|
|
||||||
|
|
||||||
self.assertEqual(
|
|
||||||
"bot-bottle-committed-dev-abc12:latest",
|
|
||||||
bottle_state.read_committed_image(slug),
|
|
||||||
)
|
|
||||||
self.assertTrue(bottle_state.is_preserved(slug))
|
|
||||||
|
|
||||||
def test_commit_slug_passes_correct_slug_to_freeze(self):
|
|
||||||
slug = "dev-abc12"
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend="docker",
|
|
||||||
))
|
|
||||||
freezer = get_freezer("docker")
|
|
||||||
captured = {}
|
|
||||||
|
|
||||||
def capture_freeze(agent: ActiveAgent) -> str:
|
|
||||||
captured["slug"] = agent.slug
|
|
||||||
return "some-ref"
|
|
||||||
|
|
||||||
with patch.object(freezer, "_freeze", side_effect=capture_freeze), \
|
|
||||||
patch("bot_bottle.backend.freeze.info"):
|
|
||||||
freezer.commit_slug(slug)
|
|
||||||
|
|
||||||
self.assertEqual(slug, captured["slug"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestDockerFreezer(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def test_commits_container_and_records_image(self):
|
|
||||||
slug = "dev-abc12"
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend="docker",
|
|
||||||
))
|
|
||||||
freezer = DockerFreezer()
|
|
||||||
agent = _make_agent(slug)
|
|
||||||
|
|
||||||
with patch("bot_bottle.backend.docker.freezer.commit_container") as mock_commit, \
|
|
||||||
patch("bot_bottle.backend.freeze.info"), \
|
|
||||||
patch("bot_bottle.backend.docker.freezer.info"):
|
|
||||||
freezer.commit(agent)
|
|
||||||
|
|
||||||
mock_commit.assert_called_once_with(
|
|
||||||
f"bot-bottle-{slug}",
|
|
||||||
f"bot-bottle-committed-{slug}:latest",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
f"bot-bottle-committed-{slug}:latest",
|
|
||||||
bottle_state.read_committed_image(slug),
|
|
||||||
)
|
|
||||||
self.assertTrue(bottle_state.is_preserved(slug))
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerFreezer(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def _write_meta(self, slug: str) -> None:
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend="macos-container",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_commits_running_container_without_stopping(self):
|
|
||||||
"""Commit should exec-tar the running container, not stop it."""
|
|
||||||
slug = "dev-abc12"
|
|
||||||
self._write_meta(slug)
|
|
||||||
freezer = MacosContainerFreezer()
|
|
||||||
agent = _make_agent(slug, "macos-container")
|
|
||||||
|
|
||||||
with patch("bot_bottle.backend.macos_container.freezer.commit_container") as mock_commit, \
|
|
||||||
patch("bot_bottle.backend.freeze.info"), \
|
|
||||||
patch("bot_bottle.backend.macos_container.freezer.info"):
|
|
||||||
freezer.commit(agent)
|
|
||||||
|
|
||||||
mock_commit.assert_called_once_with(
|
|
||||||
f"bot-bottle-{slug}",
|
|
||||||
f"bot-bottle-committed-{slug}:latest",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
f"bot-bottle-committed-{slug}:latest",
|
|
||||||
bottle_state.read_committed_image(slug),
|
|
||||||
)
|
|
||||||
self.assertTrue(bottle_state.is_preserved(slug))
|
|
||||||
|
|
||||||
|
|
||||||
class TestSmolmachinesFreezer(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def _write_meta(self, slug: str) -> None:
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend="smolmachines",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_snapshots_running_vm_without_stopping(self):
|
|
||||||
"""Commit should exec-tar the running VM, not stop it."""
|
|
||||||
slug = "dev-abc12"
|
|
||||||
self._write_meta(slug)
|
|
||||||
freezer = SmolmachinesFreezer()
|
|
||||||
agent = _make_agent(slug, "smolmachines")
|
|
||||||
|
|
||||||
with patch("bot_bottle.backend.smolmachines.freezer._snapshot_running_vm") as mock_snap, \
|
|
||||||
patch("bot_bottle.backend.freeze.info"), \
|
|
||||||
patch("bot_bottle.backend.smolmachines.freezer.info"):
|
|
||||||
freezer.commit(agent)
|
|
||||||
|
|
||||||
expected_binary = bottle_state.bottle_state_dir(slug) / "committed-smolmachine"
|
|
||||||
mock_snap.assert_called_once_with(
|
|
||||||
f"bot-bottle-{slug}",
|
|
||||||
f"bot-bottle-committed-{slug}:latest",
|
|
||||||
expected_binary,
|
|
||||||
)
|
|
||||||
expected_sidecar = str(expected_binary.with_suffix(".smolmachine"))
|
|
||||||
self.assertEqual(expected_sidecar, bottle_state.read_committed_image(slug))
|
|
||||||
self.assertTrue(bottle_state.is_preserved(slug))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -277,56 +277,5 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
|
|||||||
self.assertEqual("", loaded.backend)
|
self.assertEqual("", loaded.backend)
|
||||||
|
|
||||||
|
|
||||||
class TestCommittedImage(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
"""write_committed_image / read_committed_image round-trip."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def test_returns_none_when_absent(self):
|
|
||||||
self.assertIsNone(bottle_state.read_committed_image("dev"))
|
|
||||||
|
|
||||||
def test_write_then_read_roundtrip(self):
|
|
||||||
bottle_state.write_committed_image("dev", "bot-bottle-committed-dev:latest")
|
|
||||||
self.assertEqual(
|
|
||||||
"bot-bottle-committed-dev:latest",
|
|
||||||
bottle_state.read_committed_image("dev"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_strips_trailing_newline_on_read(self):
|
|
||||||
path = bottle_state.committed_image_path("dev")
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text("bot-bottle-committed-dev:latest\n\n")
|
|
||||||
self.assertEqual(
|
|
||||||
"bot-bottle-committed-dev:latest",
|
|
||||||
bottle_state.read_committed_image("dev"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_isolated_per_slug(self):
|
|
||||||
bottle_state.write_committed_image("dev", "bot-bottle-committed-dev:latest")
|
|
||||||
bottle_state.write_committed_image("api", "bot-bottle-committed-api:latest")
|
|
||||||
self.assertEqual(
|
|
||||||
"bot-bottle-committed-dev:latest",
|
|
||||||
bottle_state.read_committed_image("dev"),
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
"bot-bottle-committed-api:latest",
|
|
||||||
bottle_state.read_committed_image("api"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_path_under_state_dir(self):
|
|
||||||
path = bottle_state.committed_image_path("dev")
|
|
||||||
self.assertTrue(str(path).endswith("/.bot-bottle/state/dev/committed-image"))
|
|
||||||
|
|
||||||
def test_empty_content_returns_none(self):
|
|
||||||
path = bottle_state.committed_image_path("dev")
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(" \n")
|
|
||||||
self.assertIsNone(bottle_state.read_committed_image("dev"))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
"""Unit: cli.py commit command."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from bot_bottle.cli.commit import cmd_commit
|
|
||||||
from bot_bottle import supervise
|
|
||||||
from bot_bottle import bottle_state
|
|
||||||
from bot_bottle.backend.freeze import CommitCancelled
|
|
||||||
|
|
||||||
|
|
||||||
class _FakeHomeMixin:
|
|
||||||
def _setup_fake_home(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-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._restore = lambda: setattr(supervise, "bot_bottle_root", original)
|
|
||||||
|
|
||||||
def _teardown_fake_home(self):
|
|
||||||
self._restore()
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
"""cmd_commit with an explicit slug delegates to get_freezer."""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def _write_meta(self, slug: str, backend: str) -> None:
|
|
||||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
|
||||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
|
||||||
started_at="t", backend=backend,
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_commits_docker_bottle(self):
|
|
||||||
slug = "dev-abc12"
|
|
||||||
self._write_meta(slug, "docker")
|
|
||||||
|
|
||||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
|
||||||
mock_freezer = MagicMock()
|
|
||||||
mock_gf.return_value = mock_freezer
|
|
||||||
rc = cmd_commit([slug])
|
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
mock_gf.assert_called_once_with("docker")
|
|
||||||
mock_freezer.commit_slug.assert_called_once_with(slug)
|
|
||||||
|
|
||||||
def test_empty_backend_passed_to_get_freezer(self):
|
|
||||||
"""Old state dirs without a backend field pass '' to get_freezer."""
|
|
||||||
slug = "dev-abc12"
|
|
||||||
self._write_meta(slug, "")
|
|
||||||
|
|
||||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
|
||||||
mock_freezer = MagicMock()
|
|
||||||
mock_gf.return_value = mock_freezer
|
|
||||||
rc = cmd_commit([slug])
|
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
mock_gf.assert_called_once_with("")
|
|
||||||
|
|
||||||
def test_commits_macos_container_bottle(self):
|
|
||||||
slug = "dev-abc12"
|
|
||||||
self._write_meta(slug, "macos-container")
|
|
||||||
|
|
||||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
|
||||||
mock_freezer = MagicMock()
|
|
||||||
mock_gf.return_value = mock_freezer
|
|
||||||
rc = cmd_commit([slug])
|
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
mock_gf.assert_called_once_with("macos-container")
|
|
||||||
mock_freezer.commit_slug.assert_called_once_with(slug)
|
|
||||||
|
|
||||||
def test_commits_smolmachines_bottle(self):
|
|
||||||
slug = "dev-abc12"
|
|
||||||
self._write_meta(slug, "smolmachines")
|
|
||||||
|
|
||||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
|
||||||
mock_freezer = MagicMock()
|
|
||||||
mock_gf.return_value = mock_freezer
|
|
||||||
rc = cmd_commit([slug])
|
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
mock_gf.assert_called_once_with("smolmachines")
|
|
||||||
|
|
||||||
def test_returns_zero_on_commit_cancelled(self):
|
|
||||||
slug = "dev-abc12"
|
|
||||||
self._write_meta(slug, "macos-container")
|
|
||||||
|
|
||||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
|
||||||
mock_freezer = MagicMock()
|
|
||||||
mock_freezer.commit_slug.side_effect = CommitCancelled
|
|
||||||
mock_gf.return_value = mock_freezer
|
|
||||||
rc = cmd_commit([slug])
|
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCmdCommitNoActiveBottles(_FakeHomeMixin, unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._setup_fake_home()
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._teardown_fake_home()
|
|
||||||
|
|
||||||
def test_dies_when_no_active_bottles_and_no_slug(self):
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[],
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"),
|
|
||||||
) as mock_die:
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
cmd_commit([])
|
|
||||||
|
|
||||||
mock_die.assert_called_once()
|
|
||||||
|
|
||||||
def test_returns_zero_when_picker_cancelled(self):
|
|
||||||
active = MagicMock()
|
|
||||||
active.slug = "dev-abc12"
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[active],
|
|
||||||
), patch(
|
|
||||||
"bot_bottle.cli.commit.tui.filter_select", return_value=None,
|
|
||||||
):
|
|
||||||
rc = cmd_commit([])
|
|
||||||
|
|
||||||
self.assertEqual(0, rc)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -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()
|
||||||
|
|||||||
+16
-13
@@ -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."""
|
||||||
@@ -51,7 +51,7 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
|
|||||||
bottle["git-gate"] = {"repos": {
|
bottle["git-gate"] = {"repos": {
|
||||||
"upstream": {
|
"upstream": {
|
||||||
"url": "ssh://git@example.com:22/x/y.git",
|
"url": "ssh://git@example.com:22/x/y.git",
|
||||||
"key": {"provider": "static", "path": "/etc/hostname"},
|
"identity": "/etc/hostname", # any existing file
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
if with_egress:
|
if with_egress:
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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={},
|
||||||
|
|||||||
@@ -1,192 +0,0 @@
|
|||||||
"""Unit: Docker launch step uses committed image when available."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import io
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
|
||||||
from bot_bottle.backend import BottleSpec
|
|
||||||
from bot_bottle.backend.docker import launch as launch_mod
|
|
||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
|
||||||
from bot_bottle.egress import EgressPlan
|
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
|
||||||
from bot_bottle.manifest import ManifestIndex
|
|
||||||
|
|
||||||
|
|
||||||
_SLUG = "dev-abc12"
|
|
||||||
_COMMITTED_TAG = f"bot-bottle-committed-{_SLUG}:latest"
|
|
||||||
_DEFAULT_IMAGE = "bot-bottle-claude:latest"
|
|
||||||
|
|
||||||
_IDX = ManifestIndex.from_json_obj({
|
|
||||||
"bottles": {"dev": {}},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _plan(tmp: str) -> DockerBottlePlan:
|
|
||||||
stage = Path(tmp)
|
|
||||||
spec = BottleSpec(
|
|
||||||
manifest=_IDX,
|
|
||||||
agent_name="demo",
|
|
||||||
copy_cwd=False,
|
|
||||||
user_cwd=tmp,
|
|
||||||
identity=_SLUG,
|
|
||||||
)
|
|
||||||
return DockerBottlePlan(
|
|
||||||
spec=spec,
|
|
||||||
manifest=_IDX.load_for_agent("demo"),
|
|
||||||
stage_dir=stage,
|
|
||||||
git_gate_plan=GitGatePlan(
|
|
||||||
slug=_SLUG,
|
|
||||||
entrypoint_script=stage / "entrypoint.sh",
|
|
||||||
hook_script=stage / "hook.sh",
|
|
||||||
access_hook_script=stage / "access-hook.sh",
|
|
||||||
upstreams=(),
|
|
||||||
),
|
|
||||||
egress_plan=EgressPlan(
|
|
||||||
slug=_SLUG,
|
|
||||||
routes_path=stage / "egress.yaml",
|
|
||||||
routes=(),
|
|
||||||
token_env_map={},
|
|
||||||
),
|
|
||||||
supervise_plan=None,
|
|
||||||
agent_provision=AgentProvisionPlan(
|
|
||||||
template="claude",
|
|
||||||
command="claude",
|
|
||||||
prompt_mode="append_file",
|
|
||||||
image=_DEFAULT_IMAGE,
|
|
||||||
dockerfile="",
|
|
||||||
guest_home="/home/node",
|
|
||||||
instance_name=f"bot-bottle-{_SLUG}",
|
|
||||||
prompt_file=stage / "prompt.txt",
|
|
||||||
guest_env={},
|
|
||||||
),
|
|
||||||
slug=_SLUG,
|
|
||||||
forwarded_env={},
|
|
||||||
use_runsc=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestLaunchCommittedImage(unittest.TestCase):
|
|
||||||
def setUp(self) -> None:
|
|
||||||
self._tmp = tempfile.mkdtemp(prefix="launch-committed-test.")
|
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
|
||||||
import shutil
|
|
||||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
|
||||||
|
|
||||||
def _run_launch(
|
|
||||||
self,
|
|
||||||
plan: DockerBottlePlan,
|
|
||||||
*,
|
|
||||||
committed_tag: str | None = None,
|
|
||||||
image_present: bool = True,
|
|
||||||
) -> list[str]:
|
|
||||||
"""Drive launch() through its full sequence with the committed-image
|
|
||||||
behaviour controlled by the arguments. Returns the images that were
|
|
||||||
passed to `build_image` (empty list if it was never called)."""
|
|
||||||
built: list[str] = []
|
|
||||||
|
|
||||||
def fake_build(image: str, ctx: str, *, dockerfile: str = "") -> None:
|
|
||||||
del ctx, dockerfile
|
|
||||||
built.append(image)
|
|
||||||
|
|
||||||
with mock.patch.object(
|
|
||||||
launch_mod, "read_committed_image", return_value=committed_tag,
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod.docker_mod, "image_exists", return_value=image_present,
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod.docker_mod, "build_image", side_effect=fake_build,
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod, "egress_tls_init",
|
|
||||||
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod.network_mod, "network_name_for_slug",
|
|
||||||
return_value="bb-internal",
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod.network_mod, "network_egress_name_for_slug",
|
|
||||||
return_value="bb-egress",
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod, "bottle_plan_to_compose",
|
|
||||||
return_value={"services": {"agent": {}}},
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod, "write_compose_file",
|
|
||||||
return_value=Path("/tmp/compose.yml"),
|
|
||||||
), mock.patch.object(launch_mod, "compose_up"), \
|
|
||||||
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
|
||||||
mock.patch.object(launch_mod, "compose_down"), \
|
|
||||||
contextlib.redirect_stderr(io.StringIO()):
|
|
||||||
provision = mock.Mock(return_value=None)
|
|
||||||
with launch_mod.launch(plan, provision=provision):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return built
|
|
||||||
|
|
||||||
def test_skips_build_when_committed_image_present(self) -> None:
|
|
||||||
plan = _plan(self._tmp)
|
|
||||||
built = self._run_launch(plan, committed_tag=_COMMITTED_TAG, image_present=True)
|
|
||||||
self.assertEqual([], built, "build_image should not be called when committed image exists")
|
|
||||||
|
|
||||||
def test_uses_committed_image_in_compose_spec(self) -> None:
|
|
||||||
"""The compose spec renderer receives the committed image tag via
|
|
||||||
plan.image — captured here by checking what bottle_plan_to_compose
|
|
||||||
was called with."""
|
|
||||||
plan = _plan(self._tmp)
|
|
||||||
captured_plans: list[DockerBottlePlan] = []
|
|
||||||
|
|
||||||
def fake_compose(p: DockerBottlePlan) -> dict[str, Any]:
|
|
||||||
captured_plans.append(p)
|
|
||||||
return {"services": {"agent": {}}}
|
|
||||||
|
|
||||||
with mock.patch.object(
|
|
||||||
launch_mod, "read_committed_image", return_value=_COMMITTED_TAG,
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod.docker_mod, "image_exists", return_value=True,
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod.docker_mod, "build_image",
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod, "egress_tls_init",
|
|
||||||
return_value=(Path("/egress_ca"), Path("/egress_cert")),
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod.network_mod, "network_name_for_slug",
|
|
||||||
return_value="bb-internal",
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod.network_mod, "network_egress_name_for_slug",
|
|
||||||
return_value="bb-egress",
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod, "bottle_plan_to_compose", side_effect=fake_compose,
|
|
||||||
), mock.patch.object(
|
|
||||||
launch_mod, "write_compose_file",
|
|
||||||
return_value=Path("/tmp/compose.yml"),
|
|
||||||
), mock.patch.object(launch_mod, "compose_up"), \
|
|
||||||
mock.patch.object(launch_mod, "compose_dump_logs"), \
|
|
||||||
mock.patch.object(launch_mod, "compose_down"), \
|
|
||||||
contextlib.redirect_stderr(io.StringIO()):
|
|
||||||
provision = mock.Mock(return_value=None)
|
|
||||||
with launch_mod.launch(plan, provision=provision):
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.assertEqual(1, len(captured_plans))
|
|
||||||
self.assertEqual(_COMMITTED_TAG, captured_plans[0].image)
|
|
||||||
|
|
||||||
def test_falls_back_to_build_when_no_committed_image(self) -> None:
|
|
||||||
plan = _plan(self._tmp)
|
|
||||||
built = self._run_launch(plan, committed_tag=None)
|
|
||||||
self.assertEqual([_DEFAULT_IMAGE], built)
|
|
||||||
|
|
||||||
def test_falls_back_to_build_when_committed_image_missing_from_daemon(self) -> None:
|
|
||||||
plan = _plan(self._tmp)
|
|
||||||
built = self._run_launch(
|
|
||||||
plan, committed_tag=_COMMITTED_TAG, image_present=False,
|
|
||||||
)
|
|
||||||
self.assertEqual([_DEFAULT_IMAGE], built)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -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({
|
|
||||||
"bottles": {"dev": {}},
|
def _manifest() -> Manifest:
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
return Manifest.from_json_obj({
|
||||||
})
|
"bottles": {"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",
|
||||||
|
|||||||
@@ -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={},
|
||||||
|
|||||||
@@ -67,46 +67,5 @@ class TestSave(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestCommitContainer(unittest.TestCase):
|
|
||||||
def test_runs_docker_commit(self):
|
|
||||||
with patch.object(
|
|
||||||
docker_mod.subprocess, "run", return_value=_ok(),
|
|
||||||
) as run, patch.object(docker_mod, "info"):
|
|
||||||
docker_mod.commit_container(
|
|
||||||
"bot-bottle-dev-abc12",
|
|
||||||
"bot-bottle-committed-dev-abc12:latest",
|
|
||||||
)
|
|
||||||
argv = run.call_args.args[0]
|
|
||||||
self.assertEqual(
|
|
||||||
[
|
|
||||||
"docker", "commit",
|
|
||||||
"bot-bottle-dev-abc12",
|
|
||||||
"bot-bottle-committed-dev-abc12:latest",
|
|
||||||
],
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_dies_on_docker_commit_failure(self):
|
|
||||||
with patch.object(
|
|
||||||
docker_mod.subprocess, "run", return_value=_fail("No such container"),
|
|
||||||
), patch.object(
|
|
||||||
docker_mod, "die", side_effect=SystemExit("die"),
|
|
||||||
) as die:
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
docker_mod.commit_container("missing-container", "some:tag")
|
|
||||||
die.assert_called_once()
|
|
||||||
self.assertIn("missing-container", die.call_args.args[0])
|
|
||||||
|
|
||||||
def test_die_message_includes_image_tag(self):
|
|
||||||
with patch.object(
|
|
||||||
docker_mod.subprocess, "run", return_value=_fail("boom"),
|
|
||||||
), patch.object(
|
|
||||||
docker_mod, "die", side_effect=SystemExit("die"),
|
|
||||||
) as die:
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
docker_mod.commit_container("ctr", "my-tag:v1")
|
|
||||||
self.assertIn("my-tag:v1", die.call_args.args[0])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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"}],
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -181,13 +181,6 @@ class TestHookRender(unittest.TestCase):
|
|||||||
self.assertIn("BatchMode=yes", hook)
|
self.assertIn("BatchMode=yes", hook)
|
||||||
self.assertIn("ConnectTimeout=", hook)
|
self.assertIn("ConnectTimeout=", hook)
|
||||||
|
|
||||||
def test_force_push_uses_plus_refspec(self):
|
|
||||||
# A non-fast-forward push (old != zero, new not a descendant of old)
|
|
||||||
# must forward +$new:$ref so the upstream accepts the force push.
|
|
||||||
hook = git_gate_render_hook()
|
|
||||||
self.assertIn('git merge-base --is-ancestor "$old" "$new"', hook)
|
|
||||||
self.assertIn('refspec="+$new:$ref"', hook)
|
|
||||||
|
|
||||||
def test_forward_preserves_push_options(self):
|
def test_forward_preserves_push_options(self):
|
||||||
# Git exposes push options to pre-receive hooks as
|
# Git exposes push options to pre-receive hooks as
|
||||||
# GIT_PUSH_OPTION_COUNT + indexed GIT_PUSH_OPTION_N variables.
|
# GIT_PUSH_OPTION_COUNT + indexed GIT_PUSH_OPTION_N variables.
|
||||||
@@ -280,11 +273,11 @@ 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",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}}}},
|
}}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -9,18 +9,8 @@ from types import SimpleNamespace
|
|||||||
from typing import cast
|
from typing import cast
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
|
||||||
from bot_bottle.backend import BottleSpec
|
|
||||||
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.egress import EgressPlan
|
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
|
||||||
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(
|
||||||
@@ -31,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)
|
||||||
@@ -77,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",
|
||||||
@@ -129,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,
|
||||||
@@ -199,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,
|
||||||
@@ -265,80 +258,5 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_plan(stage_dir: Path) -> MacosContainerBottlePlan:
|
|
||||||
return MacosContainerBottlePlan(
|
|
||||||
spec=cast(BottleSpec, SimpleNamespace()),
|
|
||||||
manifest=_MANIFEST,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())),
|
|
||||||
egress_plan=cast(EgressPlan, SimpleNamespace()),
|
|
||||||
supervise_plan=None,
|
|
||||||
agent_provision=AgentProvisionPlan(
|
|
||||||
template="claude",
|
|
||||||
command="claude",
|
|
||||||
prompt_mode="append_file",
|
|
||||||
image="bot-bottle-agent:latest",
|
|
||||||
dockerfile="/repo/Dockerfile",
|
|
||||||
guest_home="/home/node",
|
|
||||||
instance_name="bot-bottle-dev-abc",
|
|
||||||
prompt_file=stage_dir / "prompt.txt",
|
|
||||||
guest_env={},
|
|
||||||
),
|
|
||||||
slug="dev-abc",
|
|
||||||
forwarded_env={},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerLaunchCommittedImage(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.stage_dir = Path(self._tmp.name)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def test_build_images_uses_committed_image_when_present(self):
|
|
||||||
plan = _build_plan(self.stage_dir)
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_build(image: str, context: str, *, dockerfile: str = "") -> None:
|
|
||||||
calls.append((image, context, dockerfile))
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
launch, "read_committed_image",
|
|
||||||
return_value="bot-bottle-committed-dev-abc:latest",
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "image_exists", return_value=True,
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "build_image", side_effect=fake_build,
|
|
||||||
), patch.object(launch, "info"):
|
|
||||||
updated = launch._build_images(plan)
|
|
||||||
|
|
||||||
self.assertEqual("bot-bottle-committed-dev-abc:latest", updated.image)
|
|
||||||
self.assertEqual(1, len(calls))
|
|
||||||
self.assertEqual(launch.SIDECAR_BUNDLE_IMAGE, calls[0][0])
|
|
||||||
|
|
||||||
def test_build_images_builds_agent_when_committed_image_missing(self):
|
|
||||||
plan = _build_plan(self.stage_dir)
|
|
||||||
calls = []
|
|
||||||
|
|
||||||
def fake_build(image: str, context: str, *, dockerfile: str = "") -> None:
|
|
||||||
calls.append((image, context, dockerfile))
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
launch, "read_committed_image",
|
|
||||||
return_value="bot-bottle-committed-dev-abc:latest",
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "image_exists", return_value=False,
|
|
||||||
), patch.object(
|
|
||||||
launch.container_mod, "build_image", side_effect=fake_build,
|
|
||||||
):
|
|
||||||
updated = launch._build_images(plan)
|
|
||||||
|
|
||||||
self.assertEqual("bot-bottle-agent:latest", updated.image)
|
|
||||||
self.assertEqual(2, len(calls))
|
|
||||||
self.assertEqual("bot-bottle-agent:latest", calls[1][0])
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -73,53 +73,6 @@ resolver #2
|
|||||||
)
|
)
|
||||||
self.assertTrue(run.call_args_list[-1].kwargs["check"])
|
self.assertTrue(run.call_args_list[-1].kwargs["check"])
|
||||||
|
|
||||||
def test_commit_container_execs_tar_and_builds_image(self):
|
|
||||||
# stderr is bytes because subprocess.run uses stderr=PIPE without text=True
|
|
||||||
completed = util.subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=0, stdout=b"", stderr=b"",
|
|
||||||
)
|
|
||||||
dockerfile_text = ""
|
|
||||||
|
|
||||||
def fake_build_image(image_tag: str, context: str, *, dockerfile: str = "") -> None:
|
|
||||||
nonlocal dockerfile_text
|
|
||||||
with open(dockerfile, encoding="utf-8") as f:
|
|
||||||
dockerfile_text = f.read()
|
|
||||||
|
|
||||||
with patch.object(util.subprocess, "run", return_value=completed) as run, \
|
|
||||||
patch.object(util, "build_image", side_effect=fake_build_image) as build_image, \
|
|
||||||
patch.object(util, "info"):
|
|
||||||
util.commit_container(
|
|
||||||
"bot-bottle-dev-abc12",
|
|
||||||
"bot-bottle-committed-dev-abc12:latest",
|
|
||||||
)
|
|
||||||
|
|
||||||
argv = run.call_args.args[0]
|
|
||||||
self.assertEqual("container", argv[0])
|
|
||||||
self.assertEqual("exec", argv[1])
|
|
||||||
self.assertIn("bot-bottle-dev-abc12", argv)
|
|
||||||
self.assertIn("tar", argv)
|
|
||||||
self.assertIn("--directory=/", argv)
|
|
||||||
build_image.assert_called_once()
|
|
||||||
self.assertEqual(
|
|
||||||
"bot-bottle-committed-dev-abc12:latest",
|
|
||||||
build_image.call_args.args[0],
|
|
||||||
)
|
|
||||||
self.assertIn("ADD rootfs.tar /\n", dockerfile_text)
|
|
||||||
self.assertIn("USER node\n", dockerfile_text)
|
|
||||||
self.assertIn("WORKDIR /home/node\n", dockerfile_text)
|
|
||||||
|
|
||||||
def test_commit_container_dies_on_exec_tar_failure(self):
|
|
||||||
failed = util.subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=1, stdout=b"", stderr=b"No such container",
|
|
||||||
)
|
|
||||||
with patch.object(util.subprocess, "run", return_value=failed), \
|
|
||||||
patch.object(util, "die", side_effect=SystemExit("die")) as die:
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
util.commit_container("missing-container", "some:tag")
|
|
||||||
|
|
||||||
die.assert_called_once()
|
|
||||||
self.assertIn("missing-container", die.call_args.args[0])
|
|
||||||
|
|
||||||
def test_build_image_restarts_builder_when_dns_mismatches(self):
|
def test_build_image_restarts_builder_when_dns_mismatches(self):
|
||||||
status = util.subprocess.CompletedProcess(
|
status = util.subprocess.CompletedProcess(
|
||||||
args=[],
|
args=[],
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -133,7 +112,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
|||||||
class TestAgentGitUserRejections(unittest.TestCase):
|
class TestAgentGitUserRejections(unittest.TestCase):
|
||||||
def test_agent_repos_dies_bottle_only(self):
|
def test_agent_repos_dies_bottle_only(self):
|
||||||
msg = _error_message(_manifest, agent_git={
|
msg = _error_message(_manifest, agent_git={
|
||||||
"repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}},
|
"repos": {"r": {"url": "ssh://git@x/y.git", "identity": "/dev/null"}},
|
||||||
})
|
})
|
||||||
self.assertIn("git-gate.repos", msg)
|
self.assertIn("git-gate.repos", msg)
|
||||||
self.assertIn("bottle-only", msg)
|
self.assertIn("bottle-only", msg)
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"}},
|
||||||
|
|||||||
@@ -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,11 +113,11 @@ 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", "identity": "/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", "identity": "/dev/null"}
|
||||||
|
|
||||||
def test_child_git_repos_merge_with_parent(self):
|
def test_child_git_repos_merge_with_parent(self):
|
||||||
m = _build(
|
m = _build(
|
||||||
@@ -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", "identity": "/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}}},
|
||||||
|
|||||||
+106
-136
@@ -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,10 +14,10 @@ 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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
entries = m.bottles["dev"].git
|
entries = m.bottles["dev"].git
|
||||||
@@ -30,10 +30,10 @@ 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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
e = m.bottles["dev"].git[0]
|
e = m.bottles["dev"].git[0]
|
||||||
@@ -41,105 +41,105 @@ 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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
"host_key": "ssh-ed25519 AAAA",
|
"host_key": "ssh-ed25519 AAAA",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
self.assertEqual("my-repo", m.bottles["dev"].git[0].Name)
|
self.assertEqual("my-repo", m.bottles["dev"].git[0].Name)
|
||||||
|
|
||||||
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": {"identity": "/dev/null"},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
def test_missing_key_block_dies(self):
|
def test_missing_identity_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"},
|
"identity": "/dev/null",
|
||||||
"IdentityFile": "/dev/null", # old PascalCase key
|
"IdentityFile": "/dev/null", # old PascalCase key
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
e = m.bottles["dev"].git[0]
|
e = m.bottles["dev"].git[0]
|
||||||
@@ -152,15 +152,15 @@ 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",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
"bar": {
|
"bar": {
|
||||||
"url": "ssh://git@b.example/y.git",
|
"url": "ssh://git@b.example/y.git",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}}}},
|
}}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
@@ -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,45 +187,45 @@ 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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
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"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
self.assertEqual("my.repo-name_1", m.bottles["dev"].git[0].Name)
|
self.assertEqual("my.repo-name_1", m.bottles["dev"].git[0].Name)
|
||||||
@@ -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"}},
|
||||||
})
|
})
|
||||||
@@ -243,146 +243,116 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
self.assertIn("PRD 0047", msg)
|
self.assertIn("PRD 0047", msg)
|
||||||
|
|
||||||
|
|
||||||
class TestStaticKey(unittest.TestCase):
|
class TestProvisionedKey(unittest.TestCase):
|
||||||
"""git-gate.repos entries with key.provider = "static"."""
|
"""git-gate.repos entries that use provisioned_key (PRD 0048)."""
|
||||||
|
|
||||||
def test_static_key_minimal(self):
|
def test_provisioned_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"},
|
"provisioned_key": {
|
||||||
},
|
|
||||||
}))
|
|
||||||
e = m.bottles["dev"].git[0]
|
|
||||||
self.assertEqual("bot-bottle", e.Name)
|
|
||||||
self.assertEqual("static", e.Key.provider)
|
|
||||||
self.assertEqual("/home/user/.ssh/id_ed25519", e.Key.path)
|
|
||||||
self.assertEqual("/home/user/.ssh/id_ed25519", e.IdentityFile)
|
|
||||||
|
|
||||||
def test_static_key_sets_identity_file_at_parse_time(self):
|
|
||||||
m = ManifestIndex.from_json_obj(_manifest({
|
|
||||||
"foo": {
|
|
||||||
"url": "ssh://git@github.com/foo.git",
|
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
self.assertEqual("/dev/null", m.bottles["dev"].git[0].IdentityFile)
|
|
||||||
|
|
||||||
def test_static_key_missing_path_dies(self):
|
|
||||||
with self.assertRaises(ManifestError):
|
|
||||||
ManifestIndex.from_json_obj(_manifest({
|
|
||||||
"foo": {
|
|
||||||
"url": "ssh://git@github.com/foo.git",
|
|
||||||
"key": {"provider": "static"},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
def test_static_key_unknown_field_dies(self):
|
|
||||||
with self.assertRaises(ManifestError):
|
|
||||||
ManifestIndex.from_json_obj(_manifest({
|
|
||||||
"foo": {
|
|
||||||
"url": "ssh://git@github.com/foo.git",
|
|
||||||
"key": {"provider": "static", "path": "/dev/null", "api_url": "x"},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
class TestGiteaKey(unittest.TestCase):
|
|
||||||
"""git-gate.repos entries with key.provider = "gitea"."""
|
|
||||||
|
|
||||||
def test_gitea_key_minimal(self):
|
|
||||||
m = ManifestIndex.from_json_obj(_manifest({
|
|
||||||
"bot-bottle": {
|
|
||||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
|
||||||
"key": {
|
|
||||||
"provider": "gitea",
|
"provider": "gitea",
|
||||||
"forge_token_env": "GITEA_TOKEN",
|
"token_env": "GITEA_TOKEN",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
e = m.bottles["dev"].git[0]
|
e = m.bottles["dev"].git[0]
|
||||||
self.assertEqual("bot-bottle", e.Name)
|
self.assertEqual("bot-bottle", e.Name)
|
||||||
self.assertEqual("gitea", e.Key.provider)
|
self.assertIsNotNone(e.ProvisionedKey)
|
||||||
self.assertEqual("GITEA_TOKEN", e.Key.forge_token_env)
|
assert e.ProvisionedKey is not None
|
||||||
self.assertEqual("", e.Key.api_url)
|
self.assertEqual("gitea", e.ProvisionedKey.provider)
|
||||||
|
self.assertEqual("GITEA_TOKEN", e.ProvisionedKey.token_env)
|
||||||
|
self.assertEqual("", e.ProvisionedKey.api_url)
|
||||||
self.assertEqual("", e.IdentityFile)
|
self.assertEqual("", e.IdentityFile)
|
||||||
|
|
||||||
def test_gitea_key_with_api_url(self):
|
def test_provisioned_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": {
|
"provisioned_key": {
|
||||||
"provider": "gitea",
|
"provider": "gitea",
|
||||||
"forge_token_env": "MY_TOKEN",
|
"token_env": "MY_TOKEN",
|
||||||
"api_url": "https://gitea.example.com",
|
"api_url": "https://gitea.example.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
self.assertEqual("https://gitea.example.com", m.bottles["dev"].git[0].Key.api_url)
|
pk = m.bottles["dev"].git[0].ProvisionedKey
|
||||||
|
assert pk is not None
|
||||||
|
self.assertEqual("https://gitea.example.com", pk.api_url)
|
||||||
|
|
||||||
def test_gitea_key_has_no_identity_file_at_parse_time(self):
|
def test_both_identity_and_provisioned_key_dies(self):
|
||||||
m = ManifestIndex.from_json_obj(_manifest({
|
with self.assertRaises(ManifestError) as ctx:
|
||||||
"foo": {
|
Manifest.from_json_obj(_manifest({
|
||||||
"url": "ssh://git@github.com/didericis/foo.git",
|
|
||||||
"key": {"provider": "gitea", "forge_token_env": "T"},
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
self.assertEqual("", m.bottles["dev"].git[0].IdentityFile)
|
|
||||||
|
|
||||||
def test_gitea_key_missing_forge_token_env_dies(self):
|
|
||||||
with self.assertRaises(ManifestError):
|
|
||||||
ManifestIndex.from_json_obj(_manifest({
|
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"key": {"provider": "gitea"},
|
"identity": "/dev/null",
|
||||||
|
"provisioned_key": {"provider": "gitea", "token_env": "T"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
self.assertIn("exactly one of", str(ctx.exception))
|
||||||
|
self.assertIn("got both", str(ctx.exception))
|
||||||
|
|
||||||
def test_gitea_key_unknown_field_dies(self):
|
def test_neither_identity_nor_provisioned_key_dies(self):
|
||||||
|
with self.assertRaises(ManifestError) as ctx:
|
||||||
|
Manifest.from_json_obj(_manifest({
|
||||||
|
"foo": {"url": "ssh://git@github.com/foo.git"},
|
||||||
|
}))
|
||||||
|
self.assertIn("exactly one of", str(ctx.exception))
|
||||||
|
self.assertIn("got neither", str(ctx.exception))
|
||||||
|
|
||||||
|
def test_unknown_key_in_provisioned_key_block_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": {
|
"provisioned_key": {
|
||||||
"provider": "gitea",
|
"provider": "gitea",
|
||||||
"forge_token_env": "T",
|
"token_env": "T",
|
||||||
"key_type": "rsa", # not allowed
|
"key_type": "rsa", # not allowed
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
class TestKeyBlockValidation(unittest.TestCase):
|
|
||||||
"""Validation rules on the key block shared across providers."""
|
|
||||||
|
|
||||||
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"},
|
"provisioned_key": {"token_env": "T"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
def test_unknown_provider_dies(self):
|
def test_missing_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": "github"},
|
"provisioned_key": {"provider": "gitea"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
def test_missing_key_block_dies(self):
|
def test_provisioned_key_entry_has_no_identity_file(self):
|
||||||
with self.assertRaises(ManifestError):
|
m = Manifest.from_json_obj(_manifest({
|
||||||
ManifestIndex.from_json_obj(_manifest({
|
"foo": {
|
||||||
"foo": {"url": "ssh://git@github.com/foo.git"},
|
"url": "ssh://git@github.com/didericis/foo.git",
|
||||||
}))
|
"provisioned_key": {"provider": "gitea", "token_env": "T"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
self.assertEqual("", m.bottles["dev"].git[0].IdentityFile)
|
||||||
|
|
||||||
|
def test_identity_entry_has_no_provisioned_key(self):
|
||||||
|
m = Manifest.from_json_obj(_manifest({
|
||||||
|
"foo": {
|
||||||
|
"url": "ssh://git@github.com/foo.git",
|
||||||
|
"identity": "/dev/null",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
self.assertIsNone(m.bottles["dev"].git[0].ProvisionedKey)
|
||||||
|
|
||||||
|
|
||||||
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 +360,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"}},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"}},
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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:
|
||||||
"bottles": {"dev": {}},
|
return Manifest.from_json_obj({
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"bottles": {"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.
|
||||||
|
|||||||
@@ -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,11 +72,11 @@ 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",
|
||||||
"key": {"provider": "static", "path": "/dev/null"},
|
"identity": "/dev/null",
|
||||||
},
|
},
|
||||||
}}}},
|
}}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ from __future__ import annotations
|
|||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any, cast
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle.backend.smolmachines import launch as _launch_mod
|
from bot_bottle.backend.smolmachines import launch as _launch_mod
|
||||||
@@ -143,46 +141,5 @@ class TestEnsureSmolmachine(unittest.TestCase):
|
|||||||
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
|
self.assertTrue(str(pack_args[1]).endswith(f"{digest}.smolmachine"))
|
||||||
|
|
||||||
|
|
||||||
class TestAgentFromPath(unittest.TestCase):
|
|
||||||
def _plan(self) -> Any:
|
|
||||||
return cast(Any, SimpleNamespace(
|
|
||||||
slug="dev-abc12",
|
|
||||||
agent_image="bot-bottle-claude:latest",
|
|
||||||
agent_dockerfile_path="/repo/Dockerfile",
|
|
||||||
))
|
|
||||||
|
|
||||||
def test_uses_committed_artifact_when_present(self):
|
|
||||||
with tempfile.TemporaryDirectory(prefix="committed-smolmachine.") as tmp:
|
|
||||||
artifact = Path(tmp) / "committed-smolmachine.smolmachine"
|
|
||||||
artifact.write_text("")
|
|
||||||
with patch.object(
|
|
||||||
_launch_mod, "read_committed_image", return_value=str(artifact),
|
|
||||||
), patch.object(
|
|
||||||
_launch_mod, "_ensure_smolmachine",
|
|
||||||
) as ensure, patch.object(
|
|
||||||
_launch_mod, "info",
|
|
||||||
):
|
|
||||||
result = _launch_mod._agent_from_path(self._plan())
|
|
||||||
|
|
||||||
self.assertEqual(artifact, result)
|
|
||||||
ensure.assert_not_called()
|
|
||||||
|
|
||||||
def test_falls_back_when_committed_artifact_missing(self):
|
|
||||||
packed = Path("/cache/agent.smolmachine")
|
|
||||||
with patch.object(
|
|
||||||
_launch_mod, "read_committed_image",
|
|
||||||
return_value="/missing/committed.smolmachine",
|
|
||||||
), patch.object(
|
|
||||||
_launch_mod, "_ensure_smolmachine", return_value=packed,
|
|
||||||
) as ensure:
|
|
||||||
result = _launch_mod._agent_from_path(self._plan())
|
|
||||||
|
|
||||||
self.assertEqual(packed, result)
|
|
||||||
ensure.assert_called_once_with(
|
|
||||||
"bot-bottle-claude:latest",
|
|
||||||
dockerfile="/repo/Dockerfile",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -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, Manifest
|
||||||
from bot_bottle.supervise import SupervisePlan
|
from bot_bottle.supervise import SupervisePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ def _plan(
|
|||||||
git_gate_json["repos"] = {
|
git_gate_json["repos"] = {
|
||||||
g.Name: {
|
g.Name: {
|
||||||
"url": g.Upstream,
|
"url": g.Upstream,
|
||||||
"key": {"provider": g.Key.provider or "static", "path": g.Key.path or g.IdentityFile},
|
"identity": g.IdentityFile,
|
||||||
}
|
}
|
||||||
for g in git
|
for g in git
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
@@ -362,7 +360,6 @@ class TestProvisionGit(unittest.TestCase):
|
|||||||
git=[ManifestGitEntry(
|
git=[ManifestGitEntry(
|
||||||
Name="bot-bottle",
|
Name="bot-bottle",
|
||||||
Upstream="ssh://git@host/repo.git",
|
Upstream="ssh://git@host/repo.git",
|
||||||
Key=ManifestKeyConfig(provider="static", path="~/.ssh/id_ed25519"),
|
|
||||||
IdentityFile="~/.ssh/id_ed25519",
|
IdentityFile="~/.ssh/id_ed25519",
|
||||||
)],
|
)],
|
||||||
stage_dir=self.stage,
|
stage_dir=self.stage,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ from bot_bottle.backend.smolmachines.smolvm import (
|
|||||||
machine_start,
|
machine_start,
|
||||||
machine_stop,
|
machine_stop,
|
||||||
pack_create,
|
pack_create,
|
||||||
pack_create_from_vm,
|
|
||||||
wait_exec_ready,
|
wait_exec_ready,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,17 +63,6 @@ class TestArgvShapes(unittest.TestCase):
|
|||||||
argv,
|
argv,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_pack_create_from_vm_argv(self):
|
|
||||||
with self._patch_run() as m:
|
|
||||||
pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/committed"))
|
|
||||||
argv = m.call_args.args[0]
|
|
||||||
self.assertEqual(
|
|
||||||
["smolvm", "pack", "create",
|
|
||||||
"--from-vm", "bot-bottle-dev-abc12",
|
|
||||||
"-o", "/tmp/committed"],
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_machine_create_minimal(self):
|
def test_machine_create_minimal(self):
|
||||||
with self._patch_run() as m:
|
with self._patch_run() as m:
|
||||||
machine_create("agent-xyz")
|
machine_create("agent-xyz")
|
||||||
@@ -205,14 +193,6 @@ class TestErrorPath(unittest.TestCase):
|
|||||||
with self.assertRaises(SmolvmError):
|
with self.assertRaises(SmolvmError):
|
||||||
pack_create("missing:tag", Path("/tmp/out"))
|
pack_create("missing:tag", Path("/tmp/out"))
|
||||||
|
|
||||||
def test_pack_create_from_vm_failure_raises(self):
|
|
||||||
with patch(
|
|
||||||
"bot_bottle.backend.smolmachines.smolvm.subprocess.run",
|
|
||||||
return_value=_fail("pack failed"),
|
|
||||||
):
|
|
||||||
with self.assertRaises(SmolvmError):
|
|
||||||
pack_create_from_vm("bot-bottle-dev-abc12", Path("/tmp/out"))
|
|
||||||
|
|
||||||
def test_exec_failure_returns_result(self):
|
def test_exec_failure_returns_result(self):
|
||||||
# The in-VM command's exit code is what Bottle.exec sees;
|
# The in-VM command's exit code is what Bottle.exec sees;
|
||||||
# `false` exiting non-zero is not a smolvm failure.
|
# `false` exiting non-zero is not a smolvm failure.
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user