Compare commits
2 Commits
pr-211
..
ca8c5efc22
| Author | SHA1 | Date | |
|---|---|---|---|
| ca8c5efc22 | |||
| a40d31f789 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://github.com/PyCQA/pylint)
|
[](https://github.com/PyCQA/pylint)
|
||||||
[](https://github.com/microsoft/pyright)
|
[](https://github.com/microsoft/pyright)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -191,7 +190,7 @@ class ActiveAgent:
|
|||||||
of sidecar daemons currently up for this bottle (`egress`,
|
of sidecar daemons currently up for this bottle (`egress`,
|
||||||
`git-gate`, `supervise`); the dashboard uses it to
|
`git-gate`, `supervise`); the dashboard uses it to
|
||||||
gate edit verbs. `backend_name` is the matching key in
|
gate edit verbs. `backend_name` is the matching key in
|
||||||
`_BACKENDS` (`docker` / `smolmachines` / `macos-container`) — used by the active-
|
`_BACKENDS` (`docker` / `smolmachines`) — used by the active-
|
||||||
list rendering to disambiguate and by the dashboard's
|
list rendering to disambiguate and by the dashboard's
|
||||||
re-attach path."""
|
re-attach path."""
|
||||||
|
|
||||||
@@ -531,7 +530,6 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
# each backend module can pull BottleSpec / BottlePlan / BottleBackend
|
||||||
# via `from . import ...` without hitting a partially-initialized module.
|
# via `from . import ...` without hitting a partially-initialized module.
|
||||||
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
|
||||||
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||||
|
|
||||||
|
|
||||||
@@ -541,7 +539,6 @@ from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: dis
|
|||||||
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
# unparameterized methods (prepare → plan → launch(plan), cleanup, etc.).
|
||||||
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
_BACKENDS: dict[str, BottleBackend[Any, Any]] = {
|
||||||
"docker": DockerBottleBackend(),
|
"docker": DockerBottleBackend(),
|
||||||
"macos-container": MacosContainerBottleBackend(),
|
|
||||||
"smolmachines": SmolmachinesBottleBackend(),
|
"smolmachines": SmolmachinesBottleBackend(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,24 +551,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
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
"""macOS Apple Container backend.
|
|
||||||
|
|
||||||
Selectable via `BOT_BOTTLE_BACKEND=macos-container`. This package owns
|
|
||||||
the Apple `container` CLI integration; launch remains gated until the
|
|
||||||
sidecar network enforcement shape is implemented.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .backend import MacosContainerBottleBackend
|
|
||||||
|
|
||||||
__all__ = ["MacosContainerBottleBackend"]
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""MacosContainerBottleBackend — Apple Container implementation."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Generator, Sequence
|
|
||||||
|
|
||||||
from ...agent_provider import AgentProvisionPlan
|
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...env import ResolvedEnv
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
|
||||||
from . import cleanup as _cleanup
|
|
||||||
from . import enumerate as _enumerate
|
|
||||||
from . import launch as _launch
|
|
||||||
from . import resolve_plan as _resolve_plan
|
|
||||||
from . import util as _container
|
|
||||||
from .bottle import MacosContainerBottle
|
|
||||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
class MacosContainerBottleBackend(
|
|
||||||
BottleBackend["MacosContainerBottlePlan", "MacosContainerBottleCleanupPlan"]
|
|
||||||
):
|
|
||||||
"""Apple Container backend. Selected by
|
|
||||||
`BOT_BOTTLE_BACKEND=macos-container` or
|
|
||||||
`--backend=macos-container`."""
|
|
||||||
|
|
||||||
name = "macos-container"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def is_available(cls) -> bool:
|
|
||||||
return _container.is_available()
|
|
||||||
|
|
||||||
def _preflight(self) -> None:
|
|
||||||
_resolve_plan.preflight()
|
|
||||||
|
|
||||||
def _build_guest_env(self, resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
return _resolve_plan.build_guest_env(resolved_env)
|
|
||||||
|
|
||||||
def _resolve_plan(
|
|
||||||
self,
|
|
||||||
spec: BottleSpec,
|
|
||||||
*,
|
|
||||||
slug: str,
|
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
supervise_plan: SupervisePlan | None,
|
|
||||||
stage_dir: Path,
|
|
||||||
) -> MacosContainerBottlePlan:
|
|
||||||
return _resolve_plan.resolve_plan(
|
|
||||||
spec,
|
|
||||||
slug=slug,
|
|
||||||
resolved_env=resolved_env,
|
|
||||||
agent_provision_plan=agent_provision_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def launch(
|
|
||||||
self, plan: MacosContainerBottlePlan
|
|
||||||
) -> Generator[MacosContainerBottle, None, None]:
|
|
||||||
with _launch.launch(plan, provision=self.provision) as bottle:
|
|
||||||
yield bottle
|
|
||||||
|
|
||||||
def prepare_cleanup(self) -> MacosContainerBottleCleanupPlan:
|
|
||||||
return _cleanup.prepare_cleanup()
|
|
||||||
|
|
||||||
def cleanup(self, plan: MacosContainerBottleCleanupPlan) -> None:
|
|
||||||
_cleanup.cleanup(plan)
|
|
||||||
|
|
||||||
def enumerate_active(self) -> Sequence[ActiveAgent]:
|
|
||||||
return _enumerate.enumerate_active()
|
|
||||||
|
|
||||||
def supervise_mcp_url(self, plan: MacosContainerBottlePlan) -> str:
|
|
||||||
return plan.agent_supervise_url
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""Bottle handle for Apple's `container` CLI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from typing import Callable, cast
|
|
||||||
|
|
||||||
from ...agent_provider import PromptMode, prompt_args
|
|
||||||
from .. import Bottle, ExecResult
|
|
||||||
from ..terminal import exec_shell_script
|
|
||||||
|
|
||||||
|
|
||||||
class MacosContainerBottle(Bottle):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
container: str,
|
|
||||||
teardown: Callable[[], None],
|
|
||||||
prompt_path_in_container: str | None,
|
|
||||||
*,
|
|
||||||
agent_command: str = "claude",
|
|
||||||
agent_prompt_mode: PromptMode = "append_file",
|
|
||||||
agent_provider_template: str = "claude",
|
|
||||||
terminal_title: str = "",
|
|
||||||
terminal_color: str = "",
|
|
||||||
agent_workdir: str = "/home/node",
|
|
||||||
):
|
|
||||||
self.name = container
|
|
||||||
self._teardown = teardown
|
|
||||||
self.prompt_path = prompt_path_in_container
|
|
||||||
self._agent_prompt_mode = agent_prompt_mode
|
|
||||||
self.agent_command = agent_command
|
|
||||||
self.terminal_title = terminal_title
|
|
||||||
self.terminal_color = terminal_color
|
|
||||||
self.agent_provider_template = agent_provider_template
|
|
||||||
self.agent_workdir = agent_workdir
|
|
||||||
self._closed = False
|
|
||||||
|
|
||||||
def agent_argv(self, argv: list[str], *, tty: bool = True) -> list[str]:
|
|
||||||
full_argv = list(argv)
|
|
||||||
full_argv.extend(
|
|
||||||
prompt_args(
|
|
||||||
cast(PromptMode, self._agent_prompt_mode),
|
|
||||||
self.prompt_path,
|
|
||||||
argv=full_argv,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
cmd = ["container", "exec"]
|
|
||||||
if tty:
|
|
||||||
cmd.extend(["--interactive", "--tty"])
|
|
||||||
if self.agent_workdir and self.agent_workdir != "/home/node":
|
|
||||||
cmd.extend(["--workdir", self.agent_workdir])
|
|
||||||
cmd.extend([self.name, self.agent_command, *full_argv])
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
|
||||||
agent_argv = self.agent_argv(argv, tty=tty)
|
|
||||||
script = (
|
|
||||||
exec_shell_script(agent_argv, self.terminal_title, self.terminal_color)
|
|
||||||
if tty else None
|
|
||||||
)
|
|
||||||
if script is None:
|
|
||||||
return subprocess.run(agent_argv, check=False).returncode
|
|
||||||
return subprocess.run(["sh", "-lc", script], check=False).returncode
|
|
||||||
|
|
||||||
def exec(self, script: str, *, user: str = "node") -> ExecResult:
|
|
||||||
result = subprocess.run(
|
|
||||||
["container", "exec", "--user", user, "--interactive",
|
|
||||||
self.name, "sh", "-s"],
|
|
||||||
input=script,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
return ExecResult(
|
|
||||||
returncode=result.returncode,
|
|
||||||
stdout=result.stdout,
|
|
||||||
stderr=result.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
def cp_in(self, host_path: str, container_path: str) -> None:
|
|
||||||
subprocess.run(
|
|
||||||
["container", "cp", host_path, f"{self.name}:{container_path}"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
if self._closed:
|
|
||||||
return
|
|
||||||
self._closed = True
|
|
||||||
self._teardown()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""Cleanup plan for the macOS Apple Container backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from ...log import info
|
|
||||||
from .. import BottleCleanupPlan
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MacosContainerBottleCleanupPlan(BottleCleanupPlan):
|
|
||||||
containers: tuple[str, ...] = ()
|
|
||||||
networks: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
def print(self) -> None:
|
|
||||||
if not self.containers and not self.networks:
|
|
||||||
info("macos-container cleanup: nothing to remove")
|
|
||||||
return
|
|
||||||
for name in self.containers:
|
|
||||||
info(f"macos-container container: {name}")
|
|
||||||
for name in self.networks:
|
|
||||||
info(f"macos-container network: {name}")
|
|
||||||
|
|
||||||
@property
|
|
||||||
def empty(self) -> bool:
|
|
||||||
return not self.containers and not self.networks
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"""Plan type for the macOS Apple Container backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ...agent_provider import PromptMode
|
|
||||||
from .. import BottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MacosContainerBottlePlan(BottlePlan):
|
|
||||||
slug: str
|
|
||||||
forwarded_env: dict[str, str] = field(repr=False)
|
|
||||||
agent_proxy_url: str = ""
|
|
||||||
agent_git_gate_url: str = ""
|
|
||||||
agent_supervise_url: str = ""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def container_name(self) -> str:
|
|
||||||
return self.agent_provision.instance_name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def image(self) -> str:
|
|
||||||
return self.agent_provision.image
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dockerfile_path(self) -> str:
|
|
||||||
return self.agent_provision.dockerfile
|
|
||||||
|
|
||||||
@property
|
|
||||||
def prompt_file(self) -> Path:
|
|
||||||
return self.agent_provision.prompt_file
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_command(self) -> str:
|
|
||||||
return self.agent_provision.command
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_prompt_mode(self) -> PromptMode:
|
|
||||||
return self.agent_provision.prompt_mode
|
|
||||||
|
|
||||||
@property
|
|
||||||
def agent_provider_template(self) -> str:
|
|
||||||
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
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""Cleanup for the macOS Apple Container backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...log import info, warn
|
|
||||||
from . import util as container_mod
|
|
||||||
from .bottle_cleanup_plan import MacosContainerBottleCleanupPlan
|
|
||||||
|
|
||||||
_PREFIX = "bot-bottle-"
|
|
||||||
_BUNDLE_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def _list_prefixed_containers() -> list[str]:
|
|
||||||
result = subprocess.run(
|
|
||||||
["container", "list", "--all", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
warn(f"container list failed: {result.stderr.strip()}")
|
|
||||||
return []
|
|
||||||
return sorted(
|
|
||||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
|
||||||
if name.startswith(_PREFIX) or name.startswith(_BUNDLE_PREFIX)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _list_prefixed_networks() -> list[str]:
|
|
||||||
result = subprocess.run(
|
|
||||||
["container", "network", "list", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
return sorted(
|
|
||||||
name for name in (line.strip() for line in result.stdout.splitlines())
|
|
||||||
if name.startswith(_PREFIX)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_cleanup() -> MacosContainerBottleCleanupPlan:
|
|
||||||
container_mod.require_container()
|
|
||||||
return MacosContainerBottleCleanupPlan(
|
|
||||||
containers=tuple(_list_prefixed_containers()),
|
|
||||||
networks=tuple(_list_prefixed_networks()),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup(plan: MacosContainerBottleCleanupPlan) -> None:
|
|
||||||
for name in plan.containers:
|
|
||||||
info(f"container delete --force {name}")
|
|
||||||
subprocess.run(
|
|
||||||
["container", "delete", "--force", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
for name in plan.networks:
|
|
||||||
info(f"container network delete {name}")
|
|
||||||
subprocess.run(
|
|
||||||
["container", "network", "delete", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
"""Active-agent enumeration for the macOS Apple Container backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from ...bottle_state import read_metadata
|
|
||||||
from .. import ActiveAgent
|
|
||||||
|
|
||||||
_PREFIX = "bot-bottle-"
|
|
||||||
_SIDECAR_PREFIX = "bot-bottle-sidecars-"
|
|
||||||
|
|
||||||
|
|
||||||
def enumerate_active() -> list[ActiveAgent]:
|
|
||||||
result = subprocess.run(
|
|
||||||
["container", "list", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return []
|
|
||||||
out: list[ActiveAgent] = []
|
|
||||||
for name in sorted(line.strip() for line in result.stdout.splitlines()):
|
|
||||||
if not name.startswith(_PREFIX):
|
|
||||||
continue
|
|
||||||
if name.startswith(_SIDECAR_PREFIX):
|
|
||||||
continue
|
|
||||||
slug = name[len(_PREFIX):]
|
|
||||||
metadata = read_metadata(slug)
|
|
||||||
out.append(ActiveAgent(
|
|
||||||
backend_name="macos-container",
|
|
||||||
slug=slug,
|
|
||||||
agent_name=metadata.agent_name if metadata else "?",
|
|
||||||
started_at=metadata.started_at if metadata else "",
|
|
||||||
services=(),
|
|
||||||
label=metadata.label if metadata else "",
|
|
||||||
color=metadata.color if metadata else "",
|
|
||||||
))
|
|
||||||
return out
|
|
||||||
@@ -1,426 +0,0 @@
|
|||||||
"""Launch flow for the macOS Apple Container backend.
|
|
||||||
|
|
||||||
This backend keeps the explicit proxy-env enforcement model for v1:
|
|
||||||
the agent container is attached only to a host-only Apple Container
|
|
||||||
network, while the sidecar bundle is attached to a NAT network first
|
|
||||||
and the host-only network second. The sidecar's host-only IP is
|
|
||||||
discovered from `container inspect` and stamped into the agent's
|
|
||||||
HTTP_PROXY / HTTPS_PROXY env vars.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import dataclasses
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from contextlib import ExitStack, contextmanager
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, Generator
|
|
||||||
|
|
||||||
from ...bottle_state import egress_state_dir, git_gate_state_dir
|
|
||||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER, egress_resolve_token_values
|
|
||||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
|
||||||
from ...log import die, info, warn
|
|
||||||
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.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 (
|
|
||||||
SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
|
||||||
)
|
|
||||||
from ..docker.egress import egress_tls_init
|
|
||||||
from ..util import AGENT_CA_BUNDLE, AGENT_CA_PATH
|
|
||||||
from . import util as container_mod
|
|
||||||
from .bottle import MacosContainerBottle
|
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
_REPO_DIR = str(Path(__file__).resolve().parent.parent.parent.parent)
|
|
||||||
_AGENT_SLEEP_SECONDS = "2147483647"
|
|
||||||
_GIT_HTTP_PORT = 9420
|
|
||||||
_GIT_GATE_READY_FILE = "/run/git-gate/ready"
|
|
||||||
|
|
||||||
|
|
||||||
def internal_network_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-net-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def egress_network_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-egress-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
def sidecar_container_name(slug: str) -> str:
|
|
||||||
return f"bot-bottle-sidecars-{slug}"
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def launch(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
*,
|
|
||||||
provision: Callable[[MacosContainerBottlePlan, "MacosContainerBottle"], str | None],
|
|
||||||
) -> Generator[MacosContainerBottle, None, None]:
|
|
||||||
"""Build, run, provision, and yield an Apple Container bottle."""
|
|
||||||
stack = ExitStack()
|
|
||||||
bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
|
||||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
|
||||||
|
|
||||||
def teardown() -> None:
|
|
||||||
teardown_exc: BaseException | None = None
|
|
||||||
try:
|
|
||||||
stack.close()
|
|
||||||
except BaseException as exc: # noqa: W0718 - teardown must continue
|
|
||||||
teardown_exc = exc
|
|
||||||
warn(f"macos-container teardown failed: {exc!r}")
|
|
||||||
revoke_git_gate_provisioned_keys(bottle_for_revoke, git_gate_dir_for_revoke)
|
|
||||||
if teardown_exc is not None:
|
|
||||||
raise teardown_exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
plan = _mint_certs(plan)
|
|
||||||
_build_images(plan)
|
|
||||||
|
|
||||||
internal_network = internal_network_name(plan.slug)
|
|
||||||
egress_network = egress_network_name(plan.slug)
|
|
||||||
_create_networks(internal_network, egress_network, stack)
|
|
||||||
|
|
||||||
sidecar_name = sidecar_container_name(plan.slug)
|
|
||||||
container_mod.force_remove_container(sidecar_name)
|
|
||||||
_start_sidecar_bundle(plan, sidecar_name, internal_network, egress_network)
|
|
||||||
stack.callback(container_mod.force_remove_container, sidecar_name)
|
|
||||||
_stage_git_gate(plan, sidecar_name)
|
|
||||||
|
|
||||||
sidecar_ip = container_mod.container_ipv4_on_network(
|
|
||||||
sidecar_name, internal_network,
|
|
||||||
)
|
|
||||||
plan = _stamp_agent_urls(plan, sidecar_ip)
|
|
||||||
|
|
||||||
container_mod.force_remove_container(plan.container_name)
|
|
||||||
_start_agent(plan, internal_network, sidecar_ip)
|
|
||||||
stack.callback(container_mod.force_remove_container, plan.container_name)
|
|
||||||
|
|
||||||
bottle = MacosContainerBottle(
|
|
||||||
plan.container_name,
|
|
||||||
teardown,
|
|
||||||
None,
|
|
||||||
agent_command=plan.agent_command,
|
|
||||||
agent_prompt_mode=plan.agent_prompt_mode,
|
|
||||||
agent_provider_template=plan.agent_provider_template,
|
|
||||||
terminal_title=plan.spec.label or plan.spec.agent_name,
|
|
||||||
terminal_color=plan.spec.color,
|
|
||||||
agent_workdir=plan.workspace_plan.workdir,
|
|
||||||
)
|
|
||||||
bottle.prompt_path = provision(plan, bottle)
|
|
||||||
|
|
||||||
yield bottle
|
|
||||||
finally:
|
|
||||||
teardown()
|
|
||||||
|
|
||||||
|
|
||||||
def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
|
||||||
egress_ca_host, egress_ca_cert_only = egress_tls_init(
|
|
||||||
egress_state_dir(plan.slug),
|
|
||||||
)
|
|
||||||
egress_plan = dataclasses.replace(
|
|
||||||
plan.egress_plan,
|
|
||||||
mitmproxy_ca_host_path=egress_ca_host,
|
|
||||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
|
||||||
)
|
|
||||||
return dataclasses.replace(plan, egress_plan=egress_plan)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_images(plan: MacosContainerBottlePlan) -> None:
|
|
||||||
container_mod.build_image(
|
|
||||||
SIDECAR_BUNDLE_IMAGE,
|
|
||||||
_REPO_DIR,
|
|
||||||
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
|
||||||
)
|
|
||||||
container_mod.build_image(
|
|
||||||
plan.image,
|
|
||||||
_REPO_DIR,
|
|
||||||
dockerfile=plan.dockerfile_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_networks(
|
|
||||||
internal_network: str,
|
|
||||||
egress_network: str,
|
|
||||||
stack: ExitStack,
|
|
||||||
) -> None:
|
|
||||||
container_mod.create_network(internal_network, internal=True)
|
|
||||||
stack.callback(container_mod.remove_network, internal_network)
|
|
||||||
container_mod.create_network(egress_network)
|
|
||||||
stack.callback(container_mod.remove_network, egress_network)
|
|
||||||
|
|
||||||
|
|
||||||
def _start_sidecar_bundle(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_name: str,
|
|
||||||
internal_network: str,
|
|
||||||
egress_network: str,
|
|
||||||
) -> None:
|
|
||||||
argv = _sidecar_run_argv(plan, sidecar_name, internal_network, egress_network)
|
|
||||||
effective_env = {**dict(os.environ), **plan.agent_provision.provisioned_env}
|
|
||||||
token_values = egress_resolve_token_values(
|
|
||||||
plan.egress_plan.token_env_map, effective_env,
|
|
||||||
)
|
|
||||||
env = {**os.environ, **token_values}
|
|
||||||
info(f"container run sidecar bundle {sidecar_name}")
|
|
||||||
result = subprocess.run(
|
|
||||||
argv, capture_output=True, text=True, env=env, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container run for sidecar bundle {sidecar_name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _start_agent(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
internal_network: str,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> None:
|
|
||||||
argv = _agent_run_argv(plan, internal_network, sidecar_ip)
|
|
||||||
env = {
|
|
||||||
**os.environ,
|
|
||||||
**plan.forwarded_env,
|
|
||||||
}
|
|
||||||
info(f"container run agent {plan.container_name}")
|
|
||||||
result = subprocess.run(
|
|
||||||
argv, capture_output=True, text=True, env=env, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container run for agent {plan.container_name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _stamp_agent_urls(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> MacosContainerBottlePlan:
|
|
||||||
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
|
||||||
supervise_url = ""
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
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(
|
|
||||||
plan,
|
|
||||||
agent_proxy_url=proxy_url,
|
|
||||||
agent_git_gate_url=git_gate_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(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_name: str,
|
|
||||||
internal_network: str,
|
|
||||||
egress_network: str,
|
|
||||||
) -> list[str]:
|
|
||||||
argv = [
|
|
||||||
"container", "run",
|
|
||||||
"--name", sidecar_name,
|
|
||||||
"--detach",
|
|
||||||
"--rm",
|
|
||||||
"--network", egress_network,
|
|
||||||
"--network", internal_network,
|
|
||||||
"--dns", _sidecar_dns(),
|
|
||||||
"--env", f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(_sidecar_daemons(plan))}",
|
|
||||||
]
|
|
||||||
for entry in _sidecar_env_entries(plan):
|
|
||||||
argv += ["--env", entry]
|
|
||||||
for host_path, container_path, read_only in _sidecar_mounts(plan):
|
|
||||||
argv += ["--mount", _mount_spec(host_path, container_path, read_only)]
|
|
||||||
argv.append(SIDECAR_BUNDLE_IMAGE)
|
|
||||||
return argv
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_run_argv(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
internal_network: str,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> list[str]:
|
|
||||||
argv = [
|
|
||||||
"container", "run",
|
|
||||||
"--name", plan.container_name,
|
|
||||||
"--detach",
|
|
||||||
"--rm",
|
|
||||||
"--network", internal_network,
|
|
||||||
]
|
|
||||||
for entry in _agent_env_entries(plan, sidecar_ip):
|
|
||||||
argv += ["--env", entry]
|
|
||||||
argv += [plan.image, "sleep", _AGENT_SLEEP_SECONDS]
|
|
||||||
return argv
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_dns() -> str:
|
|
||||||
return container_mod.dns_server()
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
|
||||||
daemons = ["egress"]
|
|
||||||
if plan.git_gate_plan.upstreams:
|
|
||||||
daemons += ["git-gate", "git-http"]
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
daemons.append("supervise")
|
|
||||||
return tuple(daemons)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
|
||||||
env: list[str] = []
|
|
||||||
if plan.egress_plan.routes:
|
|
||||||
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:
|
|
||||||
env += [
|
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
|
||||||
]
|
|
||||||
return tuple(env)
|
|
||||||
|
|
||||||
|
|
||||||
def _sidecar_mounts(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
) -> tuple[tuple[str, str, bool], ...]:
|
|
||||||
mounts: list[tuple[str, str, bool]] = []
|
|
||||||
|
|
||||||
ep = plan.egress_plan
|
|
||||||
mounts.append((
|
|
||||||
str(ep.mitmproxy_ca_host_path.parent),
|
|
||||||
str(Path(EGRESS_CA_IN_CONTAINER).parent),
|
|
||||||
False,
|
|
||||||
))
|
|
||||||
if ep.routes:
|
|
||||||
mounts.append((
|
|
||||||
str(_stage_routes_dir(plan)),
|
|
||||||
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
|
|
||||||
True,
|
|
||||||
))
|
|
||||||
|
|
||||||
sp = plan.supervise_plan
|
|
||||||
if sp is not None:
|
|
||||||
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
|
||||||
|
|
||||||
return tuple(mounts)
|
|
||||||
|
|
||||||
|
|
||||||
def _stage_routes_dir(plan: MacosContainerBottlePlan) -> Path:
|
|
||||||
routes_dir = plan.stage_dir / "macos-container-egress"
|
|
||||||
routes_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
shutil.copyfile(
|
|
||||||
plan.egress_plan.routes_path,
|
|
||||||
routes_dir / Path(EGRESS_ROUTES_IN_CONTAINER).name,
|
|
||||||
)
|
|
||||||
return routes_dir
|
|
||||||
|
|
||||||
|
|
||||||
def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str:
|
|
||||||
spec = f"type=bind,source={host_path},target={container_path}"
|
|
||||||
if read_only:
|
|
||||||
spec += ",readonly"
|
|
||||||
return spec
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_env_entries(
|
|
||||||
plan: MacosContainerBottlePlan,
|
|
||||||
sidecar_ip: str,
|
|
||||||
) -> tuple[str, ...]:
|
|
||||||
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
|
||||||
no_proxy = _agent_no_proxy(plan, sidecar_ip)
|
|
||||||
env = [
|
|
||||||
f"HTTPS_PROXY={proxy_url}",
|
|
||||||
f"HTTP_PROXY={proxy_url}",
|
|
||||||
f"https_proxy={proxy_url}",
|
|
||||||
f"http_proxy={proxy_url}",
|
|
||||||
f"NO_PROXY={no_proxy}",
|
|
||||||
f"no_proxy={no_proxy}",
|
|
||||||
f"NODE_EXTRA_CA_CERTS={AGENT_CA_PATH}",
|
|
||||||
f"SSL_CERT_FILE={AGENT_CA_BUNDLE}",
|
|
||||||
f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}",
|
|
||||||
]
|
|
||||||
if plan.agent_git_gate_url:
|
|
||||||
env.append(f"GIT_GATE_URL={plan.agent_git_gate_url}")
|
|
||||||
if plan.agent_supervise_url:
|
|
||||||
env.append(f"MCP_SUPERVISE_URL={plan.agent_supervise_url}")
|
|
||||||
for name, value in sorted(plan.agent_provision.guest_env.items()):
|
|
||||||
env.append(f"{name}={value}")
|
|
||||||
for name in sorted(plan.forwarded_env.keys()):
|
|
||||||
env.append(name)
|
|
||||||
return tuple(env)
|
|
||||||
|
|
||||||
|
|
||||||
def _agent_no_proxy(plan: MacosContainerBottlePlan, sidecar_ip: str) -> str:
|
|
||||||
hosts = ["localhost", "127.0.0.1", sidecar_ip]
|
|
||||||
return ",".join(hosts)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"""Prepare step for the macOS Apple Container backend."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from ...agent_provider import AgentProvisionPlan
|
|
||||||
from ...egress import EgressPlan
|
|
||||||
from ...env import ResolvedEnv
|
|
||||||
from ...git_gate import GitGatePlan
|
|
||||||
from ...supervise import SupervisePlan
|
|
||||||
from .. import BottleSpec
|
|
||||||
from . import util as container_mod
|
|
||||||
from .bottle_plan import MacosContainerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
def preflight() -> None:
|
|
||||||
container_mod.require_container()
|
|
||||||
|
|
||||||
|
|
||||||
def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
|
||||||
return dict(resolved_env.literals)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_plan(
|
|
||||||
spec: BottleSpec,
|
|
||||||
slug: str,
|
|
||||||
resolved_env: ResolvedEnv,
|
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
|
||||||
egress_plan: EgressPlan,
|
|
||||||
supervise_plan: SupervisePlan | None,
|
|
||||||
git_gate_plan: GitGatePlan,
|
|
||||||
stage_dir: Path,
|
|
||||||
) -> MacosContainerBottlePlan:
|
|
||||||
return MacosContainerBottlePlan(
|
|
||||||
spec=spec,
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
slug=slug,
|
|
||||||
forwarded_env=dict(resolved_env.forwarded),
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
agent_provision=agent_provision_plan,
|
|
||||||
)
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
"""Host-side primitives for Apple's `container` CLI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import ipaddress
|
|
||||||
import platform
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import time
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from ...log import die, info
|
|
||||||
|
|
||||||
|
|
||||||
_CONTAINER = "container"
|
|
||||||
_DEFAULT_DNS = "1.1.1.1"
|
|
||||||
|
|
||||||
|
|
||||||
def is_macos() -> bool:
|
|
||||||
return platform.system() == "Darwin"
|
|
||||||
|
|
||||||
|
|
||||||
def is_available() -> bool:
|
|
||||||
return is_macos() and shutil.which(_CONTAINER) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def require_container() -> None:
|
|
||||||
"""Fail with an install pointer if Apple Container is unavailable."""
|
|
||||||
if not is_macos():
|
|
||||||
info("BOT_BOTTLE_BACKEND=macos-container requires macOS.")
|
|
||||||
die("macos-container backend is only supported on macOS")
|
|
||||||
if shutil.which(_CONTAINER) is None:
|
|
||||||
info("Apple Container is required but was not found on PATH.")
|
|
||||||
info("Install: https://github.com/apple/container/releases")
|
|
||||||
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:
|
|
||||||
"""Build an OCI image with Apple's BuildKit-backed `container build`."""
|
|
||||||
info(
|
|
||||||
f"building image {ref} from {context} with Apple Container "
|
|
||||||
"(layer cache keeps repeat builds fast)"
|
|
||||||
)
|
|
||||||
_ensure_builder_dns()
|
|
||||||
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
|
||||||
if dockerfile:
|
|
||||||
args.extend(["-f", dockerfile])
|
|
||||||
args.append(context)
|
|
||||||
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:
|
|
||||||
return _silent_run([_CONTAINER, "image", "inspect", ref]) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def container_exists(name: str) -> bool:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "list", "--all", "--quiet"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return False
|
|
||||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
|
||||||
|
|
||||||
|
|
||||||
def force_remove_container(name: str) -> None:
|
|
||||||
if container_exists(name):
|
|
||||||
subprocess.run(
|
|
||||||
[_CONTAINER, "delete", "--force", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
args = [
|
|
||||||
_CONTAINER, "network", "create",
|
|
||||||
"--label", "bot-bottle.backend=macos-container",
|
|
||||||
]
|
|
||||||
if internal:
|
|
||||||
args.append("--internal")
|
|
||||||
args.append(name)
|
|
||||||
result = subprocess.run(
|
|
||||||
args, capture_output=True, text=True, check=False,
|
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
|
||||||
return
|
|
||||||
if "already exists" in (result.stderr or "").lower():
|
|
||||||
return
|
|
||||||
die(
|
|
||||||
f"container network create {name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def remove_network(name: str) -> None:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "network", "delete", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def inspect_container(name: str) -> dict[str, object]:
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "inspect", name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container inspect {name} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
data = json.loads(result.stdout or "[]")
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
die(f"container inspect {name} returned malformed JSON: {exc}")
|
|
||||||
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
||||||
return data[0]
|
|
||||||
if isinstance(data, dict):
|
|
||||||
return data
|
|
||||||
die(f"container inspect {name} returned an unexpected shape")
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
|
|
||||||
|
|
||||||
def container_ipv4_on_network(name: str, network: str) -> str:
|
|
||||||
data = inspect_container(name)
|
|
||||||
status = data.get("status")
|
|
||||||
networks = status.get("networks") if isinstance(status, dict) else None
|
|
||||||
if not isinstance(networks, list):
|
|
||||||
die(f"container inspect {name} did not include status.networks")
|
|
||||||
for entry in networks:
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
continue
|
|
||||||
if entry.get("network") != network:
|
|
||||||
continue
|
|
||||||
raw = entry.get("ipv4Address")
|
|
||||||
if not isinstance(raw, str) or not raw:
|
|
||||||
die(f"container {name} has no IPv4 address on {network}")
|
|
||||||
return raw.split("/", 1)[0]
|
|
||||||
die(f"container {name} is not attached to network {network}")
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
|
|
||||||
|
|
||||||
def image_id(ref: str) -> str:
|
|
||||||
"""Return the image digest/ID from `container image inspect`.
|
|
||||||
|
|
||||||
The command returns JSON on current Apple Container releases. Keep
|
|
||||||
parsing narrow and fatal so callers do not cache on an empty key.
|
|
||||||
"""
|
|
||||||
import json
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
[_CONTAINER, "image", "inspect", ref],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
die(
|
|
||||||
f"container image inspect for {ref!r} failed: "
|
|
||||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
data = json.loads(result.stdout or "{}")
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
die(f"container image inspect for {ref!r} returned malformed JSON: {exc}")
|
|
||||||
if isinstance(data, list) and data:
|
|
||||||
data = data[0]
|
|
||||||
if isinstance(data, dict):
|
|
||||||
value = data.get("id") or data.get("digest") or data.get("ID")
|
|
||||||
if value:
|
|
||||||
return str(value)
|
|
||||||
die(f"container image inspect for {ref!r} did not include an image id")
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
|
|
||||||
|
|
||||||
def save(ref: str, output: str) -> None:
|
|
||||||
subprocess.run([_CONTAINER, "image", "save", ref, "-o", output], check=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _silent_run(cmd: Iterable[str]) -> int:
|
|
||||||
return subprocess.run(
|
|
||||||
list(cmd),
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
).returncode
|
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from ..supervise import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
archive_proposal,
|
archive_proposal,
|
||||||
list_pending_proposals,
|
list_pending_proposals,
|
||||||
render_diff,
|
render_diff,
|
||||||
@@ -115,6 +116,8 @@ def _detail_lines(
|
|||||||
def _suffix_for_tool(tool: str) -> str:
|
def _suffix_for_tool(tool: str) -> str:
|
||||||
if tool == TOOL_CAPABILITY_BLOCK:
|
if tool == TOOL_CAPABILITY_BLOCK:
|
||||||
return ".dockerfile"
|
return ".dockerfile"
|
||||||
|
if tool == TOOL_GITLEAKS_ALLOW:
|
||||||
|
return ".txt"
|
||||||
return ".txt"
|
return ".txt"
|
||||||
|
|
||||||
|
|
||||||
@@ -154,7 +157,7 @@ def approve(
|
|||||||
qp, action=status, notes=notes,
|
qp, action=status, notes=notes,
|
||||||
diff_before=diff_before, diff_after=diff_after,
|
diff_before=diff_before, diff_after=diff_after,
|
||||||
)
|
)
|
||||||
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
if qp.proposal.tool in (TOOL_CAPABILITY_BLOCK, TOOL_GITLEAKS_ALLOW):
|
||||||
archive_proposal(qp.queue_dir, qp.proposal.id)
|
archive_proposal(qp.queue_dir, qp.proposal.id)
|
||||||
|
|
||||||
|
|
||||||
@@ -170,6 +173,23 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
|
|||||||
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
||||||
|
|
||||||
|
|
||||||
|
def _approve_from_tui(
|
||||||
|
stdscr: "curses._CursesWindow", # type: ignore
|
||||||
|
qp: QueuedProposal,
|
||||||
|
*,
|
||||||
|
final_file: str | None = None,
|
||||||
|
notes: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Approve from curses, prompting for any tool-specific audit note."""
|
||||||
|
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW and final_file is None:
|
||||||
|
notes = _prompt(stdscr, "allow reason (test fixture/false positive): ")
|
||||||
|
if not notes:
|
||||||
|
return "approve aborted (empty reason)"
|
||||||
|
approve(qp, final_file=final_file, notes=notes)
|
||||||
|
verb = "modified+approved" if final_file is not None else "approved"
|
||||||
|
return _approval_status(qp, verb)
|
||||||
|
|
||||||
|
|
||||||
def _write_audit(
|
def _write_audit(
|
||||||
qp: QueuedProposal,
|
qp: QueuedProposal,
|
||||||
*,
|
*,
|
||||||
@@ -353,18 +373,22 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
|
|||||||
_detail_view(stdscr, qp, green_attr=green_attr)
|
_detail_view(stdscr, qp, green_attr=green_attr)
|
||||||
elif key == ord("a"):
|
elif key == ord("a"):
|
||||||
try:
|
try:
|
||||||
approve(qp)
|
status_line = _approve_from_tui(stdscr, qp)
|
||||||
status_line = _approval_status(qp, "approved")
|
|
||||||
except ApplyError as e:
|
except ApplyError as e:
|
||||||
status_line = f"apply failed: {e}"
|
status_line = f"apply failed: {e}"
|
||||||
elif key == ord("m"):
|
elif key == ord("m"):
|
||||||
|
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW:
|
||||||
|
status_line = "modify unavailable for gitleaks-allow"
|
||||||
|
continue
|
||||||
edited = _modify(stdscr, qp)
|
edited = _modify(stdscr, qp)
|
||||||
if edited is None:
|
if edited is None:
|
||||||
status_line = "modify aborted (no change)"
|
status_line = "modify aborted (no change)"
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
status_line = _approve_from_tui(
|
||||||
status_line = _approval_status(qp, "modified+approved")
|
stdscr, qp, final_file=edited,
|
||||||
|
notes="operator modified before approving",
|
||||||
|
)
|
||||||
except ApplyError as e:
|
except ApplyError as e:
|
||||||
status_line = f"apply failed: {e}"
|
status_line = f"apply failed: {e}"
|
||||||
elif key == ord("r"):
|
elif key == ord("r"):
|
||||||
@@ -462,15 +486,20 @@ def _detail_view(
|
|||||||
offset = max(0, len(lines) - 1)
|
offset = max(0, len(lines) - 1)
|
||||||
elif key == ord("a"):
|
elif key == ord("a"):
|
||||||
try:
|
try:
|
||||||
approve(qp)
|
_approve_from_tui(stdscr, qp)
|
||||||
except ApplyError:
|
except ApplyError:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
elif key == ord("m"):
|
elif key == ord("m"):
|
||||||
|
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW:
|
||||||
|
return
|
||||||
edited = _modify(stdscr, qp)
|
edited = _modify(stdscr, qp)
|
||||||
if edited is not None:
|
if edited is not None:
|
||||||
try:
|
try:
|
||||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
_approve_from_tui(
|
||||||
|
stdscr, qp, final_file=edited,
|
||||||
|
notes="operator modified before approving",
|
||||||
|
)
|
||||||
except ApplyError:
|
except ApplyError:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+177
-24
@@ -247,6 +247,164 @@ cat > "$refs_file"
|
|||||||
|
|
||||||
zero=0000000000000000000000000000000000000000
|
zero=0000000000000000000000000000000000000000
|
||||||
|
|
||||||
|
supervise_gitleaks_allow() {
|
||||||
|
log_opts=$1
|
||||||
|
ref=$2
|
||||||
|
report_file=$(mktemp)
|
||||||
|
if ! gitleaks git \
|
||||||
|
--log-opts="$log_opts" \
|
||||||
|
--no-banner \
|
||||||
|
--redact \
|
||||||
|
--ignore-gitleaks-allow \
|
||||||
|
--report-format=json \
|
||||||
|
--report-path="$report_file" \
|
||||||
|
--exit-code 0 \
|
||||||
|
1>&2; then
|
||||||
|
rm -f "$report_file"
|
||||||
|
echo "git-gate: gitleaks inline-suppression scan failed for $ref" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
proposal_id=$(
|
||||||
|
GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
report_path = Path(sys.argv[1])
|
||||||
|
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
|
||||||
|
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
||||||
|
if not queue_dir or not slug:
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.loads(report_path.read_text() or "[]")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
sys.exit(3)
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
sys.exit(3)
|
||||||
|
if not raw:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
ref = os.environ.get("GITLEAKS_ALLOW_REF", "")
|
||||||
|
lines = [
|
||||||
|
"gitleaks inline suppression requires supervisor approval",
|
||||||
|
f"ref: {ref}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for i, finding in enumerate(raw, 1):
|
||||||
|
if not isinstance(finding, dict):
|
||||||
|
continue
|
||||||
|
file_path = finding.get("File", "")
|
||||||
|
line_no = finding.get("StartLine", finding.get("Line", ""))
|
||||||
|
rule_id = finding.get("RuleID", "")
|
||||||
|
commit = finding.get("Commit", "")
|
||||||
|
line = finding.get("Line", "")
|
||||||
|
lines.extend([
|
||||||
|
f"finding {i}:",
|
||||||
|
f" file: {file_path}",
|
||||||
|
f" line: {line_no}",
|
||||||
|
f" rule: {rule_id}",
|
||||||
|
f" commit: {commit}",
|
||||||
|
f" code: {line}",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
payload = "\n".join(lines).rstrip() + "\n"
|
||||||
|
proposal_id = str(uuid.uuid4())
|
||||||
|
proposal = {
|
||||||
|
"id": proposal_id,
|
||||||
|
"bottle_slug": slug,
|
||||||
|
"tool": "gitleaks-allow",
|
||||||
|
"proposed_file": payload,
|
||||||
|
"justification": (
|
||||||
|
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
||||||
|
"approve only for dummy test fixtures or confirmed false positives"
|
||||||
|
),
|
||||||
|
"arrival_timestamp": datetime.datetime.now(
|
||||||
|
datetime.timezone.utc
|
||||||
|
).isoformat(),
|
||||||
|
"current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||||
|
}
|
||||||
|
queue = Path(queue_dir)
|
||||||
|
queue.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = queue / f"{proposal_id}.proposal.json"
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with tmp.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(proposal, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
print(proposal_id)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
rc=$?
|
||||||
|
rm -f "$report_file"
|
||||||
|
if [ "$rc" -eq 0 ] && [ -z "$proposal_id" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
echo "git-gate: cannot route # gitleaks:allow finding to supervisor; refusing push" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
queue_dir=${SUPERVISE_QUEUE_DIR:-}
|
||||||
|
response_file="$queue_dir/${proposal_id}.response.json"
|
||||||
|
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
||||||
|
case "$timeout" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
echo "git-gate: invalid SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS=$timeout" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "git-gate: queued # gitleaks:allow supervisor approval $proposal_id" >&2
|
||||||
|
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
||||||
|
waited=0
|
||||||
|
while [ "$waited" -lt "$timeout" ]; do
|
||||||
|
if [ -f "$response_file" ]; then
|
||||||
|
status=$(python3 - "$response_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
sys.exit(1)
|
||||||
|
status = raw.get("status")
|
||||||
|
if not isinstance(status, str):
|
||||||
|
sys.exit(1)
|
||||||
|
print(status)
|
||||||
|
PY
|
||||||
|
) || status=""
|
||||||
|
case "$status" in
|
||||||
|
approved|modified)
|
||||||
|
mkdir -p "$queue_dir/processed"
|
||||||
|
mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true
|
||||||
|
mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true
|
||||||
|
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
rejected)
|
||||||
|
echo "git-gate: supervisor rejected # gitleaks:allow for $ref" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "git-gate: invalid supervisor response for # gitleaks:allow" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
echo "git-gate: supervisor approval timed out for # gitleaks:allow; refusing push" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# Phase 1: gitleaks scan each ref's incoming commits.
|
# Phase 1: gitleaks scan each ref's incoming commits.
|
||||||
while IFS=' ' read -r old new ref; do
|
while IFS=' ' read -r old new ref; do
|
||||||
[ -z "$ref" ] && continue
|
[ -z "$ref" ] && continue
|
||||||
@@ -268,6 +426,9 @@ while IFS=' ' read -r old new ref; do
|
|||||||
echo "git-gate: gitleaks rejected push to $ref" >&2
|
echo "git-gate: gitleaks rejected push to $ref" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! supervise_gitleaks_allow "$log_opts" "$ref"; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
done < "$refs_file"
|
done < "$refs_file"
|
||||||
|
|
||||||
# Phase 2: forward each ref to the upstream (`origin`, configured
|
# Phase 2: forward each ref to the upstream (`origin`, configured
|
||||||
@@ -300,8 +461,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 +548,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 +587,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 +611,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 +622,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 +631,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))
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -49,9 +49,11 @@ SUPERVISE_HOSTNAME = "supervise"
|
|||||||
SUPERVISE_PORT = 9100
|
SUPERVISE_PORT = 9100
|
||||||
|
|
||||||
TOOL_CAPABILITY_BLOCK = "capability-block"
|
TOOL_CAPABILITY_BLOCK = "capability-block"
|
||||||
|
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
|
||||||
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
||||||
TOOLS: tuple[str, ...] = (
|
TOOLS: tuple[str, ...] = (
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
TOOL_LIST_EGRESS_ROUTES,
|
TOOL_LIST_EGRESS_ROUTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
# PRD 0059: macOS Container backend
|
|
||||||
|
|
||||||
- **Status:** Active
|
|
||||||
- **Author:** Codex
|
|
||||||
- **Created:** 2026-06-10
|
|
||||||
- **Issue:** #220
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Add a `macos-container` backend that integrates Apple's `container`
|
|
||||||
CLI as a host runtime on macOS. The shipped slices register the
|
|
||||||
backend, implement reusable host primitives (`build`, `exec`, `cp`,
|
|
||||||
image inspection, cleanup, active enumeration), make launch runnable
|
|
||||||
with the proven two-network sidecar topology, and add real-runtime
|
|
||||||
coverage without weakening bot-bottle's sidecar egress model.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
bot-bottle currently has two local execution paths:
|
|
||||||
|
|
||||||
- `docker`, which runs the whole bottle topology through Docker
|
|
||||||
Compose.
|
|
||||||
- `smolmachines`, which runs the agent in smolvm but still depends on
|
|
||||||
Docker for the sidecar bundle and image-building pipeline.
|
|
||||||
|
|
||||||
Issue #220 explored removing Docker as a host dependency. A follow-up
|
|
||||||
review comment verified that smolvm can publish guest ports back to
|
|
||||||
host loopback and that another smolvm guest can reach that service
|
|
||||||
through the existing per-bottle loopback alias plus `--allow-cidr`
|
|
||||||
path. That keeps the VM-contained sidecar direction viable and rejects
|
|
||||||
the host-process sidecar fallback.
|
|
||||||
|
|
||||||
Apple's `container` CLI is another macOS-native way to run OCI images
|
|
||||||
as lightweight Linux VMs. Its current command surface includes
|
|
||||||
Docker-like `build`, `run`, `exec`, `cp`, port publishing, image
|
|
||||||
inspection, and user-defined networks. That makes it a plausible local
|
|
||||||
backend, but it does not remove the need to preserve bot-bottle's
|
|
||||||
sidecar enforcement property: the agent must not have a direct egress
|
|
||||||
path around the egress sidecar.
|
|
||||||
|
|
||||||
## Goals / Success Criteria
|
|
||||||
|
|
||||||
- `--backend=macos-container` and
|
|
||||||
`BOT_BOTTLE_BACKEND=macos-container` are accepted by the existing
|
|
||||||
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
|
|
||||||
`PATH`.
|
|
||||||
- The backend has tested wrappers for Apple Container image build,
|
|
||||||
image inspection, container `exec`, container `cp`, cleanup, and
|
|
||||||
active-agent enumeration.
|
|
||||||
- Full launch uses a host-only internal network for the agent and a
|
|
||||||
separate NAT egress network for the sidecar bundle.
|
|
||||||
- The agent container does not attach to the egress network. It reaches
|
|
||||||
allowed outbound hosts through HTTP(S)_PROXY pointing at the
|
|
||||||
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
|
|
||||||
|
|
||||||
- Do not remove or deprecate the Docker backend.
|
|
||||||
- Do not remove or deprecate the smolmachines backend.
|
|
||||||
- Do not run sidecar daemons as host processes.
|
|
||||||
- Do not launch a degraded backend where the agent can bypass the
|
|
||||||
egress sidecar through direct network access.
|
|
||||||
- Do not require Docker Desktop as part of the macOS Container backend.
|
|
||||||
|
|
||||||
## Design
|
|
||||||
|
|
||||||
### Backend name
|
|
||||||
|
|
||||||
The selectable backend name is `macos-container`. The Python package
|
|
||||||
uses `bot_bottle.backend.macos_container` because module names cannot
|
|
||||||
contain hyphens.
|
|
||||||
|
|
||||||
### Availability and preflight
|
|
||||||
|
|
||||||
`MacosContainerBottleBackend.is_available()` returns true only when:
|
|
||||||
|
|
||||||
- `platform.system() == "Darwin"`
|
|
||||||
- `container` is discoverable on `PATH`
|
|
||||||
|
|
||||||
`prepare()` calls `require_container()`, which produces a concrete
|
|
||||||
install pointer and rejects non-macOS hosts.
|
|
||||||
|
|
||||||
### Implemented primitives
|
|
||||||
|
|
||||||
The backend owns an Apple Container wrapper module instead of reusing
|
|
||||||
Docker wrappers. The wrapper maps bot-bottle's backend needs to
|
|
||||||
Apple's CLI:
|
|
||||||
|
|
||||||
| bot-bottle need | Apple Container command |
|
|
||||||
|---|---|
|
|
||||||
| Build provider image | `container build -t <ref> [-f Dockerfile] <context>` |
|
|
||||||
| Run agent commands | `container exec [--interactive --tty] <id> ...` |
|
|
||||||
| Copy files into guest | `container cp <host> <id>:<path>` |
|
|
||||||
| Inspect image identity | `container image inspect <ref>` |
|
|
||||||
| Cleanup stale containers | `container delete --force <id>` |
|
|
||||||
| Cleanup stale networks | `container network delete <name>` |
|
|
||||||
| Active enumeration | `container list --quiet` |
|
|
||||||
|
|
||||||
The bottle handle mirrors `DockerBottle`: it builds a host argv for
|
|
||||||
foreground agent execution, pipes shell snippets through stdin for
|
|
||||||
`Bottle.exec`, and exposes `cp_in` for provisioning.
|
|
||||||
|
|
||||||
### Launch topology
|
|
||||||
|
|
||||||
`launch()` uses Apple Container's two-network topology:
|
|
||||||
|
|
||||||
- create a host-only internal network for the bottle;
|
|
||||||
- create a normal NAT egress network for the sidecar bundle;
|
|
||||||
- start the sidecar bundle attached to the egress network first and the
|
|
||||||
internal network second;
|
|
||||||
- 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
|
|
||||||
proxy-env contract that existing agent tooling already honors. The
|
|
||||||
integration smoke also removes the proxy env in-guest and confirms
|
|
||||||
direct egress fails.
|
|
||||||
|
|
||||||
### Deferred git-gate support
|
|
||||||
|
|
||||||
Apple Container currently rejects single-file bind mounts, and
|
|
||||||
`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
|
|
||||||
|
|
||||||
1. Register `macos-container`, add availability/preflight, bottle
|
|
||||||
handle, utility wrappers, cleanup, active enumeration, unit tests,
|
|
||||||
and this PRD.
|
|
||||||
2. Spike Apple Container networking against real macOS 26 hosts:
|
|
||||||
repeated `--network`, internal network egress behavior, published
|
|
||||||
loopback reachability from another container, DNS behavior, and
|
|
||||||
labels/JSON output stability.
|
|
||||||
3. Implement launch once the enforcement shape is proven. Reuse the
|
|
||||||
existing sidecar bundle image and daemon subset env contract where
|
|
||||||
possible.
|
|
||||||
4. Add real-runtime integration tests guarded by `container` presence
|
|
||||||
and macOS version.
|
|
||||||
5. Consider moving smolmachines sidecar/image-building work to
|
|
||||||
VM-contained or Apple Container-backed execution only after the
|
|
||||||
`macos-container` launch path is trustworthy.
|
|
||||||
|
|
||||||
## Testing Strategy
|
|
||||||
|
|
||||||
- Unit tests cover backend registration through `known_backend_names`.
|
|
||||||
- Unit tests cover availability/preflight behavior without requiring
|
|
||||||
macOS.
|
|
||||||
- Unit tests cover `MacosContainerBottle` command construction and
|
|
||||||
stdin-based shell execution.
|
|
||||||
- Unit tests cover cleanup and active enumeration parsing.
|
|
||||||
- Unit tests cover launch argv/env construction, sidecar mount
|
|
||||||
staging, sidecar IP parsing, and git-gate rejection.
|
|
||||||
- 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
|
|
||||||
|
|
||||||
- [Issue #220 review comment](https://gitea.dideric.is/didericis/bot-bottle/issues/220#issuecomment-1980):
|
|
||||||
smolvm `--port/-p` can expose a guest service to host loopback, and
|
|
||||||
another smolvm guest can reach it through the existing per-bottle
|
|
||||||
loopback alias path.
|
|
||||||
- Apple Container command reference: `container run`, `build`, `exec`,
|
|
||||||
port publishing, and network commands.
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# PRD prd-new: Gitleaks inline suppression supervision
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** codex
|
||||||
|
- **Created:** 2026-06-10
|
||||||
|
- **Issue:** #208
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Route every `# gitleaks:allow` inline suppression through the existing
|
||||||
|
supervisor approval queue before git-gate forwards a push upstream. The
|
||||||
|
annotation remains available for test fixtures and confirmed false positives,
|
||||||
|
but it no longer lets an agent silently bypass the gate's secret scan.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Gitleaks supports inline suppression comments. With the default behaviour,
|
||||||
|
a pushed line that contains a secret-like value and `# gitleaks:allow` is
|
||||||
|
ignored by gitleaks and accepted by git-gate. That is convenient for dummy
|
||||||
|
credentials in tests, but it also gives an agent a one-line bypass for a real
|
||||||
|
secret. Once the commit lands, the diff alone cannot prove whether the
|
||||||
|
suppression was legitimate.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
1. Git-gate continues to run the normal gitleaks scan for every incoming ref.
|
||||||
|
2. After the normal scan passes, git-gate runs a second scan with
|
||||||
|
`--ignore-gitleaks-allow` and a JSON report so suppressed findings become
|
||||||
|
visible.
|
||||||
|
3. If that second scan reports no suppressed findings, the push proceeds
|
||||||
|
unchanged.
|
||||||
|
4. If it reports suppressed findings, git-gate creates a `gitleaks-allow`
|
||||||
|
supervisor proposal containing the ref, file path, line number, rule,
|
||||||
|
commit, and flagged line for each finding.
|
||||||
|
5. The push proceeds only when the supervisor explicitly approves the
|
||||||
|
proposal; rejection, malformed responses, missing supervisor configuration,
|
||||||
|
and timeout all refuse the push.
|
||||||
|
6. The supervisor TUI requires a reason when approving a `gitleaks-allow`
|
||||||
|
proposal, so the audit trail records whether the approval was for a test
|
||||||
|
fixture or a false positive.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Replacing gitleaks or changing the main secret-detection rule set.
|
||||||
|
- Removing support for `# gitleaks:allow`.
|
||||||
|
- Automatically classifying fixture files or false positives.
|
||||||
|
- Adding new supervisor transport or authentication mechanisms.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Git-gate flow
|
||||||
|
|
||||||
|
`git_gate_render_hook()` emits a `supervise_gitleaks_allow` shell helper.
|
||||||
|
For each incoming ref, git-gate first runs the existing gitleaks command. If
|
||||||
|
that scan passes, it runs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitleaks git \
|
||||||
|
--log-opts="$log_opts" \
|
||||||
|
--no-banner \
|
||||||
|
--redact \
|
||||||
|
--ignore-gitleaks-allow \
|
||||||
|
--report-format=json \
|
||||||
|
--report-path="$report_file" \
|
||||||
|
--exit-code 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The second pass keeps the push path non-interactive while producing a report
|
||||||
|
of findings that would otherwise have been hidden by inline suppression.
|
||||||
|
|
||||||
|
### Supervisor proposal
|
||||||
|
|
||||||
|
When the JSON report contains findings, an embedded Python helper writes a
|
||||||
|
proposal into `SUPERVISE_QUEUE_DIR` using the existing proposal schema. The
|
||||||
|
proposal uses:
|
||||||
|
|
||||||
|
- `tool: "gitleaks-allow"`
|
||||||
|
- a text payload with the ref and each finding's file, line, rule, commit,
|
||||||
|
and redacted code line
|
||||||
|
- a justification that tells the operator to approve only dummy test fixtures
|
||||||
|
or confirmed false positives
|
||||||
|
|
||||||
|
Git-gate then waits for `<proposal-id>.response.json` for
|
||||||
|
`SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS`, defaulting to 300 seconds.
|
||||||
|
`approved` and `modified` responses allow the push; `rejected`, invalid
|
||||||
|
responses, invalid timeout configuration, or timeout refuse it.
|
||||||
|
|
||||||
|
### Supervisor UI
|
||||||
|
|
||||||
|
`TOOL_GITLEAKS_ALLOW` is added to the supervisor tool registry. The curses
|
||||||
|
supervisor renders the proposal as text and allows approval or rejection.
|
||||||
|
Modification is unavailable for this proposal type because there is no file
|
||||||
|
patch to apply. Approval from the TUI prompts for a non-empty reason and
|
||||||
|
writes that reason to the response/audit path.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Unit tests assert that the rendered git-gate hook includes the second gitleaks
|
||||||
|
pass, supervisor queue fields, and fail-closed messages. Supervisor tests cover
|
||||||
|
the new tool constant, proposal archiving, and the required TUI approval
|
||||||
|
reason.
|
||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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()
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -71,11 +44,8 @@ class TestGetBottleBackend(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestKnownBackendNames(unittest.TestCase):
|
class TestKnownBackendNames(unittest.TestCase):
|
||||||
def test_returns_backends_sorted(self):
|
def test_returns_both_backends_sorted(self):
|
||||||
self.assertEqual(
|
self.assertEqual(("docker", "smolmachines"), known_backend_names())
|
||||||
("docker", "macos-container", "smolmachines"),
|
|
||||||
known_backend_names(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEnumerateActiveAgents(unittest.TestCase):
|
class TestEnumerateActiveAgents(unittest.TestCase):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -199,6 +192,30 @@ class TestHookRender(unittest.TestCase):
|
|||||||
self.assertIn('set -- "$@" --push-option="$opt"', hook)
|
self.assertIn('set -- "$@" --push-option="$opt"', hook)
|
||||||
self.assertIn('git push "$@" origin "$refspec"', hook)
|
self.assertIn('git push "$@" origin "$refspec"', hook)
|
||||||
|
|
||||||
|
def test_inline_gitleaks_allow_routes_to_supervisor(self):
|
||||||
|
hook = git_gate_render_hook()
|
||||||
|
# First gitleaks runs normally; only if that passes does the
|
||||||
|
# hook ask gitleaks to ignore inline allow comments and report
|
||||||
|
# the suppressed findings for human approval.
|
||||||
|
self.assertIn("--ignore-gitleaks-allow", hook)
|
||||||
|
self.assertIn("--report-format=json", hook)
|
||||||
|
self.assertIn('"tool": "gitleaks-allow"', hook)
|
||||||
|
self.assertIn("SUPERVISE_QUEUE_DIR", hook)
|
||||||
|
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
|
||||||
|
self.assertIn("supervisor approved # gitleaks:allow", hook)
|
||||||
|
self.assertIn("supervisor rejected # gitleaks:allow", hook)
|
||||||
|
|
||||||
|
def test_inline_gitleaks_allow_fails_closed_without_supervisor(self):
|
||||||
|
hook = git_gate_render_hook()
|
||||||
|
self.assertIn(
|
||||||
|
"cannot route # gitleaks:allow finding to supervisor; refusing push",
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"supervisor approval timed out for # gitleaks:allow; refusing push",
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestAccessHookRender(unittest.TestCase):
|
class TestAccessHookRender(unittest.TestCase):
|
||||||
def test_access_hook_refreshes_origin_on_upload_pack(self):
|
def test_access_hook_refreshes_origin_on_upload_pack(self):
|
||||||
@@ -284,7 +301,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"}},
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
"""Unit: Apple Container bottle command construction."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle.backend.macos_container.bottle import MacosContainerBottle
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerBottle(unittest.TestCase):
|
|
||||||
def test_agent_argv_uses_container_exec(self):
|
|
||||||
bottle = MacosContainerBottle(
|
|
||||||
"bot-bottle-dev-abc",
|
|
||||||
lambda: None,
|
|
||||||
None,
|
|
||||||
agent_command="codex",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
[
|
|
||||||
"container", "exec", "--interactive", "--tty",
|
|
||||||
"bot-bottle-dev-abc", "codex", "run",
|
|
||||||
],
|
|
||||||
bottle.agent_argv(["run"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_agent_argv_includes_workdir(self):
|
|
||||||
bottle = MacosContainerBottle(
|
|
||||||
"bot-bottle-dev-abc",
|
|
||||||
lambda: None,
|
|
||||||
None,
|
|
||||||
agent_workdir="/home/node/workspace",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
[
|
|
||||||
"container", "exec", "--interactive", "--tty",
|
|
||||||
"--workdir", "/home/node/workspace",
|
|
||||||
"bot-bottle-dev-abc", "claude",
|
|
||||||
],
|
|
||||||
bottle.agent_argv([]),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_exec_pipes_script_to_shell(self):
|
|
||||||
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
|
|
||||||
with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run:
|
|
||||||
run.return_value.returncode = 7
|
|
||||||
run.return_value.stdout = "out"
|
|
||||||
run.return_value.stderr = "err"
|
|
||||||
result = bottle.exec("echo hi", user="root")
|
|
||||||
self.assertEqual(7, result.returncode)
|
|
||||||
self.assertEqual(
|
|
||||||
[
|
|
||||||
"container", "exec", "--user", "root", "--interactive",
|
|
||||||
"bot-bottle-dev-abc", "sh", "-s",
|
|
||||||
],
|
|
||||||
run.call_args.args[0],
|
|
||||||
)
|
|
||||||
self.assertEqual("echo hi", run.call_args.kwargs["input"])
|
|
||||||
|
|
||||||
def test_cp_in_uses_container_cp(self):
|
|
||||||
bottle = MacosContainerBottle("bot-bottle-dev-abc", lambda: None, None)
|
|
||||||
with patch("bot_bottle.backend.macos_container.bottle.subprocess.run") as run:
|
|
||||||
bottle.cp_in("/tmp/src", "/home/node/src")
|
|
||||||
self.assertEqual(
|
|
||||||
["container", "cp", "/tmp/src", "bot-bottle-dev-abc:/home/node/src"],
|
|
||||||
run.call_args.args[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
"""Unit: Apple Container cleanup/enumeration helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle.backend.macos_container import cleanup, enumerate as enum_mod
|
|
||||||
from bot_bottle.backend.macos_container.bottle_cleanup_plan import (
|
|
||||||
MacosContainerBottleCleanupPlan,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerCleanup(unittest.TestCase):
|
|
||||||
def test_lists_prefixed_containers(self):
|
|
||||||
completed = cleanup.subprocess.CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=0,
|
|
||||||
stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n",
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
with patch.object(cleanup.subprocess, "run", return_value=completed):
|
|
||||||
self.assertEqual(
|
|
||||||
["bot-bottle-a", "bot-bottle-sidecars-a"],
|
|
||||||
cleanup._list_prefixed_containers(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_cleanup_deletes_containers_and_networks(self):
|
|
||||||
plan = MacosContainerBottleCleanupPlan(
|
|
||||||
containers=("bot-bottle-a",),
|
|
||||||
networks=("bot-bottle-net-a",),
|
|
||||||
)
|
|
||||||
with patch.object(cleanup.subprocess, "run") as run:
|
|
||||||
cleanup.cleanup(plan)
|
|
||||||
self.assertEqual(
|
|
||||||
["container", "delete", "--force", "bot-bottle-a"],
|
|
||||||
run.call_args_list[0].args[0],
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
["container", "network", "delete", "bot-bottle-net-a"],
|
|
||||||
run.call_args_list[1].args[0],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerEnumerate(unittest.TestCase):
|
|
||||||
def test_enumerate_active_reads_metadata(self):
|
|
||||||
completed = enum_mod.subprocess.CompletedProcess(
|
|
||||||
args=[],
|
|
||||||
returncode=0,
|
|
||||||
stdout="bot-bottle-a\nbot-bottle-sidecars-a\nother\n",
|
|
||||||
stderr="",
|
|
||||||
)
|
|
||||||
|
|
||||||
class _Metadata:
|
|
||||||
agent_name = "impl"
|
|
||||||
started_at = "2026-06-10T00:00:00Z"
|
|
||||||
label = "Implement"
|
|
||||||
color = "blue"
|
|
||||||
|
|
||||||
with patch.object(enum_mod.subprocess, "run", return_value=completed), \
|
|
||||||
patch.object(enum_mod, "read_metadata", return_value=_Metadata()):
|
|
||||||
agents = enum_mod.enumerate_active()
|
|
||||||
self.assertEqual(1, len(agents))
|
|
||||||
self.assertEqual("macos-container", agents[0].backend_name)
|
|
||||||
self.assertEqual("a", agents[0].slug)
|
|
||||||
self.assertEqual("impl", agents[0].agent_name)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,262 +0,0 @@
|
|||||||
"""Unit: Apple Container launch argv construction."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import cast
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle.backend.macos_container import launch
|
|
||||||
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
|
||||||
|
|
||||||
|
|
||||||
def _plan(
|
|
||||||
*,
|
|
||||||
stage_dir: Path,
|
|
||||||
git: bool = False,
|
|
||||||
supervise: bool = False,
|
|
||||||
agent_git_gate_url: str = "",
|
|
||||||
agent_supervise_url: str = "",
|
|
||||||
) -> MacosContainerBottlePlan:
|
|
||||||
routes_path = stage_dir / "source-routes.yaml"
|
|
||||||
routes_path.write_text("routes: []\n", encoding="utf-8")
|
|
||||||
ca_dir = stage_dir / "egress-ca"
|
|
||||||
ca_dir.mkdir(exist_ok=True)
|
|
||||||
ca_path = ca_dir / "mitmproxy-ca.pem"
|
|
||||||
ca_path.write_text("ca\n", encoding="utf-8")
|
|
||||||
egress_plan = SimpleNamespace(
|
|
||||||
mitmproxy_ca_host_path=ca_path,
|
|
||||||
routes_path=routes_path,
|
|
||||||
routes=("route",),
|
|
||||||
token_env_map={"EGRESS_TOKEN_0": "HOST_TOKEN"},
|
|
||||||
)
|
|
||||||
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(
|
|
||||||
name="origin",
|
|
||||||
identity_file=str(key_path),
|
|
||||||
known_hosts_file=known_hosts_path,
|
|
||||||
)
|
|
||||||
git_gate_plan = SimpleNamespace(
|
|
||||||
upstreams=(upstream,),
|
|
||||||
entrypoint_script=entrypoint,
|
|
||||||
hook_script=hook,
|
|
||||||
access_hook_script=access_hook,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
git_gate_plan = SimpleNamespace(upstreams=())
|
|
||||||
supervise_plan = (
|
|
||||||
SimpleNamespace(queue_dir=Path("/state/supervise/queue"))
|
|
||||||
if supervise else None
|
|
||||||
)
|
|
||||||
agent_provision = SimpleNamespace(
|
|
||||||
guest_env={"LITERAL": "value"},
|
|
||||||
provisioned_env={"CODEX_HOME": "/run/codex-home"},
|
|
||||||
)
|
|
||||||
return cast(MacosContainerBottlePlan, SimpleNamespace(
|
|
||||||
spec=SimpleNamespace(),
|
|
||||||
stage_dir=stage_dir,
|
|
||||||
slug="dev-abc",
|
|
||||||
container_name="bot-bottle-dev-abc",
|
|
||||||
image="bot-bottle-agent:latest",
|
|
||||||
forwarded_env={"OAUTH_TOKEN": "host-value"},
|
|
||||||
egress_plan=egress_plan,
|
|
||||||
git_gate_plan=git_gate_plan,
|
|
||||||
supervise_plan=supervise_plan,
|
|
||||||
agent_provision=agent_provision,
|
|
||||||
agent_git_gate_url=agent_git_gate_url,
|
|
||||||
agent_supervise_url=agent_supervise_url,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self._tmp = tempfile.TemporaryDirectory()
|
|
||||||
self.stage_dir = Path(self._tmp.name)
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
self._tmp.cleanup()
|
|
||||||
|
|
||||||
def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self):
|
|
||||||
plan = _plan(stage_dir=self.stage_dir, supervise=True)
|
|
||||||
with patch.object(launch.os, "environ", {
|
|
||||||
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
|
|
||||||
}):
|
|
||||||
argv = launch._sidecar_run_argv(
|
|
||||||
plan,
|
|
||||||
"bot-bottle-sidecars-dev-abc",
|
|
||||||
"bot-bottle-net-dev-abc",
|
|
||||||
"bot-bottle-egress-dev-abc",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
[
|
|
||||||
"--network", "bot-bottle-egress-dev-abc",
|
|
||||||
"--network", "bot-bottle-net-dev-abc",
|
|
||||||
],
|
|
||||||
argv[argv.index("--network"):argv.index("--dns")],
|
|
||||||
)
|
|
||||||
self.assertIn("--dns", argv)
|
|
||||||
self.assertEqual("9.9.9.9", argv[argv.index("--dns") + 1])
|
|
||||||
self.assertIn(
|
|
||||||
"BOT_BOTTLE_SIDECAR_DAEMONS=egress,supervise",
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
self.assertIn("EGRESS_TOKEN_0", argv)
|
|
||||||
self.assertIn(
|
|
||||||
f"type=bind,source={self.stage_dir / 'egress-ca'},target=/home/mitmproxy/.mitmproxy",
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
routes_dir = self.stage_dir / "macos-container-egress"
|
|
||||||
self.assertIn(
|
|
||||||
f"type=bind,source={routes_dir},target=/etc/egress,readonly",
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
"routes: []\n",
|
|
||||||
(routes_dir / "routes.yaml").read_text(encoding="utf-8"),
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"type=bind,source=/state/supervise/queue,target=/run/supervise/queue",
|
|
||||||
argv,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_agent_env_points_proxy_at_sidecar_ip(self):
|
|
||||||
plan = _plan(
|
|
||||||
stage_dir=self.stage_dir,
|
|
||||||
agent_git_gate_url="http://192.168.128.2:9420",
|
|
||||||
agent_supervise_url="http://192.168.128.2:9100/",
|
|
||||||
)
|
|
||||||
env = launch._agent_env_entries(plan, "192.168.128.2")
|
|
||||||
self.assertIn("HTTPS_PROXY=http://192.168.128.2:9099", env)
|
|
||||||
self.assertIn("HTTP_PROXY=http://192.168.128.2:9099", env)
|
|
||||||
self.assertIn("https_proxy=http://192.168.128.2:9099", env)
|
|
||||||
self.assertIn("http_proxy=http://192.168.128.2:9099", env)
|
|
||||||
self.assertIn("NO_PROXY=localhost,127.0.0.1,192.168.128.2", env)
|
|
||||||
self.assertIn("NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/bot-bottle-mitm-ca.crt", env)
|
|
||||||
self.assertIn("SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", env)
|
|
||||||
self.assertIn("REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt", env)
|
|
||||||
self.assertIn("GIT_GATE_URL=http://192.168.128.2:9420", env)
|
|
||||||
self.assertIn("MCP_SUPERVISE_URL=http://192.168.128.2:9100/", env)
|
|
||||||
self.assertIn("LITERAL=value", env)
|
|
||||||
self.assertIn("OAUTH_TOKEN", env)
|
|
||||||
self.assertNotIn("CODEX_HOME", env)
|
|
||||||
|
|
||||||
def test_agent_run_uses_internal_network_only(self):
|
|
||||||
plan = _plan(stage_dir=self.stage_dir)
|
|
||||||
argv = launch._agent_run_argv(
|
|
||||||
plan, "bot-bottle-net-dev-abc", "192.168.128.2",
|
|
||||||
)
|
|
||||||
self.assertIn("--network", argv)
|
|
||||||
self.assertEqual("bot-bottle-net-dev-abc", argv[argv.index("--network") + 1])
|
|
||||||
self.assertNotIn("bot-bottle-egress-dev-abc", argv)
|
|
||||||
self.assertEqual(["bot-bottle-agent:latest", "sleep", "2147483647"], argv[-3:])
|
|
||||||
|
|
||||||
def test_git_gate_daemons_are_ready_gated(self):
|
|
||||||
plan = _plan(stage_dir=self.stage_dir, git=True)
|
|
||||||
self.assertEqual(
|
|
||||||
("egress", "git-gate", "git-http"),
|
|
||||||
launch._sidecar_daemons(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__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
"""Unit: Apple Container utility helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from bot_bottle.backend.macos_container import util
|
|
||||||
|
|
||||||
|
|
||||||
class TestMacosContainerAvailability(unittest.TestCase):
|
|
||||||
def test_available_only_on_macos_with_container(self):
|
|
||||||
with patch.object(util.platform, "system", return_value="Darwin"), \
|
|
||||||
patch.object(util.shutil, "which", return_value="/usr/local/bin/container"):
|
|
||||||
self.assertTrue(util.is_available())
|
|
||||||
|
|
||||||
def test_not_available_off_macos(self):
|
|
||||||
with patch.object(util.platform, "system", return_value="Linux"), \
|
|
||||||
patch.object(util.shutil, "which", return_value="/usr/local/bin/container"):
|
|
||||||
self.assertFalse(util.is_available())
|
|
||||||
|
|
||||||
def test_require_container_dies_when_missing(self):
|
|
||||||
with patch.object(util.platform, "system", return_value="Darwin"), \
|
|
||||||
patch.object(util.shutil, "which", return_value=None), \
|
|
||||||
patch.object(util, "die", side_effect=SystemExit("die")):
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
util.require_container()
|
|
||||||
|
|
||||||
|
|
||||||
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):
|
|
||||||
status = util.subprocess.CompletedProcess(
|
|
||||||
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")
|
|
||||||
self.assertEqual(
|
|
||||||
[
|
|
||||||
"container", "build", "-t", "bot-bottle-agent:latest",
|
|
||||||
"--dns", "9.9.9.9", "-f", "/repo/Dockerfile", "/repo",
|
|
||||||
],
|
|
||||||
run.call_args_list[-1].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,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_container_exists_parses_quiet_list(self):
|
|
||||||
completed = util.subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=0, stdout="bot-bottle-a\nother\n", stderr="",
|
|
||||||
)
|
|
||||||
with patch.object(util.subprocess, "run", return_value=completed):
|
|
||||||
self.assertTrue(util.container_exists("bot-bottle-a"))
|
|
||||||
self.assertFalse(util.container_exists("bot-bottle-b"))
|
|
||||||
|
|
||||||
def test_image_id_reads_json_digest(self):
|
|
||||||
completed = util.subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=0, stdout='{"digest":"sha256:abc"}', stderr="",
|
|
||||||
)
|
|
||||||
with patch.object(util.subprocess, "run", return_value=completed):
|
|
||||||
self.assertEqual("sha256:abc", util.image_id("demo:latest"))
|
|
||||||
|
|
||||||
def test_container_ipv4_on_network_reads_inspect_json(self):
|
|
||||||
payload = """[{
|
|
||||||
"status": {
|
|
||||||
"networks": [
|
|
||||||
{
|
|
||||||
"network": "bot-bottle-net-demo",
|
|
||||||
"ipv4Address": "192.168.128.2/24"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}]"""
|
|
||||||
completed = util.subprocess.CompletedProcess(
|
|
||||||
args=[], returncode=0, stdout=payload, stderr="",
|
|
||||||
)
|
|
||||||
with patch.object(util.subprocess, "run", return_value=completed):
|
|
||||||
self.assertEqual(
|
|
||||||
"192.168.128.2",
|
|
||||||
util.container_ipv4_on_network(
|
|
||||||
"bot-bottle-sidecars-demo",
|
|
||||||
"bot-bottle-net-demo",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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"}},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from bot_bottle.supervise import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
archive_proposal,
|
archive_proposal,
|
||||||
audit_log_path,
|
audit_log_path,
|
||||||
list_pending_proposals,
|
list_pending_proposals,
|
||||||
@@ -318,6 +319,7 @@ class TestToolConstants(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
(
|
(
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
supervise.TOOL_LIST_EGRESS_ROUTES,
|
supervise.TOOL_LIST_EGRESS_ROUTES,
|
||||||
),
|
),
|
||||||
supervise.TOOLS,
|
supervise.TOOLS,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bot_bottle import supervise
|
from bot_bottle import supervise
|
||||||
from bot_bottle.cli import supervise as supervise_cli
|
from bot_bottle.cli import supervise as supervise_cli
|
||||||
@@ -21,6 +22,7 @@ from bot_bottle.supervise import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
read_audit_entries,
|
read_audit_entries,
|
||||||
read_response,
|
read_response,
|
||||||
sha256_hex,
|
sha256_hex,
|
||||||
@@ -33,6 +35,7 @@ FIXED = datetime(2026, 5, 25, 12, 0, 0, tzinfo=timezone.utc)
|
|||||||
def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal:
|
def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal:
|
||||||
payloads = {
|
payloads = {
|
||||||
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
|
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
|
||||||
|
TOOL_GITLEAKS_ALLOW: "file: tests/test_fixture.py\nline: 3\n",
|
||||||
}
|
}
|
||||||
payload = payloads.get(tool, "")
|
payload = payloads.get(tool, "")
|
||||||
return Proposal.new(
|
return Proposal.new(
|
||||||
@@ -154,6 +157,28 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
supervise_cli.approve(qp)
|
supervise_cli.approve(qp)
|
||||||
self.assertEqual([], read_audit_entries("egress", "dev"))
|
self.assertEqual([], read_audit_entries("egress", "dev"))
|
||||||
|
|
||||||
|
def test_approve_archives_gitleaks_allow(self):
|
||||||
|
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||||
|
supervise_cli.approve(qp, notes="dummy fixture")
|
||||||
|
resp = read_response(qp.queue_dir / "processed", qp.proposal.id)
|
||||||
|
self.assertEqual(STATUS_APPROVED, resp.status)
|
||||||
|
self.assertEqual("dummy fixture", resp.notes)
|
||||||
|
|
||||||
|
def test_tui_gitleaks_allow_requires_reason(self):
|
||||||
|
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||||
|
with patch.object(supervise_cli, "_prompt", return_value=""):
|
||||||
|
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||||
|
self.assertEqual("approve aborted (empty reason)", status)
|
||||||
|
self.assertFalse((qp.queue_dir / "processed").exists())
|
||||||
|
|
||||||
|
def test_tui_gitleaks_allow_writes_reason(self):
|
||||||
|
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||||
|
with patch.object(supervise_cli, "_prompt", return_value="test fixture"):
|
||||||
|
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||||
|
self.assertIn("approved gitleaks-allow", status)
|
||||||
|
resp = read_response(qp.queue_dir / "processed", qp.proposal.id)
|
||||||
|
self.assertEqual("test fixture", resp.notes)
|
||||||
|
|
||||||
|
|
||||||
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
||||||
# # DISABLED — capability_apply functionality is currently commented out.
|
# # DISABLED — capability_apply functionality is currently commented out.
|
||||||
|
|||||||
Reference in New Issue
Block a user