Compare commits

..

3 Commits

Author SHA1 Message Date
didericis d923871fd2 feat(macos-container): launch explicit-proxy bottles
lint / lint (push) Successful in 1m50s
test / unit (pull_request) Successful in 37s
test / integration (pull_request) Successful in 19s
2026-06-10 19:46:39 -04:00
didericis-codex 7350494944 docs: link macos container prd to review comment
test / unit (pull_request) Successful in 42s
test / integration (pull_request) Successful in 23s
2026-06-10 18:34:18 +00:00
didericis-codex 4abad499b6 feat: add macos container backend scaffold
lint / lint (push) Successful in 1m54s
test / unit (pull_request) Successful in 35s
test / integration (pull_request) Successful in 20s
2026-06-10 18:14:17 +00:00
31 changed files with 258 additions and 2024 deletions
+4 -7
View File
@@ -7,13 +7,10 @@ with a curated set of skills and env vars. The point is to run agents with
broad permissions inside a sandbox, so a misbehaving agent cannot reach the broad permissions inside a sandbox, so a misbehaving agent cannot reach the
host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates host. A Python CLI (entry point `cli.py`, package `bot_bottle/`) orchestrates
the runtime lifecycle and the copying of skills and env vars into it. the runtime lifecycle and the copying of skills and env vars into it.
The default backend on compatible macOS hosts is macos-container: The default backend is smolmachines on macOS: agents run in a libkrun
agents and sidecar bundles run through Apple's `container` CLI without micro-VM, while the sidecar bundle still uses Docker. The legacy Docker
requiring Docker. The smolmachines backend remains available with backend remains available with `BOT_BOTTLE_BACKEND=docker` or
`BOT_BOTTLE_BACKEND=smolmachines` or `--backend=smolmachines`; agents `--backend=docker`.
run in a libkrun micro-VM, while the sidecar bundle still uses Docker.
The legacy Docker backend remains available with `BOT_BOTTLE_BACKEND=docker`
or `--backend=docker`.
## Goals ## Goals
+8 -35
View File
@@ -5,7 +5,7 @@
# bot-bottle # bot-bottle
[![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml) [![test](https://gitea.dideric.is/didericis/bot-bottle/actions/workflows/test.yml/badge.svg?branch=main)](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
[![pylint](https://img.shields.io/badge/pylint-9.93%2F10-brightgreen)](https://github.com/PyCQA/pylint) [![pylint](https://img.shields.io/badge/pylint-9.94%2F10-brightgreen)](https://github.com/PyCQA/pylint)
[![pyright](https://img.shields.io/badge/pyright-0%20errors-brightgreen)](https://github.com/microsoft/pyright) [![pyright](https://img.shields.io/badge/pyright-0%20errors-brightgreen)](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.
@@ -14,7 +14,7 @@
## Features ## Features
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default. - **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default.
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only. - **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential. - **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load. - **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
@@ -23,15 +23,12 @@
- **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other. - **Parallel, isolated bottles** — each bottle runs in its own backend-owned isolation boundary; bottles don't share state or talk to each other.
- **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding. - **Provider templates (Claude, Codex)** — `Dockerfile.claude` / `Dockerfile.codex`, or a bottle-supplied Dockerfile. Claude auth via long-lived OAuth token; Codex via opt-in host device-auth forwarding.
- **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required. - **gVisor auto-detect** — on Linux hosts where `runsc` is registered with Docker, every bottle launches under it for a userspace syscall barrier; no manifest config required.
- **Apple Container backend (macOS default when available)** — runs the agent and sidecar bundle with Apple's `container` CLI, using a host-only agent network plus a separate sidecar egress network. - **Smolmachines backend (macOS default)** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend.
- **Smolmachines backend** — runs the agent in a libkrun micro-VM while the sidecar bundle stays in Docker. TSI and smolmachines DNS filtering close the raw DNS exfiltration gap that exists in the legacy Docker backend. - **Legacy Docker backend** — still available for examples, CI, and hosts without smolvm via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
## Architecture ## Architecture
On the default macOS Apple Container backend, a bottle is an agent container on a host-only internal network plus a sidecar bundle attached to both that internal network and a NAT egress network. The agent gets HTTP(S)_PROXY and CA bundle env vars pointing at the sidecar's internal-network IP, so HTTP/HTTPS traffic flows through the sidecar instead of direct egress. `bottle.git` / git-gate is intentionally deferred on this backend until a safe Apple Container key-delivery path exists. On the default smolmachines backend, a bottle is an agent micro-VM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars through a per-bottle loopback alias allowed by TSI; smolmachines handles DNS filtering below the guest OS.
On the smolmachines backend, a bottle is an agent micro-VM plus a Docker sidecar bundle for egress, git-gate, and supervise. The VM reaches the sidecars through a per-bottle loopback alias allowed by TSI; smolmachines handles DNS filtering below the guest OS.
On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `sidecars` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box. On the legacy Docker backend, the same logical bottle is two containers per agent: an `agent` container and a `sidecars` container. They share a per-agent Docker `--internal` network; the agent has no default route off-box.
@@ -70,9 +67,9 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
## Quickstart ## Quickstart
On compatible macOS hosts, the default backend requires Apple's `container` CLI and does not require Docker. The smolmachines backend requires Docker on the host for the sidecar bundle plus smolvm. The legacy Docker backend requires Docker. Claude bottles also need a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`. Requires Docker on the host for the sidecar bundle, smolvm on macOS for the default backend, and a long-lived Claude Code OAuth token (`claude setup-token`) exported as `BOT_BOTTLE_CLAUDE_OAUTH_TOKEN`.
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend. Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where smolvm is not installed.
```sh ```sh
./cli.py start <agent> # builds the image on first run, drops you into claude ./cli.py start <agent> # builds the image on first run, drops you into claude
@@ -106,15 +103,8 @@ egress:
routes: routes:
- host: gitea.dideric.is - host: gitea.dideric.is
auth: auth:
scheme: token # Bearer | token scheme: token
token_ref: BOT_BOTTLE_GITEA_TOKEN token_ref: BOT_BOTTLE_GITEA_TOKEN
matches: # optional — restrict to specific paths/methods/headers
- paths:
- {type: prefix, value: /api/v1/}
methods: [GET, POST, PATCH, DELETE]
dlp: # optional — per-route detector overrides (default: all on)
outbound_detectors: [token_patterns, known_secrets]
inbound_detectors: false # disable response scanning for this host
--- ---
The `gitea-dev` bottle. Provider auth via the inherited Claude route; The `gitea-dev` bottle. Provider auth via the inherited Claude route;
@@ -133,23 +123,6 @@ skills:
You help maintain Gitea-hosted projects. You help maintain Gitea-hosted projects.
```` ````
**Egress route fields:**
| Field | Required | Description |
|---|---|---|
| `host` | yes | Hostname to allowlist. One entry per host. |
| `role` | no | Provider-specific role string (e.g. `claude_code_oauth`). Wires built-in auth flows; set by provider templates, not manually. |
| `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. |
| `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. |
| `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. |
| `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. |
| `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. |
| `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. |
| `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). |
| `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). |
| `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). |
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`. More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
## Trademarks ## Trademarks
+5 -13
View File
@@ -24,10 +24,9 @@ backend exposes five methods:
enough metadata for callers (CLI `list active`, dashboard enough metadata for callers (CLI `list active`, dashboard
agents pane) to render a row. agents pane) to render a row.
Selection is driven by `--backend` on `start` or BOT_BOTTLE_BACKEND Selection is driven by `--backend` on `start` or
(env var). When neither is set, compatible macOS hosts default to BOT_BOTTLE_BACKEND (env var; default "smolmachines"). Per PRD 0003 the
`macos-container`; other hosts default to `smolmachines`. Per PRD 0003 manifest does not carry a backend field; the host picks.
the manifest does not carry a backend field; the host picks.
""" """
from __future__ import annotations from __future__ import annotations
@@ -554,24 +553,17 @@ def get_bottle_backend(
`name` precedence: `name` precedence:
1. explicit arg (CLI `--backend=<name>` passes through here) 1. explicit arg (CLI `--backend=<name>` passes through here)
2. BOT_BOTTLE_BACKEND env var 2. BOT_BOTTLE_BACKEND env var
3. `macos-container` on compatible macOS hosts 3. default `smolmachines`
4. default `smolmachines`
Dies with a pointer at the known backends if the chosen name Dies with a pointer at the known backends if the chosen name
isn't implemented.""" isn't implemented."""
resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or _default_backend_name() resolved = name or os.environ.get("BOT_BOTTLE_BACKEND") or "smolmachines"
if resolved not in _BACKENDS: if resolved not in _BACKENDS:
known = ", ".join(sorted(_BACKENDS)) known = ", ".join(sorted(_BACKENDS))
die(f"unknown backend {resolved!r}; known backends: {known}") die(f"unknown backend {resolved!r}; known backends: {known}")
return _BACKENDS[resolved] return _BACKENDS[resolved]
def _default_backend_name() -> str:
if has_backend("macos-container"):
return "macos-container"
return "smolmachines"
def known_backend_names() -> tuple[str, ...]: def known_backend_names() -> tuple[str, ...]:
"""Sorted tuple of all backend keys in `_BACKENDS`. Used by """Sorted tuple of all backend keys in `_BACKENDS`. Used by
argparse (`--backend` choices) and the dashboard's backend argparse (`--backend` choices) and the dashboard's backend
@@ -25,7 +25,7 @@ from .bottle_plan import MacosContainerBottlePlan
class MacosContainerBottleBackend( class MacosContainerBottleBackend(
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"] BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"]
): ):
"""Apple Container backend. Selected by """Experimental Apple Container backend. Selected by
`BOT_BOTTLE_BACKEND=macos-container` or `BOT_BOTTLE_BACKEND=macos-container` or
`--backend=macos-container`.""" `--backend=macos-container`."""
@@ -44,15 +44,3 @@ class MacosContainerBottlePlan(BottlePlan):
@property @property
def agent_provider_template(self) -> str: def agent_provider_template(self) -> str:
return self.agent_provision.template return self.agent_provision.template
@property
def git_gate_insteadof_host(self) -> str:
if self.agent_git_gate_url.startswith("http://"):
return self.agent_git_gate_url.removeprefix("http://").rstrip("/")
return super().git_gate_insteadof_host
@property
def git_gate_insteadof_scheme(self) -> str:
if self.agent_git_gate_url.startswith("http://"):
return "http"
return super().git_gate_insteadof_scheme
+16 -80
View File
@@ -23,14 +23,7 @@ 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
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
from ...util import expand_tilde
from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT from ..docker.egress import EGRESS_CA_IN_CONTAINER, EGRESS_PORT
from ..docker.git_gate import (
GIT_GATE_ACCESS_HOOK_IN_CONTAINER,
GIT_GATE_CREDS_DIR_IN_CONTAINER,
GIT_GATE_ENTRYPOINT_IN_CONTAINER,
GIT_GATE_HOOK_IN_CONTAINER,
)
from ..docker.sidecar_bundle import ( from ..docker.sidecar_bundle import (
SIDECAR_BUNDLE_DOCKERFILE, SIDECAR_BUNDLE_DOCKERFILE,
SIDECAR_BUNDLE_IMAGE, SIDECAR_BUNDLE_IMAGE,
@@ -43,9 +36,7 @@ from .bottle_plan import MacosContainerBottlePlan
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent) _REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
_AGENT_SLEEP_SECONDS = "2147483647" _SIDECAR_SLEEP_SECONDS = "2147483647"
_GIT_HTTP_PORT = 9420
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
def internal_network_name(slug: str) -> str: def internal_network_name(slug: str) -> str:
@@ -83,6 +74,7 @@ def launch(
raise teardown_exc raise teardown_exc
try: try:
_validate_supported_plan(plan)
plan = _mint_certs(plan) plan = _mint_certs(plan)
_build_images(plan) _build_images(plan)
@@ -94,7 +86,6 @@ def launch(
container_mod.force_remove_container(sidecar_name) container_mod.force_remove_container(sidecar_name)
_start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network) _start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network)
stack.callback(container_mod.force_remove_container, sidecar_name) stack.callback(container_mod.force_remove_container, sidecar_name)
_stage_git_gate(plan, sidecar_name)
sidecar_ip = container_mod.container_ipv4_on_network( sidecar_ip = container_mod.container_ipv4_on_network(
sidecar_name, internal_network, sidecar_name, internal_network,
@@ -135,6 +126,17 @@ def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
return dataclasses.replace(plan, egress_plan=egress_plan) return dataclasses.replace(plan, egress_plan=egress_plan)
def _validate_supported_plan(plan: MacosContainerBottlePlan) -> None:
if plan.git_gate_plan.upstreams:
die(
"macos-container backend launch does not support bottle.git yet: "
"Apple Container cannot bind-mount individual SSH key files, "
"and this backend will not mount broad host key directories. "
"Use docker/smolmachines for git-gate bottles until a safe key "
"delivery path lands."
)
def _build_images(plan: MacosContainerBottlePlan) -> None: def _build_images(plan: MacosContainerBottlePlan) -> None:
container_mod.build_image( container_mod.build_image(
SIDECAR_BUNDLE_IMAGE, SIDECAR_BUNDLE_IMAGE,
@@ -211,76 +213,14 @@ def _stamp_agent_urls(
supervise_url = "" supervise_url = ""
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/" supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
git_gate_url = ""
if plan.git_gate_plan.upstreams:
git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}"
return dataclasses.replace( return dataclasses.replace(
plan, plan,
agent_proxy_url=proxy_url, agent_proxy_url=proxy_url,
agent_git_gate_url=git_gate_url, agent_git_gate_url="",
agent_supervise_url=supervise_url, agent_supervise_url=supervise_url,
) )
def _stage_git_gate(plan: MacosContainerBottlePlan, sidecar_name: str) -> None:
gp = plan.git_gate_plan
if not gp.upstreams:
return
container_mod.exec_container(
sidecar_name,
[
"mkdir",
"-p",
str(Path(GIT_GATE_HOOK_IN_CONTAINER).parent),
GIT_GATE_CREDS_DIR_IN_CONTAINER,
"/git",
str(Path(_GIT_GATE_READY_FILE).parent),
],
)
for host_path, container_path in _git_gate_files(plan):
container_mod.copy_into_container(
sidecar_name, host_path, container_path,
)
container_mod.exec_container(
sidecar_name,
[
"sh",
"-c",
"chmod 755 "
f"{GIT_GATE_ENTRYPOINT_IN_CONTAINER} "
f"{GIT_GATE_HOOK_IN_CONTAINER} "
f"{GIT_GATE_ACCESS_HOOK_IN_CONTAINER} && "
f"chmod 600 {GIT_GATE_CREDS_DIR_IN_CONTAINER}/* && "
f"touch {_GIT_GATE_READY_FILE}",
],
)
def _git_gate_files(
plan: MacosContainerBottlePlan,
) -> tuple[tuple[str, str], ...]:
gp = plan.git_gate_plan
files: list[tuple[str, str]] = [
(str(gp.entrypoint_script), GIT_GATE_ENTRYPOINT_IN_CONTAINER),
(str(gp.hook_script), GIT_GATE_HOOK_IN_CONTAINER),
(str(gp.access_hook_script), GIT_GATE_ACCESS_HOOK_IN_CONTAINER),
]
for upstream in gp.upstreams:
files.append((
expand_tilde(upstream.identity_file),
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-key",
))
if upstream.known_hosts_file:
files.append((
str(upstream.known_hosts_file),
f"{GIT_GATE_CREDS_DIR_IN_CONTAINER}/{upstream.name}-known_hosts",
))
return tuple(files)
def _sidecar_run_argv( def _sidecar_run_argv(
plan: MacosContainerBottlePlan, plan: MacosContainerBottlePlan,
sidecar_name: str, sidecar_name: str,
@@ -319,18 +259,16 @@ def _agent_run_argv(
] ]
for entry in _agent_env_entries(plan, sidecar_ip): for entry in _agent_env_entries(plan, sidecar_ip):
argv += ["--env", entry] argv += ["--env", entry]
argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS] argv += [plan.image, "sleep", _SIDECAR_SLEEP_SECONDS]
return argv return argv
def _sidecar_dns() -> str: def _sidecar_dns() -> str:
return container_mod.dns_server() return os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "1.1.1.1")
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]: def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
daemons = ["egress"] daemons = ["egress"]
if plan.git_gate_plan.upstreams:
daemons += ["git-gate", "git-http"]
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
daemons.append("supervise") daemons.append("supervise")
return tuple(daemons) return tuple(daemons)
@@ -340,8 +278,6 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
env: list[str] = [] env: list[str] = []
if plan.egress_plan.routes: if plan.egress_plan.routes:
env.extend(sorted(plan.egress_plan.token_env_map.keys())) env.extend(sorted(plan.egress_plan.token_env_map.keys()))
if plan.git_gate_plan.upstreams:
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
if plan.supervise_plan is not None: if plan.supervise_plan is not None:
env += [ env += [
f"SUPERVISE_BOTTLE_SLUG={plan.slug}", f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
+1 -196
View File
@@ -3,19 +3,15 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
import ipaddress
import platform import platform
import shutil import shutil
import subprocess import subprocess
import time
from typing import Iterable from typing import Iterable
from ...log import die, info from ...log import die, info
_CONTAINER = "container" _CONTAINER = "container"
_DEFAULT_DNS = "1.1.1.1"
def is_macos() -> bool: def is_macos() -> bool:
@@ -35,27 +31,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:
override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip()
if override:
return override
return _host_ipv4_dns() or _DEFAULT_DNS
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None: def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
@@ -64,144 +39,13 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
f"building image {ref} from {context} with Apple Container " f"building image {ref} from {context} with Apple Container "
"(layer cache keeps repeat builds fast)" "(layer cache keeps repeat builds fast)"
) )
_ensure_builder_dns() args = [_CONTAINER, "build", "-t", ref]
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
if dockerfile: if dockerfile:
args.extend(["-f", dockerfile]) args.extend(["-f", dockerfile])
args.append(context) args.append(context)
subprocess.run(args, check=True) subprocess.run(args, check=True)
def _ensure_builder_dns() -> None:
dns = dns_server()
status = _builder_status()
override = os.environ.get("BOT_BOTTLE_MACOS_CONTAINER_DNS", "").strip()
if _builder_running(status) and _builder_resolves_build_hosts():
if override and not _builder_has_dns(status, dns):
_restart_builder_with_dns(dns)
return
_restart_builder_with_dns(dns)
def _restart_builder_with_dns(dns: str) -> None:
subprocess.run(
[_CONTAINER, "builder", "stop"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
subprocess.run(
[_CONTAINER, "builder", "start", "--dns", dns],
check=True,
)
def _host_ipv4_dns() -> str:
if not is_macos():
return ""
result = subprocess.run(
["scutil", "--dns"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return ""
blocks: list[list[str]] = []
current: list[str] = []
for line in result.stdout.splitlines():
if line.startswith("resolver #") and current:
blocks.append(current)
current = []
current.append(line)
if current:
blocks.append(current)
for direct_only in (True, False):
for block in blocks:
text = "\n".join(block)
if direct_only and "Directly Reachable Address" not in text:
continue
for line in block:
if "nameserver[" not in line or ":" not in line:
continue
candidate = line.split(":", 1)[1].strip()
if _usable_ipv4(candidate):
return candidate
return ""
def _usable_ipv4(value: str) -> bool:
try:
address = ipaddress.ip_address(value)
except ValueError:
return False
return (
address.version == 4
and not address.is_loopback
and not address.is_link_local
and not address.is_multicast
and not address.is_unspecified
)
def _builder_status() -> list[dict[str, object]]:
result = subprocess.run(
[_CONTAINER, "builder", "status", "--format", "json"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return []
try:
data = json.loads(result.stdout or "[]")
except json.JSONDecodeError:
return []
if isinstance(data, list):
return [entry for entry in data if isinstance(entry, dict)]
if isinstance(data, dict):
return [data]
return []
def _builder_running(status: list[dict[str, object]]) -> bool:
for entry in status:
entry_status = entry.get("status")
if isinstance(entry_status, dict) and entry_status.get("state") == "running":
return True
return False
def _builder_dns_nameservers(status: list[dict[str, object]]) -> list[str]:
out: list[str] = []
for entry in status:
config = entry.get("configuration")
config_dns = config.get("dns") if isinstance(config, dict) else None
nameservers = (
config_dns.get("nameservers")
if isinstance(config_dns, dict)
else None
)
if not isinstance(nameservers, list):
continue
out.extend(name for name in nameservers if isinstance(name, str))
return out
def _builder_has_dns(status: list[dict[str, object]], dns: str) -> bool:
return dns in _builder_dns_nameservers(status)
def _builder_resolves_build_hosts() -> bool:
result = subprocess.run(
[_CONTAINER, "exec", "buildkit", "getent", "hosts", "deb.debian.org"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
def image_exists(ref: str) -> bool: def image_exists(ref: str) -> bool:
return _silent_run([_CONTAINER, "image", "inspect", ref]) == 0 return _silent_run([_CONTAINER, "image", "inspect", ref]) == 0
@@ -228,45 +72,6 @@ def force_remove_container(name: str) -> None:
) )
def copy_into_container(name: str, host_path: str, container_path: str) -> None:
cmd = [_CONTAINER, "cp", host_path, f"{name}:{container_path}"]
result = _run_container_op(cmd)
if result.returncode != 0:
die(
f"container cp into {name}:{container_path} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def exec_container(name: str, argv: list[str]) -> None:
result = _run_container_op([_CONTAINER, "exec", name, *argv])
if result.returncode != 0:
die(
f"container exec in {name} failed: "
f"{(result.stderr or '').strip() or '<no stderr>'}"
)
def _run_container_op(cmd: list[str]) -> subprocess.CompletedProcess[str]:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
)
for _ in range(19):
if result.returncode == 0:
return result
time.sleep(0.1)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=False,
)
return result
def create_network(name: str, *, internal: bool = False) -> None: def create_network(name: str, *, internal: bool = False) -> None:
args = [ args = [
_CONTAINER, "network", "create", _CONTAINER, "network", "create",
+3 -3
View File
@@ -47,7 +47,7 @@ def cmd_start(argv: list[str]) -> int:
default=None, default=None,
help=( help=(
"backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND " "backend to launch the bottle on (default: $BOT_BOTTLE_BACKEND "
"or host auto-selection). Overrides the env var when set." "or 'smolmachines'). Overrides the env var when set."
), ),
) )
parser.add_argument( parser.add_argument(
@@ -107,8 +107,8 @@ def prepare_with_preflight(
injected callable, prompt y/N via the injected callable. injected callable, prompt y/N via the injected callable.
`backend_name` selects which backend prepares the plan `backend_name` selects which backend prepares the plan
(`None` → `$BOT_BOTTLE_BACKEND` → host auto-selection). The CLI (`None` → `$BOT_BOTTLE_BACKEND` → `smolmachines`). The CLI passes
passes whatever `--backend` resolved to. whatever `--backend` resolved to.
Returns `(plan, identity)`. `plan` is None on dry-run or Returns `(plan, identity)`. `plan` is None on dry-run or
operator-N, but `identity` is set as soon as `backend.prepare` operator-N, but `identity` is set as soon as `backend.prepare`
+1 -1
View File
@@ -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
@@ -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
+16 -24
View File
@@ -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))
+1 -2
View File
@@ -56,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.
@@ -64,7 +64,6 @@ __all__ = [
"ManifestError", "ManifestError",
"ManifestGitEntry", "ManifestGitEntry",
"ManifestGitUser", "ManifestGitUser",
"ManifestKeyConfig",
"ManifestAgentProvider", "ManifestAgentProvider",
"EGRESS_AUTH_SCHEMES", "EGRESS_AUTH_SCHEMES",
"ManifestEgressRoute", "ManifestEgressRoute",
+66 -73
View File
@@ -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)
+2 -20
View File
@@ -59,7 +59,6 @@ class _DaemonSpec:
# reads to inject `Authorization` headers on configured routes; # reads to inject `Authorization` headers on configured routes;
# no other daemon in the bundle should see these values. # no other daemon in the bundle should see these values.
_EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",) _EGRESS_ONLY_ENV_PREFIXES: tuple[str, ...] = ("EGRESS_TOKEN_",)
_READY_GATED_DAEMONS: tuple[str, ...] = ("git-gate", "git-http")
def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]: def _env_for_daemon(name: str, base_env: dict[str, str]) -> dict[str, str]:
@@ -83,22 +82,6 @@ _DAEMONS: tuple[_DaemonSpec, ...] = (
) )
def _argv_for_daemon(name: str, argv: Sequence[str], env: dict[str, str]) -> list[str]:
ready_file = env.get("BOT_BOTTLE_GIT_GATE_READY_FILE", "").strip()
if name not in _READY_GATED_DAEMONS or not ready_file:
return list(argv)
return [
"/bin/sh",
"-c",
"while [ ! -f \"$BOT_BOTTLE_GIT_GATE_READY_FILE\" ]; do "
"sleep 0.1; "
"done; "
"exec \"$@\"",
name,
*argv,
]
def _selected_daemons( def _selected_daemons(
env: dict[str, str], env: dict[str, str],
all_daemons: Sequence[_DaemonSpec] | None = None, all_daemons: Sequence[_DaemonSpec] | None = None,
@@ -135,13 +118,12 @@ def _pump(name: str, stream: IO[bytes]) -> None:
def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]: def _spawn(spec: _DaemonSpec) -> subprocess.Popen[bytes]:
env = _env_for_daemon(spec.name, dict(os.environ))
proc = subprocess.Popen( proc = subprocess.Popen(
_argv_for_daemon(spec.name, spec.argv, env), list(spec.argv),
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
bufsize=0, bufsize=0,
env=env, env=_env_for_daemon(spec.name, dict(os.environ)),
) )
threading.Thread( threading.Thread(
target=_pump, args=(spec.name, proc.stdout), daemon=True target=_pump, args=(spec.name, proc.stdout), daemon=True
@@ -1,18 +1,19 @@
# 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
## Summary ## Summary
Add a `macos-container` backend that integrates Apple's `container` Add an experimental `macos-container` backend that integrates Apple's
CLI as a host runtime on macOS. The shipped slices register the `container` CLI as a host runtime on macOS. The first shipped slice
backend, implement reusable host primitives (`build`, `exec`, `cp`, registers the backend, implements the reusable host primitives
image inspection, cleanup, active enumeration), make launch runnable (`build`, `exec`, `cp`, image inspection, cleanup, active
with the proven two-network sidecar topology, and add real-runtime enumeration), and blocks full launch behind an explicit network
coverage without weakening bot-bottle's sidecar egress model. enforcement guard. This creates a real integration point without
weakening bot-bottle's sidecar egress model.
## Problem ## Problem
@@ -43,27 +44,20 @@ path around the egress sidecar.
- `--backend=macos-container` and - `--backend=macos-container` and
`BOT_BOTTLE_BACKEND=macos-container` are accepted by the existing `BOT_BOTTLE_BACKEND=macos-container` are accepted by the existing
backend selector. backend selector.
- Compatible macOS hosts default to `macos-container` when
`BOT_BOTTLE_BACKEND` and `--backend` are both unset.
- Backend availability is true only on macOS hosts with `container` on - Backend availability is true only on macOS hosts with `container` on
`PATH`. `PATH`.
- The backend has tested wrappers for Apple Container image build, - The backend has tested wrappers for Apple Container image build,
image inspection, container `exec`, container `cp`, cleanup, and image inspection, container `exec`, container `cp`, cleanup, and
active-agent enumeration. active-agent enumeration.
- Full launch uses a host-only internal network for the agent and a - Full launch fails loudly with an operator-facing message until the
separate NAT egress network for the sidecar bundle. sidecar network enforcement design is implemented.
- The agent container does not attach to the egress network. It reaches - The PRD records the remaining launch work so the next PR can make the
allowed outbound hosts through HTTP(S)_PROXY pointing at the backend runnable without revisiting registration or wrapper plumbing.
sidecar's internal-network IP.
- `bottle.git` / git-gate bottles fail loudly on this backend until a
safe Apple Container key-delivery path exists.
- Real-runtime integration coverage is present and guarded by macOS and
Apple Container availability.
## Non-goals ## Non-goals
- Do not remove or deprecate the Docker backend. - Do not remove or deprecate the Docker backend.
- Do not remove or deprecate the smolmachines backend. - Do not change the default backend from `smolmachines`.
- Do not run sidecar daemons as host processes. - Do not run sidecar daemons as host processes.
- Do not launch a degraded backend where the agent can bypass the - Do not launch a degraded backend where the agent can bypass the
egress sidecar through direct network access. egress sidecar through direct network access.
@@ -107,38 +101,25 @@ The bottle handle mirrors `DockerBottle`: it builds a host argv for
foreground agent execution, pipes shell snippets through stdin for foreground agent execution, pipes shell snippets through stdin for
`Bottle.exec`, and exposes `cp_in` for provisioning. `Bottle.exec`, and exposes `cp_in` for provisioning.
### Launch topology ### Launch guard
`launch()` uses Apple Container's two-network topology: `launch()` is intentionally not enabled in the first slice. It exits
with a fatal message explaining that sidecar network enforcement still
needs implementation.
- create a host-only internal network for the bottle; This is deliberate. A runnable backend that places the agent on a
- create a normal NAT egress network for the sidecar bundle; normal outbound network while relying on environment variables for
- start the sidecar bundle attached to the egress network first and the proxying would violate bot-bottle's egress model. The runnable version
internal network second; must prove one of these shapes:
- discover the sidecar's internal-network IPv4 address from
`container inspect`;
- start the agent attached only to the internal network, with
HTTP_PROXY / HTTPS_PROXY / lowercase proxy vars pointing at the
sidecar IP and egress port.
This keeps the agent off the outbound network while preserving the - Apple Container supports the equivalent of Docker's two-network
proxy-env contract that existing agent tooling already honors. The sidecar topology: agent on an internal-only network, sidecar on both
integration smoke also removes the proxy env in-guest and confirms internal and egress networks.
direct egress fails. - The sidecar bundle runs as a separate VM/container with published
loopback ports, and the agent runtime can be constrained to only
### Deferred git-gate support reach that per-bottle loopback alias.
- Apple Container init/network hooks can enforce the egress sidecar as
Apple Container currently rejects single-file bind mounts, and the only outbound path before the agent process starts.
`container cp` into a stopped container is not available. Starting the
container earlier would allow `container cp` into a running container,
but it would also mean delivering SSH private key material into a live
sidecar before the git-gate daemon is ready to own it. Mounting broad
host SSH directories is not acceptable.
For this PRD, `bottle.git` / git-gate support is explicitly deferred on
the `macos-container` backend. Bottles with git-gate upstreams fail
loudly and should use `docker` or `smolmachines` until a narrower key
delivery design lands.
## Implementation chunks ## Implementation chunks
@@ -166,19 +147,8 @@ delivery design lands.
- Unit tests cover `MacosContainerBottle` command construction and - Unit tests cover `MacosContainerBottle` command construction and
stdin-based shell execution. stdin-based shell execution.
- Unit tests cover cleanup and active enumeration parsing. - Unit tests cover cleanup and active enumeration parsing.
- Unit tests cover launch argv/env construction, sidecar mount - Future integration tests must run on a host with Apple Container
staging, sidecar IP parsing, and git-gate rejection. installed and should verify egress cannot bypass the sidecar.
- Integration tests run on macOS hosts with Apple Container installed
and verify that egress cannot bypass the sidecar. They also preflight
Apple Container BuildKit DNS because image builds must resolve
package mirrors before a launch smoke can be meaningful. The backend
probes the running builder before image builds and leaves it alone
when its current resolver works. If the probe fails, or if the
operator explicitly sets `BOT_BOTTLE_MACOS_CONTAINER_DNS`, the backend
restarts the Apple Container builder with the configured DNS server.
Without an explicit override, that server is discovered from the
host's directly reachable IPv4 resolver before falling back to a
public resolver.
## References ## References
@@ -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.
+1 -6
View File
@@ -5,15 +5,10 @@ agent_provider:
egress: egress:
routes: routes:
- host: api.anthropic.com - host: api.anthropic.com
role: claude_code_oauth # wires Claude Code OAuth; do not change role: claude_code_oauth
auth: auth:
scheme: Bearer scheme: Bearer
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
# dlp is omitted → all detectors on by default (token_patterns,
# known_secrets outbound; naive_injection_detection inbound).
# To disable inbound scanning for this route:
# dlp:
# inbound_detectors: false
--- ---
Common Claude provider boundary. Drop this file into Common Claude provider boundary. Drop this file into
+2 -2
View File
@@ -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...",
}, },
}, },
@@ -1,239 +0,0 @@
"""Integration: macOS Container launch topology.
End-to-end against Apple's real `container` runtime. The smoke launches
a bottle with the experimental macOS Container backend and verifies the
properties that make the explicit-proxy launch acceptable:
- the agent can exec commands after provisioning;
- HTTP(S)_PROXY points at the sidecar's internal-network IP;
- allowlisted HTTPS reaches the egress sidecar;
- direct egress with proxy env removed fails from the internal-only
agent network;
- non-allowlisted proxy traffic is blocked.
Skipped under Gitea Actions and on hosts without Apple's `container`.
"""
from __future__ import annotations
import os
import platform
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
from bot_bottle.backend import BottleSpec, get_bottle_backend
from bot_bottle.backend.macos_container.util import (
dns_server as _container_dns_server,
is_available as _container_available,
)
from bot_bottle.manifest import Manifest
_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief."
def _minimal_agent_dockerfile(path: Path) -> None:
path.write_text(
"\n".join((
"FROM node:22-slim",
"RUN apt-get update \\",
" && apt-get install -y --no-install-recommends \\",
" ca-certificates curl git \\",
" && rm -rf /var/lib/apt/lists/*",
"USER node",
"WORKDIR /home/node",
"CMD [\"sleep\", \"infinity\"]",
"",
)),
encoding="utf-8",
)
def _minimal_manifest(dockerfile: Path) -> Manifest:
return Manifest.from_json_obj({
"bottles": {
"dev": {
"agent_provider": {
"template": "pi",
"dockerfile": str(dockerfile),
"settings": {
"provider": "example",
"base_url": "https://example.com/v1",
"models": ["smoke"],
},
},
"egress": {
"routes": [
{"host": "example.com"},
],
},
},
},
"agents": {
"demo": {
"skills": [],
"prompt": _AGENT_PROMPT,
"bottle": "dev",
},
},
})
def _buildkit_dns_available() -> bool:
if platform.system() != "Darwin" or not _container_available():
return False
stage = Path(tempfile.mkdtemp(prefix="cb-container-buildkit-dns."))
image = "bot-bottle-buildkit-dns-check:latest"
try:
dockerfile = stage / "Dockerfile"
dockerfile.write_text(
"FROM debian:bookworm-slim\n"
"RUN getent hosts deb.debian.org\n",
encoding="utf-8",
)
result = subprocess.run(
[
"container", "build",
"--dns", _container_dns_server(),
"-t", image,
"-f", str(dockerfile),
str(stage),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
return result.returncode == 0
finally:
subprocess.run(
["container", "image", "delete", image],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
shutil.rmtree(stage, ignore_errors=True)
@unittest.skipIf(
os.environ.get("GITEA_ACTIONS") == "true",
"skipped under act_runner: cannot host Apple Container VMs",
)
@unittest.skipUnless(
platform.system() == "Darwin",
"Apple Container is macOS-only",
)
@unittest.skipUnless(
_container_available(),
"Apple Container not on PATH; install from "
"https://github.com/apple/container/releases",
)
@unittest.skipUnless(
_buildkit_dns_available(),
"Apple Container BuildKit cannot resolve deb.debian.org on this host",
)
class TestMacosContainerLaunch(unittest.TestCase):
"""Launch once and reuse the bottle across probes."""
@classmethod
def setUpClass(cls) -> None:
cls.stage = Path(tempfile.mkdtemp(prefix="cb-macos-container-launch."))
cls._launch = None
cls.bottle = None
dockerfile = cls.stage / "Dockerfile.agent-smoke"
_minimal_agent_dockerfile(dockerfile)
os.environ["BOT_BOTTLE_BACKEND"] = "macos-container"
try:
backend = get_bottle_backend()
spec = BottleSpec(
manifest=_minimal_manifest(dockerfile),
agent_name="demo",
copy_cwd=False,
user_cwd=str(cls.stage),
)
cls.plan = backend.prepare(spec, stage_dir=cls.stage)
cls._launch = backend.launch(cls.plan)
cls.bottle = cls._launch.__enter__()
except BaseException:
if cls._launch is not None:
cls._launch.__exit__(None, None, None)
shutil.rmtree(cls.stage, ignore_errors=True)
os.environ.pop("BOT_BOTTLE_BACKEND", None)
raise
@classmethod
def tearDownClass(cls) -> None:
try:
if cls._launch is not None:
cls._launch.__exit__(None, None, None)
finally:
shutil.rmtree(cls.stage, ignore_errors=True)
os.environ.pop("BOT_BOTTLE_BACKEND", None)
def test_smoke_exec_echo(self):
r = self.bottle.exec( # type: ignore[union-attr]
"echo hello-from-macos-container"
)
self.assertEqual(0, r.returncode, msg=r.stderr)
self.assertIn("hello-from-macos-container", r.stdout)
def test_proxy_env_points_at_sidecar_internal_ip(self):
r = self.bottle.exec( # type: ignore[union-attr]
"printf '%s\n' \"$HTTPS_PROXY\" \"$HTTP_PROXY\" "
"\"$NO_PROXY\" \"$NODE_EXTRA_CA_CERTS\""
)
self.assertEqual(0, r.returncode, msg=r.stderr)
values = [line.strip() for line in r.stdout.splitlines()]
self.assertEqual(4, len(values), values)
self.assertEqual(values[0], values[1], values)
self.assertRegex(values[0], r"^http://[0-9.]+:9099$")
self.assertNotIn("127.0.0.1", values[0])
sidecar_host = values[0].removeprefix("http://").removesuffix(":9099")
self.assertIn(sidecar_host, values[2])
self.assertEqual(
"/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt",
values[3],
)
def test_allowlisted_https_reaches_egress_proxy(self):
r = self.bottle.exec( # type: ignore[union-attr]
"curl -fsS --max-time 20 https://example.com >/dev/null && echo OK"
)
self.assertEqual(0, r.returncode, msg=r.stderr + r.stdout)
self.assertIn("OK", r.stdout)
def test_direct_egress_bypass_without_proxy_fails(self):
r = self.bottle.exec( # type: ignore[union-attr]
"env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy "
"curl -s --show-error --max-time 5 https://example.com 2>&1 || true"
)
self.assertTrue(
"refused" in r.stdout.lower()
or "timed out" in r.stdout.lower()
or "unreachable" in r.stdout.lower()
or "failed" in r.stdout.lower()
or "could not resolve" in r.stdout.lower()
or "connection reset" in r.stdout.lower(),
f"expected direct egress to fail; got: {r.stdout!r}",
)
def test_non_allowlisted_host_fails_through_proxy(self):
r = self.bottle.exec( # type: ignore[union-attr]
"curl -s --show-error --max-time 10 https://iana.org 2>&1 || true"
)
self.assertTrue(
"403" in r.stdout
or "502" in r.stdout
or "blocked" in r.stdout.lower()
or "not allowed" in r.stdout.lower()
or "not in the bottle's egress.routes allowlist" in r.stdout.lower()
or "forbidden" in r.stdout.lower()
or "failed" in r.stdout.lower(),
f"expected non-allowlisted proxy request to fail; got: {r.stdout!r}",
)
if __name__ == "__main__":
unittest.main()
+2 -29
View File
@@ -32,35 +32,8 @@ class TestGetBottleBackend(unittest.TestCase):
b = get_bottle_backend() b = get_bottle_backend()
self.assertEqual("smolmachines", b.name) self.assertEqual("smolmachines", b.name)
def test_default_macos_container_when_available(self): def test_default_smolmachines(self):
class _FakeBackend: with patch.dict(os.environ, {}, clear=True):
name = "macos-container"
def is_available(self) -> bool:
return True
with patch.dict(os.environ, {}, clear=True), \
patch.object(backend_mod, "_BACKENDS", {
"macos-container": _FakeBackend(),
"smolmachines": _FakeBackend(),
}):
b = get_bottle_backend()
self.assertEqual("macos-container", b.name)
def test_default_smolmachines_when_macos_container_unavailable(self):
class _FakeBackend:
def __init__(self, name: str, available: bool) -> None:
self.name = name
self._available = available
def is_available(self) -> bool:
return self._available
with patch.dict(os.environ, {}, clear=True), \
patch.object(backend_mod, "_BACKENDS", {
"macos-container": _FakeBackend("macos-container", False),
"smolmachines": _FakeBackend("smolmachines", False),
}):
b = get_bottle_backend() b = get_bottle_backend()
self.assertEqual("smolmachines", b.name) self.assertEqual("smolmachines", b.name)
+1 -1
View File
@@ -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:
+1 -8
View File
@@ -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.
@@ -284,7 +277,7 @@ class TestPrepare(unittest.TestCase):
"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"}},
+9 -108
View File
@@ -34,26 +34,16 @@ def _plan(
token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"}, token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"},
) )
if git: if git:
key_path = stage_dir / "origin-key"
key_path.write_text("key\n", encoding="utf-8")
known_hosts_path = stage_dir / "origin-known-hosts"
known_hosts_path.write_text("example.com ssh-ed25519 AAAA\n", encoding="utf-8")
entrypoint = stage_dir / "git_gate_entrypoint.sh"
entrypoint.write_text("#!/bin/sh\n", encoding="utf-8")
hook = stage_dir / "git_gate_pre_receive.sh"
hook.write_text("#!/bin/sh\n", encoding="utf-8")
access_hook = stage_dir / "git_gate_access_hook.sh"
access_hook.write_text("#!/bin/sh\n", encoding="utf-8")
upstream = SimpleNamespace( upstream = SimpleNamespace(
name="origin", name="origin",
identity_file=str(key_path), identity_file="/host/key",
known_hosts_file=known_hosts_path, known_hosts_file=Path("/host/known_hosts"),
) )
git_gate_plan = SimpleNamespace( git_gate_plan = SimpleNamespace(
upstreams=(upstream,), upstreams=(upstream,),
entrypoint_script=entrypoint, entrypoint_script=Path("/state/git/entrypoint.sh"),
hook_script=hook, hook_script=Path("/state/git/pre-receive"),
access_hook_script=access_hook, access_hook_script=Path("/state/git/access.sh"),
) )
else: else:
git_gate_plan = SimpleNamespace(upstreams=()) git_gate_plan = SimpleNamespace(upstreams=())
@@ -66,7 +56,6 @@ def _plan(
provisioned_env={"CODEX_HOME": "/run/codex-home"}, provisioned_env={"CODEX_HOME": "/run/codex-home"},
) )
return cast(MacosContainerBottlePlan, SimpleNamespace( return cast(MacosContainerBottlePlan, SimpleNamespace(
spec=SimpleNamespace(),
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",
@@ -163,99 +152,11 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
self.assertNotIn("bot-bottle-egress-dev-abc", argv) self.assertNotIn("bot-bottle-egress-dev-abc", argv)
self.assertEqual(["bot-bottle-agent:latest", "sleep", "2147483647"], argv[-3:]) self.assertEqual(["bot-bottle-agent:latest", "sleep", "2147483647"], argv[-3:])
def test_git_gate_daemons_are_ready_gated(self): def test_git_gate_is_blocked_until_safe_key_delivery_exists(self):
plan = _plan(stage_dir=self.stage_dir, git=True) plan = _plan(stage_dir=self.stage_dir, git=True)
self.assertEqual( with patch.object(launch, "die", side_effect=SystemExit("die")):
("egress", "git-gate", "git-http"), with self.assertRaises(SystemExit):
launch._sidecar_daemons(plan), launch._validate_supported_plan(plan)
)
self.assertIn(
"BOT_BOTTLE_GIT_GATE_READY_FILE=/run/git-gate/ready",
launch._sidecar_env_entries(plan),
)
def test_stamp_agent_urls_includes_git_http_when_git_gate_exists(self):
plan = _plan(stage_dir=self.stage_dir, git=True, supervise=True)
with patch.object(launch.dataclasses, "replace") as replace:
launch._stamp_agent_urls(plan, "192.168.128.2")
replace.assert_called_once_with(
plan,
agent_proxy_url="http://192.168.128.2:9099",
agent_git_gate_url="http://192.168.128.2:9420",
agent_supervise_url="http://192.168.128.2:9100/",
)
def test_macos_plan_uses_http_git_gate_rewrites(self):
base = _plan(
stage_dir=self.stage_dir,
git=True,
agent_git_gate_url="http://192.168.128.2:9420",
)
plan = MacosContainerBottlePlan(
spec=base.spec,
stage_dir=base.stage_dir,
git_gate_plan=base.git_gate_plan,
egress_plan=base.egress_plan,
supervise_plan=base.supervise_plan,
agent_provision=base.agent_provision,
slug=base.slug,
forwarded_env=base.forwarded_env,
agent_git_gate_url=base.agent_git_gate_url,
)
self.assertEqual(
"192.168.128.2:9420",
plan.git_gate_insteadof_host,
)
self.assertEqual("http", plan.git_gate_insteadof_scheme)
def test_stage_git_gate_copies_files_and_releases_ready_marker(self):
plan = _plan(stage_dir=self.stage_dir, git=True)
with (
patch.object(launch.container_mod, "exec_container") as exec_container,
patch.object(launch.container_mod, "copy_into_container") as copy_in,
):
launch._stage_git_gate(plan, "sidecar")
exec_container.assert_any_call(
"sidecar",
[
"mkdir",
"-p",
"/etc/git-gate",
"/git-gate/creds",
"/git",
"/run/git-gate",
],
)
copied = [call.args for call in copy_in.call_args_list]
self.assertIn(
(
"sidecar",
str(self.stage_dir / "git_gate_entrypoint.sh"),
"/git-gate-entrypoint.sh",
),
copied,
)
self.assertIn(
(
"sidecar",
str(self.stage_dir / "origin-key"),
"/git-gate/creds/origin-key",
),
copied,
)
self.assertIn(
(
"sidecar",
str(self.stage_dir / "origin-known-hosts"),
"/git-gate/creds/origin-known_hosts",
),
copied,
)
self.assertIn(
"touch /run/git-gate/ready",
exec_container.call_args_list[-1].args[1][-1],
)
if __name__ == "__main__": if __name__ == "__main__":
+4 -124
View File
@@ -28,137 +28,17 @@ class TestMacosContainerAvailability(unittest.TestCase):
class TestMacosContainerCommands(unittest.TestCase): class TestMacosContainerCommands(unittest.TestCase):
def test_dns_server_prefers_direct_host_ipv4_resolver(self):
scutil = util.subprocess.CompletedProcess(
args=[],
returncode=0,
stdout="""
resolver #1
nameserver[0] : 100.100.100.100
reach : 0x00000003 (Reachable,Transient Connection)
resolver #2
nameserver[0] : 2600:4041:5c43:b900::1
nameserver[1] : 192.168.1.1
reach : 0x00020002 (Reachable,Directly Reachable Address)
""",
stderr="",
)
with patch.object(util.os, "environ", {}), \
patch.object(util.platform, "system", return_value="Darwin"), \
patch.object(util.subprocess, "run", return_value=scutil):
self.assertEqual("192.168.1.1", util.dns_server())
def test_build_image(self): def test_build_image(self):
status = util.subprocess.CompletedProcess( with patch.object(util.subprocess, "run") as run:
args=[],
returncode=0,
stdout=(
'[{"status":{"state":"running"},'
'"configuration":{"dns":{"nameservers":["9.9.9.9"]}}}]'
),
stderr="",
)
with patch.object(util.subprocess, "run", return_value=status) as run, \
patch.object(util.os, "environ", {
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
}):
util.build_image("bot-bottle-agent:latest", "/repo", dockerfile="/repo/Dockerfile") util.build_image("bot-bottle-agent:latest", "/repo", dockerfile="/repo/Dockerfile")
self.assertEqual( self.assertEqual(
[ [
"container", "build", "-t", "bot-bottle-agent:latest", "container", "build", "-t", "bot-bottle-agent:latest",
"--dns", "9.9.9.9", "-f", "/repo/Dockerfile", "/repo", "-f", "/repo/Dockerfile", "/repo",
], ],
run.call_args_list[-1].args[0], run.call_args.args[0],
)
self.assertTrue(run.call_args_list[-1].kwargs["check"])
def test_build_image_restarts_builder_when_dns_mismatches(self):
status = util.subprocess.CompletedProcess(
args=[],
returncode=0,
stdout=(
'[{"status":{"state":"running"},'
'"configuration":{"dns":{"nameservers":[]}}}]'
),
stderr="",
)
with patch.object(util.subprocess, "run", return_value=status) as run, \
patch.object(util.os, "environ", {
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
}):
util.build_image("bot-bottle-agent:latest", "/repo")
calls = [c.args[0] for c in run.call_args_list]
self.assertIn(["container", "builder", "stop"], calls)
self.assertIn(
["container", "builder", "start", "--dns", "9.9.9.9"],
calls,
)
self.assertEqual(
[
"container", "build", "-t", "bot-bottle-agent:latest",
"--dns", "9.9.9.9", "/repo",
],
calls[-1],
)
def test_build_image_leaves_working_builder_with_different_dns_alone(self):
status = util.subprocess.CompletedProcess(
args=[],
returncode=0,
stdout=(
'[{"status":{"state":"running"},'
'"configuration":{"dns":{"nameservers":["8.8.8.8"]}}}]'
),
stderr="",
)
probe = util.subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr="",
)
build = util.subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr="",
)
with patch.object(util, "dns_server", return_value="192.168.1.1"), \
patch.object(util.os, "environ", {}), \
patch.object(util.subprocess, "run", side_effect=[status, probe, build]) as run:
util.build_image("bot-bottle-agent:latest", "/repo")
calls = [c.args[0] for c in run.call_args_list]
self.assertNotIn(["container", "builder", "stop"], calls)
self.assertNotIn(
["container", "builder", "start", "--dns", "192.168.1.1"],
calls,
)
def test_build_image_restarts_builder_when_dns_probe_fails(self):
status = util.subprocess.CompletedProcess(
args=[],
returncode=0,
stdout=(
'[{"status":{"state":"running"},'
'"configuration":{"dns":{"nameservers":["8.8.8.8"]}}}]'
),
stderr="",
)
failed_probe = util.subprocess.CompletedProcess(
args=[], returncode=2, stdout="", stderr="",
)
ok = util.subprocess.CompletedProcess(
args=[], returncode=0, stdout="", stderr="",
)
with patch.object(util, "dns_server", return_value="192.168.1.1"), \
patch.object(util.os, "environ", {}), \
patch.object(
util.subprocess,
"run",
side_effect=[status, failed_probe, ok, ok, ok],
) as run:
util.build_image("bot-bottle-agent:latest", "/repo")
calls = [c.args[0] for c in run.call_args_list]
self.assertIn(["container", "builder", "stop"], calls)
self.assertIn(
["container", "builder", "start", "--dns", "192.168.1.1"],
calls,
) )
self.assertTrue(run.call_args.kwargs["check"])
def test_container_exists_parses_quiet_list(self): def test_container_exists_parses_quiet_list(self):
completed = util.subprocess.CompletedProcess( completed = util.subprocess.CompletedProcess(
+1 -1
View File
@@ -112,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)
+3 -3
View File
@@ -116,8 +116,8 @@ class TestExtendsGitMerge(unittest.TestCase):
"""git-gate.user overlays by field; git-gate.repos merges by upstream """git-gate.user overlays by field; git-gate.repos merges by upstream
host, with child entries replacing duplicate hosts.""" 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(
@@ -131,7 +131,7 @@ class TestExtendsGitMerge(unittest.TestCase):
self.assertEqual(["a", "b"], names) self.assertEqual(["a", "b"], names)
def test_child_git_repo_replaces_same_host(self): def test_child_git_repo_replaces_same_host(self):
replacement = {"url": "ssh://git@host-a/replacement.git", "key": {"provider": "static", "path": "/dev/null"}} replacement = {"url": "ssh://git@host-a/replacement.git", "identity": "/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={
+74 -104
View File
@@ -17,7 +17,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.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
@@ -33,7 +33,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.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]
@@ -44,7 +44,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.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)
@@ -53,7 +53,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.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",
}, },
})) }))
@@ -63,7 +63,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.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)
@@ -71,10 +71,10 @@ class TestGitEntryParsing(unittest.TestCase):
def test_missing_url_dies(self): def test_missing_url_dies(self):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
Manifest.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):
Manifest.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"},
@@ -85,7 +85,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.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
}, },
})) }))
@@ -95,7 +95,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.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",
}, },
})) }))
@@ -104,7 +104,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.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",
}, },
})) }))
@@ -113,7 +113,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.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",
}, },
})) }))
@@ -122,7 +122,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.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",
}, },
})) }))
@@ -131,7 +131,7 @@ class TestGitEntryParsing(unittest.TestCase):
Manifest.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",
}, },
})) }))
@@ -139,7 +139,7 @@ class TestGitEntryParsing(unittest.TestCase):
m = Manifest.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]
@@ -156,11 +156,11 @@ class TestGitEntryCrossValidation(unittest.TestCase):
"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"}},
@@ -190,7 +190,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
Manifest.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",
}, },
})) }))
@@ -199,7 +199,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
Manifest.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",
}, },
})) }))
@@ -208,7 +208,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
Manifest.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",
}, },
})) }))
@@ -217,7 +217,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
Manifest.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",
}, },
})) }))
@@ -225,7 +225,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
m = Manifest.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)
@@ -243,141 +243,111 @@ 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 = Manifest.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 = Manifest.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):
Manifest.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):
Manifest.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 = Manifest.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 = Manifest.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 = Manifest.from_json_obj(_manifest({ with self.assertRaises(ManifestError) as ctx:
"foo": {
"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):
Manifest.from_json_obj(_manifest({ Manifest.from_json_obj(_manifest({
"foo": { "foo": {
"url": "ssh://git@github.com/foo.git", "url": "ssh://git@github.com/foo.git",
"key": {"provider": "gitea"}, "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):
Manifest.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):
Manifest.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):
Manifest.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({
Manifest.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):
+1 -1
View File
@@ -76,7 +76,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
"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"}},
-23
View File
@@ -21,7 +21,6 @@ from unittest.mock import patch
from bot_bottle.sidecar_init import ( from bot_bottle.sidecar_init import (
_DaemonSpec, _DaemonSpec,
_Supervisor, _Supervisor,
_argv_for_daemon,
_env_for_daemon, _env_for_daemon,
_selected_daemons, _selected_daemons,
) )
@@ -121,28 +120,6 @@ class TestSelectedDaemons(unittest.TestCase):
self.assertEqual([d.name for d in got], ["egress", "git-gate"]) self.assertEqual([d.name for d in got], ["egress", "git-gate"])
class TestDaemonArgv(unittest.TestCase):
def test_git_daemons_wait_for_ready_marker_when_configured(self):
argv = _argv_for_daemon(
"git-gate",
("/bin/sh", "/git-gate-entrypoint.sh"),
{"BOT_BOTTLE_GIT_GATE_READY_FILE": "/run/git-gate/ready"},
)
self.assertEqual("/bin/sh", argv[0])
self.assertEqual("-c", argv[1])
self.assertIn("BOT_BOTTLE_GIT_GATE_READY_FILE", argv[2])
self.assertEqual("git-gate", argv[3])
self.assertEqual(["/bin/sh", "/git-gate-entrypoint.sh"], argv[4:])
def test_non_git_daemon_does_not_wait_for_ready_marker(self):
argv = _argv_for_daemon(
"egress",
("/bin/sh", "/app/egress-entrypoint.sh"),
{"BOT_BOTTLE_GIT_GATE_READY_FILE": "/run/git-gate/ready"},
)
self.assertEqual(["/bin/sh", "/app/egress-entrypoint.sh"], argv)
class TestSupervisor(unittest.TestCase): class TestSupervisor(unittest.TestCase):
"""End-to-end: drive `_Supervisor` directly with fake commands. """End-to-end: drive `_Supervisor` directly with fake commands.
We don't go through `main()` because main installs signal We don't go through `main()` because main installs signal
+2 -3
View File
@@ -33,7 +33,7 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
from bot_bottle.backend.util import AGENT_CA_PATH from bot_bottle.backend.util import AGENT_CA_PATH
from bot_bottle.egress import EgressPlan, EgressRoute from bot_bottle.egress import EgressPlan, EgressRoute
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, Manifest 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
} }
@@ -360,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,