Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49c2ed0b93 | |||
| a666f9fe54 | |||
| eaf6b1f72e | |||
| ca910f8f4f | |||
| 338c08a243 | |||
| 6faa6f67aa | |||
| b6ae6af63a | |||
| ad72eeddc1 | |||
| 61f89de2da | |||
| 1ba185d1e0 | |||
| e82dbaba09 | |||
| d7fbe8e8a9 | |||
| 50f5b3aa7f | |||
| 45a096413f | |||
| c6479d62e4 | |||
| d0cad3a559 | |||
| c2ddac1be5 | |||
| 446414144e | |||
| 8188d6304e | |||
| 9f7c067e85 | |||
| 90e84a52e6 | |||
| 75755a472f | |||
| 2f3dc57fa9 | |||
| 302920e290 | |||
| ca1b4afaea | |||
| d2072b13be | |||
| 36c5b7025b | |||
| 515a95a79d | |||
| 0bace7615a | |||
| c0d3f16519 | |||
| 508c537deb | |||
| d99dba037c | |||
| 9a878bd885 | |||
| 0f72843150 | |||
| fd6b14fb32 | |||
| 9f9aa2e762 | |||
| 454baaf3a1 | |||
| 8a092504b8 | |||
| e7dacf7d86 | |||
| 9b929d0684 | |||
| ec41f629a4 | |||
| d9a9eef276 | |||
| 5204b98777 | |||
| 14ae89580a | |||
| 4808ef557a | |||
| 0a7e166b35 | |||
| a920203730 | |||
| e02fab15d0 | |||
| 11cf12188d | |||
| 701df6cb2f | |||
| ea6bc5a170 | |||
| ecaae708f7 | |||
| 2e790268b0 | |||
| a421d1d688 | |||
| d2d50be65a | |||
| 1ad710a041 | |||
| b411577e76 | |||
| cdfaaa3de8 | |||
| 7f2352287e | |||
| 7cb967770e | |||
| 80eca740d6 | |||
| 369d332204 | |||
| 31cde11b0d | |||
| c41751f3b9 | |||
| e2422c20a0 | |||
| de71533a17 | |||
| 88c4f61901 | |||
| c666eaa63f | |||
| 83eb9e4041 | |||
| 33333ac4d9 | |||
| 4d56f515bc | |||
| c48c3688b8 | |||
| 6040b20e6e | |||
| f2775101a0 | |||
| dd99c495f4 | |||
| eb64a52ffa | |||
| d11e3940fa | |||
| a32c0c7865 | |||
| ccb2956562 | |||
| c6362fda7b | |||
| cb321f7ad4 | |||
| 311cd46185 | |||
| 28335f453f | |||
| a1aa8feb85 | |||
| cb3bb209d6 | |||
| 6e73cc4d86 | |||
| 64fac71025 | |||
| f8ac22c316 | |||
| 9465857a99 | |||
| 200306f1cf | |||
| 77bdaf0a96 | |||
| 7e344bbb53 | |||
| 5eb27cd9a8 | |||
| 5808d0b828 | |||
| 7a991e1f5e | |||
| 5606797ac2 | |||
| ebbb4053cf | |||
| eb3e64ea8f | |||
| 0ec1085238 | |||
| 4c39b45e34 | |||
| 3ea35ba5d2 | |||
| 7c6ab62e26 | |||
| da42740156 | |||
| 56ef71060a | |||
| 294a6ed023 | |||
| 468ab8c290 | |||
| 2596c18954 | |||
| 3ccd09ed0d | |||
| 996a260a98 | |||
| 3375df3f52 | |||
| c9842ce831 | |||
| d314ccf455 | |||
| 31b29631b6 | |||
| 1c11110da5 | |||
| 25ca14a8a2 | |||
| b5b7f15ef9 | |||
| 85e64b5134 | |||
| 1a5b6e25f8 | |||
| 54760964cf | |||
| e463670649 | |||
| 6e6890ebd9 | |||
| 609b3ed090 | |||
| 65faa40b9a | |||
| 9f97de115b | |||
| 8f21f4df19 | |||
| ff7a52c1d2 | |||
| 4ed6b84863 | |||
| 7a124d7d25 | |||
| f00c567469 | |||
| 6f0e5b4589 | |||
| 5da4d05bf2 | |||
| 1a8718ca9d | |||
| c1c225aa05 | |||
| dc7c10d6fe | |||
| a827b0841e | |||
| a9c93ea9df | |||
| bb69af31f8 | |||
| 7644da4280 | |||
| 13e4af421d | |||
| f2d5307573 |
@@ -0,0 +1,9 @@
|
||||
[run]
|
||||
branch = True
|
||||
source = .
|
||||
|
||||
[report]
|
||||
omit =
|
||||
bot_bottle/egress_addon.py
|
||||
bot_bottle/cli/tui.py
|
||||
tests/*
|
||||
@@ -39,8 +39,14 @@ jobs:
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dev requirements
|
||||
run: python3 -m pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run unit tests
|
||||
run: python3 -m unittest discover -t . -s tests/unit -v
|
||||
run: python3 -m coverage run -m unittest discover -t . -s tests/unit -v
|
||||
|
||||
- name: Report unit coverage
|
||||
run: python3 -m coverage report -m
|
||||
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
- '**.py'
|
||||
- '.pylintrc'
|
||||
- 'pyrightconfig.json'
|
||||
- '.coveragerc'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -45,10 +46,19 @@ jobs:
|
||||
echo "errors=$ERRORS" >> $GITHUB_OUTPUT
|
||||
echo "Pyright errors: $ERRORS"
|
||||
|
||||
- name: Run coverage and extract percentage
|
||||
id: coverage
|
||||
run: |
|
||||
python -m coverage run -m unittest discover -t . -s tests/unit > /dev/null 2>&1 || true
|
||||
PERCENT=$(python -m coverage report 2>/dev/null | grep '^TOTAL' | grep -oP '\d+(?=%)' | tail -1)
|
||||
echo "percent=$PERCENT" >> $GITHUB_OUTPUT
|
||||
echo "Coverage: $PERCENT%"
|
||||
|
||||
- name: Update badges in README
|
||||
run: |
|
||||
PYLINT_SCORE="${{ steps.pylint.outputs.score }}"
|
||||
PYRIGHT_ERRORS="${{ steps.pyright.outputs.errors }}"
|
||||
COVERAGE_PERCENT="${{ steps.coverage.outputs.percent }}"
|
||||
|
||||
PYLINT_SCORE_ENCODED=$(echo "$PYLINT_SCORE" | sed 's|/|%2F|g')
|
||||
|
||||
@@ -58,9 +68,12 @@ jobs:
|
||||
if [ -n "$PYRIGHT_ERRORS" ]; then
|
||||
sed -i "s|/badge/pyright-[^)]*|/badge/pyright-${PYRIGHT_ERRORS}%20errors-brightgreen|" README.md
|
||||
fi
|
||||
if [ -n "$COVERAGE_PERCENT" ]; then
|
||||
sed -i "s|/badge/coverage-[^)]*|/badge/coverage-${COVERAGE_PERCENT}%25-brightgreen|" README.md
|
||||
fi
|
||||
|
||||
echo "Updated badges:"
|
||||
grep -E "pylint|pyright" README.md | head -2
|
||||
grep -E "pylint|pyright|coverage" README.md | head -3
|
||||
|
||||
- name: Commit and push badge updates
|
||||
run: |
|
||||
@@ -73,7 +86,7 @@ jobs:
|
||||
else
|
||||
echo "Badge changes detected, committing..."
|
||||
git add README.md
|
||||
MSG="chore: update quality badges"$'\n\n'"- Pylint: ${{ steps.pylint.outputs.score }}"$'\n'"- Pyright: ${{ steps.pyright.outputs.errors }} errors"$'\n\n'"[skip ci]"
|
||||
MSG="chore: update quality badges"$'\n\n'"- Pylint: ${{ steps.pylint.outputs.score }}"$'\n'"- Pyright: ${{ steps.pyright.outputs.errors }} errors"$'\n'"- Coverage: ${{ steps.coverage.outputs.percent }}%"$'\n\n'"[skip ci]"
|
||||
git commit -m "$MSG"
|
||||
git push
|
||||
fi
|
||||
|
||||
@@ -22,3 +22,4 @@ venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
# bot-bottle
|
||||
|
||||
[](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://coverage.readthedocs.io/)
|
||||
|
||||
**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 +15,8 @@
|
||||
|
||||
## Features
|
||||
|
||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default.
|
||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
|
||||
- **Per-route token-match policy** — each egress route picks what happens when the outbound DLP catches a token via `dlp.outbound_on_match`: `supervise` (default) holds the request and surfaces it in `./cli.py supervise` for approval (an approved value is remembered for the life of the proxy); `redact` scrubs the value and forwards; `block` is a hard `403`. Cuts false-positive friction without weakening default-deny.
|
||||
- **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.
|
||||
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
||||
@@ -24,7 +26,7 @@
|
||||
- **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.
|
||||
- **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** — 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. Runs on macOS (Hypervisor.framework) and Linux (KVM, `/dev/kvm`).
|
||||
- **Legacy Docker backend** — still available for examples, CI, and hosts without Apple Container via `BOT_BOTTLE_BACKEND=docker` or `--backend=docker`.
|
||||
|
||||
## Architecture
|
||||
@@ -70,10 +72,26 @@ When the agent exits, `cli.py` tears down every sidecar and both networks; nothi
|
||||
|
||||
## 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`.
|
||||
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` (macOS or Linux). 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`.
|
||||
|
||||
Use `BOT_BOTTLE_BACKEND=docker ./cli.py start <agent>` on hosts where Apple Container is not installed and Docker is the desired backend.
|
||||
|
||||
### smolmachines on Linux
|
||||
|
||||
The smolmachines backend runs on Linux as well as macOS. On Linux, `smolvm`/libkrun use KVM, so the host needs:
|
||||
|
||||
- **`/dev/kvm`** present and accessible. Load `kvm-intel` or `kvm-amd` (and enable virtualization in BIOS/firmware). The invoking user must be in the `kvm` group: `sudo usermod -aG kvm "$USER"` then re-login. bot-bottle preflights this and reports exactly what's missing.
|
||||
- **`smolvm`** on `PATH`: `curl -sSL https://smolmachines.com/install.sh | sh`.
|
||||
- **Docker** for the sidecar bundle and image build, same as macOS.
|
||||
|
||||
Per-bottle isolation works the same as macOS without any `ifconfig`/sudo step — all of `127.0.0.0/8` is already loopback on Linux, so each bottle's sidecar bundle is published on its own `127.0.0.<N>` and TSI's allowlist is scoped to that `/32`.
|
||||
|
||||
```sh
|
||||
BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>
|
||||
```
|
||||
|
||||
> **NixOS:** enable `virtualisation.docker`, ensure the KVM module is loaded (`boot.kernelModules = [ "kvm-intel" ];` or `kvm-amd`), and add your user to the `kvm` and `docker` groups. If you run bottles from a Gitea Actions runner, use a `host`-label runner so Docker, `smolvm`, and `/dev/kvm` are all reachable from the job. `smolvm` isn't in nixpkgs — install the release binary (pin the version) and put it on the runner's `PATH`.
|
||||
|
||||
```sh
|
||||
./cli.py start <agent> # builds the image on first run, drops you into claude
|
||||
```
|
||||
@@ -106,8 +124,15 @@ egress:
|
||||
routes:
|
||||
- host: gitea.dideric.is
|
||||
auth:
|
||||
scheme: token
|
||||
scheme: token # Bearer | 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;
|
||||
@@ -126,6 +151,26 @@ skills:
|
||||
You help maintain Gitea-hosted projects.
|
||||
````
|
||||
|
||||
**Egress route fields:**
|
||||
|
||||
| Field | Required | Description |
|
||||
|---|---|---|
|
||||
| `host` | yes | Hostname to allowlist. One entry per host. |
|
||||
| `role` | no | Reserved for future use. The key is recognised but any value is currently rejected at load. Provider auth routes (e.g. Claude's `api.anthropic.com`) are injected automatically from `agent_provider.auth_token`, not via `role`. |
|
||||
| `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`). |
|
||||
| `dlp.outbound_on_match` | no | What to do when an outbound token is detected: `supervise` (default for manifest routes — hold for operator approval), `redact` (scrub the value and forward), or `block` (hard 403). Agent-provider routes (e.g. `api.anthropic.com`) default to `redact`. |
|
||||
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
|
||||
|
||||
When an outbound DLP detector matches a token, the route's `dlp.outbound_on_match` policy decides what happens. Under the default `supervise`, the proxy queues an `egress-token-allow` proposal for the operator's `./cli.py supervise` TUI and holds the request open until it is answered (or `EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS`, default 300s, elapses — after which it fails closed). The operator never sees the raw token, only the host, method, path, and a redacted snippet; approving adds the value to an in-memory safelist for the life of the egress proxy. Under `redact`, the matched value is scrubbed from the body, headers, and path and the request is forwarded (failing closed if a match lands somewhere unredactable, like the hostname). Under `block` it stays a hard `403`. Structural blocks (CRLF injection) and not-in-allowlist host blocks are always hard `403`s regardless of policy.
|
||||
|
||||
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
|
||||
|
||||
@@ -61,7 +61,6 @@ class AgentProviderRuntime:
|
||||
prompt_mode: PromptMode
|
||||
bypass_args: tuple[str, ...]
|
||||
resume_args: tuple[str, ...]
|
||||
remote_control_args: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -240,7 +239,7 @@ class AgentProvider(ABC):
|
||||
BottleBackend.provision_workspace against the running bottle."""
|
||||
from .log import info
|
||||
|
||||
manifest_bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
manifest_bottle = plan.manifest.bottle
|
||||
if manifest_bottle.git:
|
||||
from .git_gate import GIT_GATE_HOSTNAME, git_gate_render_gitconfig
|
||||
gate_host = getattr(plan, "git_gate_insteadof_host", GIT_GATE_HOSTNAME)
|
||||
@@ -371,6 +370,15 @@ def build_agent_provision_plan(
|
||||
)
|
||||
|
||||
|
||||
def provider_startup_args(
|
||||
provider_settings: dict[str, object] | None,
|
||||
) -> tuple[str, ...]:
|
||||
raw = (provider_settings or {}).get("startup_args", ())
|
||||
if not isinstance(raw, (list, tuple)):
|
||||
return ()
|
||||
return tuple(arg for arg in raw if isinstance(arg, str))
|
||||
|
||||
|
||||
def prompt_args(
|
||||
prompt_mode: PromptMode,
|
||||
prompt_path: str | None,
|
||||
@@ -382,7 +390,7 @@ def prompt_args(
|
||||
if prompt_mode == "append_file":
|
||||
return ["--append-system-prompt-file", prompt_path]
|
||||
if prompt_mode == "read_prompt_file":
|
||||
if argv and "resume" in argv:
|
||||
if argv and ("resume" in argv or "remote-control" in argv):
|
||||
return []
|
||||
return [f"Read and follow the instructions in {prompt_path}."]
|
||||
if prompt_mode == "print_read_prompt_file":
|
||||
|
||||
@@ -45,7 +45,7 @@ from ..agent_provider import AgentProvisionPlan, get_provider, build_agent_provi
|
||||
from ..egress import EgressPlan
|
||||
from ..git_gate import GitGatePlan
|
||||
from ..log import die, info
|
||||
from ..manifest import ManifestGitEntry, Manifest
|
||||
from ..manifest import Manifest, ManifestIndex
|
||||
from ..supervise import SupervisePlan
|
||||
from ..util import expand_tilde
|
||||
from ..env import resolve_env, ResolvedEnv
|
||||
@@ -61,7 +61,7 @@ class BottleSpec:
|
||||
Resolved values (image names, container name, scratch paths, runsc
|
||||
availability) live on the plan, not the spec."""
|
||||
|
||||
manifest: Manifest
|
||||
manifest: ManifestIndex
|
||||
agent_name: str
|
||||
copy_cwd: bool
|
||||
user_cwd: str
|
||||
@@ -72,6 +72,9 @@ class BottleSpec:
|
||||
identity: str = ""
|
||||
label: str = ""
|
||||
color: str = ""
|
||||
# Ordered bottle names selected at launch (issue #269). When non-empty
|
||||
# they are merged in order and replace the agent's `bottle:` field.
|
||||
bottle_names: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -80,6 +83,7 @@ class BottlePlan(ABC):
|
||||
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
||||
|
||||
spec: BottleSpec
|
||||
manifest: Manifest
|
||||
stage_dir: Path
|
||||
git_gate_plan: GitGatePlan
|
||||
|
||||
@@ -108,13 +112,12 @@ class BottlePlan(ABC):
|
||||
def workspace_plan(self) -> WorkspacePlan:
|
||||
return workspace_plan(self.spec, guest_home=self.guest_home)
|
||||
|
||||
def print(self, *, remote_control: bool) -> None:
|
||||
def print(self) -> None:
|
||||
"""Render the y/N preflight summary to stderr."""
|
||||
del remote_control
|
||||
spec = self.spec
|
||||
manifest = spec.manifest
|
||||
agent = manifest.agents[spec.agent_name]
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
manifest = self.manifest
|
||||
agent = manifest.agent
|
||||
bottle = manifest.bottle
|
||||
|
||||
env_names = visible_agent_env_names(
|
||||
sorted(
|
||||
@@ -129,9 +132,13 @@ class BottlePlan(ABC):
|
||||
info(f"provider : {self.agent_provision.template}")
|
||||
print_multi("env ", env_names)
|
||||
print_multi("skills ", list(agent.skills))
|
||||
info(f"bottle : {agent.bottle}")
|
||||
effective_bottles = (
|
||||
list(spec.bottle_names) if spec.bottle_names
|
||||
else ([agent.bottle] if agent.bottle else [])
|
||||
)
|
||||
print_multi("bottle ", effective_bottles)
|
||||
|
||||
identity = manifest.git_identity_summary(spec.agent_name)
|
||||
identity = manifest.git_identity_summary()
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
|
||||
@@ -289,15 +296,14 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
write_launch_metadata,
|
||||
)
|
||||
|
||||
self._validate(spec)
|
||||
manifest = self._validate(spec)
|
||||
|
||||
self._preflight()
|
||||
|
||||
manifest = spec.manifest
|
||||
manifest_bottle = manifest.bottle_for(spec.agent_name)
|
||||
manifest_bottle = manifest.bottle
|
||||
manifest_agent_provider = manifest_bottle.agent_provider
|
||||
agent_provider = get_provider(manifest_agent_provider.template)
|
||||
resolved_env = resolve_env(manifest, spec.agent_name)
|
||||
resolved_env = resolve_env(manifest)
|
||||
workspace = workspace_plan(spec, guest_home=agent_provider.guest_home)
|
||||
|
||||
slug = mint_slug(spec)
|
||||
@@ -313,7 +319,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
else:
|
||||
agent_dockerfile_path = str(agent_provider.dockerfile)
|
||||
|
||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, spec)
|
||||
agent_dir, prompt_file = prepare_agent_state_dir(slug, manifest)
|
||||
|
||||
agent_provision_plan = build_agent_provision_plan(
|
||||
template=manifest_agent_provider.template,
|
||||
@@ -337,6 +343,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
|
||||
return self._resolve_plan(
|
||||
spec,
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
@@ -355,18 +362,18 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
"""
|
||||
pass
|
||||
|
||||
def _validate(self, spec: BottleSpec) -> None:
|
||||
"""Cross-backend pre-launch checks. Confirms the agent exists,
|
||||
the named skills are present on the host, and every git
|
||||
IdentityFile resolves. Subclasses with additional preconditions
|
||||
should override and call `super()._validate(spec)` first."""
|
||||
manifest = spec.manifest
|
||||
manifest.require_agent(spec.agent_name)
|
||||
agent = manifest.agents[spec.agent_name]
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
self._validate_skills(agent.skills)
|
||||
self._validate_git_entries(bottle.git)
|
||||
self._validate_agent_provider_dockerfile(spec)
|
||||
def _validate(self, spec: BottleSpec) -> Manifest:
|
||||
"""Cross-backend pre-launch checks. Parses the selected agent and
|
||||
its bottle (raising ManifestError on invalid content), confirms
|
||||
skills are present on the host, and every git IdentityFile resolves.
|
||||
|
||||
Returns the loaded Manifest for the selected agent. Subclasses with
|
||||
additional preconditions should override and call
|
||||
`super()._validate(spec)` first."""
|
||||
manifest = spec.manifest.load_for_agent(spec.agent_name, spec.bottle_names)
|
||||
self._validate_skills(manifest.agent.skills)
|
||||
self._validate_agent_provider_dockerfile(spec, manifest)
|
||||
return manifest
|
||||
|
||||
def _validate_skills(self, skills: Sequence[str]) -> None:
|
||||
"""Each named skill must be a directory under the host's
|
||||
@@ -380,18 +387,8 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
f"Create it under ~/.claude/skills/, then re-run."
|
||||
)
|
||||
|
||||
def _validate_git_entries(self, entries: Sequence[ManifestGitEntry]) -> None:
|
||||
"""Each entry's IdentityFile must exist on the host (after
|
||||
expanding leading ~) — the git-gate copies it in at start time
|
||||
to authenticate the upstream push (PRD 0008). Shape is already
|
||||
enforced by Manifest validation; this only checks presence."""
|
||||
for entry in entries:
|
||||
key = expand_tilde(entry.IdentityFile)
|
||||
if not os.path.isfile(key):
|
||||
die(f"git upstream key file not found for '{entry.Name}': {key}")
|
||||
|
||||
def _validate_agent_provider_dockerfile(self, spec: BottleSpec) -> None:
|
||||
bottle = spec.manifest.bottle_for(spec.agent_name)
|
||||
def _validate_agent_provider_dockerfile(self, spec: BottleSpec, manifest: Manifest) -> None:
|
||||
bottle = manifest.bottle
|
||||
dockerfile = bottle.agent_provider.dockerfile
|
||||
if not dockerfile:
|
||||
return
|
||||
@@ -399,15 +396,19 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
||||
if not path.is_absolute():
|
||||
path = Path(spec.user_cwd) / path
|
||||
if not path.is_file():
|
||||
effective = (
|
||||
", ".join(spec.bottle_names) if spec.bottle_names else manifest.agent.bottle
|
||||
)
|
||||
die(
|
||||
f"agent_provider.dockerfile for bottle "
|
||||
f"'{spec.manifest.agents[spec.agent_name].bottle}' not found: {path}"
|
||||
f"'{effective}' not found: {path}"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _resolve_plan(self,
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
@@ -534,6 +535,11 @@ from .docker import DockerBottleBackend # noqa: E402 # pylint: disable=wrong-i
|
||||
from .macos_container import MacosContainerBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||
from .smolmachines import SmolmachinesBottleBackend # noqa: E402 # pylint: disable=wrong-import-position
|
||||
|
||||
# Freezer is imported after the backend classes for the same reason:
|
||||
# Freezer.commit_slug constructs ActiveAgent, which must be fully
|
||||
# defined first.
|
||||
from .freeze import CommitCancelled, Freezer, get_freezer # noqa: E402 # pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
# The dict is heterogeneous: each value is a BottleBackend specialized
|
||||
# over its own plan type. Concrete plan types are erased here because
|
||||
@@ -621,9 +627,12 @@ __all__ = [
|
||||
"BottleCleanupPlan",
|
||||
"BottlePlan",
|
||||
"BottleSpec",
|
||||
"CommitCancelled",
|
||||
"ExecResult",
|
||||
"Freezer",
|
||||
"enumerate_active_agents",
|
||||
"get_bottle_backend",
|
||||
"get_freezer",
|
||||
"has_backend",
|
||||
"known_backend_names",
|
||||
]
|
||||
|
||||
@@ -30,6 +30,7 @@ from ...egress import EgressPlan
|
||||
from ...env import ResolvedEnv
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...supervise import SupervisePlan
|
||||
from ...manifest import Manifest
|
||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||
from . import cleanup as _cleanup
|
||||
from . import enumerate as _enumerate
|
||||
@@ -63,6 +64,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
self,
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
@@ -73,6 +75,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
||||
) -> DockerBottlePlan:
|
||||
return _resolve_plan.resolve_plan(
|
||||
spec,
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
"""capability_apply — host-side orchestrator for capability-block
|
||||
remediation (PRD 0016).
|
||||
|
||||
On approval of a capability-block proposal, the dashboard calls
|
||||
apply_capability_change(slug, new_dockerfile) which:
|
||||
|
||||
1. Snapshots the agent's transcript dir to
|
||||
~/.bot-bottle/state/<slug>/transcript/ (best-effort).
|
||||
2. Pushes the agent's working tree via `git push` (best-effort —
|
||||
no upstream / no commits / no git repo all skip with a log).
|
||||
3. Writes the new Dockerfile to
|
||||
~/.bot-bottle/state/<slug>/Dockerfile (PRD 0016 Phase 1
|
||||
state). The next `cli.py start <agent>` picks it up.
|
||||
4. Force-removes the agent container + all sidecars + the
|
||||
per-bottle networks. Idempotent — missing resources are not
|
||||
errors.
|
||||
|
||||
Returns (before, after) Dockerfile contents so the dashboard can
|
||||
record / render the diff. (capability-block has no audit log per
|
||||
PRD 0013 — the per-bottle Dockerfile state is its own record.)
|
||||
|
||||
This is "fire-and-forget" from the agent's perspective: by the time
|
||||
the dashboard writes the response file the supervise sidecar is
|
||||
gone, so the agent's tool call connection drops without ever
|
||||
receiving the response. The replacement agent (next manual
|
||||
`cli.py start`) sees the new Dockerfile and starts from there.
|
||||
v1 does not auto-relaunch — see PRD 0016's capability-block return
|
||||
semantics open question.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from ...agent_provider import get_provider
|
||||
from ...log import info, warn
|
||||
from ...bottle_state import (
|
||||
mark_preserved,
|
||||
per_bottle_dockerfile,
|
||||
transcript_snapshot_dir,
|
||||
write_per_bottle_dockerfile,
|
||||
)
|
||||
from .sidecar_bundle import sidecar_bundle_container_name
|
||||
|
||||
|
||||
# Agent home inside the container (per the repo Dockerfile's
|
||||
# `USER node` + `WORKDIR /home/node`). Used to locate the transcript
|
||||
# dir + the workspace dir for git push.
|
||||
_AGENT_HOME_IN_CONTAINER = "/home/node"
|
||||
_AGENT_TRANSCRIPT_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/.claude"
|
||||
_AGENT_WORKSPACE_IN_CONTAINER = f"{_AGENT_HOME_IN_CONTAINER}/workspace"
|
||||
|
||||
# Per-bottle resource name patterns (mirroring prepare.py).
|
||||
def _agent_container_name(slug: str) -> str:
|
||||
return f"bot-bottle-{slug}"
|
||||
|
||||
|
||||
def _per_bottle_container_names(slug: str) -> list[str]:
|
||||
"""All container names that belong to this bottle. Missing
|
||||
containers are silently skipped by the teardown helper, so it's
|
||||
fine to include names that don't exist for a given bottle."""
|
||||
return [
|
||||
_agent_container_name(slug),
|
||||
sidecar_bundle_container_name(slug),
|
||||
]
|
||||
|
||||
|
||||
def _per_bottle_network_names(slug: str) -> list[str]:
|
||||
return [
|
||||
f"bot-bottle-net-{slug}",
|
||||
f"bot-bottle-egress-{slug}",
|
||||
]
|
||||
|
||||
|
||||
class CapabilityApplyError(RuntimeError):
|
||||
"""Raised when the apply fails in a way that should keep the
|
||||
proposal pending (so the operator can retry). Best-effort
|
||||
failures (transcript snapshot, git push) do not raise — they
|
||||
just log and proceed."""
|
||||
|
||||
|
||||
# --- Public helpers --------------------------------------------------------
|
||||
|
||||
|
||||
def fetch_current_dockerfile(slug: str) -> str:
|
||||
"""Return the Dockerfile content the next `cli.py start <agent>`
|
||||
would use for this bottle. If a per-bottle override exists, that
|
||||
one; otherwise the repo's Dockerfile.
|
||||
|
||||
Used by the operator-edit verb to show the current source of
|
||||
truth, and by apply_capability_change for the before-diff."""
|
||||
override = per_bottle_dockerfile(slug)
|
||||
if override is not None:
|
||||
return override
|
||||
repo_dockerfile = get_provider("claude").dockerfile
|
||||
if repo_dockerfile.is_file():
|
||||
return repo_dockerfile.read_text()
|
||||
raise CapabilityApplyError(
|
||||
f"no per-bottle Dockerfile for {slug} and no provider Dockerfile at "
|
||||
f"{repo_dockerfile}"
|
||||
)
|
||||
|
||||
|
||||
def apply_capability_change(slug: str, new_dockerfile: str) -> tuple[str, str]:
|
||||
"""End-to-end capability-block remediation. See module docstring
|
||||
for the sequence. Returns (before, after) Dockerfile content."""
|
||||
if not new_dockerfile.strip():
|
||||
raise CapabilityApplyError("proposed Dockerfile is empty")
|
||||
before = fetch_current_dockerfile(slug)
|
||||
|
||||
snapshot_transcript(slug)
|
||||
_push_working_tree(slug)
|
||||
write_per_bottle_dockerfile(slug, new_dockerfile)
|
||||
# Set the preserve marker BEFORE teardown so cli.py's session-end
|
||||
# cleanup sees it and keeps the state dir intact for the
|
||||
# operator's `cli.py resume <identity>`. Without the marker the
|
||||
# state dir would be deleted as part of normal session end.
|
||||
mark_preserved(slug)
|
||||
_teardown_bottle(slug)
|
||||
|
||||
return before, new_dockerfile
|
||||
|
||||
|
||||
# --- Internals -------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
def snapshot_transcript(slug: str) -> None:
|
||||
"""`docker cp` /home/node/.claude out of the agent container into
|
||||
~/.bot-bottle/state/<slug>/transcript/. Best-effort: missing
|
||||
container, missing dir, or cp error all log a warning and return.
|
||||
The transcript is what `claude --resume` reads to pick up where
|
||||
the agent left off.
|
||||
|
||||
Called from two places:
|
||||
- capability-apply, before tearing the bottle down.
|
||||
- cli.py's session-end path, before the launch context closes,
|
||||
so a crash or normal exit also leaves a transcript on disk
|
||||
(deleted along with the state dir on clean exit, kept on
|
||||
crash or capability-block per the preserve marker)."""
|
||||
container = _agent_container_name(slug)
|
||||
dest = transcript_snapshot_dir(slug)
|
||||
if dest.exists():
|
||||
# Remove any prior snapshot so the new one is a clean copy.
|
||||
shutil.rmtree(dest, ignore_errors=True)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
r = subprocess.run(
|
||||
["docker", "cp", f"{container}:{_AGENT_TRANSCRIPT_IN_CONTAINER}", str(dest)],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
warn(
|
||||
f"transcript snapshot skipped "
|
||||
f"({(r.stderr or '').strip() or 'no transcript dir in container?'})"
|
||||
)
|
||||
return
|
||||
info(f"transcript snapshotted to {dest}")
|
||||
|
||||
|
||||
def _push_working_tree(slug: str) -> None:
|
||||
"""`docker exec <agent> git push` from /home/node/workspace.
|
||||
Best-effort: not-a-git-repo, no upstream, nothing-to-push, no
|
||||
network all log a warning and return. The replacement bottle
|
||||
will pick up whatever's actually upstream."""
|
||||
container = _agent_container_name(slug)
|
||||
r = subprocess.run(
|
||||
[
|
||||
"docker", "exec", container, "sh", "-c",
|
||||
f"cd {_AGENT_WORKSPACE_IN_CONTAINER} && "
|
||||
f"git rev-parse --is-inside-work-tree >/dev/null 2>&1 && "
|
||||
f"git push origin HEAD 2>&1 || true",
|
||||
],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
warn(
|
||||
f"capability-apply: git push skipped "
|
||||
f"({(r.stderr or '').strip() or 'docker exec failed'})"
|
||||
)
|
||||
return
|
||||
output = (r.stdout or "").strip()
|
||||
if output:
|
||||
info(f"capability-apply: git push: {output}")
|
||||
else:
|
||||
info("capability-apply: git push ran (no output — likely not a git workspace)")
|
||||
|
||||
|
||||
def _teardown_bottle(slug: str) -> None:
|
||||
"""Force-remove all per-bottle docker resources. Idempotent —
|
||||
`docker rm -f` / `docker network rm` silently ignore missing
|
||||
names, so this can be called even mid-rebuild."""
|
||||
info(f"capability-apply: tearing down bottle {slug}")
|
||||
for name in _per_bottle_container_names(slug):
|
||||
subprocess.run(
|
||||
["docker", "rm", "-f", name],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
for net in _per_bottle_network_names(slug):
|
||||
subprocess.run(
|
||||
["docker", "network", "rm", net],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CapabilityApplyError",
|
||||
"apply_capability_change",
|
||||
"fetch_current_dockerfile",
|
||||
"snapshot_transcript",
|
||||
]
|
||||
@@ -28,11 +28,12 @@ from typing import Any
|
||||
from ...egress import (
|
||||
EGRESS_HOSTNAME,
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
egress_agent_env_entries,
|
||||
egress_sidecar_env_entries,
|
||||
)
|
||||
from ...git_gate import GIT_GATE_HOSTNAME
|
||||
from ...log import die, warn
|
||||
from ...supervise import (
|
||||
CURRENT_CONFIG_DIR_IN_AGENT,
|
||||
QUEUE_DIR_IN_CONTAINER,
|
||||
SUPERVISE_HOSTNAME,
|
||||
SUPERVISE_PORT,
|
||||
@@ -134,9 +135,8 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
ep = plan.egress_plan
|
||||
volumes.append(_bind(ep.mitmproxy_ca_host_path, EGRESS_CA_IN_CONTAINER))
|
||||
if ep.routes:
|
||||
volumes.append(_bind(ep.routes_path, EGRESS_ROUTES_IN_CONTAINER))
|
||||
for token_env in sorted(ep.token_env_map.keys()):
|
||||
env.append(token_env)
|
||||
volumes.append(_bind(ep.routes_path.parent, str(Path(EGRESS_ROUTES_IN_CONTAINER).parent)))
|
||||
env.extend(egress_sidecar_env_entries(ep))
|
||||
|
||||
# --- git-gate -----------------------------------------------------
|
||||
gp = plan.git_gate_plan
|
||||
@@ -220,6 +220,7 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
# never lands on argv or in the compose file.
|
||||
for name in sorted(plan.forwarded_env.keys()):
|
||||
env.append(name)
|
||||
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": plan.image,
|
||||
@@ -231,15 +232,6 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
if plan.use_runsc:
|
||||
service["runtime"] = "runsc"
|
||||
|
||||
volumes: list[dict[str, Any]] = []
|
||||
if plan.supervise_plan is not None:
|
||||
volumes.append(_bind(
|
||||
plan.supervise_plan.current_config_dir,
|
||||
CURRENT_CONFIG_DIR_IN_AGENT,
|
||||
))
|
||||
if volumes:
|
||||
service["volumes"] = volumes
|
||||
|
||||
# The init supervisor inside the bundle owns intra-bundle
|
||||
# daemon ordering, so the agent only waits for the bundle
|
||||
# container itself.
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
"""Host-side helper for egress sidecar inspection (issue #198).
|
||||
"""Host-side helper for egress sidecar inspection and live updates.
|
||||
|
||||
`_merge_single_route`, `add_route`, and `apply_routes_change` were
|
||||
removed when the egress-block MCP tool was dropped. The remaining
|
||||
helpers support runtime inspection and validation of the routes file
|
||||
without modifying it at runtime.
|
||||
The approve path uses this module to validate a proposed routes file,
|
||||
write it to the bottle's live egress state dir, and signal the sidecar
|
||||
bundle so the mitmproxy addon reloads it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ...egress import EGRESS_ROUTES_IN_CONTAINER
|
||||
from ...egress_addon_core import load_routes
|
||||
from ...log import warn
|
||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
||||
from .sidecar_bundle import sidecar_bundle_container_name
|
||||
|
||||
|
||||
class EgressApplyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def fetch_current_routes(slug: str) -> str:
|
||||
container = sidecar_bundle_container_name(slug)
|
||||
r = subprocess.run(
|
||||
@@ -33,17 +30,31 @@ def fetch_current_routes(slug: str) -> str:
|
||||
return r.stdout
|
||||
|
||||
|
||||
def validate_routes_content(content: str) -> None:
|
||||
try:
|
||||
load_routes(content)
|
||||
except ValueError as e:
|
||||
raise EgressApplyError(
|
||||
f"proposed routes.yaml is not valid: {e}"
|
||||
) from e
|
||||
class DockerEgressApplicator(EgressApplicator):
|
||||
def _signal_bundle_reload(self, slug: str) -> None:
|
||||
container = sidecar_bundle_container_name(slug)
|
||||
result = subprocess.run(
|
||||
["docker", "kill", "--signal", "HUP", container],
|
||||
capture_output=True, text=True, check=False, env=os.environ,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
||||
warn(
|
||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
||||
f"{last_error or 'docker kill failed'}"
|
||||
)
|
||||
raise EgressApplyError(
|
||||
f"could not reload egress bundle {container}: "
|
||||
f"{last_error or 'docker kill failed'}"
|
||||
)
|
||||
|
||||
|
||||
applicator = DockerEgressApplicator()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DockerEgressApplicator",
|
||||
"EgressApplyError",
|
||||
"applicator",
|
||||
"fetch_current_routes",
|
||||
"validate_routes_content",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""DockerFreezer — snapshot a Docker bottle via `docker commit`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import ActiveAgent
|
||||
from ..freeze import Freezer
|
||||
from .util import commit_container
|
||||
from ...log import info
|
||||
|
||||
|
||||
class DockerFreezer(Freezer):
|
||||
"""Freezes a Docker bottle by running `docker commit`."""
|
||||
|
||||
backend_name = "docker"
|
||||
|
||||
def _freeze(self, agent: ActiveAgent) -> str:
|
||||
container = f"bot-bottle-{agent.slug}"
|
||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
||||
commit_container(container, image_tag)
|
||||
return image_tag
|
||||
|
||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||
info(f"to export for migration: docker save {image_ref} -o {slug}.tar")
|
||||
@@ -47,6 +47,7 @@ from ...bottle_state import (
|
||||
bottle_state_dir,
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
read_committed_image,
|
||||
)
|
||||
from .compose import (
|
||||
bottle_plan_to_compose,
|
||||
@@ -75,7 +76,7 @@ def launch(
|
||||
Teardown on exit."""
|
||||
stack = ExitStack()
|
||||
|
||||
_bottle_for_revoke = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
_bottle_for_revoke = plan.manifest.bottle
|
||||
_git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||
|
||||
def teardown() -> None:
|
||||
@@ -91,12 +92,22 @@ def launch(
|
||||
)
|
||||
|
||||
try:
|
||||
# Step 1: agent image build. Sidecar images get built lazily by
|
||||
# `docker compose up` via the renderer's `build:` directives.
|
||||
docker_mod.build_image(
|
||||
plan.image, _REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
# Step 1: agent image. Use a committed snapshot when one exists
|
||||
# and is present in the local daemon; otherwise build from the
|
||||
# Dockerfile. Sidecar images get built lazily by `docker compose
|
||||
# up` via the renderer's `build:` directives.
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and docker_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
plan = dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||
)
|
||||
else:
|
||||
docker_mod.build_image(
|
||||
plan.image, _REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
|
||||
internal_network = network_mod.network_name_for_slug(plan.slug)
|
||||
egress_network = network_mod.network_egress_name_for_slug(plan.slug)
|
||||
@@ -176,7 +187,7 @@ def launch(
|
||||
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_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
||||
terminal_color=plan.spec.color,
|
||||
agent_workdir=plan.workspace_plan.workdir,
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from .. import BottleSpec
|
||||
from ...env import ResolvedEnv
|
||||
from ...agent_provider import AgentProvisionPlan
|
||||
from ...egress import EgressPlan
|
||||
from ...manifest import Manifest
|
||||
from ...supervise import SupervisePlan
|
||||
from ...git_gate import GitGatePlan
|
||||
|
||||
@@ -31,6 +32,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
|
||||
def resolve_plan(
|
||||
spec: BottleSpec,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
@@ -48,6 +50,7 @@ def resolve_plan(
|
||||
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
manifest=manifest,
|
||||
stage_dir=stage_dir,
|
||||
slug=slug,
|
||||
forwarded_env=dict(resolved_env.forwarded),
|
||||
|
||||
@@ -152,6 +152,21 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
# )
|
||||
|
||||
|
||||
def commit_container(container_name: str, image_tag: str) -> None:
|
||||
"""Run `docker commit <container_name> <image_tag>` to snapshot the
|
||||
running container's filesystem state as a local Docker image."""
|
||||
result = subprocess.run(
|
||||
["docker", "commit", container_name, image_tag],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"docker commit {container_name!r} → {image_tag!r} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
info(f"committed {container_name!r} → {image_tag!r}")
|
||||
|
||||
|
||||
def image_id(ref: str) -> str:
|
||||
"""Return the content-addressed image ID (e.g.
|
||||
`sha256:abcd...`) for `ref`. The smolmachines backend keys its
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Shared base class for host-side egress apply across backends.
|
||||
|
||||
Each backend subclasses EgressApplicator and overrides _signal_bundle_reload
|
||||
with the backend-specific kill command.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
|
||||
from ..bottle_state import egress_state_dir
|
||||
from ..egress import EGRESS_ROUTES_FILENAME
|
||||
from ..egress_addon_core import LOG_OFF, load_config
|
||||
|
||||
|
||||
class EgressApplyError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class EgressApplicator(ABC):
|
||||
def apply_routes_change(self, slug: str, content: str) -> tuple[str, str]:
|
||||
"""Persist `content` to the live routes file and reload egress."""
|
||||
self.validate_routes_content(content)
|
||||
routes_path = self._routes_path(slug)
|
||||
routes_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
before = routes_path.read_text(encoding="utf-8") if routes_path.exists() else ""
|
||||
routes_path.write_text(content, encoding="utf-8")
|
||||
routes_path.chmod(0o600)
|
||||
self._signal_bundle_reload(slug)
|
||||
return before, content
|
||||
|
||||
@staticmethod
|
||||
def validate_routes_content(content: str) -> None:
|
||||
try:
|
||||
config = load_config(content)
|
||||
except ValueError as e:
|
||||
raise EgressApplyError(
|
||||
f"proposed routes.yaml is not valid: {e}"
|
||||
) from e
|
||||
if config.log != LOG_OFF:
|
||||
raise EgressApplyError(
|
||||
"proposed routes.yaml must not change egress logging"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _routes_path(slug: str) -> Path:
|
||||
return egress_state_dir(slug) / EGRESS_ROUTES_FILENAME
|
||||
|
||||
@abstractmethod
|
||||
def _signal_bundle_reload(self, slug: str) -> None: ...
|
||||
|
||||
|
||||
__all__ = ["EgressApplicator", "EgressApplyError"]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Freezer — snapshot a running bottle to a resumable artifact.
|
||||
|
||||
Follows the same pattern as BottleBackend: a shared base class with
|
||||
common post-freeze steps (write committed-image path, mark preserved,
|
||||
print resume hint) and backend-specific subclasses in their respective
|
||||
backend directories.
|
||||
|
||||
Entry points:
|
||||
Freezer.commit(agent) — freeze by ActiveAgent
|
||||
Freezer.commit_slug(slug) — convenience wrapper for cmd_commit
|
||||
get_freezer(backend_name) — factory
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from . import ActiveAgent
|
||||
from ..bottle_state import mark_preserved, write_committed_image
|
||||
from ..log import die, info
|
||||
|
||||
|
||||
class CommitCancelled(Exception):
|
||||
"""Raised by Freezer._freeze when the user declines a confirmation prompt."""
|
||||
|
||||
|
||||
class Freezer(ABC):
|
||||
"""Freezes a running bottle to a resumable artifact.
|
||||
|
||||
The base class owns the shared post-commit steps:
|
||||
- write_committed_image — records the artifact path in per-bottle state
|
||||
- mark_preserved — prevents teardown from removing the state dir
|
||||
- resume hint — printed to stderr after the snapshot
|
||||
|
||||
Subclasses implement _freeze with the backend-specific snapshot
|
||||
operation and optionally override _export_hint for migration hints.
|
||||
"""
|
||||
|
||||
backend_name: str
|
||||
|
||||
def commit(self, agent: ActiveAgent) -> None:
|
||||
"""Freeze the bottle for `agent` to a resumable artifact.
|
||||
|
||||
Calls _freeze for the backend-specific snapshot, then writes the
|
||||
committed image reference to per-bottle state and marks the bottle
|
||||
preserved so the next `./cli.py resume` boots from the snapshot.
|
||||
|
||||
Raises CommitCancelled if the user declines an interactive
|
||||
confirmation prompt (e.g. the macos-container stop prompt).
|
||||
"""
|
||||
image_ref = self._freeze(agent)
|
||||
write_committed_image(agent.slug, image_ref)
|
||||
mark_preserved(agent.slug)
|
||||
info(f"to resume from this snapshot: ./cli.py resume {agent.slug}")
|
||||
self._export_hint(agent.slug, image_ref)
|
||||
|
||||
@abstractmethod
|
||||
def _freeze(self, agent: ActiveAgent) -> str:
|
||||
"""Backend-specific snapshot. Returns the image tag or artifact path
|
||||
stored by write_committed_image. Raises CommitCancelled if the user
|
||||
declines a stop-confirmation prompt."""
|
||||
|
||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||
"""Optionally print an export-for-migration hint after committing.
|
||||
Overridden by backends that provide a meaningful export command."""
|
||||
|
||||
def commit_slug(self, slug: str) -> None:
|
||||
"""Convenience entry for cmd_commit when only a slug is available."""
|
||||
from ..bottle_state import read_metadata
|
||||
metadata = read_metadata(slug)
|
||||
agent = ActiveAgent(
|
||||
backend_name=self.backend_name,
|
||||
slug=slug,
|
||||
agent_name=metadata.agent_name if metadata else "",
|
||||
started_at=metadata.started_at if metadata else "",
|
||||
services=(),
|
||||
)
|
||||
self.commit(agent)
|
||||
|
||||
|
||||
def get_freezer(backend_name: str) -> Freezer:
|
||||
"""Return the Freezer for the named backend.
|
||||
|
||||
backend_name "" is treated as "docker" for backward compatibility
|
||||
with state dirs written before the backend field was added."""
|
||||
resolved = backend_name or "docker"
|
||||
if resolved == "docker":
|
||||
from .docker.freezer import DockerFreezer
|
||||
return DockerFreezer()
|
||||
if resolved == "macos-container":
|
||||
from .macos_container.freezer import MacosContainerFreezer
|
||||
return MacosContainerFreezer()
|
||||
if resolved == "smolmachines":
|
||||
from .smolmachines.freezer import SmolmachinesFreezer
|
||||
return SmolmachinesFreezer()
|
||||
die(
|
||||
f"commit is only supported for docker, macos-container, and "
|
||||
f"smolmachines; backend {backend_name!r} has no freezer"
|
||||
)
|
||||
raise AssertionError("unreachable")
|
||||
@@ -11,6 +11,7 @@ from ...egress import EgressPlan
|
||||
from ...env import ResolvedEnv
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...supervise import SupervisePlan
|
||||
from ...manifest import Manifest
|
||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||
from . import cleanup as _cleanup
|
||||
from . import enumerate as _enumerate
|
||||
@@ -45,6 +46,7 @@ class MacosContainerBottleBackend(
|
||||
self,
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
@@ -55,6 +57,7 @@ class MacosContainerBottleBackend(
|
||||
) -> MacosContainerBottlePlan:
|
||||
return _resolve_plan.resolve_plan(
|
||||
spec,
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
|
||||
@@ -2,12 +2,41 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Callable, cast
|
||||
|
||||
from ...agent_provider import PromptMode, prompt_args
|
||||
from .. import Bottle, ExecResult
|
||||
from ..terminal import exec_shell_script
|
||||
from . import pty_forward as _pty_forward
|
||||
|
||||
|
||||
_PTY_FORWARD_SCRIPT = _pty_forward.__file__
|
||||
_TERMINAL_ENV_NAMES = (
|
||||
"TERM",
|
||||
"COLORTERM",
|
||||
"TERM_PROGRAM",
|
||||
"TERM_PROGRAM_VERSION",
|
||||
"KITTY_WINDOW_ID",
|
||||
"KITTY_PID",
|
||||
"WEZTERM_PANE",
|
||||
"WEZTERM_UNIX_SOCKET",
|
||||
"GHOSTTY_BIN_DIR",
|
||||
"GHOSTTY_RESOURCES_DIR",
|
||||
"ITERM_SESSION_ID",
|
||||
"VTE_VERSION",
|
||||
"KONSOLE_VERSION",
|
||||
"ALACRITTY_WINDOW_ID",
|
||||
)
|
||||
|
||||
|
||||
def _terminal_env_names() -> tuple[str, ...]:
|
||||
return tuple(
|
||||
name for name in _TERMINAL_ENV_NAMES
|
||||
if name == "TERM" or os.environ.get(name)
|
||||
)
|
||||
|
||||
|
||||
class MacosContainerBottle(Bottle):
|
||||
@@ -44,13 +73,24 @@ class MacosContainerBottle(Bottle):
|
||||
argv=full_argv,
|
||||
)
|
||||
)
|
||||
cmd = ["container", "exec"]
|
||||
container_exec = ["container", "exec"]
|
||||
if tty:
|
||||
cmd.extend(["--interactive", "--tty"])
|
||||
container_exec.extend(["--interactive", "--tty"])
|
||||
# Forward terminal capability hints so TUIs can enable modified-key
|
||||
# protocols. Use bare env names: values stay in the child env, not
|
||||
# on argv, and pty_forward supplies a TERM fallback when needed.
|
||||
for name in _terminal_env_names():
|
||||
container_exec.extend(["--env", name])
|
||||
if self.agent_workdir and self.agent_workdir != "/home/node":
|
||||
cmd.extend(["--workdir", self.agent_workdir])
|
||||
cmd.extend([self.name, self.agent_command, *full_argv])
|
||||
return cmd
|
||||
container_exec.extend(["--workdir", self.agent_workdir])
|
||||
container_exec.extend([self.name, self.agent_command, *full_argv])
|
||||
if tty:
|
||||
# Wrap with the raw-mode forwarder: container exec does not put
|
||||
# the host terminal into raw mode itself, so the line discipline
|
||||
# buffers modifier-key sequences until CR. The wrapper sets raw
|
||||
# mode before exec and restores it on exit.
|
||||
return [sys.executable, _PTY_FORWARD_SCRIPT, "--", *container_exec]
|
||||
return container_exec
|
||||
|
||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||
agent_argv = self.agent_argv(argv, tty=tty)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Host-side egress apply for the macos-container backend.
|
||||
|
||||
Uses `container kill --signal HUP` (Apple Container framework) instead
|
||||
of `docker kill` to signal the sidecar bundle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
from ...log import warn
|
||||
from ..egress_apply import EgressApplicator, EgressApplyError
|
||||
from .launch import sidecar_container_name
|
||||
|
||||
|
||||
class MacOSContainerEgressApplicator(EgressApplicator):
|
||||
def _signal_bundle_reload(self, slug: str) -> None:
|
||||
container = sidecar_container_name(slug)
|
||||
result = subprocess.run(
|
||||
["container", "kill", "--signal", "HUP", container],
|
||||
capture_output=True, text=True, check=False, env=os.environ,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
last_error = (result.stderr or "").strip() or (result.stdout or "").strip()
|
||||
warn(
|
||||
f"egress: routes updated on disk for {slug}, but bundle reload failed: "
|
||||
f"{last_error or 'container kill failed'}"
|
||||
)
|
||||
raise EgressApplyError(
|
||||
f"could not reload egress bundle {container}: "
|
||||
f"{last_error or 'container kill failed'}"
|
||||
)
|
||||
|
||||
|
||||
applicator = MacOSContainerEgressApplicator()
|
||||
|
||||
|
||||
__all__ = ["MacOSContainerEgressApplicator", "EgressApplyError", "applicator"]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""MacosContainerFreezer — snapshot a macOS container bottle.
|
||||
|
||||
Apple Container removes containers when they stop, making stop-then-export
|
||||
impossible. Instead, commit_container execs into the running container and
|
||||
streams the root filesystem via tar. The bottle continues running after commit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import ActiveAgent
|
||||
from ..freeze import Freezer
|
||||
from .util import commit_container
|
||||
from ...log import info
|
||||
|
||||
|
||||
class MacosContainerFreezer(Freezer):
|
||||
"""Freezes a macOS-container bottle via exec-tar + image rebuild."""
|
||||
|
||||
backend_name = "macos-container"
|
||||
|
||||
def _freeze(self, agent: ActiveAgent) -> str:
|
||||
container = f"bot-bottle-{agent.slug}"
|
||||
image_tag = f"bot-bottle-committed-{agent.slug}:latest"
|
||||
commit_container(container, image_tag)
|
||||
return image_tag
|
||||
|
||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||
info(
|
||||
f"to export for migration: "
|
||||
f"container image save {image_ref} -o {slug}.tar"
|
||||
)
|
||||
@@ -12,14 +12,22 @@ 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 ...bottle_state import (
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
read_committed_image,
|
||||
)
|
||||
from ...egress import (
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
egress_agent_env_entries,
|
||||
egress_resolve_token_values,
|
||||
egress_sidecar_env_entries,
|
||||
)
|
||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||
from ...log import die, info, warn
|
||||
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
||||
@@ -68,7 +76,7 @@ def launch(
|
||||
) -> 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)
|
||||
bottle_for_revoke = plan.manifest.bottle
|
||||
git_gate_dir_for_revoke = git_gate_state_dir(plan.slug)
|
||||
|
||||
def teardown() -> None:
|
||||
@@ -84,7 +92,7 @@ def launch(
|
||||
|
||||
try:
|
||||
plan = _mint_certs(plan)
|
||||
_build_images(plan)
|
||||
plan = _build_images(plan)
|
||||
|
||||
internal_network = internal_network_name(plan.slug)
|
||||
egress_network = egress_network_name(plan.slug)
|
||||
@@ -112,7 +120,7 @@ def launch(
|
||||
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_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
||||
terminal_color=plan.spec.color,
|
||||
agent_workdir=plan.workspace_plan.workdir,
|
||||
)
|
||||
@@ -135,17 +143,28 @@ def _mint_certs(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
||||
return dataclasses.replace(plan, egress_plan=egress_plan)
|
||||
|
||||
|
||||
def _build_images(plan: MacosContainerBottlePlan) -> None:
|
||||
def _build_images(plan: MacosContainerBottlePlan) -> MacosContainerBottlePlan:
|
||||
container_mod.build_image(
|
||||
SIDECAR_BUNDLE_IMAGE,
|
||||
_REPO_DIR,
|
||||
dockerfile=SIDECAR_BUNDLE_DOCKERFILE,
|
||||
)
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and container_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
return dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(
|
||||
plan.agent_provision,
|
||||
image=committed,
|
||||
),
|
||||
)
|
||||
container_mod.build_image(
|
||||
plan.image,
|
||||
_REPO_DIR,
|
||||
dockerfile=plan.dockerfile_path,
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
def _create_networks(
|
||||
@@ -314,7 +333,6 @@ def _agent_run_argv(
|
||||
"container", "run",
|
||||
"--name", plan.container_name,
|
||||
"--detach",
|
||||
"--rm",
|
||||
"--network", internal_network,
|
||||
]
|
||||
for entry in _agent_env_entries(plan, sidecar_ip):
|
||||
@@ -337,9 +355,7 @@ def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
||||
|
||||
|
||||
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()))
|
||||
env: list[str] = list(egress_sidecar_env_entries(plan.egress_plan))
|
||||
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:
|
||||
@@ -364,7 +380,7 @@ def _sidecar_mounts(
|
||||
))
|
||||
if ep.routes:
|
||||
mounts.append((
|
||||
str(_stage_routes_dir(plan)),
|
||||
str(ep.routes_path.parent),
|
||||
str(Path(EGRESS_ROUTES_IN_CONTAINER).parent),
|
||||
True,
|
||||
))
|
||||
@@ -375,17 +391,6 @@ def _sidecar_mounts(
|
||||
|
||||
return tuple(mounts)
|
||||
|
||||
|
||||
def _stage_routes_dir(plan: MacosContainerBottlePlan) -> Path:
|
||||
routes_dir = plan.stage_dir / "macos-container-egress"
|
||||
routes_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(
|
||||
plan.egress_plan.routes_path,
|
||||
routes_dir / Path(EGRESS_ROUTES_IN_CONTAINER).name,
|
||||
)
|
||||
return routes_dir
|
||||
|
||||
|
||||
def _mount_spec(host_path: str, container_path: str, read_only: bool) -> str:
|
||||
spec = f"type=bind,source={host_path},target={container_path}"
|
||||
if read_only:
|
||||
@@ -418,6 +423,7 @@ def _agent_env_entries(
|
||||
env.append(f"{name}={value}")
|
||||
for name in sorted(plan.forwarded_env.keys()):
|
||||
env.append(name)
|
||||
env.extend(egress_agent_env_entries(plan.egress_plan))
|
||||
return tuple(env)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Host-side raw-mode wrapper for `container exec --interactive --tty`.
|
||||
|
||||
Apple's `container exec --interactive --tty` does not set the host terminal to
|
||||
raw mode before starting its I/O relay. Without raw mode the kernel line
|
||||
discipline buffers modifier-key escape sequences (e.g. Shift+Enter in
|
||||
modifyOtherKeys mode produces \\x1b[13;2~) until a carriage-return arrives, so
|
||||
they never reach Claude Code inside the container.
|
||||
|
||||
This module sets the host terminal to raw mode, spawns the inner argv (the
|
||||
container exec command), and restores the original terminal attributes on
|
||||
exit. When stdin is not a TTY (piped invocations, CI) it falls through to a
|
||||
bare subprocess.run so callers do not need to special-case non-interactive
|
||||
contexts.
|
||||
|
||||
Usage (the `--` separator is the API contract — everything after it is the
|
||||
inner command):
|
||||
|
||||
python pty_forward.py -- container exec --interactive --tty <name> <cmd>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import termios
|
||||
import tty
|
||||
|
||||
|
||||
def _inner_env() -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
env.setdefault("TERM", "xterm-256color")
|
||||
return env
|
||||
|
||||
|
||||
def _run_inner(inner: list[str]) -> int:
|
||||
return subprocess.run(inner, check=False, env=_inner_env()).returncode
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Entry point. ``argv`` shape: ``-- <inner-argv...>``."""
|
||||
if len(argv) < 2 or argv[0] != "--":
|
||||
sys.stderr.write(
|
||||
"usage: python pty_forward.py -- <container-exec-argv...>\n"
|
||||
)
|
||||
return 2
|
||||
inner = argv[1:]
|
||||
|
||||
try:
|
||||
fd = sys.stdin.fileno()
|
||||
except OSError:
|
||||
return _run_inner(inner)
|
||||
|
||||
if not os.isatty(fd):
|
||||
return _run_inner(inner)
|
||||
|
||||
try:
|
||||
old = termios.tcgetattr(fd)
|
||||
except termios.error:
|
||||
return _run_inner(inner)
|
||||
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
return _run_inner(inner)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -9,6 +9,7 @@ from ...egress import EgressPlan
|
||||
from ...env import ResolvedEnv
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...supervise import SupervisePlan
|
||||
from ...manifest import Manifest
|
||||
from .. import BottleSpec
|
||||
from . import util as container_mod
|
||||
from .bottle_plan import MacosContainerBottlePlan
|
||||
@@ -24,6 +25,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
|
||||
def resolve_plan(
|
||||
spec: BottleSpec,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
@@ -34,6 +36,7 @@ def resolve_plan(
|
||||
) -> MacosContainerBottlePlan:
|
||||
return MacosContainerBottlePlan(
|
||||
spec=spec,
|
||||
manifest=manifest,
|
||||
stage_dir=stage_dir,
|
||||
slug=slug,
|
||||
forwarded_env=dict(resolved_env.forwarded),
|
||||
|
||||
@@ -8,6 +8,7 @@ import ipaddress
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Iterable
|
||||
|
||||
@@ -35,6 +36,20 @@ def require_container() -> 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:
|
||||
@@ -53,11 +68,63 @@ def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||
_ensure_builder_dns()
|
||||
args = [_CONTAINER, "build", "-t", ref, "--dns", dns_server()]
|
||||
if dockerfile:
|
||||
# `container build` resolves -f relative to the current working
|
||||
# directory, not the build context. Anchor a relative Dockerfile to
|
||||
# the context so builds work from any cwd.
|
||||
if not os.path.isabs(dockerfile):
|
||||
dockerfile = os.path.join(context, dockerfile)
|
||||
args.extend(["-f", dockerfile])
|
||||
args.append(context)
|
||||
subprocess.run(args, check=True)
|
||||
|
||||
|
||||
def commit_container(container_name: str, image_tag: str) -> None:
|
||||
"""Snapshot a running Apple Container as a local image.
|
||||
|
||||
`container export` requires a stopped container, but Apple Container
|
||||
removes containers when they stop, making stop-then-export impossible.
|
||||
Instead, exec into the running container as root and stream the root
|
||||
filesystem out via tar, then build a new image from that archive.
|
||||
The bottle continues running after commit.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-container-commit.") as tmp:
|
||||
rootfs_tar = os.path.join(tmp, "rootfs.tar")
|
||||
dockerfile = os.path.join(tmp, "Dockerfile")
|
||||
with open(rootfs_tar, "wb") as tar_out:
|
||||
result = subprocess.run(
|
||||
[
|
||||
_CONTAINER, "exec",
|
||||
"--user", "root",
|
||||
container_name,
|
||||
"tar", "--create",
|
||||
"--exclude=./proc",
|
||||
"--exclude=./sys",
|
||||
"--exclude=./dev",
|
||||
"--exclude=./run",
|
||||
"--file=-",
|
||||
"--directory=/",
|
||||
".",
|
||||
],
|
||||
stdout=tar_out,
|
||||
stderr=subprocess.PIPE,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container exec tar {container_name!r} failed: "
|
||||
f"{(result.stderr or b'').decode().strip() or '<no stderr>'}"
|
||||
)
|
||||
with open(dockerfile, "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"FROM scratch\n"
|
||||
"ADD rootfs.tar /\n"
|
||||
"USER node\n"
|
||||
"WORKDIR /home/node\n"
|
||||
)
|
||||
build_image(image_tag, tmp, dockerfile=dockerfile)
|
||||
info(f"committed {container_name!r} → {image_tag!r}")
|
||||
|
||||
|
||||
def _ensure_builder_dns() -> None:
|
||||
dns = dns_server()
|
||||
status = _builder_status()
|
||||
@@ -204,6 +271,36 @@ def container_exists(name: str) -> bool:
|
||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
||||
|
||||
|
||||
def container_is_running(name: str) -> bool:
|
||||
"""Return True if the named container is currently running.
|
||||
|
||||
`container list` without `--all` lists only running containers."""
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "list", "--quiet"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return name in {line.strip() for line in result.stdout.splitlines()}
|
||||
|
||||
|
||||
def stop_container(name: str) -> None:
|
||||
"""Stop the named container without deleting it."""
|
||||
result = subprocess.run(
|
||||
[_CONTAINER, "stop", name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
die(
|
||||
f"container stop {name!r} failed: "
|
||||
f"{(result.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
|
||||
|
||||
def force_remove_container(name: str) -> None:
|
||||
if container_exists(name):
|
||||
subprocess.run(
|
||||
|
||||
@@ -26,15 +26,25 @@ from ..bottle_state import (
|
||||
)
|
||||
from ..egress import Egress, EgressPlan
|
||||
from ..git_gate import GitGate, GitGatePlan
|
||||
from ..manifest import ManifestBottle
|
||||
from ..manifest import Manifest, ManifestBottle
|
||||
from ..supervise import Supervise, SupervisePlan
|
||||
from . import BottleSpec
|
||||
|
||||
|
||||
def mint_slug(spec: BottleSpec) -> str:
|
||||
"""Return the bottle identity: the recorded identity for a resume,
|
||||
or a freshly minted one for a new start."""
|
||||
return spec.identity or bottle_identity(spec.agent_name)
|
||||
or a freshly minted one for a new start.
|
||||
|
||||
When a label is provided it becomes the full slug (no random suffix),
|
||||
so two launches with the same label collide by design. When no label
|
||||
is given the identity is minted with a random suffix to avoid
|
||||
collisions between anonymous launches of the same agent."""
|
||||
if spec.identity:
|
||||
return spec.identity
|
||||
if spec.label:
|
||||
from .docker import util as docker_mod
|
||||
return docker_mod.slugify(spec.label)
|
||||
return bottle_identity(spec.agent_name)
|
||||
|
||||
|
||||
def write_launch_metadata(
|
||||
@@ -53,14 +63,14 @@ def write_launch_metadata(
|
||||
backend=backend,
|
||||
label=spec.label,
|
||||
color=spec.color,
|
||||
bottle_names=spec.bottle_names,
|
||||
))
|
||||
|
||||
|
||||
def prepare_agent_state_dir(slug: str, spec: BottleSpec) -> tuple[Path, Path]:
|
||||
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
|
||||
"""Create the agent state subdir, write the prompt file.
|
||||
Returns (agent_dir, prompt_file)."""
|
||||
manifest = spec.manifest
|
||||
agent = manifest.agents[spec.agent_name]
|
||||
agent = manifest.agent
|
||||
agent_dir = agent_state_dir(slug)
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
prompt_file = agent_dir / "prompt.txt"
|
||||
|
||||
@@ -18,6 +18,7 @@ from ...egress import EgressPlan
|
||||
from ...env import ResolvedEnv
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...supervise import SupervisePlan
|
||||
from ...manifest import Manifest
|
||||
from .. import ActiveAgent, BottleBackend, BottleSpec
|
||||
from . import cleanup as _cleanup
|
||||
from . import enumerate as _enumerate
|
||||
@@ -55,6 +56,7 @@ class SmolmachinesBottleBackend(
|
||||
self,
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
@@ -65,6 +67,7 @@ class SmolmachinesBottleBackend(
|
||||
) -> SmolmachinesBottlePlan:
|
||||
return _resolve_plan.resolve_plan(
|
||||
spec,
|
||||
manifest=manifest,
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=agent_provision_plan,
|
||||
|
||||
@@ -145,7 +145,12 @@ class SmolmachinesBottle(Bottle):
|
||||
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
|
||||
# Use sh -c (not -lc) so the script inherits PATH from the calling
|
||||
# process. sh -l sources login-shell init files (e.g. /etc/profile)
|
||||
# which may NOT include smolvm's location when it was installed via
|
||||
# homebrew. The calling process (./cli.py) already has smolvm on PATH
|
||||
# (provision steps succeed), so -c is sufficient.
|
||||
return subprocess.run(["sh", "-c", script], check=False).returncode
|
||||
|
||||
# smolvm/libkrun can SIGKILL an otherwise-normal exec during
|
||||
# early-VM provisioning. Retry once after a short settle so
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Egress apply for the smolmachines backend.
|
||||
|
||||
The smolmachines sidecar bundle runs as a host-side Docker container,
|
||||
so egress signalling is identical to the docker backend.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..docker.egress_apply import ( # noqa: F401
|
||||
DockerEgressApplicator,
|
||||
EgressApplyError,
|
||||
applicator,
|
||||
fetch_current_routes,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DockerEgressApplicator",
|
||||
"EgressApplyError",
|
||||
"applicator",
|
||||
"fetch_current_routes",
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""SmolmachinesFreezer — snapshot a smolmachines bottle.
|
||||
|
||||
`smolvm pack create --from-vm` requires the VM to be stopped, and smolvm
|
||||
removes VMs when stopped (same issue as Apple Container). Instead, exec
|
||||
into the running VM as root to write a gzip-compressed tar of the root
|
||||
filesystem to /var/tmp, then copy it to the host with `smolvm machine cp`,
|
||||
build a Docker image from the archive, convert it to a smolmachine artifact
|
||||
via the existing registry pipeline, and record the sidecar path. The VM
|
||||
stays running throughout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from .. import ActiveAgent
|
||||
from ..freeze import Freezer
|
||||
from ..docker import util as docker_mod
|
||||
from .local_registry import crane_push_tarball, ephemeral_registry
|
||||
from .smolvm import machine_cp, machine_exec, pack_create
|
||||
from ...bottle_state import bottle_state_dir
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
# Temp file written inside the VM during commit. Lives in /var/tmp
|
||||
# (on-disk, unlike tmpfs /tmp) to survive for machine_cp.
|
||||
_VM_COMMIT_TAR = "/var/tmp/.bot-bottle-commit.tar.gz"
|
||||
|
||||
|
||||
class SmolmachinesFreezer(Freezer):
|
||||
"""Freezes a smolmachines bottle via exec-tar + Docker image + smolmachine pack.
|
||||
|
||||
The VM is NOT stopped. We exec into the running VM to write a compressed
|
||||
tar of the root filesystem to /var/tmp, copy it to the host with
|
||||
machine_cp, build a Docker image (Docker's ADD decompresses .tar.gz
|
||||
automatically), then run the same image→registry→pack_create pipeline
|
||||
that _ensure_smolmachine uses for fresh builds."""
|
||||
|
||||
backend_name = "smolmachines"
|
||||
|
||||
def _freeze(self, agent: ActiveAgent) -> str:
|
||||
machine = f"bot-bottle-{agent.slug}"
|
||||
image_ref = f"bot-bottle-committed-{agent.slug}:latest"
|
||||
output_dir = bottle_state_dir(agent.slug)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
binary = output_dir / "committed-smolmachine"
|
||||
sidecar = output_dir / "committed-smolmachine.smolmachine"
|
||||
_snapshot_running_vm(machine, image_ref, binary)
|
||||
return str(sidecar)
|
||||
|
||||
def _export_hint(self, slug: str, image_ref: str) -> None:
|
||||
info(f"to export for migration: cp {image_ref} {slug}.smolmachine")
|
||||
|
||||
|
||||
def _snapshot_running_vm(machine: str, image_ref: str, binary: Path) -> None:
|
||||
"""Exec-tar the running VM, build a Docker image, and pack to a smolmachine.
|
||||
|
||||
binary: destination for the launcher (sibling .smolmachine is the artifact
|
||||
that machine_create --from consumes, same convention as pack_create).
|
||||
"""
|
||||
with tempfile.TemporaryDirectory(prefix="bot-bottle-vm-commit.") as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
# Use .tar.gz — Docker ADD decompresses automatically and the
|
||||
# compressed archive fits in the VM's /var/tmp more easily.
|
||||
rootfs_tar_gz = tmp_path / "rootfs.tar.gz"
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
|
||||
_exec_tar_to_file(machine, rootfs_tar_gz)
|
||||
|
||||
dockerfile.write_text(
|
||||
"FROM scratch\n"
|
||||
"ADD rootfs.tar.gz /\n"
|
||||
"USER node\n"
|
||||
"WORKDIR /home/node\n"
|
||||
)
|
||||
docker_mod.build_image(image_ref, str(tmp_path), dockerfile=str(dockerfile))
|
||||
|
||||
image_tarball = binary.parent / "committed.image.tar"
|
||||
docker_mod.save(image_ref, str(image_tarball))
|
||||
try:
|
||||
with ephemeral_registry() as handle:
|
||||
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
|
||||
push_ref = f"{handle.push_endpoint}/bot-bottle-committed:{digest}"
|
||||
pack_ref = f"{handle.pull_endpoint}/bot-bottle-committed:{digest}"
|
||||
crane_push_tarball(handle, str(image_tarball), push_ref)
|
||||
pack_create(pack_ref, binary)
|
||||
finally:
|
||||
image_tarball.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _exec_tar_to_file(machine: str, dest: Path) -> None:
|
||||
"""Snapshot the running VM's root filesystem to dest (.tar.gz).
|
||||
|
||||
Writes a gzip-compressed tar to _VM_COMMIT_TAR inside the VM via
|
||||
machine_exec (same mechanism as provisioning), then copies it to the
|
||||
host with machine_cp. This avoids binary-stdout piping through the
|
||||
smolvm exec channel, which does not reliably handle large binary output.
|
||||
|
||||
A connectivity probe (machine_exec true) runs first so a concurrent-exec
|
||||
limitation (smolvm may reject a second exec while -i -t is active) is
|
||||
reported clearly rather than as a silent failure."""
|
||||
# Connectivity probe — if smolvm rejects concurrent exec while an
|
||||
# interactive session is running, fail clearly here.
|
||||
probe = machine_exec(machine, ["true"])
|
||||
if probe.returncode != 0:
|
||||
die(
|
||||
f"smolvm exec is not available for {machine!r} "
|
||||
f"(exit {probe.returncode}: {probe.stderr.strip() or probe.stdout.strip() or '<no output>'}). "
|
||||
f"If an interactive session is active, smolvm may not support concurrent exec."
|
||||
)
|
||||
|
||||
# Create the compressed tar inside the VM.
|
||||
# tar exits 1 when files change during archiving (normal for a live
|
||||
# filesystem); only treat exit > 1 as fatal.
|
||||
tar_result = machine_exec(
|
||||
machine,
|
||||
[
|
||||
"tar", "--create", "--gzip",
|
||||
"--exclude=./proc",
|
||||
"--exclude=./sys",
|
||||
"--exclude=./dev",
|
||||
"--exclude=./run",
|
||||
# /tmp and /var/tmp are ephemeral. Their stale contents
|
||||
# (e.g. /tmp/claude-<uid>) have uid remapped by smolvm's
|
||||
# pack process, causing Claude Code to refuse to use them
|
||||
# on resume. Exclude both; _init_vm recreates them with
|
||||
# mkdir -p + correct ownership on every boot.
|
||||
"--exclude=./tmp",
|
||||
"--exclude=./var/tmp",
|
||||
f"--file={_VM_COMMIT_TAR}",
|
||||
"--directory=/",
|
||||
".",
|
||||
],
|
||||
)
|
||||
if tar_result.returncode > 1:
|
||||
die(
|
||||
f"smolvm exec tar {machine!r} failed (exit {tar_result.returncode}): "
|
||||
f"{tar_result.stderr.strip() or tar_result.stdout.strip() or '<no output>'}"
|
||||
)
|
||||
|
||||
# Copy from VM to host, then clean up.
|
||||
try:
|
||||
machine_cp(f"{machine}:{_VM_COMMIT_TAR}", str(dest))
|
||||
finally:
|
||||
machine_exec(machine, ["rm", "-f", _VM_COMMIT_TAR])
|
||||
@@ -23,7 +23,9 @@ from typing import Callable, Generator
|
||||
|
||||
from ...egress import (
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
egress_agent_env_entries,
|
||||
egress_resolve_token_values,
|
||||
egress_sidecar_env_entries,
|
||||
)
|
||||
from ...supervise import QUEUE_DIR_IN_CONTAINER, SUPERVISE_PORT
|
||||
from ...util import expand_tilde
|
||||
@@ -40,8 +42,12 @@ from ..docker.git_gate import (
|
||||
GIT_GATE_HOOK_IN_CONTAINER,
|
||||
)
|
||||
from ...git_gate import revoke_git_gate_provisioned_keys
|
||||
from ...log import warn
|
||||
from ...bottle_state import egress_state_dir, git_gate_state_dir
|
||||
from ...log import info, warn
|
||||
from ...bottle_state import (
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
read_committed_image,
|
||||
)
|
||||
from . import loopback_alias as _loopback
|
||||
from . import sidecar_bundle as _bundle
|
||||
from . import smolvm as _smolvm
|
||||
@@ -85,14 +91,7 @@ def launch(
|
||||
plan = _start_bundle(plan, network, loopback_ip, stack)
|
||||
plan = _discover_urls(plan, loopback_ip)
|
||||
|
||||
# Build the agent image and pack it into a `.smolmachine`
|
||||
# artifact (or hit the per-Dockerfile-digest cache). Runs
|
||||
# here, not in prepare, so the docker-build output doesn't
|
||||
# garble the dashboard's preflight modal.
|
||||
agent_from_path = _ensure_smolmachine(
|
||||
plan.agent_image,
|
||||
dockerfile=plan.agent_dockerfile_path,
|
||||
)
|
||||
agent_from_path = _agent_from_path(plan)
|
||||
|
||||
_launch_vm(plan, agent_from_path, loopback_ip, stack)
|
||||
_init_vm(plan)
|
||||
@@ -104,7 +103,7 @@ def launch(
|
||||
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_title=f"{plan.spec.label} ({plan.spec.agent_name})" if plan.spec.label else plan.spec.agent_name,
|
||||
terminal_color=plan.spec.color,
|
||||
agent_workdir=plan.workspace_plan.workdir,
|
||||
)
|
||||
@@ -130,7 +129,7 @@ def _teardown_smolmachines(
|
||||
except BaseException as exc: # noqa: W0718 — teardown must not fail
|
||||
teardown_exc = exc
|
||||
warn(f"smolmachines teardown failed: {exc!r}")
|
||||
bottle = plan.spec.manifest.bottle_for(plan.spec.agent_name)
|
||||
bottle = plan.manifest.bottle
|
||||
revoke_git_gate_provisioned_keys(bottle, git_gate_state_dir(plan.slug))
|
||||
if teardown_exc is not None:
|
||||
raise teardown_exc
|
||||
@@ -142,10 +141,12 @@ def _allocate_resources(
|
||||
) -> tuple[str, str]:
|
||||
"""Reserve a loopback alias and create the per-bottle docker bridge.
|
||||
|
||||
macOS only routes 127.0.0.1 by default; the per-bottle alias
|
||||
scopes TSI's allowlist to this bottle's published ports so the
|
||||
agent can't reach other bottles' or host services' ports on
|
||||
loopback. No-op on Linux."""
|
||||
The per-bottle alias scopes TSI's allowlist to this bottle's
|
||||
published ports so the agent can't reach other bottles' or host
|
||||
services' ports on loopback. On macOS `ensure_pool` first
|
||||
sudo-aliases the pool on `lo0`; on Linux that's a no-op since
|
||||
all of 127.0.0.0/8 is already loopback, but the per-bottle
|
||||
allocation runs on both."""
|
||||
_loopback.ensure_pool()
|
||||
loopback_ip = _loopback.allocate(plan.slug)
|
||||
network = _bundle.bundle_network_name(plan.slug)
|
||||
@@ -191,9 +192,11 @@ def _discover_urls(
|
||||
return the plan with URLs + guest_env stamped in.
|
||||
|
||||
Docker container IPs (192.168.x.x in the daemon's bridge)
|
||||
aren't reachable from the smolvm guest on macOS — TSI uses
|
||||
macOS networking, and macOS sees the daemon's bridge via the
|
||||
published-port loopback forward only.
|
||||
aren't reachable from the smolvm guest — TSI proxies the
|
||||
guest's connects through the host, and the host reaches the
|
||||
bundle only via its published-port loopback forward (the
|
||||
daemon's bridge isn't on the TSI allowlist). The agent dials
|
||||
the published port on the per-bottle loopback alias.
|
||||
|
||||
NO_PROXY includes the per-bottle loopback alias so the
|
||||
supervise + git-gate URLs bypass HTTPS_PROXY."""
|
||||
@@ -217,16 +220,23 @@ def _discover_urls(
|
||||
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
|
||||
|
||||
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
|
||||
no_proxy = f"{existing_no_proxy},{loopback_ip}"
|
||||
guest_env = {
|
||||
**plan.guest_env,
|
||||
"HTTPS_PROXY": agent_proxy_url,
|
||||
"HTTP_PROXY": agent_proxy_url,
|
||||
"NO_PROXY": f"{existing_no_proxy},{loopback_ip}",
|
||||
"https_proxy": agent_proxy_url,
|
||||
"http_proxy": agent_proxy_url,
|
||||
"NO_PROXY": no_proxy,
|
||||
"no_proxy": no_proxy,
|
||||
}
|
||||
if agent_git_gate_host:
|
||||
guest_env["GIT_GATE_URL"] = f"http://{agent_git_gate_host}"
|
||||
if agent_supervise_url:
|
||||
guest_env["MCP_SUPERVISE_URL"] = agent_supervise_url
|
||||
for entry in egress_agent_env_entries(plan.egress_plan):
|
||||
name, value = entry.split("=", 1)
|
||||
guest_env[name] = value
|
||||
|
||||
return dataclasses.replace(
|
||||
plan,
|
||||
@@ -246,10 +256,11 @@ def _launch_vm(
|
||||
"""Create, patch, and start the smolvm VM; register teardown.
|
||||
|
||||
--allow-cidr is the per-bottle loopback alias so the guest can
|
||||
only reach this bottle's bundle ports. force_allowlist patches
|
||||
smolvm 0.8.0's silent-drop of --allow-cidr when combined with
|
||||
--from. Smolfile isn't usable here — smolvm 0.8.0 makes --from
|
||||
and --smolfile mutually exclusive."""
|
||||
only reach this bottle's bundle ports. force_allowlist then
|
||||
confirms the allowlist persisted (patching smolvm 0.8.0's
|
||||
silent-drop of --allow-cidr when combined with --from) and
|
||||
fails closed if it can't. Smolfile isn't usable here — smolvm
|
||||
0.8.0 makes --from and --smolfile mutually exclusive."""
|
||||
_smolvm.machine_create(
|
||||
plan.machine_name,
|
||||
from_path=agent_from_path,
|
||||
@@ -257,9 +268,10 @@ def _launch_vm(
|
||||
env=plan.guest_env,
|
||||
)
|
||||
stack.callback(_smolvm.machine_delete, plan.machine_name)
|
||||
# Workaround smolvm 0.8.0: `--allow-cidr` is silently dropped
|
||||
# when combined with `--from`. Patch the persisted state DB
|
||||
# before start so the booted VM's TSI actually enforces.
|
||||
# Confirm the booted VM's TSI allowlist will actually enforce the
|
||||
# /32 before start (smolvm 0.8.0 silently drops `--allow-cidr`
|
||||
# with `--from`, so the persisted state DB is patched if needed).
|
||||
# Fails closed if enforcement can't be confirmed.
|
||||
_loopback.force_allowlist(plan.machine_name, [f"{loopback_ip}/32"])
|
||||
_smolvm.machine_start(plan.machine_name)
|
||||
stack.callback(_smolvm.machine_stop, plan.machine_name)
|
||||
@@ -269,16 +281,24 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None:
|
||||
"""Repair filesystem ownership and wait for exec channel readiness.
|
||||
|
||||
Ownership repair: smolvm's pack process remaps files to the host
|
||||
invoker's uid (501 on macOS). /home/node must be node:node so
|
||||
invoker's uid (e.g. 501 on macOS, 1000 on Linux). The chowns use
|
||||
names not numbers so they're correct on either. /home/node must
|
||||
be node:node so
|
||||
Claude Code can write ~/.claude.json; /tmp + /var/tmp need root
|
||||
mode 1777 so non-root processes can create per-uid scratch dirs.
|
||||
All folded into one sh -c to avoid back-to-back exec calls
|
||||
immediately after machine_start (libkrun exec-channel race).
|
||||
|
||||
mkdir -p guards: when booting from a committed snapshot, /tmp and
|
||||
/var/tmp are excluded from the archive (they're ephemeral and their
|
||||
stale contents would have wrong uid after smolvm's uid remap). The
|
||||
directories must be created before chown/chmod can set permissions.
|
||||
|
||||
wait_exec_ready polls until the exec channel is ready for the
|
||||
subsequent provision calls, replacing the empirical sleep."""
|
||||
_smolvm.machine_exec(plan.machine_name, [
|
||||
"sh", "-c",
|
||||
"mkdir -p /tmp /var/tmp && "
|
||||
"chown -R node:node /home/node && "
|
||||
"chown root:root /tmp /var/tmp && "
|
||||
"chmod 1777 /tmp /var/tmp",
|
||||
@@ -308,12 +328,8 @@ def _bundle_launch_spec(
|
||||
ep = plan.egress_plan
|
||||
volumes.append((str(ep.mitmproxy_ca_host_path), EGRESS_CA_IN_CONTAINER, True))
|
||||
if ep.routes:
|
||||
volumes.append((str(ep.routes_path), EGRESS_ROUTES_IN_CONTAINER, True))
|
||||
# Bare-name entries for upstream-token slots. Their values
|
||||
# come from the docker-run subprocess env (inherited from
|
||||
# the operator's shell), never landing on argv.
|
||||
for token_env in sorted(ep.token_env_map.keys()):
|
||||
env.append(token_env)
|
||||
volumes.append((str(ep.routes_path.parent), str(Path(EGRESS_ROUTES_IN_CONTAINER).parent), True))
|
||||
env.extend(egress_sidecar_env_entries(ep))
|
||||
|
||||
# --- git-gate ---------------------------------------------
|
||||
gp = plan.git_gate_plan
|
||||
@@ -382,6 +398,30 @@ def _resolve_token_env(
|
||||
return egress_resolve_token_values(plan.egress_plan.token_env_map, effective_env)
|
||||
|
||||
|
||||
def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
||||
"""Return the `.smolmachine` artifact used for `machine create --from`.
|
||||
|
||||
Prefer a committed VM artifact when one is recorded and still
|
||||
present. If the file was removed, fall back to the normal image
|
||||
build + pack cache path.
|
||||
"""
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed:
|
||||
committed_path = Path(committed)
|
||||
if committed_path.is_file():
|
||||
info(f"using committed smolmachine {str(committed_path)!r}")
|
||||
return committed_path
|
||||
|
||||
# Build the agent image and pack it into a `.smolmachine`
|
||||
# artifact (or hit the per-Dockerfile-digest cache). Runs here,
|
||||
# not in prepare, so the docker-build output doesn't garble the
|
||||
# dashboard's preflight modal.
|
||||
return _ensure_smolmachine(
|
||||
plan.agent_image,
|
||||
dockerfile=plan.agent_dockerfile_path,
|
||||
)
|
||||
|
||||
|
||||
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||
"""Build the agent docker image and convert it into a
|
||||
`.smolmachine` artifact, caching the result under
|
||||
|
||||
@@ -33,10 +33,13 @@ sudo-add the missing pool on first use per boot — the aliases
|
||||
persist on `lo0` until reboot, so subsequent launches don't
|
||||
prompt.
|
||||
|
||||
Linux native daemons share the host's network namespace; the
|
||||
whole `127.0.0.0/8` is reachable by default and aliases are
|
||||
unnecessary. The pool logic detects native-Linux and skips sudo
|
||||
entirely; the DB patch is also gated on macOS.
|
||||
On Linux the whole `127.0.0.0/8` is already routed to `lo`, so
|
||||
docker can publish a bundle's ports directly on `127.0.0.<N>`
|
||||
with no `ifconfig`/sudo step. `ensure_pool` is therefore a no-op
|
||||
on Linux, but per-bottle alias *allocation* and the TSI allowlist
|
||||
DB patch run on both platforms — the isolation property is
|
||||
identical, it's just cheaper to set up on Linux. The state-DB
|
||||
path differs per platform (see `_smolvm_db_path`).
|
||||
|
||||
Allocation is coordinated by inspecting running bundle
|
||||
containers' published host IPs — each bottle's bundle owns the
|
||||
@@ -47,6 +50,7 @@ from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -57,20 +61,34 @@ from typing import Iterable
|
||||
from ...log import die, info
|
||||
|
||||
|
||||
# smolvm's persistent VM state on macOS — a SQLite DB whose `vms`
|
||||
# table holds one JSON BLOB per machine. The Linux path is
|
||||
# different, but smolmachines is macOS-only in v1 (PRD 0023) so
|
||||
# we hard-code this. If the file moves under us we'll see a
|
||||
# clear FileNotFoundError; not worth defensive cross-platform
|
||||
# detection until the backend actually needs Linux.
|
||||
_SMOLVM_DB_PATH = (
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "smolvm"
|
||||
/ "server"
|
||||
/ "smolvm.db"
|
||||
)
|
||||
def _smolvm_db_path() -> Path:
|
||||
"""smolvm's persistent VM state — a SQLite DB whose `vms` table
|
||||
holds one JSON BLOB per machine. macOS stores it under
|
||||
`Application Support`; Linux follows the XDG base-dir spec
|
||||
(`$XDG_DATA_HOME`, default `~/.local/share`).
|
||||
|
||||
NOTE: the Linux location is inferred from smolvm's documented
|
||||
`~/.local/share` install layout and must be confirmed against a
|
||||
real Linux smolvm install. If it's wrong, `force_allowlist`'s
|
||||
fail-closed check turns it into a clear launch-time error rather
|
||||
than a silent escape."""
|
||||
if platform.system() == "Darwin":
|
||||
return (
|
||||
Path.home()
|
||||
/ "Library"
|
||||
/ "Application Support"
|
||||
/ "smolvm"
|
||||
/ "server"
|
||||
/ "smolvm.db"
|
||||
)
|
||||
xdg_data = os.environ.get("XDG_DATA_HOME")
|
||||
base = Path(xdg_data) if xdg_data else Path.home() / ".local" / "share"
|
||||
return base / "smolvm" / "server" / "smolvm.db"
|
||||
|
||||
|
||||
# Resolved once at import: the host platform doesn't change within a
|
||||
# process. Tests patch this attribute directly.
|
||||
_SMOLVM_DB_PATH = _smolvm_db_path()
|
||||
|
||||
|
||||
# Sixteen aliases by default. Tunable for hosts that want more
|
||||
@@ -131,51 +149,74 @@ def ensure_pool() -> None:
|
||||
|
||||
|
||||
def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
|
||||
"""Patch smolvm's persistent VM-state DB to set the machine's
|
||||
`allowed_cidrs` to the given list. Workaround for smolvm
|
||||
0.8.0's silent-drop of `--allow-cidr` when used with `--from`.
|
||||
"""Ensure the machine's persisted TSI allowlist equals
|
||||
`allowed_cidrs`, failing **closed** if that can't be confirmed.
|
||||
|
||||
Must run AFTER `smolvm machine create` (the row has to
|
||||
exist) and BEFORE `smolvm machine start` (smolvm reads the
|
||||
row on start; in-flight VMs don't pick up changes). Once
|
||||
smolvm honors the CLI flag upstream this whole function is
|
||||
redundant — flag-respecting create + remove this call from
|
||||
launch.
|
||||
Runs on both macOS and Linux. It exists because smolvm 0.8.0
|
||||
silently drops `--allow-cidr` when combined with `--from`, so
|
||||
the allowlist has to be written into smolvm's persistent state
|
||||
DB before `machine start`. Rather than assume the flag was
|
||||
dropped, we read the persisted row and only patch when it
|
||||
doesn't already match — so a newer smolvm that honors the flag
|
||||
is left untouched.
|
||||
|
||||
No-op on non-macOS — the DB path differs and the Linux
|
||||
smolmachines code path isn't exercised in v1."""
|
||||
if not _is_macos():
|
||||
return
|
||||
Must run AFTER `smolvm machine create` (the row has to exist)
|
||||
and BEFORE `smolvm machine start` (smolvm reads the row on
|
||||
start; in-flight VMs don't pick up changes).
|
||||
|
||||
Fail-closed: if the state DB is missing, the row is missing, or
|
||||
the allowlist still doesn't match after patching, we `die()`
|
||||
rather than boot a VM whose egress confinement we can't verify
|
||||
— an unconfirmed allowlist is a sandbox-escape risk (the agent
|
||||
VM could reach all of host loopback)."""
|
||||
want = list(allowed_cidrs)
|
||||
if not _SMOLVM_DB_PATH.is_file():
|
||||
die(
|
||||
f"smolvm state DB not found at {_SMOLVM_DB_PATH}. "
|
||||
f"smolvm 0.8.0 expected? `smolvm --version` to check."
|
||||
f"smolvm state DB not found at {_SMOLVM_DB_PATH}; cannot "
|
||||
f"confirm the TSI allowlist is enforced. Refusing to launch "
|
||||
f"(fail-closed). Check `smolvm --version` and the DB "
|
||||
f"location for your platform."
|
||||
)
|
||||
con = sqlite3.connect(str(_SMOLVM_DB_PATH))
|
||||
try:
|
||||
cur = con.cursor()
|
||||
row = cur.execute(
|
||||
"SELECT data FROM vms WHERE name = ?", (machine_name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
die(
|
||||
f"smolvm DB has no row for machine {machine_name!r} — "
|
||||
f"machine_create must run before force_allowlist."
|
||||
cfg = _read_machine_cfg(con, machine_name)
|
||||
if cfg.get("allowed_cidrs") != want:
|
||||
cfg["allowed_cidrs"] = want
|
||||
# Write as BLOB (the column type smolvm uses) — passing a
|
||||
# plain str makes sqlite store it as Text and smolvm then
|
||||
# fails to read it.
|
||||
con.execute(
|
||||
"UPDATE vms SET data = ? WHERE name = ?",
|
||||
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
|
||||
)
|
||||
con.commit()
|
||||
cfg = _read_machine_cfg(con, machine_name)
|
||||
if cfg.get("allowed_cidrs") != want:
|
||||
die(
|
||||
f"could not enforce TSI allowlist {want!r} for machine "
|
||||
f"{machine_name!r} (persisted value is "
|
||||
f"{cfg.get('allowed_cidrs')!r}). Refusing to launch "
|
||||
f"(fail-closed)."
|
||||
)
|
||||
cfg = json.loads(row[0])
|
||||
cfg["allowed_cidrs"] = list(allowed_cidrs)
|
||||
# Write as BLOB (the column type smolvm uses) — passing a
|
||||
# plain str makes sqlite store it as Text and smolvm then
|
||||
# fails to read it.
|
||||
cur.execute(
|
||||
"UPDATE vms SET data = ? WHERE name = ?",
|
||||
(sqlite3.Binary(json.dumps(cfg).encode()), machine_name),
|
||||
)
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def _read_machine_cfg(con: sqlite3.Connection, machine_name: str) -> dict[str, object]:
|
||||
"""Read + JSON-decode a machine's `data` BLOB from the smolvm
|
||||
state DB. Dies (fail-closed) if the row is missing — the caller
|
||||
can't confirm enforcement without it."""
|
||||
row = con.execute(
|
||||
"SELECT data FROM vms WHERE name = ?", (machine_name,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
die(
|
||||
f"smolvm DB has no row for machine {machine_name!r} — "
|
||||
f"machine_create must run before force_allowlist."
|
||||
)
|
||||
return json.loads(row[0])
|
||||
|
||||
|
||||
def allocate(_slug: str) -> str:
|
||||
"""Pick the lowest-numbered alias from the pool not already
|
||||
in use by a running smolmachines bundle. Bails when the pool
|
||||
@@ -184,16 +225,17 @@ def allocate(_slug: str) -> str:
|
||||
used (no on-disk reservation, allocation is purely
|
||||
docker-state-driven).
|
||||
|
||||
On non-macOS the whole `127.0.0.0/8` is loopback by default;
|
||||
`127.0.0.1` is fine to share and we skip the alias dance.
|
||||
This still returns a deterministic address so launch.py's
|
||||
callers don't have to branch on platform.
|
||||
Runs on both platforms: the allocation logic (docker-state
|
||||
inspection + the file lock) is platform-independent. macOS
|
||||
needs `ensure_pool` to have aliased the addresses on `lo0`
|
||||
first; on Linux all of `127.0.0.0/8` is already loopback, so
|
||||
docker can publish on the chosen `127.0.0.<N>` with no setup.
|
||||
Per-bottle scoping (so the agent can't reach other bottles' or
|
||||
host services' loopback ports) therefore holds on both.
|
||||
|
||||
An exclusive file lock serialises concurrent calls so two
|
||||
simultaneous launches don't read the same docker state and
|
||||
claim the same alias."""
|
||||
if not _is_macos():
|
||||
return "127.0.0.1"
|
||||
_ALLOC_LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(_ALLOC_LOCK_PATH, "w", encoding="utf-8") as lf:
|
||||
fcntl.flock(lf, fcntl.LOCK_EX)
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from .. import BottleSpec
|
||||
from ...manifest import Manifest
|
||||
from ...env import ResolvedEnv
|
||||
from ...agent_provider import AgentProvisionPlan
|
||||
from ...egress import EgressPlan
|
||||
@@ -46,6 +47,7 @@ def build_guest_env(resolved_env: ResolvedEnv) -> dict[str, str]:
|
||||
|
||||
def resolve_plan(
|
||||
spec: BottleSpec,
|
||||
manifest: Manifest,
|
||||
slug: str,
|
||||
resolved_env: ResolvedEnv,
|
||||
agent_provision_plan: AgentProvisionPlan,
|
||||
@@ -67,6 +69,7 @@ def resolve_plan(
|
||||
|
||||
return SmolmachinesBottlePlan(
|
||||
spec=spec,
|
||||
manifest=manifest,
|
||||
stage_dir=stage_dir,
|
||||
slug=slug,
|
||||
bundle_subnet=subnet,
|
||||
|
||||
@@ -25,6 +25,7 @@ smolvm binary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
@@ -94,6 +95,16 @@ def pack_create(image: str, output: Path) -> None:
|
||||
_smolvm("pack", "create", "--image", image, "-o", str(output))
|
||||
|
||||
|
||||
def pack_create_from_vm(name: str, output: Path) -> None:
|
||||
"""`smolvm pack create --from-vm <name> -o <output>`.
|
||||
|
||||
Snapshots an existing persistent VM into a pack artifact. As
|
||||
with `pack_create`, smolvm writes a launcher at `output` and the
|
||||
bootable sidecar at `output.smolmachine`.
|
||||
"""
|
||||
_smolvm("pack", "create", "--from-vm", name, "-o", str(output))
|
||||
|
||||
|
||||
# --- Machine lifecycle ---------------------------------------------------
|
||||
|
||||
|
||||
@@ -143,6 +154,21 @@ def machine_create(
|
||||
_smolvm(*args)
|
||||
|
||||
|
||||
def machine_is_running(name: str) -> bool:
|
||||
"""Return True if the named VM is in the 'running' state."""
|
||||
result = _smolvm("machine", "ls", "--json", check=False)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
try:
|
||||
machines = json.loads(result.stdout or "[]")
|
||||
except ValueError:
|
||||
return False
|
||||
return any(
|
||||
isinstance(m, dict) and m.get("name") == name and m.get("state") == "running"
|
||||
for m in machines
|
||||
)
|
||||
|
||||
|
||||
def machine_start(name: str) -> None:
|
||||
"""`smolvm machine start --name NAME`."""
|
||||
_smolvm("machine", "start", "--name", name)
|
||||
|
||||
@@ -5,26 +5,58 @@ unit-tested without importing the docker subprocess paths."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
|
||||
from ...log import die
|
||||
|
||||
# libkrun's Linux backend drives the guest through KVM, so the host
|
||||
# must expose `/dev/kvm` and the invoking user must be able to open
|
||||
# it. macOS uses Hypervisor.framework and needs no device node.
|
||||
_KVM_DEVICE = "/dev/kvm"
|
||||
|
||||
|
||||
def smolmachines_preflight() -> None:
|
||||
"""Ensure `smolvm` is on PATH before the launch flow runs.
|
||||
Called from `_resolve_plan`; gives the operator a clear
|
||||
install pointer rather than a cryptic FileNotFoundError
|
||||
later. `gvproxy` is no longer required — see the PRD's design
|
||||
pivot section."""
|
||||
if shutil.which("smolvm") is not None:
|
||||
return
|
||||
die(
|
||||
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
||||
"PATH. Install with: "
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh. "
|
||||
"To use the legacy Docker backend instead, set "
|
||||
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
|
||||
)
|
||||
"""Ensure the host can run the smolmachines backend before the
|
||||
launch flow starts. Called from `_resolve_plan`; surfaces a
|
||||
clear, actionable error instead of a cryptic `smolvm` failure
|
||||
deep in launch.
|
||||
|
||||
Checks `smolvm` is on PATH (both platforms) and, on Linux,
|
||||
that `/dev/kvm` exists and is accessible. `gvproxy` is no
|
||||
longer required — see the PRD's design pivot section."""
|
||||
if shutil.which("smolvm") is None:
|
||||
die(
|
||||
"BOT_BOTTLE_BACKEND=smolmachines requires `smolvm` on "
|
||||
"PATH. Install with: "
|
||||
"curl -sSL https://smolmachines.com/install.sh | sh. "
|
||||
"To use the legacy Docker backend instead, set "
|
||||
"BOT_BOTTLE_BACKEND=docker or pass --backend=docker."
|
||||
)
|
||||
if platform.system() == "Linux":
|
||||
_preflight_kvm()
|
||||
|
||||
|
||||
def _preflight_kvm() -> None:
|
||||
"""Linux-only: libkrun needs `/dev/kvm`. Distinguish 'KVM not
|
||||
enabled' from 'no permission' so the operator knows which to
|
||||
fix."""
|
||||
if not os.path.exists(_KVM_DEVICE):
|
||||
die(
|
||||
f"BOT_BOTTLE_BACKEND=smolmachines needs {_KVM_DEVICE} on "
|
||||
"Linux but it is missing. Enable KVM: load the kvm-intel "
|
||||
"or kvm-amd kernel module (and confirm virtualization is "
|
||||
"enabled in BIOS/firmware). To use the legacy Docker "
|
||||
"backend instead, set BOT_BOTTLE_BACKEND=docker."
|
||||
)
|
||||
if not os.access(_KVM_DEVICE, os.R_OK | os.W_OK):
|
||||
die(
|
||||
f"{_KVM_DEVICE} exists but is not readable/writable by the "
|
||||
"current user. Add your user to the `kvm` group "
|
||||
"(`sudo usermod -aG kvm \"$USER\"`) and re-login, or run "
|
||||
"with access to the device."
|
||||
)
|
||||
|
||||
|
||||
def smolmachines_bundle_subnet(slug: str) -> tuple[str, str, str]:
|
||||
|
||||
@@ -12,22 +12,11 @@ import shlex
|
||||
# uses true/24-bit colors for its own chrome, which would otherwise bypass
|
||||
# the palette entirely.
|
||||
_COLORS: dict[str, tuple[int, str, int, str, str]] = {
|
||||
"black": (0, "#2d2d2d", 8, "#5c5c5c", "#0a0a0a"),
|
||||
"red": (1, "#c0392b", 9, "#e74c3c", "#1a0707"),
|
||||
"green": (2, "#27ae60", 10, "#2ecc71", "#071a09"),
|
||||
"yellow": (3, "#d4ac0d", 11, "#f1c40f", "#1a1507"),
|
||||
"blue": (4, "#2471a3", 12, "#3498db", "#07071a"),
|
||||
"magenta": (5, "#7d3c98", 13, "#9b59b6", "#12071a"),
|
||||
"cyan": (6, "#148f77", 14, "#1abc9c", "#071a1a"),
|
||||
"white": (7, "#bdc3c7", 15, "#ecf0f1", "#111111"),
|
||||
"bright-black": (8, "#5c5c5c", 0, "#2d2d2d", "#111111"),
|
||||
"bright-red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
|
||||
"bright-green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
|
||||
"bright-yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
|
||||
"bright-blue": (12, "#3498db", 4, "#2471a3", "#080820"),
|
||||
"bright-magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
|
||||
"bright-cyan": (14, "#1abc9c", 6, "#148f77", "#082020"),
|
||||
"bright-white": (15, "#ecf0f1", 7, "#bdc3c7", "#151515"),
|
||||
"red": (9, "#e74c3c", 1, "#c0392b", "#200808"),
|
||||
"green": (10, "#2ecc71", 2, "#27ae60", "#082008"),
|
||||
"yellow": (11, "#f1c40f", 3, "#d4ac0d", "#201808"),
|
||||
"blue": (12, "#3498db", 4, "#2471a3", "#080820"),
|
||||
"magenta": (13, "#9b59b6", 5, "#7d3c98", "#160820"),
|
||||
}
|
||||
|
||||
# OSC 104 resets all indexed palette entries; OSC 111 resets default background.
|
||||
|
||||
+49
-16
@@ -1,8 +1,7 @@
|
||||
"""Per-bottle persistent state (PRD 0016).
|
||||
"""Per-bottle persistent state.
|
||||
|
||||
Holds the per-bottle Dockerfile override that capability-block
|
||||
remediation writes, the transcript snapshot the state-preservation
|
||||
helper saves before teardown, and the launch metadata that lets
|
||||
Holds optional per-bottle Dockerfile overrides, the transcript snapshot
|
||||
the state-preservation helper saves before teardown, and the launch metadata that lets
|
||||
`cli.py resume <identity>` reconstruct a bottle's spec. State
|
||||
lives at:
|
||||
|
||||
@@ -43,6 +42,7 @@ from . import supervise as _supervise
|
||||
# Directory layout: ~/.bot-bottle/state/<identity>/...
|
||||
_STATE_SUBDIR = "state"
|
||||
_PER_BOTTLE_DOCKERFILE_NAME = "Dockerfile"
|
||||
_COMMITTED_IMAGE_NAME = "committed-image"
|
||||
_TRANSCRIPT_SUBDIR = "transcript"
|
||||
# Per-sidecar scratch subdirs. PRD 0018 chunk 2: bind-mount sources
|
||||
# live here so chunk 3's `docker compose up` can find them at stable
|
||||
@@ -60,7 +60,7 @@ _METADATA_NAME = "metadata.json"
|
||||
_LIVE_CONFIG_SUBDIR = "live-config"
|
||||
LIVE_CONFIG_ROUTES_NAME = "routes.yaml"
|
||||
LIVE_CONFIG_ALLOWLIST_NAME = "allowlist"
|
||||
# Empty marker file. capability_apply writes it before teardown so
|
||||
# Empty marker file. Session preservation writes it before teardown so
|
||||
# cli.py's session-end cleanup knows to preserve the state dir for
|
||||
# `cli.py resume <identity>`. Absent = clean up.
|
||||
_PRESERVE_MARKER = ".preserve"
|
||||
@@ -111,6 +111,10 @@ class BottleMetadata:
|
||||
backend: str = ""
|
||||
label: str = ""
|
||||
color: str = ""
|
||||
# Ordered bottle names selected at launch (issue #269). Empty tuple
|
||||
# for state dirs written before this change; resume falls back to
|
||||
# the agent's `bottle:` field in that case.
|
||||
bottle_names: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def metadata_path(identity: str) -> Path:
|
||||
@@ -138,6 +142,10 @@ def read_metadata(identity: str) -> BottleMetadata | None:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
raw_typed = cast(dict[str, object], raw)
|
||||
raw_bottle_names = raw_typed.get("bottle_names", [])
|
||||
bottle_names: tuple[str, ...] = ()
|
||||
if isinstance(raw_bottle_names, list):
|
||||
bottle_names = tuple(str(n) for n in raw_bottle_names if isinstance(n, str))
|
||||
return BottleMetadata(
|
||||
identity=str(raw_typed.get("identity", identity)),
|
||||
agent_name=str(raw_typed.get("agent_name", "")),
|
||||
@@ -148,6 +156,7 @@ def read_metadata(identity: str) -> BottleMetadata | None:
|
||||
backend=str(raw_typed.get("backend", "")),
|
||||
label=str(raw_typed.get("label", "")),
|
||||
color=str(raw_typed.get("color", "")),
|
||||
bottle_names=bottle_names,
|
||||
)
|
||||
|
||||
|
||||
@@ -163,8 +172,7 @@ def per_bottle_dockerfile_path(identity: str) -> Path:
|
||||
|
||||
def per_bottle_dockerfile(identity: str) -> str | None:
|
||||
"""Return the per-bottle Dockerfile content if present, else
|
||||
None. None means: use the repo's Dockerfile (the original
|
||||
pre-capability-block behavior)."""
|
||||
None. None means: use the provider or manifest Dockerfile."""
|
||||
p = per_bottle_dockerfile_path(identity)
|
||||
if p.is_file():
|
||||
return p.read_text()
|
||||
@@ -179,6 +187,32 @@ def write_per_bottle_dockerfile(identity: str, content: str) -> Path:
|
||||
return p
|
||||
|
||||
|
||||
def committed_image_path(identity: str) -> Path:
|
||||
return bottle_state_dir(identity) / _COMMITTED_IMAGE_NAME
|
||||
|
||||
|
||||
def write_committed_image(identity: str, image_tag: str) -> Path:
|
||||
"""Persist the committed image tag for `identity`. The next
|
||||
`cli.py resume <identity>` will boot from this image instead of
|
||||
rebuilding from the Dockerfile."""
|
||||
path = committed_image_path(identity)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(image_tag.strip() + "\n")
|
||||
path.chmod(0o644)
|
||||
return path
|
||||
|
||||
|
||||
def read_committed_image(identity: str) -> str | None:
|
||||
"""Return the committed image tag for `identity`, or None if no
|
||||
commit has been recorded. Used by the Docker launch step to skip
|
||||
the Dockerfile build when a committed snapshot exists."""
|
||||
path = committed_image_path(identity)
|
||||
if not path.is_file():
|
||||
return None
|
||||
tag = path.read_text().strip()
|
||||
return tag or None
|
||||
|
||||
|
||||
def per_bottle_image_tag(identity: str) -> str:
|
||||
"""Image tag for a rebuilt bottle. Distinct from the base
|
||||
bot-bottle-claude:latest so per-bottle rebuilds don't collide in
|
||||
@@ -222,9 +256,7 @@ def write_live_config(
|
||||
|
||||
|
||||
def transcript_snapshot_dir(identity: str) -> Path:
|
||||
"""Where capability_apply stashes the agent's transcript before
|
||||
teardown, so the next `cli.py start <agent>` can offer to
|
||||
resume from it."""
|
||||
"""Where agent session snapshots are kept for resume flows."""
|
||||
return bottle_state_dir(identity) / _TRANSCRIPT_SUBDIR
|
||||
|
||||
|
||||
@@ -251,8 +283,7 @@ def git_gate_state_dir(identity: str) -> Path:
|
||||
|
||||
|
||||
def supervise_state_dir(identity: str) -> Path:
|
||||
"""State subdir for the supervise sidecar's current-config dir
|
||||
(bind-mounted into the agent at /etc/bot-bottle/current-config).
|
||||
"""State subdir reserved for supervise sidecar bind-mount sources.
|
||||
The queue dir is intentionally NOT under here — it lives at
|
||||
~/.bot-bottle/queue/<slug>/ alongside the audit logs, so it
|
||||
survives state-dir cleanup."""
|
||||
@@ -274,9 +305,8 @@ def preserve_marker_path(identity: str) -> Path:
|
||||
|
||||
def mark_preserved(identity: str) -> Path:
|
||||
"""Mark this bottle's state for preservation across session
|
||||
teardown. Written by capability_apply.apply_capability_change so
|
||||
cli.py's session-end cleanup leaves the state dir intact for a
|
||||
subsequent `cli.py resume`."""
|
||||
teardown so cli.py's session-end cleanup leaves the state dir
|
||||
intact for a subsequent `cli.py resume`."""
|
||||
path = preserve_marker_path(identity)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
@@ -289,7 +319,7 @@ def is_preserved(identity: str) -> bool:
|
||||
|
||||
def clear_preserve_marker(identity: str) -> None:
|
||||
"""Idempotent removal. Called at fresh launch (start or resume)
|
||||
so a marker left from a prior capability-block doesn't keep
|
||||
so a marker left from a prior preserved session doesn't keep
|
||||
state alive past the next normal session-end."""
|
||||
try:
|
||||
preserve_marker_path(identity).unlink()
|
||||
@@ -314,6 +344,7 @@ __all__ = [
|
||||
"bottle_state_dir",
|
||||
"cleanup_state",
|
||||
"clear_preserve_marker",
|
||||
"committed_image_path",
|
||||
"egress_state_dir",
|
||||
"git_gate_state_dir",
|
||||
"is_preserved",
|
||||
@@ -323,9 +354,11 @@ __all__ = [
|
||||
"per_bottle_dockerfile_path",
|
||||
"per_bottle_image_tag",
|
||||
"preserve_marker_path",
|
||||
"read_committed_image",
|
||||
"read_metadata",
|
||||
"supervise_state_dir",
|
||||
"transcript_snapshot_dir",
|
||||
"write_committed_image",
|
||||
"write_metadata",
|
||||
"write_per_bottle_dockerfile",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Main CLI dispatcher.
|
||||
|
||||
Commands: cleanup, edit, info, init, list, resume, start, supervise
|
||||
Commands: cleanup, commit, edit, info, init, list, resume, start, supervise
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -12,6 +12,7 @@ from ..manifest import ManifestError
|
||||
from ._common import PROG
|
||||
from . import list as _list_mod
|
||||
from .cleanup import cmd_cleanup
|
||||
from .commit import cmd_commit
|
||||
from .edit import cmd_edit
|
||||
from .info import cmd_info
|
||||
from .init import cmd_init
|
||||
@@ -23,6 +24,7 @@ cmd_list = _list_mod.cmd_list
|
||||
|
||||
COMMANDS = {
|
||||
"cleanup": cmd_cleanup,
|
||||
"commit": cmd_commit,
|
||||
"edit": cmd_edit,
|
||||
"info": cmd_info,
|
||||
"init": cmd_init,
|
||||
@@ -37,6 +39,7 @@ def usage() -> None:
|
||||
sys.stderr.write(f"usage: {PROG} <command> [args...]\n\n")
|
||||
sys.stderr.write("Commands:\n")
|
||||
sys.stderr.write(" cleanup stop and remove all active bot-bottle containers\n")
|
||||
sys.stderr.write(" commit snapshot a running bottle's container state to a Docker image\n")
|
||||
sys.stderr.write(" edit open an agent in vim for editing\n")
|
||||
sys.stderr.write(" info print env, skills, and prompt details for a named agent\n")
|
||||
sys.stderr.write(" init interactively create a new agent and add it to bot-bottle.json\n")
|
||||
|
||||
@@ -13,9 +13,8 @@ dirs are shared layout, so docker is the single owner of that
|
||||
bucket.
|
||||
|
||||
State dirs with `.preserve` are intentionally never touched — they
|
||||
hold capability-block rebuilds or crash snapshots the operator may
|
||||
want to `resume`. Manual `rm -rf ~/.bot-bottle/state/<identity>`
|
||||
is the path for those.
|
||||
hold preserved sessions the operator may want to `resume`. Manual
|
||||
`rm -rf ~/.bot-bottle/state/<identity>` is the path for those.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""commit: freeze a running bottle's state to a resumable artifact.
|
||||
|
||||
Docker bottles are committed to a local Docker image. Macos-container
|
||||
bottles are exported and rebuilt as a local Apple Container image.
|
||||
Smolmachines bottles are packed from the running VM into a
|
||||
`.smolmachine` artifact. The resulting reference is stored in
|
||||
per-bottle state so the next `./cli.py resume <slug>` boots from the
|
||||
snapshot instead of rebuilding from the Dockerfile.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from ..backend import enumerate_active_agents
|
||||
from ..backend.freeze import CommitCancelled, get_freezer
|
||||
from ..bottle_state import read_metadata
|
||||
from ..log import die
|
||||
from ._common import PROG
|
||||
from . import tui
|
||||
|
||||
|
||||
def cmd_commit(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} commit", add_help=True)
|
||||
parser.add_argument(
|
||||
"slug",
|
||||
nargs="?",
|
||||
default=None,
|
||||
help=(
|
||||
"bottle slug from `cli.py list active` "
|
||||
"(omit to pick interactively)"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
slug = args.slug
|
||||
if slug is None:
|
||||
active = enumerate_active_agents()
|
||||
if not active:
|
||||
die("no active bottles; start one with `./cli.py start`")
|
||||
choices = [a.slug for a in active]
|
||||
slug = tui.filter_select(choices, title="Select bottle to commit")
|
||||
if slug is None:
|
||||
return 0
|
||||
|
||||
metadata = read_metadata(slug)
|
||||
backend = metadata.backend if metadata else ""
|
||||
|
||||
try:
|
||||
get_freezer(backend).commit_slug(slug)
|
||||
except CommitCancelled:
|
||||
return 0
|
||||
return 0
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
|
||||
from ..log import info
|
||||
from ..manifest import Manifest
|
||||
from ..manifest import ManifestIndex
|
||||
from ._common import PROG, USER_CWD
|
||||
|
||||
|
||||
@@ -14,11 +14,12 @@ def cmd_info(argv: list[str]) -> int:
|
||||
parser.add_argument("name", help="agent name defined in bot-bottle.json")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
manifest = Manifest.resolve(USER_CWD)
|
||||
manifest.require_agent(args.name)
|
||||
names = ManifestIndex.resolve(USER_CWD)
|
||||
names.require_agent(args.name)
|
||||
manifest = names.load_for_agent(args.name)
|
||||
|
||||
agent = manifest.agents[args.name]
|
||||
bottle = manifest.bottle_for(args.name)
|
||||
agent = manifest.agent
|
||||
bottle = manifest.bottle
|
||||
env_names = list(bottle.env.keys())
|
||||
prompt_first_line = agent.prompt.splitlines()[0] if agent.prompt else ""
|
||||
|
||||
@@ -31,7 +32,7 @@ def cmd_info(argv: list[str]) -> int:
|
||||
f"first line: {prompt_first_line or '(empty)'}"
|
||||
)
|
||||
info(f"bottle : {agent.bottle}")
|
||||
identity = manifest.git_identity_summary(args.name)
|
||||
identity = manifest.git_identity_summary()
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
if bottle.git:
|
||||
|
||||
+9
-20
@@ -7,26 +7,15 @@ import os
|
||||
import sys
|
||||
|
||||
from ..backend import enumerate_active_agents
|
||||
from ..manifest import Manifest
|
||||
from ..manifest import ManifestIndex
|
||||
from ._common import PROG, USER_CWD
|
||||
|
||||
_ANSI_COLOR_CODES: dict[str, str] = {
|
||||
"black": "\033[30m",
|
||||
"red": "\033[31m",
|
||||
"green": "\033[32m",
|
||||
"yellow": "\033[33m",
|
||||
"blue": "\033[34m",
|
||||
"magenta": "\033[35m",
|
||||
"cyan": "\033[36m",
|
||||
"white": "\033[37m",
|
||||
"bright-black": "\033[90m",
|
||||
"bright-red": "\033[91m",
|
||||
"bright-green": "\033[92m",
|
||||
"bright-yellow": "\033[93m",
|
||||
"bright-blue": "\033[94m",
|
||||
"bright-magenta": "\033[95m",
|
||||
"bright-cyan": "\033[96m",
|
||||
"bright-white": "\033[97m",
|
||||
"red": "\033[91m",
|
||||
"green": "\033[92m",
|
||||
"yellow": "\033[93m",
|
||||
"blue": "\033[94m",
|
||||
"magenta": "\033[95m",
|
||||
}
|
||||
_ANSI_RESET = "\033[0m"
|
||||
|
||||
@@ -51,8 +40,8 @@ def cmd_list(argv: list[str]) -> int:
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.scope == "available":
|
||||
manifest = Manifest.resolve(USER_CWD)
|
||||
for name in manifest.agents.keys():
|
||||
manifest = ManifestIndex.resolve(USER_CWD)
|
||||
for name in manifest.all_agent_names:
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
@@ -66,7 +55,7 @@ def cmd_list(argv: list[str]) -> int:
|
||||
# Tab-separated keeps the format stable for shell pipelines.
|
||||
for b in active:
|
||||
services = ",".join(b.services) if b.services else "-"
|
||||
display_name = b.label if b.label else b.agent_name
|
||||
display_name = f"{b.label} ({b.agent_name})" if b.label else b.agent_name
|
||||
colored_name = _ansi_label(display_name, b.color)
|
||||
print(f"{b.backend_name}\t{b.slug}\t{colored_name}\t{services}")
|
||||
return 0
|
||||
|
||||
@@ -4,13 +4,12 @@ Reads ~/.bot-bottle/state/<identity>/metadata.json to recover the
|
||||
(agent_name, cwd, copy_cwd) the bottle was originally started with,
|
||||
then runs the same launch core as `start` — but pinned to the
|
||||
recorded identity so the new bottle picks up any per-bottle Dockerfile
|
||||
(from capability-block apply) and transcript snapshot under the same
|
||||
state dir.
|
||||
override and transcript snapshot under the same state dir.
|
||||
|
||||
Use case: an agent calls capability-block, the dashboard approves
|
||||
and tears down the bottle, the operator runs
|
||||
Use case: an interrupted or preserved bottle needs to be relaunched;
|
||||
the operator runs
|
||||
./cli.py resume <identity>
|
||||
to bring up the replacement with the new capabilities baked in.
|
||||
to bring up the replacement from the recorded state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,7 +19,7 @@ import argparse
|
||||
from ..backend import BottleSpec
|
||||
from ..bottle_state import read_metadata
|
||||
from ..log import die
|
||||
from ..manifest import Manifest
|
||||
from ..manifest import ManifestIndex
|
||||
from ._common import PROG, USER_CWD
|
||||
from .start import _launch_bottle
|
||||
|
||||
@@ -28,7 +27,6 @@ from .start import _launch_bottle
|
||||
def cmd_resume(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} resume", add_help=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--remote-control", action="store_true")
|
||||
parser.add_argument(
|
||||
"identity",
|
||||
help="bottle identity from a prior `start` (see its session-end output)",
|
||||
@@ -42,7 +40,7 @@ def cmd_resume(argv: list[str]) -> int:
|
||||
f"check ~/.bot-bottle/state/ or run `cli.py start` to create a new bottle"
|
||||
)
|
||||
|
||||
manifest = Manifest.resolve(USER_CWD)
|
||||
manifest = ManifestIndex.resolve(USER_CWD)
|
||||
manifest.require_agent(metadata.agent_name)
|
||||
|
||||
spec = BottleSpec(
|
||||
@@ -51,11 +49,11 @@ def cmd_resume(argv: list[str]) -> int:
|
||||
copy_cwd=metadata.copy_cwd,
|
||||
user_cwd=metadata.cwd or USER_CWD,
|
||||
identity=metadata.identity,
|
||||
bottle_names=tuple(metadata.bottle_names),
|
||||
)
|
||||
backend_name = metadata.backend or None
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=args.dry_run,
|
||||
remote_control=args.remote_control,
|
||||
backend_name=backend_name,
|
||||
)
|
||||
|
||||
+179
-20
@@ -20,18 +20,19 @@ from ..agent_provider import runtime_for
|
||||
from ..backend import (
|
||||
Bottle,
|
||||
BottleSpec,
|
||||
enumerate_active_agents,
|
||||
get_bottle_backend,
|
||||
known_backend_names,
|
||||
)
|
||||
from ..backend.docker import util as docker_mod
|
||||
from ..backend.docker.bottle_plan import DockerBottlePlan
|
||||
from ..bottle_state import (
|
||||
cleanup_state,
|
||||
is_preserved,
|
||||
mark_preserved,
|
||||
)
|
||||
# from ..backend.docker.capability_apply import snapshot_transcript
|
||||
from ..log import info
|
||||
from ..manifest import Manifest
|
||||
from ..manifest import Manifest, ManifestIndex
|
||||
from ._common import PROG, USER_CWD, read_tty_line
|
||||
from . import tui
|
||||
|
||||
@@ -40,7 +41,6 @@ def cmd_start(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(prog=f"{PROG} start", add_help=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
parser.add_argument("--cwd", action="store_true", help="copy host cwd into the running bottle")
|
||||
parser.add_argument("--remote-control", action="store_true")
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=known_backend_names(),
|
||||
@@ -60,12 +60,12 @@ def cmd_start(argv: list[str]) -> int:
|
||||
|
||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||
|
||||
manifest = Manifest.resolve(USER_CWD)
|
||||
manifest = ManifestIndex.resolve(USER_CWD)
|
||||
|
||||
agent_name: str | None = args.name
|
||||
if agent_name is None:
|
||||
agent_name = tui.filter_select(
|
||||
sorted(manifest.agents.keys()),
|
||||
manifest.all_agent_names,
|
||||
title="Select agent",
|
||||
)
|
||||
if agent_name is None:
|
||||
@@ -73,7 +73,25 @@ def cmd_start(argv: list[str]) -> int:
|
||||
|
||||
backend_name: str | None = args.backend
|
||||
|
||||
# Bottle multiselect: always show after agent selection so operators
|
||||
# can compose bottles at launch time without editing agent manifests.
|
||||
available_bottles = manifest.all_bottle_names
|
||||
lineage_map = _bottle_lineage(manifest)
|
||||
display_labels = [lineage_map.get(n, n) for n in available_bottles]
|
||||
label_to_name = {lineage_map.get(n, n): n for n in available_bottles}
|
||||
initial_bottle = _peek_agent_bottle(manifest, agent_name)
|
||||
initial_labels = [lineage_map.get(initial_bottle, initial_bottle)] if initial_bottle else []
|
||||
selected_labels = tui.filter_multiselect(
|
||||
display_labels,
|
||||
title="Select bottles",
|
||||
initial=initial_labels,
|
||||
)
|
||||
if selected_labels is None:
|
||||
return 0
|
||||
bottle_names = tuple(label_to_name.get(lbl, lbl) for lbl in selected_labels)
|
||||
|
||||
label, color = tui.name_color_modal(default_label=agent_name)
|
||||
label, color = _resolve_unique_label(label, color)
|
||||
|
||||
spec = BottleSpec(
|
||||
manifest=manifest,
|
||||
@@ -82,11 +100,11 @@ def cmd_start(argv: list[str]) -> int:
|
||||
user_cwd=USER_CWD,
|
||||
label=label,
|
||||
color=color,
|
||||
bottle_names=bottle_names,
|
||||
)
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
dry_run=dry_run,
|
||||
remote_control=args.remote_control,
|
||||
backend_name=backend_name,
|
||||
)
|
||||
|
||||
@@ -131,7 +149,7 @@ def prepare_with_preflight(
|
||||
|
||||
|
||||
def attach_agent(
|
||||
bottle: Bottle, *, remote_control: bool = False, resume: bool = False,
|
||||
bottle: Bottle, *, resume: bool = False,
|
||||
agent_provider_template: str = "claude",
|
||||
startup_args: tuple[str, ...] = (),
|
||||
) -> int:
|
||||
@@ -150,8 +168,6 @@ def attach_agent(
|
||||
"(Ctrl-D or 'exit' to leave; container will be removed)"
|
||||
)
|
||||
agent_args = list(runtime.bypass_args)
|
||||
if remote_control:
|
||||
agent_args.extend(runtime.remote_control_args)
|
||||
agent_args.extend(startup_args)
|
||||
if resume:
|
||||
agent_args.extend(runtime.resume_args)
|
||||
@@ -191,6 +207,53 @@ def _identity_from_plan(plan: object) -> str:
|
||||
return getattr(plan, "slug", "")
|
||||
|
||||
|
||||
def _peek_agent_bottle(manifest: ManifestIndex, agent_name: str) -> str:
|
||||
"""Return the `bottle:` value from the named agent's frontmatter without
|
||||
fully parsing the agent file, or "" when absent or unreadable.
|
||||
|
||||
Used to pre-populate the bottle multiselect with the agent's default
|
||||
bottle so operators who haven't removed `bottle:` from their manifests
|
||||
don't need to re-select it every time."""
|
||||
if manifest.home_md is None:
|
||||
# Eager mode (from_json_obj): agent is pre-parsed.
|
||||
if agent_name in manifest.agents:
|
||||
return manifest.agents[agent_name].bottle
|
||||
return ""
|
||||
|
||||
from ..manifest_loader import scan_agent_names
|
||||
from ..yaml_subset import YamlSubsetError, parse_frontmatter
|
||||
|
||||
home_agents = scan_agent_names(manifest.home_md / "agents")
|
||||
cwd_agents: dict[str, Path] = {}
|
||||
if manifest.cwd_md is not None:
|
||||
cwd_agents = scan_agent_names(manifest.cwd_md / "agents")
|
||||
merged = {**home_agents, **cwd_agents}
|
||||
path = merged.get(agent_name)
|
||||
if path is None:
|
||||
return ""
|
||||
try:
|
||||
fm, _ = parse_frontmatter(path.read_text())
|
||||
bottle = fm.get("bottle", "")
|
||||
return str(bottle) if isinstance(bottle, str) else ""
|
||||
except (OSError, YamlSubsetError):
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
|
||||
"""Re-prompt with a disclaimer until the label's slug is not already
|
||||
in use among running bottles. Passes through unchanged when no
|
||||
collision is found on the first check."""
|
||||
while True:
|
||||
slug_candidate = docker_mod.slugify(label)
|
||||
active_slugs = {a.slug for a in enumerate_active_agents()}
|
||||
if slug_candidate not in active_slugs:
|
||||
return label, color
|
||||
label, color = tui.name_color_modal(
|
||||
default_label=label,
|
||||
disclaimer=f'"{label}" is already in use',
|
||||
)
|
||||
|
||||
|
||||
def _text_prompt_yes() -> bool:
|
||||
"""Default `prompt_yes` for CLI use: reads y/N from the
|
||||
controlling tty via stderr prompt + tty-line read."""
|
||||
@@ -200,17 +263,118 @@ def _text_prompt_yes() -> bool:
|
||||
return reply in ("y", "Y", "yes", "YES")
|
||||
|
||||
|
||||
def _text_render_preflight(*, remote_control: bool):
|
||||
def _text_render_preflight():
|
||||
def _render(plan: DockerBottlePlan) -> None:
|
||||
plan.print(remote_control=remote_control)
|
||||
print(file=sys.stderr)
|
||||
print(_manifest_to_yaml(plan.manifest), file=sys.stderr)
|
||||
return _render
|
||||
|
||||
|
||||
def _bottle_lineage(manifest: ManifestIndex) -> dict[str, str]:
|
||||
"""Return {bottle_name: lineage_label} for bottles that have an extends chain.
|
||||
|
||||
Bottles without a parent are omitted (the caller falls back to the bare name).
|
||||
Labels show the chain root-first: e.g. 'dev -> bot-bottle-dev -> claude-dev'."""
|
||||
if manifest.home_md is None:
|
||||
return {}
|
||||
bottles_dir = manifest.home_md / "bottles"
|
||||
if not bottles_dir.is_dir():
|
||||
return {}
|
||||
|
||||
from ..yaml_subset import YamlSubsetError, parse_frontmatter
|
||||
|
||||
extends_of: dict[str, str] = {}
|
||||
for path in bottles_dir.glob("*.md"):
|
||||
try:
|
||||
fm, _ = parse_frontmatter(path.read_text())
|
||||
parent = fm.get("extends", "")
|
||||
if isinstance(parent, str) and parent:
|
||||
extends_of[path.stem] = parent
|
||||
except (OSError, YamlSubsetError):
|
||||
pass
|
||||
|
||||
labels: dict[str, str] = {}
|
||||
for name in extends_of:
|
||||
chain = [name]
|
||||
seen = {name}
|
||||
cur = name
|
||||
while cur in extends_of:
|
||||
par = extends_of[cur]
|
||||
if par in seen:
|
||||
break
|
||||
chain.append(par)
|
||||
seen.add(par)
|
||||
cur = par
|
||||
labels[name] = " -> ".join(reversed(chain))
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
def _manifest_to_yaml(manifest: Manifest) -> str:
|
||||
"""Serialize the resolved Manifest to a YAML string for preflight display."""
|
||||
lines: list[str] = []
|
||||
|
||||
agent = manifest.agent
|
||||
lines.append("agent:")
|
||||
if agent.skills:
|
||||
lines.append(" skills:")
|
||||
for s in agent.skills:
|
||||
lines.append(f" - {s}")
|
||||
if not agent.git_user.is_empty():
|
||||
lines.append(" git-gate:")
|
||||
lines.append(" user:")
|
||||
if agent.git_user.name:
|
||||
lines.append(f" name: {agent.git_user.name}")
|
||||
if agent.git_user.email:
|
||||
lines.append(f" email: {agent.git_user.email}")
|
||||
|
||||
bottle = manifest.bottle
|
||||
lines.append("bottle:")
|
||||
|
||||
if bottle.agent_provider.template != "claude" or bottle.agent_provider.dockerfile:
|
||||
lines.append(" agent_provider:")
|
||||
lines.append(f" template: {bottle.agent_provider.template}")
|
||||
if bottle.agent_provider.dockerfile:
|
||||
lines.append(f" dockerfile: {bottle.agent_provider.dockerfile}")
|
||||
|
||||
if bottle.env:
|
||||
lines.append(" env:")
|
||||
for k, v in sorted(bottle.env.items()):
|
||||
lines.append(f" {k}: {v}")
|
||||
|
||||
has_git_gate = not bottle.git_user.is_empty() or bottle.git
|
||||
if has_git_gate:
|
||||
lines.append(" git-gate:")
|
||||
if not bottle.git_user.is_empty():
|
||||
lines.append(" user:")
|
||||
if bottle.git_user.name:
|
||||
lines.append(f" name: {bottle.git_user.name}")
|
||||
if bottle.git_user.email:
|
||||
lines.append(f" email: {bottle.git_user.email}")
|
||||
if bottle.git:
|
||||
lines.append(" repos:")
|
||||
for entry in bottle.git:
|
||||
lines.append(f" {entry.Name}:")
|
||||
lines.append(f" url: {entry.Upstream}")
|
||||
|
||||
if bottle.egress.routes:
|
||||
lines.append(" egress:")
|
||||
lines.append(" routes:")
|
||||
for r in bottle.egress.routes:
|
||||
lines.append(f" - host: {r.Host}")
|
||||
if r.AuthScheme:
|
||||
lines.append(f" auth:")
|
||||
lines.append(f" scheme: {r.AuthScheme}")
|
||||
|
||||
lines.append(f" supervise: {'true' if bottle.supervise else 'false'}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _launch_bottle(
|
||||
spec: BottleSpec,
|
||||
*,
|
||||
dry_run: bool,
|
||||
remote_control: bool,
|
||||
backend_name: str | None = None,
|
||||
) -> int:
|
||||
"""Shared launch core for `start` and `resume`. Builds the plan,
|
||||
@@ -222,7 +386,7 @@ def _launch_bottle(
|
||||
plan, identity = prepare_with_preflight(
|
||||
spec,
|
||||
stage_dir=stage_dir,
|
||||
render_preflight=_text_render_preflight(remote_control=remote_control),
|
||||
render_preflight=_text_render_preflight(),
|
||||
prompt_yes=_text_prompt_yes,
|
||||
dry_run=dry_run,
|
||||
backend_name=backend_name,
|
||||
@@ -235,7 +399,6 @@ def _launch_bottle(
|
||||
agent_provider_template = getattr(plan, "agent_provider_template", "claude")
|
||||
exit_code = attach_agent(
|
||||
bottle,
|
||||
remote_control=remote_control,
|
||||
agent_provider_template=agent_provider_template,
|
||||
startup_args=plan.agent_provision.startup_args,
|
||||
)
|
||||
@@ -245,12 +408,8 @@ def _launch_bottle(
|
||||
)
|
||||
# While the container is still alive: always snapshot the
|
||||
# transcript and — if the agent exited non-zero — mark
|
||||
# the state for preservation. Capability-block already
|
||||
# did both before triggering teardown from the dashboard;
|
||||
# this picks up crashes / Ctrl-Cs / OOM kills the same
|
||||
# way. snapshot_transcript is best-effort so the
|
||||
# capability-block path's prior snapshot isn't clobbered
|
||||
# when the container is already gone.
|
||||
# the state for preservation. This picks up crashes /
|
||||
# Ctrl-Cs / OOM kills before cleanup removes the state dir.
|
||||
if agent_provider_template == "claude":
|
||||
capture_claude_session_state(identity, exit_code)
|
||||
return 0
|
||||
|
||||
+85
-43
@@ -2,8 +2,8 @@
|
||||
act on them (approve / modify / reject).
|
||||
|
||||
Curses-based TUI; modify-then-approve shells out to $EDITOR. The
|
||||
approval handler wires to PRD 0016 (capability-block), which rebuilds
|
||||
the bottle Dockerfile. The egress-block tool was removed in issue #198.
|
||||
Egress proposals are queued for operator review as full routes.yaml
|
||||
updates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,17 +20,19 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .. import supervise as _supervise
|
||||
# from ..bottle_state import read_metadata
|
||||
# from ..backend.docker.capability_apply import (
|
||||
# CapabilityApplyError,
|
||||
# apply_capability_change,
|
||||
# )
|
||||
from ..bottle_state import read_metadata
|
||||
from ..backend.docker.egress_apply import (
|
||||
EgressApplyError,
|
||||
applicator as _docker_applicator,
|
||||
)
|
||||
from ..backend.macos_container.egress_apply import (
|
||||
applicator as _macos_applicator,
|
||||
)
|
||||
from ..backend.smolmachines.egress_apply import (
|
||||
applicator as _smolmachines_applicator,
|
||||
)
|
||||
from ..log import Die, error, info
|
||||
|
||||
|
||||
class CapabilityApplyError(RuntimeError):
|
||||
"""Placeholder while capability_apply is disabled."""
|
||||
|
||||
from ..supervise import (
|
||||
COMPONENT_FOR_TOOL,
|
||||
AuditEntry,
|
||||
@@ -39,8 +41,10 @@ from ..supervise import (
|
||||
STATUS_APPROVED,
|
||||
STATUS_MODIFIED,
|
||||
STATUS_REJECTED,
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
archive_proposal,
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
list_pending_proposals,
|
||||
render_diff,
|
||||
write_audit_entry,
|
||||
@@ -51,6 +55,11 @@ from ._common import PROG
|
||||
|
||||
_REFRESH_INTERVAL_MS = 1000
|
||||
|
||||
# Proposal tools whose payload is a read-only report, not a file the operator
|
||||
# edits: modify is unavailable and approval requires a recorded reason for the
|
||||
# audit trail.
|
||||
_REPORT_ONLY_TOOLS: tuple[str, ...] = (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QueuedProposal:
|
||||
@@ -63,7 +72,17 @@ class QueuedProposal:
|
||||
# Errors any remediation engine may raise. Caught by the TUI key
|
||||
# handlers and surfaced in the status line so a failed apply keeps
|
||||
# the proposal pending rather than crashing curses.
|
||||
ApplyError = (CapabilityApplyError,)
|
||||
ApplyError = (EgressApplyError,)
|
||||
|
||||
|
||||
def apply_routes_change(slug: str, content: str) -> tuple[str, str]:
|
||||
meta = read_metadata(slug)
|
||||
backend = meta.backend if meta is not None else ""
|
||||
if backend == "macos-container":
|
||||
return _macos_applicator.apply_routes_change(slug, content)
|
||||
if backend == "smolmachines":
|
||||
return _smolmachines_applicator.apply_routes_change(slug, content)
|
||||
return _docker_applicator.apply_routes_change(slug, content)
|
||||
|
||||
|
||||
def discover_pending() -> list[QueuedProposal]:
|
||||
@@ -113,8 +132,10 @@ def _detail_lines(
|
||||
|
||||
|
||||
def _suffix_for_tool(tool: str) -> str:
|
||||
if tool == TOOL_CAPABILITY_BLOCK:
|
||||
return ".dockerfile"
|
||||
if tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
||||
return ".yaml"
|
||||
if tool in (TOOL_GITLEAKS_ALLOW, TOOL_EGRESS_TOKEN_ALLOW):
|
||||
return ".txt"
|
||||
return ".txt"
|
||||
|
||||
|
||||
@@ -129,19 +150,14 @@ def approve(
|
||||
) -> None:
|
||||
"""Apply the proposal, write the waiting response, and audit it."""
|
||||
status = STATUS_MODIFIED if final_file is not None else STATUS_APPROVED
|
||||
file_to_apply = final_file if final_file is not None else qp.proposal.proposed_file
|
||||
|
||||
diff_before, diff_after = "", ""
|
||||
# if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||
# _meta = read_metadata(qp.proposal.bottle_slug)
|
||||
# if _meta is not None and not _meta.compose_project:
|
||||
# raise CapabilityApplyError(
|
||||
# "capability-block remediation is not supported for smolmachines "
|
||||
# "bottles. Reject this proposal or handle the capability change "
|
||||
# "manually, then restart the bottle."
|
||||
# )
|
||||
# diff_before, diff_after = apply_capability_change(
|
||||
# qp.proposal.bottle_slug, file_to_apply,
|
||||
# )
|
||||
if qp.proposal.tool in (TOOL_EGRESS_ALLOW, TOOL_EGRESS_BLOCK):
|
||||
diff_before, diff_after = apply_routes_change(
|
||||
qp.proposal.bottle_slug,
|
||||
file_to_apply,
|
||||
)
|
||||
|
||||
response = Response(
|
||||
proposal_id=qp.proposal.id,
|
||||
@@ -154,9 +170,6 @@ def approve(
|
||||
qp, action=status, notes=notes,
|
||||
diff_before=diff_before, diff_after=diff_after,
|
||||
)
|
||||
if qp.proposal.tool == TOOL_CAPABILITY_BLOCK:
|
||||
archive_proposal(qp.queue_dir, qp.proposal.id)
|
||||
|
||||
|
||||
def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||
"""Write a rejection response and an audit entry."""
|
||||
@@ -170,6 +183,23 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
|
||||
_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 in _REPORT_ONLY_TOOLS and final_file is None:
|
||||
notes = _prompt(stdscr, "allow reason (false positive / legitimately needed): ")
|
||||
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(
|
||||
qp: QueuedProposal,
|
||||
*,
|
||||
@@ -241,7 +271,10 @@ def cmd_supervise(argv: list[str]) -> int:
|
||||
return e.code if isinstance(e.code, int) else 1
|
||||
except Exception as e: # noqa: W0718 — catch supervise crash for logging
|
||||
log_path = _write_crash_log(e)
|
||||
error(f"supervise crashed: {type(e).__name__}: {e}")
|
||||
error(
|
||||
f"supervise crashed: {type(e).__name__}: {e}",
|
||||
context={"error_type": type(e).__name__, "crash_log": str(log_path)},
|
||||
)
|
||||
error(f"full traceback written to {log_path}")
|
||||
return 1
|
||||
return 0
|
||||
@@ -286,7 +319,7 @@ def _list_once() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _try_init_green() -> int:
|
||||
def _try_init_green() -> int: # pragma: no cover
|
||||
"""Initialise a green color pair and return its attr, or 0."""
|
||||
try:
|
||||
curses.start_color()
|
||||
@@ -297,7 +330,7 @@ def _try_init_green() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
|
||||
def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore # pragma: no cover
|
||||
curses.curs_set(0)
|
||||
stdscr.timeout(_REFRESH_INTERVAL_MS)
|
||||
green_attr = _try_init_green()
|
||||
@@ -353,18 +386,22 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
|
||||
_detail_view(stdscr, qp, green_attr=green_attr)
|
||||
elif key == ord("a"):
|
||||
try:
|
||||
approve(qp)
|
||||
status_line = _approval_status(qp, "approved")
|
||||
status_line = _approve_from_tui(stdscr, qp)
|
||||
except ApplyError as e:
|
||||
status_line = f"apply failed: {e}"
|
||||
elif key == ord("m"):
|
||||
if qp.proposal.tool in _REPORT_ONLY_TOOLS:
|
||||
status_line = f"modify unavailable for {qp.proposal.tool}"
|
||||
continue
|
||||
edited = _modify(stdscr, qp)
|
||||
if edited is None:
|
||||
status_line = "modify aborted (no change)"
|
||||
else:
|
||||
try:
|
||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
||||
status_line = _approval_status(qp, "modified+approved")
|
||||
status_line = _approve_from_tui(
|
||||
stdscr, qp, final_file=edited,
|
||||
notes="operator modified before approving",
|
||||
)
|
||||
except ApplyError as e:
|
||||
status_line = f"apply failed: {e}"
|
||||
elif key == ord("r"):
|
||||
@@ -383,7 +420,7 @@ def _render(
|
||||
status_line: str,
|
||||
*,
|
||||
green_attr: int = 0, # noqa: F841 — unused, but required by interface
|
||||
) -> None:
|
||||
) -> None: # pragma: no cover
|
||||
stdscr.erase()
|
||||
h, w = stdscr.getmaxyx()
|
||||
header = f"bot-bottle supervise ({len(pending)} pending)"
|
||||
@@ -434,7 +471,7 @@ def _detail_view(
|
||||
qp: QueuedProposal,
|
||||
*,
|
||||
green_attr: int = 0,
|
||||
) -> None:
|
||||
) -> None: # pragma: no cover
|
||||
"""Render the full proposal. Scrollable. Press q to return."""
|
||||
lines = _detail_lines(qp, green_attr=green_attr)
|
||||
offset = 0
|
||||
@@ -462,15 +499,20 @@ def _detail_view(
|
||||
offset = max(0, len(lines) - 1)
|
||||
elif key == ord("a"):
|
||||
try:
|
||||
approve(qp)
|
||||
_approve_from_tui(stdscr, qp)
|
||||
except ApplyError:
|
||||
pass
|
||||
return
|
||||
elif key == ord("m"):
|
||||
if qp.proposal.tool in _REPORT_ONLY_TOOLS:
|
||||
return
|
||||
edited = _modify(stdscr, qp)
|
||||
if edited is not None:
|
||||
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:
|
||||
pass
|
||||
return
|
||||
@@ -481,7 +523,7 @@ def _detail_view(
|
||||
return
|
||||
|
||||
|
||||
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore
|
||||
def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None: # type: ignore # pragma: no cover
|
||||
"""Suspend curses, open $EDITOR on the proposed file, return edited content."""
|
||||
suffix = _suffix_for_tool(qp.proposal.tool)
|
||||
curses.endwin()
|
||||
@@ -492,7 +534,7 @@ def _modify(stdscr: "curses._CursesWindow", qp: QueuedProposal) -> str | None:
|
||||
return edited
|
||||
|
||||
|
||||
def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: # type: ignore
|
||||
def _prompt(stdscr: "curses._CursesWindow", label: str) -> str: # type: ignore # pragma: no cover
|
||||
"""One-line input at the bottom of the screen."""
|
||||
curses.curs_set(1)
|
||||
h, _ = stdscr.getmaxyx()
|
||||
|
||||
+311
-19
@@ -17,6 +17,43 @@ import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def filter_multiselect(
|
||||
items: list[str],
|
||||
*,
|
||||
title: str = "",
|
||||
initial: Optional[list[str]] = None,
|
||||
tty_path: str = "/dev/tty",
|
||||
) -> Optional[list[str]]:
|
||||
"""Render a multi-select picker over *items*.
|
||||
|
||||
Returns the ordered list of selected items, or ``None`` if the user
|
||||
cancelled (Esc / ``q`` / Ctrl-C / Ctrl-D with no items).
|
||||
|
||||
Press Space to toggle the item under the cursor.
|
||||
Press Enter to confirm the current selection.
|
||||
Press Ctrl-D to confirm the current selection (returns even if empty).
|
||||
Press Esc/q to cancel (returns None).
|
||||
|
||||
*initial* pre-populates the selection in insertion order. Items
|
||||
added are appended; removed items leave the remaining order unchanged.
|
||||
"""
|
||||
if not items:
|
||||
return []
|
||||
|
||||
try:
|
||||
tty_fd = open(tty_path, "r+b", buffering=0)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
try:
|
||||
fd_dup = os.dup(tty_fd.fileno())
|
||||
return _run_multiselect(
|
||||
items, title=title, initial=list(initial or []), tty_fd=fd_dup
|
||||
)
|
||||
finally:
|
||||
tty_fd.close()
|
||||
|
||||
|
||||
def filter_select(
|
||||
items: list[str],
|
||||
*,
|
||||
@@ -221,25 +258,275 @@ def _addstr_safe(screen: Any, row: int, col: int, text: str, attr: int = curses.
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# filter_multiselect internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_KEY_SPACE = 32
|
||||
|
||||
|
||||
def _run_multiselect(
|
||||
items: list[str], *, title: str, initial: list[str], tty_fd: int
|
||||
) -> Optional[list[str]]:
|
||||
"""Drive a curses multi-select session on *tty_fd*."""
|
||||
os.environ.setdefault("TERM", "xterm-256color")
|
||||
|
||||
orig_stdin = sys.__stdin__
|
||||
orig_stdout = sys.__stdout__
|
||||
|
||||
try:
|
||||
import io
|
||||
tty_text = io.TextIOWrapper(io.FileIO(tty_fd, mode='r+'), write_through=True)
|
||||
sys.__stdin__ = tty_text # type: ignore[assignment]
|
||||
sys.__stdout__ = tty_text # type: ignore[assignment]
|
||||
|
||||
screen = curses.initscr()
|
||||
curses.noecho()
|
||||
curses.cbreak()
|
||||
screen.keypad(True)
|
||||
|
||||
try:
|
||||
result = _multiselect_loop(screen, items, title=title, initial=initial)
|
||||
finally:
|
||||
screen.keypad(False)
|
||||
curses.nocbreak()
|
||||
curses.echo()
|
||||
curses.endwin()
|
||||
except Exception: # noqa: W0718
|
||||
return None
|
||||
finally:
|
||||
sys.__stdin__ = orig_stdin # type: ignore[assignment]
|
||||
sys.__stdout__ = orig_stdout # type: ignore[assignment]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _multiselect_loop(
|
||||
screen: Any, items: list[str], *, title: str, initial: list[str]
|
||||
) -> Optional[list[str]]:
|
||||
query = ""
|
||||
cursor = 0
|
||||
selected: list[str] = [s for s in initial if s in items]
|
||||
# focus = "filter": navigate + toggle items in the filterable list
|
||||
# focus = "order": navigate + reorder items in the selected list
|
||||
focus = "filter"
|
||||
order_cursor = 0
|
||||
|
||||
while True:
|
||||
filtered = _filter_items(items, query)
|
||||
|
||||
if not filtered:
|
||||
cursor = 0
|
||||
elif cursor >= len(filtered):
|
||||
cursor = len(filtered) - 1
|
||||
|
||||
if not selected:
|
||||
order_cursor = 0
|
||||
if focus == "order":
|
||||
focus = "filter"
|
||||
elif order_cursor >= len(selected):
|
||||
order_cursor = len(selected) - 1
|
||||
|
||||
try:
|
||||
_render_multiselect(
|
||||
screen, filtered, cursor,
|
||||
query=query, title=title, selected=selected,
|
||||
focus=focus, order_cursor=order_cursor,
|
||||
)
|
||||
except curses.error:
|
||||
return None
|
||||
|
||||
try:
|
||||
key = screen.getch()
|
||||
except KeyboardInterrupt:
|
||||
return None
|
||||
|
||||
if key in (_KEY_ESC, _KEY_CTRL_C, ord("q")):
|
||||
return None
|
||||
|
||||
if key == _KEY_CTRL_D:
|
||||
return list(selected)
|
||||
|
||||
# Tab toggles between filter and order focus.
|
||||
if key == ord("\t"):
|
||||
if focus == "filter" and selected:
|
||||
focus = "order"
|
||||
order_cursor = 0
|
||||
else:
|
||||
focus = "filter"
|
||||
continue
|
||||
|
||||
if focus == "filter":
|
||||
if key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r")):
|
||||
return list(selected)
|
||||
|
||||
elif key == _KEY_SPACE:
|
||||
if filtered:
|
||||
item = filtered[cursor]
|
||||
if item in selected:
|
||||
selected.remove(item)
|
||||
else:
|
||||
selected.append(item)
|
||||
|
||||
elif key in (curses.KEY_UP, ord("k")):
|
||||
if cursor > 0:
|
||||
cursor -= 1
|
||||
|
||||
elif key in (curses.KEY_DOWN, ord("j")):
|
||||
if cursor < len(filtered) - 1:
|
||||
cursor += 1
|
||||
|
||||
elif key in (curses.KEY_BACKSPACE, _KEY_BACKSPACE_WIN, 127):
|
||||
query = query[:-1]
|
||||
new_filtered = _filter_items(items, query)
|
||||
if cursor >= len(new_filtered):
|
||||
cursor = max(0, len(new_filtered) - 1)
|
||||
|
||||
elif 32 <= key <= 126 and key != _KEY_SPACE:
|
||||
query += chr(key)
|
||||
cursor = 0
|
||||
|
||||
else: # focus == "order"
|
||||
if key in (curses.KEY_UP, ord("k")):
|
||||
if order_cursor > 0:
|
||||
order_cursor -= 1
|
||||
|
||||
elif key in (curses.KEY_DOWN, ord("j")):
|
||||
if order_cursor < len(selected) - 1:
|
||||
order_cursor += 1
|
||||
|
||||
elif key == ord("K"):
|
||||
# Move selected item up (earlier in order).
|
||||
if order_cursor > 0:
|
||||
i = order_cursor
|
||||
selected[i - 1], selected[i] = selected[i], selected[i - 1]
|
||||
order_cursor -= 1
|
||||
|
||||
elif key == ord("J"):
|
||||
# Move selected item down (later in order).
|
||||
if order_cursor < len(selected) - 1:
|
||||
i = order_cursor
|
||||
selected[i], selected[i + 1] = selected[i + 1], selected[i]
|
||||
order_cursor += 1
|
||||
|
||||
elif key in (curses.KEY_ENTER, _KEY_ENTER_ALT, ord("\r"), _KEY_SPACE):
|
||||
# Remove item from selection while in order mode.
|
||||
del selected[order_cursor]
|
||||
if order_cursor >= len(selected) and order_cursor > 0:
|
||||
order_cursor -= 1
|
||||
|
||||
|
||||
def _render_multiselect(
|
||||
screen: Any,
|
||||
filtered: list[str],
|
||||
cursor: int,
|
||||
*,
|
||||
query: str,
|
||||
title: str,
|
||||
selected: list[str],
|
||||
focus: str = "filter",
|
||||
order_cursor: int = 0,
|
||||
) -> None:
|
||||
screen.erase()
|
||||
rows, cols = screen.getmaxyx()
|
||||
min_rows = 7
|
||||
|
||||
if rows < min_rows:
|
||||
raise curses.error("terminal too small")
|
||||
|
||||
sep = "─" * min(cols - 1, 40)
|
||||
row = 0
|
||||
|
||||
if title and row < rows - 1:
|
||||
_addstr_safe(screen, row, 0, title[:cols - 1], curses.A_BOLD)
|
||||
row += 1
|
||||
|
||||
# Filter line — dim when focus is on the order panel.
|
||||
filter_label = f"Filter: {query}"
|
||||
filter_hint = " [Tab: reorder]" if focus == "filter" and selected else ""
|
||||
filter_attr = curses.A_DIM if focus == "order" else curses.A_NORMAL
|
||||
if row < rows - 1:
|
||||
_addstr_safe(screen, row, 0, (filter_label + filter_hint)[:cols - 1], filter_attr)
|
||||
row += 1
|
||||
|
||||
if row < rows - 1:
|
||||
_addstr_safe(screen, row, 0, sep)
|
||||
row += 1
|
||||
|
||||
# Compute how many rows the bottom order panel needs.
|
||||
# Cap the visible selected list to keep the filter list legible.
|
||||
order_rows = min(len(selected), max(1, (rows - row) // 3)) if selected else 0
|
||||
# Bottom reserved: sep + order_rows + sep + help = order_rows + 3
|
||||
bottom_reserved = order_rows + 3
|
||||
|
||||
list_start = row
|
||||
list_rows = rows - list_start - bottom_reserved
|
||||
if list_rows < 1:
|
||||
list_rows = 1
|
||||
|
||||
selected_set = set(selected)
|
||||
filter_dim = focus == "order"
|
||||
scroll = max(0, cursor - list_rows + 1)
|
||||
visible = filtered[scroll: scroll + list_rows]
|
||||
|
||||
for idx, item in enumerate(visible):
|
||||
abs_idx = scroll + idx
|
||||
mark = "[*]" if item in selected_set else "[ ]"
|
||||
prefix = "> " if (abs_idx == cursor and focus == "filter") else " "
|
||||
line = (prefix + mark + " " + item)[:cols - 1]
|
||||
item_attr = curses.A_DIM if filter_dim else (
|
||||
curses.A_REVERSE if abs_idx == cursor else curses.A_NORMAL
|
||||
)
|
||||
if row < rows - bottom_reserved:
|
||||
_addstr_safe(screen, row, 0, line, item_attr)
|
||||
row += 1
|
||||
|
||||
# Separator before the order panel.
|
||||
if row < rows - (order_rows + 2):
|
||||
_addstr_safe(screen, row, 0, sep)
|
||||
row += 1
|
||||
|
||||
# Order panel.
|
||||
order_scroll = max(0, order_cursor - order_rows + 1)
|
||||
order_visible = selected[order_scroll: order_scroll + order_rows]
|
||||
for idx, item in enumerate(order_visible):
|
||||
abs_idx = order_scroll + idx
|
||||
is_active = focus == "order" and abs_idx == order_cursor
|
||||
prefix = "> " if is_active else " "
|
||||
line = (prefix + item)[:cols - 1]
|
||||
attr = curses.A_REVERSE if is_active else curses.A_NORMAL
|
||||
if row < rows - 2:
|
||||
_addstr_safe(screen, row, 0, line, attr)
|
||||
row += 1
|
||||
|
||||
if row < rows - 1:
|
||||
_addstr_safe(screen, row, 0, sep)
|
||||
row += 1
|
||||
|
||||
if focus == "filter":
|
||||
help_line = "[↑↓/jk] move [Space] toggle [Enter] confirm [Tab] reorder [Esc/q] cancel"
|
||||
else:
|
||||
help_line = "[↑↓/jk] cursor [K/J] reorder [Space/Enter] remove [Tab] back [Ctrl-D] done"
|
||||
if row < rows:
|
||||
_addstr_safe(screen, min(rows - 1, row), 0, help_line[:cols - 1])
|
||||
|
||||
screen.refresh()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# name_color_modal — two-step label + color picker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ANSI_COLORS = [
|
||||
"red", "green", "blue", "yellow", "magenta", "cyan", "white", "black",
|
||||
"bright-red", "bright-green", "bright-blue", "bright-yellow",
|
||||
"bright-magenta", "bright-cyan", "bright-white", "bright-black",
|
||||
"red", "green", "yellow", "blue", "magenta",
|
||||
]
|
||||
|
||||
_CURSES_COLOR_MAP: dict[str, int] = {
|
||||
"black": curses.COLOR_BLACK,
|
||||
"red": curses.COLOR_RED,
|
||||
"green": curses.COLOR_GREEN,
|
||||
"yellow": curses.COLOR_YELLOW,
|
||||
"blue": curses.COLOR_BLUE,
|
||||
"magenta": curses.COLOR_MAGENTA,
|
||||
"cyan": curses.COLOR_CYAN,
|
||||
"white": curses.COLOR_WHITE,
|
||||
}
|
||||
|
||||
_COLOR_NONE = "(none)"
|
||||
@@ -248,11 +535,15 @@ _COLOR_NONE = "(none)"
|
||||
def name_color_modal(
|
||||
default_label: str,
|
||||
*,
|
||||
disclaimer: str = "",
|
||||
tty_path: str = "/dev/tty",
|
||||
) -> tuple[str, str]:
|
||||
"""Present a two-step curses modal: first edit the agent label,
|
||||
then optionally pick a color.
|
||||
|
||||
``disclaimer`` is shown below the input field — use it to surface
|
||||
an error from a previous attempt (e.g. name already in use).
|
||||
|
||||
Returns ``(label, color)`` where ``color`` is one of the 16 ANSI
|
||||
color name strings or ``""`` for no color. Falls back to
|
||||
``(default_label, "")`` on any error (terminal too small, not a tty).
|
||||
@@ -264,14 +555,14 @@ def name_color_modal(
|
||||
|
||||
try:
|
||||
fd_dup = os.dup(tty_fd.fileno())
|
||||
return _run_name_color(default_label, tty_fd=fd_dup)
|
||||
return _run_name_color(default_label, tty_fd=fd_dup, disclaimer=disclaimer)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
return default_label, ""
|
||||
finally:
|
||||
tty_fd.close()
|
||||
|
||||
|
||||
def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
||||
def _run_name_color(default_label: str, *, tty_fd: int, disclaimer: str = "") -> tuple[str, str]:
|
||||
import io
|
||||
orig_stdin = sys.__stdin__
|
||||
orig_stdout = sys.__stdout__
|
||||
@@ -286,7 +577,7 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
||||
curses.cbreak()
|
||||
screen.keypad(True)
|
||||
try:
|
||||
label = _label_step(screen, default_label)
|
||||
label = _label_step(screen, default_label, disclaimer=disclaimer)
|
||||
color = _color_step(screen, label)
|
||||
finally:
|
||||
screen.keypad(False)
|
||||
@@ -299,14 +590,14 @@ def _run_name_color(default_label: str, *, tty_fd: int) -> tuple[str, str]:
|
||||
return label, color
|
||||
|
||||
|
||||
def _label_step(screen: Any, default_label: str) -> str:
|
||||
def _label_step(screen: Any, default_label: str, *, disclaimer: str = "") -> str:
|
||||
"""Step 1: edit the label. First printable key replaces the
|
||||
pre-fill; subsequent keys append. Enter confirms."""
|
||||
text = default_label
|
||||
replaced = False # True once the user has typed their first char
|
||||
|
||||
while True:
|
||||
_render_label(screen, text)
|
||||
_render_label(screen, text, disclaimer=disclaimer)
|
||||
try:
|
||||
key = screen.getch()
|
||||
except KeyboardInterrupt:
|
||||
@@ -330,7 +621,7 @@ def _label_step(screen: Any, default_label: str) -> str:
|
||||
text += chr(key)
|
||||
|
||||
|
||||
def _render_label(screen: Any, text: str) -> None:
|
||||
def _render_label(screen: Any, text: str, *, disclaimer: str = "") -> None:
|
||||
screen.erase()
|
||||
rows, cols = screen.getmaxyx()
|
||||
sep = "─" * min(cols - 1, 40)
|
||||
@@ -338,8 +629,12 @@ def _render_label(screen: Any, text: str) -> None:
|
||||
_addstr_safe(screen, 1, 0, sep)
|
||||
_addstr_safe(screen, 2, 0, text[:cols - 1], curses.A_REVERSE)
|
||||
_addstr_safe(screen, 3, 0, sep)
|
||||
if rows > 5:
|
||||
_addstr_safe(screen, 5, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
|
||||
row = 4
|
||||
if disclaimer and rows > row + 1:
|
||||
_addstr_safe(screen, row, 0, disclaimer[:cols - 1], curses.A_BOLD)
|
||||
row += 1
|
||||
if rows > row + 1:
|
||||
_addstr_safe(screen, row, 0, "[any key] edit [Enter] confirm", curses.A_DIM)
|
||||
screen.refresh()
|
||||
|
||||
|
||||
@@ -379,13 +674,10 @@ def _init_color_pairs() -> dict[str, int]:
|
||||
curses.use_default_colors()
|
||||
pair_idx = 2 # pair 1 reserved for other uses
|
||||
for name in _ANSI_COLORS:
|
||||
base = name.replace("bright-", "")
|
||||
fg = _CURSES_COLOR_MAP.get(base, curses.COLOR_WHITE)
|
||||
fg = _CURSES_COLOR_MAP.get(name, curses.COLOR_WHITE)
|
||||
try:
|
||||
curses.init_pair(pair_idx, fg, -1)
|
||||
attr = curses.color_pair(pair_idx)
|
||||
if name.startswith("bright-"):
|
||||
attr |= curses.A_BOLD
|
||||
attr = curses.color_pair(pair_idx) | curses.A_BOLD
|
||||
attrs[name] = attr
|
||||
pair_idx += 1
|
||||
except curses.error:
|
||||
|
||||
@@ -21,7 +21,7 @@ FROM node:22-slim
|
||||
# to it) works against egress's bumped TLS without the agent needing
|
||||
# local DNS.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# App-specific deps. Python isn't required by claude-code itself
|
||||
@@ -36,7 +36,7 @@ RUN apt-get update \
|
||||
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
||||
# rolling forward; an unpinned install would mean rebuilds silently pick
|
||||
# up new behavior.
|
||||
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.126 \
|
||||
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Run as a non-root user. The node image already provides a `node` user
|
||||
|
||||
@@ -20,6 +20,7 @@ from ...agent_provider import (
|
||||
AgentProvisionDir,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
provider_startup_args,
|
||||
)
|
||||
from ...backend.docker import util as docker_mod
|
||||
from ...egress import EgressRoute
|
||||
@@ -42,41 +43,19 @@ def _prompt_path(guest_home: str) -> str:
|
||||
|
||||
|
||||
_STATUS_LINE_COLORS = {
|
||||
"black": "\033[30m",
|
||||
"red": "\033[31m",
|
||||
"green": "\033[32m",
|
||||
"yellow": "\033[33m",
|
||||
"blue": "\033[34m",
|
||||
"magenta": "\033[35m",
|
||||
"cyan": "\033[36m",
|
||||
"white": "\033[37m",
|
||||
"bright-black": "\033[90m",
|
||||
"bright-red": "\033[91m",
|
||||
"bright-green": "\033[92m",
|
||||
"bright-yellow": "\033[93m",
|
||||
"bright-blue": "\033[94m",
|
||||
"bright-magenta": "\033[95m",
|
||||
"bright-cyan": "\033[96m",
|
||||
"bright-white": "\033[97m",
|
||||
"red": "\033[91m",
|
||||
"green": "\033[92m",
|
||||
"yellow": "\033[93m",
|
||||
"blue": "\033[94m",
|
||||
"magenta": "\033[95m",
|
||||
}
|
||||
|
||||
_CLAUDE_THEME_COLORS = {
|
||||
"black": "black",
|
||||
"red": "red",
|
||||
"green": "green",
|
||||
"yellow": "yellow",
|
||||
"blue": "blue",
|
||||
"magenta": "magenta",
|
||||
"cyan": "cyan",
|
||||
"white": "white",
|
||||
"bright-black": "blackBright",
|
||||
"bright-red": "redBright",
|
||||
"bright-green": "greenBright",
|
||||
"bright-yellow": "yellowBright",
|
||||
"bright-blue": "blueBright",
|
||||
"bright-magenta": "magentaBright",
|
||||
"bright-cyan": "cyanBright",
|
||||
"bright-white": "whiteBright",
|
||||
"red": "redBright",
|
||||
"green": "greenBright",
|
||||
"yellow": "yellowBright",
|
||||
"blue": "blueBright",
|
||||
"magenta": "magentaBright",
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +91,6 @@ _RUNTIME = AgentProviderRuntime(
|
||||
prompt_mode="append_file",
|
||||
bypass_args=("--dangerously-skip-permissions",),
|
||||
resume_args=("--continue",),
|
||||
remote_control_args=("--remote-control",),
|
||||
)
|
||||
|
||||
|
||||
@@ -137,8 +115,9 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
color: str = "",
|
||||
provider_settings: dict[str, object] | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
del forward_host_credentials, host_env, provider_settings
|
||||
del forward_host_credentials, host_env
|
||||
resolved_guest_env = dict(guest_env or {})
|
||||
startup_args = provider_startup_args(provider_settings)
|
||||
guest_home = self.guest_home
|
||||
trusted_path = trusted_project_path or guest_home
|
||||
|
||||
@@ -221,6 +200,7 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
env_vars=env_vars,
|
||||
guest_env=resolved_guest_env,
|
||||
has_prompt=has_prompt,
|
||||
startup_args=startup_args,
|
||||
dirs=dirs,
|
||||
files=tuple(files),
|
||||
egress_routes=egress_routes,
|
||||
@@ -233,7 +213,7 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
when the agent has no skills."""
|
||||
from ...backend.util import host_skill_dir
|
||||
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
agent = plan.manifest.agent
|
||||
if not agent.skills:
|
||||
return
|
||||
skills_dir = _skills_dir(plan.guest_home)
|
||||
@@ -262,7 +242,7 @@ class ClaudeAgentProvider(AgentProvider):
|
||||
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||
user="root",
|
||||
)
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
agent = plan.manifest.agent
|
||||
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
|
||||
|
||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# bot-bottle Codex provider image.
|
||||
#
|
||||
# Mirrors the default Claude image shape: Node LTS, git/network tooling,
|
||||
# non-root node user, and the provider CLI installed globally.
|
||||
# non-root node user, and the provider CLI installed for that user.
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates curl procps ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# App-specific deps. Python isn't required by codex itself
|
||||
@@ -17,12 +17,15 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 python3-pip python3-venv \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g --no-fund --no-audit @openai/codex@0.136.0 \
|
||||
&& npm cache clean --force
|
||||
|
||||
USER node
|
||||
WORKDIR /home/node
|
||||
|
||||
RUN mkdir -p /home/node/.codex
|
||||
ENV PATH="/home/node/.local/bin:${PATH}"
|
||||
|
||||
# Remote-control support requires the standalone Codex install layout
|
||||
# under ~/.codex/packages/standalone/current. The npm package can run
|
||||
# the TUI, but remote-control commands expect this installer-owned path.
|
||||
RUN mkdir -p /home/node/.codex \
|
||||
&& curl -fsSL https://chatgpt.com/codex/install.sh | sh
|
||||
|
||||
CMD ["codex"]
|
||||
|
||||
@@ -22,6 +22,7 @@ from ...agent_provider import (
|
||||
AgentProvisionCommand,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
provider_startup_args,
|
||||
)
|
||||
from .codex_auth import codex_host_access_token, write_codex_dummy_auth_file
|
||||
from ...egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute
|
||||
@@ -54,7 +55,6 @@ _RUNTIME = AgentProviderRuntime(
|
||||
prompt_mode="read_prompt_file",
|
||||
bypass_args=("--dangerously-bypass-approvals-and-sandbox",),
|
||||
resume_args=("resume", "--last"),
|
||||
remote_control_args=(),
|
||||
)
|
||||
|
||||
|
||||
@@ -79,8 +79,9 @@ class CodexAgentProvider(AgentProvider):
|
||||
color: str = "",
|
||||
provider_settings: dict[str, object] | None = None,
|
||||
) -> AgentProvisionPlan:
|
||||
del auth_token, label, color, provider_settings
|
||||
del auth_token, label, color
|
||||
resolved_guest_env = dict(guest_env or {})
|
||||
startup_args = provider_startup_args(provider_settings)
|
||||
guest_home = self.guest_home
|
||||
trusted_path = trusted_project_path or guest_home
|
||||
|
||||
@@ -163,6 +164,7 @@ class CodexAgentProvider(AgentProvider):
|
||||
env_vars=env_vars,
|
||||
guest_env=resolved_guest_env,
|
||||
has_prompt=has_prompt,
|
||||
startup_args=startup_args,
|
||||
dirs=tuple(dirs),
|
||||
files=tuple(files),
|
||||
pre_copy=tuple(pre_copy),
|
||||
@@ -177,7 +179,7 @@ class CodexAgentProvider(AgentProvider):
|
||||
skills."""
|
||||
from ...backend.util import host_skill_dir
|
||||
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
agent = plan.manifest.agent
|
||||
if not agent.skills:
|
||||
return
|
||||
skills_dir = _skills_dir(plan.guest_home)
|
||||
@@ -206,7 +208,7 @@ class CodexAgentProvider(AgentProvider):
|
||||
f"chown node:node {prompt_path} && chmod 600 {prompt_path}",
|
||||
user="root",
|
||||
)
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
agent = plan.manifest.agent
|
||||
return prompt_path if plan.agent_provision.has_prompt or agent.prompt else None
|
||||
|
||||
def provision(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
@@ -261,8 +263,8 @@ class CodexAgentProvider(AgentProvider):
|
||||
return
|
||||
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
||||
r = bottle.exec(
|
||||
f"codex mcp add --transport http "
|
||||
f"{_SUPERVISE_MCP_NAME} {supervise_url}",
|
||||
f"codex mcp add {_SUPERVISE_MCP_NAME} --url "
|
||||
f"{shlex.quote(supervise_url)}",
|
||||
user="node",
|
||||
)
|
||||
if r.returncode != 0:
|
||||
@@ -270,7 +272,7 @@ class CodexAgentProvider(AgentProvider):
|
||||
f"`codex mcp add supervise` failed (exit {r.returncode}): "
|
||||
f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
|
||||
f"register manually with: "
|
||||
f"codex mcp add --transport http supervise {supervise_url}"
|
||||
f"codex mcp add supervise --url {shlex.quote(supervise_url)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
|
||||
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
|
||||
|
||||
@@ -13,7 +19,12 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ...deploy_key_provisioner import DeployKeyProvisioner
|
||||
from ...deploy_key_provisioner import DeployKeyCollisionError, DeployKeyProvisioner
|
||||
|
||||
# Timeout for ssh-keygen and Gitea API HTTP calls. A hung Gitea instance at
|
||||
# prepare time would stall bottle launch indefinitely without this bound.
|
||||
_API_TIMEOUT_SECS = 30
|
||||
_KEYGEN_TIMEOUT_SECS = 10
|
||||
|
||||
|
||||
class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
||||
@@ -40,6 +51,7 @@ class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=_KEYGEN_TIMEOUT_SECS,
|
||||
)
|
||||
private_key = key_path.read_bytes()
|
||||
public_key = key_path.with_suffix(".pub").read_text().strip()
|
||||
@@ -61,10 +73,15 @@ class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
with urllib.request.urlopen(req, timeout=_API_TIMEOUT_SECS) as resp:
|
||||
body = json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
_body = _read_error_body(exc)
|
||||
if exc.code == 422:
|
||||
raise DeployKeyCollisionError(
|
||||
f"deploy key collision for {owner_repo!r} "
|
||||
f"(title={title!r}): key title or content already registered — {_body}"
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"failed to create deploy key for {owner_repo}: "
|
||||
f"HTTP {exc.code} — {_body}"
|
||||
@@ -87,7 +104,7 @@ class GiteaDeployKeyProvisioner(DeployKeyProvisioner):
|
||||
method="DELETE",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req):
|
||||
with urllib.request.urlopen(req, timeout=_API_TIMEOUT_SECS):
|
||||
pass
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
|
||||
@@ -21,6 +21,7 @@ from ...agent_provider import (
|
||||
AgentProvisionDir,
|
||||
AgentProvisionFile,
|
||||
AgentProvisionPlan,
|
||||
provider_startup_args,
|
||||
)
|
||||
from ...egress import EgressRoute
|
||||
from ...log import die, info
|
||||
@@ -165,7 +166,6 @@ _RUNTIME = AgentProviderRuntime(
|
||||
prompt_mode="append_system_prompt",
|
||||
bypass_args=(),
|
||||
resume_args=(),
|
||||
remote_control_args=(),
|
||||
)
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ class PiAgentProvider(AgentProvider):
|
||||
models_payload, base_url, api_key_env, models, provider_name = (
|
||||
_pi_models_json(settings)
|
||||
)
|
||||
extra_startup_args = provider_startup_args(provider_settings)
|
||||
models_file = state_dir / "pi-models.json"
|
||||
models_file.write_text(json.dumps(models_payload, indent=2) + "\n")
|
||||
models_file.chmod(0o600)
|
||||
@@ -219,6 +220,7 @@ class PiAgentProvider(AgentProvider):
|
||||
startup_args=(
|
||||
"--models",
|
||||
",".join(f"{provider_name}/{model}" for model in models),
|
||||
*extra_startup_args,
|
||||
),
|
||||
dirs=(AgentProvisionDir(f"{guest_home}/.pi/agent"),),
|
||||
files=(AgentProvisionFile(models_file, _models_path(guest_home)),),
|
||||
@@ -232,7 +234,7 @@ class PiAgentProvider(AgentProvider):
|
||||
def provision_skills(self, plan: "BottlePlan", bottle: "Bottle") -> None:
|
||||
from ...backend.util import host_skill_dir
|
||||
|
||||
agent = plan.spec.manifest.agents[plan.spec.agent_name]
|
||||
agent = plan.manifest.agent
|
||||
if not agent.skills:
|
||||
return
|
||||
skills_dir = _skills_dir(plan.guest_home)
|
||||
|
||||
@@ -11,6 +11,10 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class DeployKeyCollisionError(RuntimeError):
|
||||
"""Raised when a deploy key title or public key already exists on the repo."""
|
||||
|
||||
|
||||
class DeployKeyProvisioner(ABC):
|
||||
"""Manages a single deploy-key lifecycle on a remote forge."""
|
||||
|
||||
|
||||
+190
-7
@@ -15,6 +15,8 @@ import gzip
|
||||
import re
|
||||
import typing
|
||||
import unicodedata
|
||||
from math import log2
|
||||
from collections import Counter
|
||||
from urllib.parse import quote as url_quote
|
||||
|
||||
try:
|
||||
@@ -78,16 +80,27 @@ TOKEN_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
||||
)
|
||||
|
||||
|
||||
def scan_token_patterns(text: str, *, location: str = "body") -> ScanResult | None:
|
||||
def scan_token_patterns(
|
||||
text: str,
|
||||
*,
|
||||
location: str = "body",
|
||||
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||
) -> ScanResult | None:
|
||||
normalized = _normalize_text(text)
|
||||
for name, pattern in TOKEN_PATTERNS:
|
||||
m = pattern.search(normalized)
|
||||
if m is not None:
|
||||
for m in pattern.finditer(normalized):
|
||||
value = m.group(0)
|
||||
# A value the supervisor has approved (PRD 0062) is no longer a
|
||||
# block — keep scanning so a second, un-approved token in the
|
||||
# same request is still caught.
|
||||
if safe_tokens is not None and value in safe_tokens:
|
||||
continue
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason=f"{name} found in {location}",
|
||||
location=location,
|
||||
context=_snippet(text, m.start(), m.end()),
|
||||
context=_snippet(normalized, m.start(), m.end()),
|
||||
matched=value,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -96,20 +109,21 @@ def redact_tokens(
|
||||
text: str,
|
||||
*,
|
||||
env: typing.Mapping[str, str] | None = None,
|
||||
sensitive_prefixes: tuple[str, ...] = ("EGRESS_TOKEN_",),
|
||||
) -> str:
|
||||
"""Replace token pattern matches and (if env given) provisioned secrets with REDACT."""
|
||||
for _, pattern in TOKEN_PATTERNS:
|
||||
text = pattern.sub(REDACT, text)
|
||||
if env is not None:
|
||||
for key, value in env.items():
|
||||
if key.startswith("EGRESS_TOKEN_") and value:
|
||||
if any(key.startswith(p) for p in sensitive_prefixes) and value:
|
||||
for variant in _encoded_variants(value):
|
||||
text = text.replace(variant, REDACT)
|
||||
return text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Known secrets detector (Phase 1b)
|
||||
# Known secrets detector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _encoded_variants(secret: str) -> list[str]:
|
||||
@@ -150,26 +164,179 @@ def _encoded_variants(secret: str) -> list[str]:
|
||||
return variants
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fragmentation-resistant helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Minimum length of alnum projection for projection-based checks to run.
|
||||
# Short secrets produce too many false positives in projection space.
|
||||
_ALNUM_MIN_LEN = 8
|
||||
|
||||
# Minimum window length for the partial-substring sliding scan.
|
||||
PARTIAL_MATCH_MIN_LEN = 12
|
||||
|
||||
|
||||
def _alnum_projection(text: str) -> str:
|
||||
"""Return text with every non-alphanumeric character stripped.
|
||||
|
||||
Used for fragmentation-resistant matching: separator-injected secrets
|
||||
(spaces, hyphens, dots inserted between characters) are identical to
|
||||
their originals in alnum projection space.
|
||||
"""
|
||||
return "".join(c for c in text if c.isalnum())
|
||||
|
||||
|
||||
def _find_partial_window(secret_alnum: str, text_alnum: str, min_len: int) -> int | None:
|
||||
"""Return the position in text_alnum where any min_len-char window of
|
||||
secret_alnum first appears, or None.
|
||||
|
||||
Slides a window of width min_len across secret_alnum and searches for
|
||||
each window in text_alnum. The first hit position is returned.
|
||||
"""
|
||||
if len(secret_alnum) < min_len or len(text_alnum) < min_len:
|
||||
return None
|
||||
for i in range(len(secret_alnum) - min_len + 1):
|
||||
window = secret_alnum[i:i + min_len]
|
||||
pos = text_alnum.find(window)
|
||||
if pos >= 0:
|
||||
return pos
|
||||
return None
|
||||
|
||||
|
||||
def scan_known_secrets(
|
||||
text: str,
|
||||
*,
|
||||
location: str = "body",
|
||||
env: typing.Mapping[str, str] | None = None,
|
||||
sensitive_prefixes: tuple[str, ...] = ("EGRESS_TOKEN_",),
|
||||
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||
) -> ScanResult | None:
|
||||
if env is None:
|
||||
return None
|
||||
|
||||
# Pre-compute alnum projection of the scan text once; reused per secret.
|
||||
text_alnum: str | None = None
|
||||
|
||||
for key, value in env.items():
|
||||
if not key.startswith("EGRESS_TOKEN_") or not value:
|
||||
if not any(key.startswith(p) for p in sensitive_prefixes) or not value:
|
||||
continue
|
||||
|
||||
# Pass 1: exact match across encoded variants (original behaviour).
|
||||
approved_exact = False
|
||||
for variant in _encoded_variants(value):
|
||||
pos = text.find(variant)
|
||||
if pos >= 0:
|
||||
# The supervisor approves the exact encoded variant found
|
||||
# (PRD 0062); a different encoding of the same secret is a
|
||||
# fresh block.
|
||||
if safe_tokens is not None and variant in safe_tokens:
|
||||
approved_exact = True
|
||||
continue
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason=f"provisioned secret from {key} found in {location}",
|
||||
location=location,
|
||||
context=_snippet(text, pos, pos + len(variant)),
|
||||
matched=variant,
|
||||
)
|
||||
if approved_exact:
|
||||
# Exact match was found and approved; projection passes would
|
||||
# fire on the same value, so skip them for this secret.
|
||||
continue
|
||||
|
||||
# Pass 2 & 3: fragmentation-resistant projection checks.
|
||||
secret_alnum = _alnum_projection(value)
|
||||
if len(secret_alnum) < _ALNUM_MIN_LEN:
|
||||
continue
|
||||
|
||||
if text_alnum is None:
|
||||
text_alnum = _alnum_projection(text)
|
||||
|
||||
# Pass 2: full alnum-projection exact match (catches separator injection).
|
||||
pos2 = text_alnum.find(secret_alnum)
|
||||
if pos2 >= 0:
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason=(
|
||||
f"provisioned secret from {key} found in {location} "
|
||||
f"(fragmented match — separator injection)"
|
||||
),
|
||||
location=location,
|
||||
context=_snippet(text_alnum, pos2, pos2 + len(secret_alnum)),
|
||||
)
|
||||
|
||||
# Pass 3: sliding-window partial match (catches chunked-substring leaks).
|
||||
pos3 = _find_partial_window(secret_alnum, text_alnum, PARTIAL_MATCH_MIN_LEN)
|
||||
if pos3 is not None:
|
||||
return ScanResult(
|
||||
severity="block",
|
||||
reason=(
|
||||
f"provisioned secret from {key} found in {location} "
|
||||
f"(partial match — at least {PARTIAL_MATCH_MIN_LEN} consecutive "
|
||||
f"alphanumeric chars)"
|
||||
),
|
||||
location=location,
|
||||
context=_snippet(text_alnum, pos3, pos3 + PARTIAL_MATCH_MIN_LEN),
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entropy detector (warn-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sliding window size and step for the entropy scan.
|
||||
ENTROPY_WINDOW = 64
|
||||
ENTROPY_STEP = 32
|
||||
|
||||
# Bits-per-character threshold. Random ASCII printable ≈ 6.6 bits; random
|
||||
# lowercase hex ≈ 4 bits; random base64url ≈ 6 bits. 5.5 sits above
|
||||
# typical structured data (JSON, URLs) while staying below truly random
|
||||
# content.
|
||||
ENTROPY_BLOCK_THRESHOLD = 5.5
|
||||
|
||||
|
||||
def _shannon_entropy(text: str) -> float:
|
||||
if not text:
|
||||
return 0.0
|
||||
counts = Counter(text)
|
||||
n = len(text)
|
||||
return -sum((c / n) * log2(c / n) for c in counts.values())
|
||||
|
||||
|
||||
def scan_entropy(
|
||||
text: str,
|
||||
*,
|
||||
location: str = "body",
|
||||
window: int = ENTROPY_WINDOW,
|
||||
threshold: float = ENTROPY_BLOCK_THRESHOLD,
|
||||
) -> ScanResult | None:
|
||||
"""Warn-only detector: flag windows of `window` chars with Shannon entropy
|
||||
above `threshold` bits per character.
|
||||
|
||||
Never blocks; always returns severity='warn'. Disabled by default —
|
||||
routes must opt in via dlp.outbound_detectors=['entropy'].
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
step = max(1, window // 2)
|
||||
end = len(text)
|
||||
# Scan overlapping windows; also check the final tail if shorter than window.
|
||||
positions = list(range(0, end - window + 1, step))
|
||||
if end < window:
|
||||
positions = [0]
|
||||
elif (end - window) % step != 0:
|
||||
positions.append(end - window)
|
||||
for i in positions:
|
||||
chunk = text[i:i + window]
|
||||
if _shannon_entropy(chunk) >= threshold:
|
||||
return ScanResult(
|
||||
severity="warn",
|
||||
reason=f"high-entropy content in {location} (possible encrypted exfil)",
|
||||
location=location,
|
||||
context=_snippet(text, i, i + len(chunk)),
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -265,6 +432,14 @@ _CRLF_ENCODED_RE = re.compile(r"%0[dD]%0[aA]", re.ASCII)
|
||||
_CRLF_HEADER_INJECT_RE = re.compile(r"\r\n[A-Za-z][A-Za-z0-9\-]+\s*:", re.ASCII)
|
||||
|
||||
|
||||
def strip_crlf(text: str) -> str:
|
||||
"""Remove URL-encoded and literal CRLF injection sequences from a request
|
||||
surface (PRD 0062 redact policy). Used to scrub the request line / headers
|
||||
so the request can be forwarded instead of hard-blocked."""
|
||||
text = _CRLF_ENCODED_RE.sub("", text)
|
||||
return _CRLF_HEADER_INJECT_RE.sub(lambda m: m.group(0)[2:], text)
|
||||
|
||||
|
||||
def scan_crlf_injection(text: str) -> ScanResult | None:
|
||||
if _CRLF_ENCODED_RE.search(text):
|
||||
return ScanResult(
|
||||
@@ -280,12 +455,20 @@ def scan_crlf_injection(text: str) -> ScanResult | None:
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ENTROPY_BLOCK_THRESHOLD",
|
||||
"ENTROPY_WINDOW",
|
||||
"ENTROPY_STEP",
|
||||
"PARTIAL_MATCH_MIN_LEN",
|
||||
"REDACT",
|
||||
"SNIPPET_CONTEXT",
|
||||
"TOKEN_PATTERNS",
|
||||
"_alnum_projection",
|
||||
"_shannon_entropy",
|
||||
"redact_tokens",
|
||||
"scan_crlf_injection",
|
||||
"scan_entropy",
|
||||
"scan_known_secrets",
|
||||
"scan_naive_injection",
|
||||
"scan_token_patterns",
|
||||
"strip_crlf",
|
||||
]
|
||||
|
||||
+105
-12
@@ -10,12 +10,14 @@ specific and lives on concrete subclasses (see
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import secrets
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .egress_addon_core import (
|
||||
ON_MATCH_REDACT,
|
||||
HeaderMatch as CoreHeaderMatch,
|
||||
MatchEntry as CoreMatchEntry,
|
||||
PathMatch as CorePathMatch,
|
||||
@@ -31,6 +33,51 @@ CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
||||
EGRESS_HOSTNAME = "egress"
|
||||
|
||||
EGRESS_ROUTES_IN_CONTAINER = "/etc/egress/routes.yaml"
|
||||
EGRESS_ROUTES_FILENAME = Path(EGRESS_ROUTES_IN_CONTAINER).name
|
||||
|
||||
_CANARY_ENV_WORDS = (
|
||||
"ACCORD",
|
||||
"ANCHOR",
|
||||
"ATLAS",
|
||||
"CANON",
|
||||
"CIPHER",
|
||||
"EMBER",
|
||||
"FALCON",
|
||||
"HARBOR",
|
||||
"LANTERN",
|
||||
"MARBLE",
|
||||
"NOVA",
|
||||
"ORBIT",
|
||||
"PIVOT",
|
||||
"RADIUS",
|
||||
"SUMMIT",
|
||||
"VECTOR",
|
||||
)
|
||||
|
||||
|
||||
def _random_canary_env() -> str:
|
||||
first = secrets.choice(_CANARY_ENV_WORDS)
|
||||
remaining = tuple(word for word in _CANARY_ENV_WORDS if word != first)
|
||||
second = secrets.choice(remaining)
|
||||
return f"{first}_{second}_SECRET"
|
||||
|
||||
|
||||
def egress_sidecar_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
|
||||
"""Return sidecar env entries needed by egress across all backends."""
|
||||
env: list[str] = []
|
||||
if plan.routes:
|
||||
env.extend(sorted(plan.token_env_map.keys()))
|
||||
if plan.canary and plan.canary_env:
|
||||
env.append(f"{plan.canary_env}={plan.canary}")
|
||||
env.append(f"BOT_BOTTLE_SENSITIVE_PREFIXES={plan.canary_env}")
|
||||
return tuple(env)
|
||||
|
||||
|
||||
def egress_agent_env_entries(plan: "EgressPlan") -> tuple[str, ...]:
|
||||
"""Return agent-visible egress env entries shared by all backends."""
|
||||
if plan.canary and plan.canary_env:
|
||||
return (f"{plan.canary_env}={plan.canary}",)
|
||||
return ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -63,6 +110,8 @@ class EgressPlan:
|
||||
mitmproxy_ca_host_path: Path = Path()
|
||||
mitmproxy_ca_cert_only_host_path: Path = Path()
|
||||
log: int = 0
|
||||
canary: str = ""
|
||||
canary_env: str = ""
|
||||
|
||||
|
||||
def egress_manifest_routes(
|
||||
@@ -94,6 +143,7 @@ def egress_manifest_routes(
|
||||
git_fetch=r.GitFetch,
|
||||
outbound_detectors=r.OutboundDetectors,
|
||||
inbound_detectors=r.InboundDetectors,
|
||||
outbound_on_match=r.OutboundOnMatch,
|
||||
))
|
||||
return tuple(out)
|
||||
|
||||
@@ -104,12 +154,27 @@ def egress_routes_for_bottle(
|
||||
) -> tuple[EgressRoute, ...]:
|
||||
manifest = egress_manifest_routes(bottle)
|
||||
provisioned_hosts = {pr.host.lower() for pr in provider_routes}
|
||||
merged = list(provider_routes) + [
|
||||
merged = list(_default_provider_on_match(provider_routes)) + [
|
||||
r for r in manifest if r.host.lower() not in provisioned_hosts
|
||||
]
|
||||
return _assign_token_slots(merged)
|
||||
|
||||
|
||||
def _default_provider_on_match(
|
||||
provider_routes: tuple[EgressRoute, ...],
|
||||
) -> tuple[EgressRoute, ...]:
|
||||
"""Provider routes (the agent talking to its own LLM API) default to the
|
||||
`redact` on-match policy (PRD 0062): high-volume conversation payloads are
|
||||
the worst source of token-shaped false positives, so a match is scrubbed
|
||||
and forwarded rather than hard-blocked or queued for the operator. A
|
||||
provider that sets `outbound_on_match` explicitly keeps its choice."""
|
||||
return tuple(
|
||||
r if r.outbound_on_match
|
||||
else dataclasses.replace(r, outbound_on_match=ON_MATCH_REDACT)
|
||||
for r in provider_routes
|
||||
)
|
||||
|
||||
|
||||
def _assign_token_slots(
|
||||
routes: list[EgressRoute],
|
||||
) -> tuple[EgressRoute, ...]:
|
||||
@@ -145,6 +210,17 @@ def egress_token_env_map(
|
||||
return out
|
||||
|
||||
|
||||
def _yaml_str_escape(s: str) -> str:
|
||||
"""Escape a string for use inside a YAML double-quoted scalar."""
|
||||
return (
|
||||
s.replace("\\", "\\\\")
|
||||
.replace('"', '\\"')
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t")
|
||||
)
|
||||
|
||||
|
||||
def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
||||
fields: dict[str, object] = {"host": r.host}
|
||||
if r.auth_scheme and r.token_env:
|
||||
@@ -176,7 +252,11 @@ def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
||||
fields["matches"] = matches_data
|
||||
if r.git_fetch:
|
||||
fields["git"] = {"fetch": True}
|
||||
if r.outbound_detectors is not None or r.inbound_detectors is not None:
|
||||
if (
|
||||
r.outbound_detectors is not None
|
||||
or r.inbound_detectors is not None
|
||||
or r.outbound_on_match
|
||||
):
|
||||
dlp: dict[str, object] = {}
|
||||
if r.outbound_detectors is not None:
|
||||
dlp["outbound_detectors"] = (
|
||||
@@ -188,6 +268,8 @@ def _route_to_yaml_fields(r: Route) -> dict[str, object]:
|
||||
False if not r.inbound_detectors
|
||||
else list(r.inbound_detectors)
|
||||
)
|
||||
if r.outbound_on_match:
|
||||
dlp["outbound_on_match"] = r.outbound_on_match
|
||||
fields["dlp"] = dlp
|
||||
return fields
|
||||
|
||||
@@ -201,12 +283,12 @@ def _render_match_entry(entry: dict[str, object]) -> list[str]:
|
||||
for pd in entry["paths"]: # type: ignore[union-attr]
|
||||
pd_dict: dict[str, str] = pd # type: ignore[assignment]
|
||||
if "type" in pd_dict:
|
||||
lines.append(f' - type: "{pd_dict["type"]}"')
|
||||
lines.append(f' value: "{pd_dict["value"]}"')
|
||||
lines.append(f' - type: "{_yaml_str_escape(pd_dict["type"])}"')
|
||||
lines.append(f' value: "{_yaml_str_escape(pd_dict["value"])}"')
|
||||
else:
|
||||
lines.append(f' - value: "{pd_dict["value"]}"')
|
||||
lines.append(f' - value: "{_yaml_str_escape(pd_dict["value"])}"')
|
||||
if "methods" in entry:
|
||||
methods_str = ", ".join(f'"{m}"' for m in entry["methods"]) # type: ignore[union-attr]
|
||||
methods_str = ", ".join(f'"{_yaml_str_escape(m)}"' for m in entry["methods"]) # type: ignore[union-attr]
|
||||
prefix = " - " if first_key else " "
|
||||
lines.append(f'{prefix}methods: [{methods_str}]')
|
||||
first_key = False
|
||||
@@ -216,8 +298,8 @@ def _render_match_entry(entry: dict[str, object]) -> list[str]:
|
||||
first_key = False
|
||||
for hd in entry["headers"]: # type: ignore[union-attr]
|
||||
hd_dict: dict[str, str] = hd # type: ignore[assignment]
|
||||
lines.append(f' - name: "{hd_dict["name"]}"')
|
||||
lines.append(f' value: "{hd_dict["value"]}"')
|
||||
lines.append(f' - name: "{_yaml_str_escape(hd_dict["name"])}"')
|
||||
lines.append(f' value: "{_yaml_str_escape(hd_dict["value"])}"')
|
||||
if first_key:
|
||||
lines.append(" - {}")
|
||||
return lines
|
||||
@@ -237,10 +319,10 @@ def egress_render_routes(
|
||||
return "\n".join(lines) + "\n"
|
||||
for r in routes:
|
||||
f = _route_to_yaml_fields(r)
|
||||
lines.append(f' - host: "{f["host"]}"')
|
||||
lines.append(f' - host: "{_yaml_str_escape(str(f["host"]))}"')
|
||||
if "auth_scheme" in f:
|
||||
lines.append(f' auth_scheme: "{f["auth_scheme"]}"')
|
||||
lines.append(f' token_env: "{f["token_env"]}"')
|
||||
lines.append(f' auth_scheme: "{_yaml_str_escape(str(f["auth_scheme"]))}"')
|
||||
lines.append(f' token_env: "{_yaml_str_escape(str(f["token_env"]))}"')
|
||||
if "matches" in f:
|
||||
lines.append(" matches:")
|
||||
for entry in f["matches"]: # type: ignore[union-attr]
|
||||
@@ -259,6 +341,8 @@ def egress_render_routes(
|
||||
elif isinstance(dv, list):
|
||||
items_str = ", ".join(f'"{x}"' for x in dv)
|
||||
lines.append(f" {dk}: [{items_str}]")
|
||||
elif isinstance(dv, str):
|
||||
lines.append(f' {dk}: "{_yaml_str_escape(dv)}"')
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
@@ -295,20 +379,27 @@ class Egress(ABC):
|
||||
) -> EgressPlan:
|
||||
routes = egress_routes_for_bottle(bottle, provider_routes)
|
||||
log = bottle.egress.Log
|
||||
routes_path = stage_dir / "egress_routes.yaml"
|
||||
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
|
||||
routes_path.write_text(egress_render_routes(routes, log=log))
|
||||
routes_path.chmod(0o600)
|
||||
# Generate a per-session fake secret under a plausible random env name.
|
||||
# The sidecar marks that exact env name as sensitive for known-secret
|
||||
# scanning; the agent receives the same name/value as exfil bait.
|
||||
canary = secrets.token_urlsafe(32)
|
||||
return EgressPlan(
|
||||
slug=slug,
|
||||
routes_path=routes_path,
|
||||
routes=routes,
|
||||
token_env_map=egress_token_env_map(routes),
|
||||
log=log,
|
||||
canary=canary,
|
||||
canary_env=_random_canary_env(),
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CODEX_HOST_CREDENTIAL_TOKEN_REF",
|
||||
"EGRESS_HOSTNAME",
|
||||
"EGRESS_ROUTES_FILENAME",
|
||||
"EGRESS_ROUTES_IN_CONTAINER",
|
||||
"Egress",
|
||||
"EgressPlan",
|
||||
@@ -317,5 +408,7 @@ __all__ = [
|
||||
"egress_render_routes",
|
||||
"egress_resolve_token_values",
|
||||
"egress_routes_for_bottle",
|
||||
"egress_agent_env_entries",
|
||||
"egress_sidecar_env_entries",
|
||||
"egress_token_env_map",
|
||||
]
|
||||
|
||||
+284
-24
@@ -5,7 +5,7 @@ egress container."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
@@ -17,9 +17,15 @@ from mitmproxy import http # type: ignore[import-not-found] # pylint: disable=
|
||||
from egress_addon_core import ( # type: ignore[import-not-found] # pylint: disable=import-error
|
||||
LOG_BLOCKS,
|
||||
LOG_FULL,
|
||||
DEFAULT_OUTBOUND_ON_MATCH,
|
||||
ON_MATCH_BLOCK,
|
||||
ON_MATCH_REDACT,
|
||||
Config,
|
||||
Route,
|
||||
ScanResult,
|
||||
build_inbound_scan_text,
|
||||
build_outbound_scan_text,
|
||||
build_token_allow_payload,
|
||||
decide,
|
||||
decide_git_fetch,
|
||||
is_git_fetch_request,
|
||||
@@ -27,28 +33,61 @@ from egress_addon_core import ( # type: ignore[import-not-found] # pylint: dis
|
||||
load_config,
|
||||
match_route,
|
||||
outbound_scan_headers,
|
||||
route_to_yaml_dict,
|
||||
scan_inbound,
|
||||
scan_outbound,
|
||||
)
|
||||
|
||||
try:
|
||||
from dlp_detectors import redact_tokens # type: ignore[import-not-found]
|
||||
from dlp_detectors import redact_tokens, strip_crlf # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from bot_bottle.dlp_detectors import redact_tokens # type: ignore[import-not-found]
|
||||
from bot_bottle.dlp_detectors import ( # type: ignore[import-not-found]
|
||||
redact_tokens,
|
||||
strip_crlf,
|
||||
)
|
||||
|
||||
try:
|
||||
import supervise as _sv # type: ignore[import-not-found]
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from bot_bottle import supervise as _sv # type: ignore[import-not-found]
|
||||
|
||||
|
||||
DEFAULT_ROUTES_PATH = "/etc/egress/routes.yaml"
|
||||
|
||||
INTROSPECT_HOST = "_egress.local"
|
||||
|
||||
# Seconds the egress proxy holds a token-blocked request open waiting for the
|
||||
# operator's supervisor decision (PRD 0062), overridable via env.
|
||||
DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS = 300.0
|
||||
# Filesystem poll cadence while awaiting the operator's response.
|
||||
TOKEN_ALLOW_POLL_INTERVAL_SECONDS = 0.5
|
||||
|
||||
# Fixed operator guidance attached to every token-allow proposal.
|
||||
_TOKEN_ALLOW_JUSTIFICATION = (
|
||||
"egress DLP blocked an outbound request carrying a detected token. "
|
||||
"Approve only if this value is a false positive or a credential this "
|
||||
"request legitimately needs; the value is then allowed for the life of "
|
||||
"this bottle's egress proxy."
|
||||
)
|
||||
|
||||
|
||||
class EgressAddon:
|
||||
def __init__(self) -> None:
|
||||
self.routes_path = os.environ.get("EGRESS_ROUTES", DEFAULT_ROUTES_PATH)
|
||||
self.config: Config = Config(routes=())
|
||||
# Tokens the operator has approved this session (PRD 0062). In-memory
|
||||
# only — a restart re-prompts. Mutated only from the asyncio loop that
|
||||
# runs the addon hooks, so no lock is needed.
|
||||
self.safe_tokens: set[str] = set()
|
||||
self._supervise_queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "").strip()
|
||||
self._supervise_slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "").strip()
|
||||
self._token_allow_timeout = _token_allow_timeout_from_env(os.environ)
|
||||
self._reload(initial=True)
|
||||
self._install_sighup()
|
||||
|
||||
def _supervise_available(self) -> bool:
|
||||
return bool(self._supervise_queue_dir and self._supervise_slug)
|
||||
|
||||
def _reload(self, *, initial: bool = False) -> None:
|
||||
try:
|
||||
text = Path(self.routes_path).read_text(encoding="utf-8")
|
||||
@@ -82,7 +121,7 @@ class EgressAddon:
|
||||
def _serve_introspection(self, flow: http.HTTPFlow, path: str) -> None:
|
||||
if path == "/allowlist":
|
||||
payload = json.dumps(
|
||||
{"routes": [dataclasses.asdict(r) for r in self.config.routes]},
|
||||
{"routes": [route_to_yaml_dict(r) for r in self.config.routes]},
|
||||
indent=2,
|
||||
).encode("utf-8")
|
||||
flow.response = http.Response.make(
|
||||
@@ -121,31 +160,42 @@ class EgressAddon:
|
||||
)
|
||||
|
||||
def _log_request(self, flow: http.HTTPFlow) -> None:
|
||||
headers = {
|
||||
k: redact_tokens(v, env=os.environ)
|
||||
for k, v in flow.request.headers.items()
|
||||
if k.lower() != "authorization"
|
||||
}
|
||||
body = redact_tokens(flow.request.get_text(strict=False) or "", env=os.environ)
|
||||
sys.stderr.write(
|
||||
json.dumps({
|
||||
"event": "egress_request",
|
||||
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
|
||||
"method": flow.request.method,
|
||||
"path": redact_tokens(flow.request.path, env=os.environ),
|
||||
"headers": dict(flow.request.headers),
|
||||
"body": flow.request.get_text(strict=False) or "",
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
})
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
def _log_response(self, flow: http.HTTPFlow) -> None:
|
||||
headers = {
|
||||
k: redact_tokens(v, env=os.environ)
|
||||
for k, v in flow.response.headers.items()
|
||||
}
|
||||
body = redact_tokens(flow.response.get_text(strict=False) or "", env=os.environ)
|
||||
sys.stderr.write(
|
||||
json.dumps({
|
||||
"event": "egress_response",
|
||||
"host": flow.request.pretty_host,
|
||||
"status": flow.response.status_code,
|
||||
"headers": dict(flow.response.headers),
|
||||
"body": flow.response.get_text(strict=False) or "",
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
})
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
def request(self, flow: http.HTTPFlow) -> None:
|
||||
async def request(self, flow: http.HTTPFlow) -> None:
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
|
||||
if flow.request.pretty_host == INTROSPECT_HOST:
|
||||
@@ -157,21 +207,11 @@ class EgressAddon:
|
||||
# Hostname is included to catch DNS-tunnelling exfiltration attempts.
|
||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
||||
if route is not None:
|
||||
body = flow.request.get_text(strict=False) or ""
|
||||
scan_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host,
|
||||
request_path,
|
||||
query,
|
||||
outbound_scan_headers(route, dict(flow.request.headers)),
|
||||
body,
|
||||
)
|
||||
dlp_result = scan_outbound(route, scan_text, os.environ)
|
||||
if dlp_result is not None and dlp_result.severity == "block":
|
||||
ctx = self._req_ctx(flow)
|
||||
if dlp_result.context:
|
||||
ctx = {**ctx, "context": dlp_result.context}
|
||||
self._block(flow, f"egress DLP: {dlp_result.reason}", ctx=ctx)
|
||||
if not await self._handle_outbound_dlp(flow, route):
|
||||
return
|
||||
# The redact policy may have rewritten the request line; recompute
|
||||
# the path/query the git checks below rely on.
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
|
||||
if is_git_push_request(request_path, query):
|
||||
self._block(
|
||||
@@ -221,6 +261,202 @@ class EgressAddon:
|
||||
if self.config.log >= LOG_FULL:
|
||||
self._log_request(flow)
|
||||
|
||||
def _block_dlp(self, flow: http.HTTPFlow, result: ScanResult) -> None:
|
||||
ctx = self._req_ctx(flow)
|
||||
if result.context:
|
||||
ctx = {**ctx, "context": result.context}
|
||||
self._block(flow, f"egress DLP: {result.reason}", ctx=ctx)
|
||||
|
||||
async def _handle_outbound_dlp(
|
||||
self,
|
||||
flow: http.HTTPFlow,
|
||||
route: Route,
|
||||
) -> bool:
|
||||
"""Scan the outbound request and apply the route's on-match policy
|
||||
(PRD 0062). Returns True if the request may be forwarded, False if a
|
||||
403 response has been written to `flow`.
|
||||
|
||||
Loops so the supervise policy can re-scan after each approval — a
|
||||
second, un-approved token in the same request is still caught."""
|
||||
while True:
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
body = flow.request.get_text(strict=False) or ""
|
||||
headers = outbound_scan_headers(route, dict(flow.request.headers))
|
||||
scan_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, body,
|
||||
)
|
||||
# CRLF is scanned only over the request line + headers, never the
|
||||
# body (see scan_outbound) — a body is not an injection vector.
|
||||
crlf_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, "",
|
||||
)
|
||||
result = scan_outbound(
|
||||
route, scan_text, os.environ,
|
||||
safe_tokens=self.safe_tokens, crlf_text=crlf_text,
|
||||
)
|
||||
if result is None or result.severity != "block":
|
||||
return True
|
||||
|
||||
policy = route.outbound_on_match or DEFAULT_OUTBOUND_ON_MATCH
|
||||
|
||||
# redact scrubs every detection (tokens and structural CRLF) and
|
||||
# forwards; it fails closed only if a match survives the scrub.
|
||||
if policy == ON_MATCH_REDACT:
|
||||
if self._redact_outbound(flow, route):
|
||||
if self.config.log >= LOG_BLOCKS:
|
||||
sys.stderr.write(json.dumps({
|
||||
"event": "egress_redacted",
|
||||
"reason": f"egress DLP: {result.reason}",
|
||||
**self._req_ctx(flow),
|
||||
}) + "\n")
|
||||
return True
|
||||
self._block(
|
||||
flow,
|
||||
f"egress DLP: {result.reason}; redaction could not remove "
|
||||
"all matches (e.g. a match in the hostname)",
|
||||
ctx=self._req_ctx(flow),
|
||||
)
|
||||
return False
|
||||
|
||||
# Structural blocks (CRLF, no safelist-able value) cannot be
|
||||
# supervised — there is nothing to approve and remember — so under
|
||||
# block/supervise they are a hard 403.
|
||||
if policy == ON_MATCH_BLOCK or not result.matched:
|
||||
self._block_dlp(flow, result)
|
||||
return False
|
||||
|
||||
# supervise (default): hold the request for operator approval.
|
||||
# Fall back to a hard 403 when supervise isn't wired for the bottle.
|
||||
if not self._supervise_available():
|
||||
self._block_dlp(flow, result)
|
||||
return False
|
||||
approved = await self._supervise_token_block(flow, request_path, result)
|
||||
if not approved:
|
||||
return False # _supervise_token_block wrote the 403 response
|
||||
# loop: the approved value is now in safe_tokens; re-scan.
|
||||
|
||||
def _redact_outbound(self, flow: http.HTTPFlow, route: Route) -> bool:
|
||||
"""Scrub detected tokens (and CRLF injection sequences) from the mutable
|
||||
request surfaces (body, headers, path/query) and re-scan. Returns True
|
||||
if the request is now clean; False if a block-severity match remains on
|
||||
a surface redaction cannot rewrite (the hostname) so the caller fails
|
||||
closed."""
|
||||
body = flow.request.get_text(strict=False)
|
||||
if body:
|
||||
redacted_body = redact_tokens(body, env=os.environ)
|
||||
if redacted_body != body:
|
||||
flow.request.text = redacted_body
|
||||
for name, value in list(flow.request.headers.items()):
|
||||
if name.lower() == "host":
|
||||
continue # routing-critical; never a legitimate token
|
||||
redacted = strip_crlf(redact_tokens(value, env=os.environ))
|
||||
if redacted != value:
|
||||
flow.request.headers[name] = redacted
|
||||
redacted_path = strip_crlf(redact_tokens(flow.request.path, env=os.environ))
|
||||
if redacted_path != flow.request.path:
|
||||
flow.request.path = redacted_path
|
||||
|
||||
request_path, _, query = flow.request.path.partition("?")
|
||||
new_body = flow.request.get_text(strict=False) or ""
|
||||
headers = outbound_scan_headers(route, dict(flow.request.headers))
|
||||
scan_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, new_body,
|
||||
)
|
||||
crlf_text = build_outbound_scan_text(
|
||||
flow.request.pretty_host, request_path, query, headers, "",
|
||||
)
|
||||
result = scan_outbound(route, scan_text, os.environ, crlf_text=crlf_text)
|
||||
return result is None or result.severity != "block"
|
||||
|
||||
async def _supervise_token_block(
|
||||
self,
|
||||
flow: http.HTTPFlow,
|
||||
request_path: str,
|
||||
result: ScanResult,
|
||||
) -> bool:
|
||||
"""Route a token DLP block to the operator's supervisor queue and wait.
|
||||
|
||||
Returns True if the operator approved (the matched value is added to
|
||||
`self.safe_tokens` and the caller re-scans); False if the request must
|
||||
be blocked (a 403 response has been written to `flow`)."""
|
||||
host = flow.request.pretty_host
|
||||
payload = build_token_allow_payload(
|
||||
redact_tokens(host, env=os.environ),
|
||||
flow.request.method,
|
||||
redact_tokens(request_path, env=os.environ),
|
||||
result,
|
||||
)
|
||||
proposal = _sv.Proposal.new(
|
||||
bottle_slug=self._supervise_slug,
|
||||
tool=_sv.TOOL_EGRESS_TOKEN_ALLOW,
|
||||
proposed_file=payload,
|
||||
justification=_TOKEN_ALLOW_JUSTIFICATION,
|
||||
current_file_hash=_sv.sha256_hex(payload),
|
||||
)
|
||||
queue_dir = Path(self._supervise_queue_dir)
|
||||
try:
|
||||
_sv.write_proposal(queue_dir, proposal)
|
||||
except OSError as e:
|
||||
sys.stderr.write(
|
||||
f"egress: could not queue token-allow proposal: {e}; "
|
||||
"blocking request\n"
|
||||
)
|
||||
self._block(flow, f"egress DLP: {result.reason}", ctx=self._req_ctx(flow))
|
||||
return False
|
||||
|
||||
sys.stderr.write(json.dumps({
|
||||
"event": "egress_token_supervise",
|
||||
"reason": f"egress DLP: {result.reason}",
|
||||
"proposal": proposal.id,
|
||||
**self._req_ctx(flow),
|
||||
}) + "\n")
|
||||
|
||||
response = await self._await_token_response(queue_dir, proposal.id)
|
||||
_sv.archive_proposal(queue_dir, proposal.id)
|
||||
|
||||
if response is not None and response.status in (
|
||||
_sv.STATUS_APPROVED, _sv.STATUS_MODIFIED,
|
||||
):
|
||||
self.safe_tokens.add(result.matched)
|
||||
if self.config.log >= LOG_BLOCKS:
|
||||
sys.stderr.write(json.dumps({
|
||||
"event": "egress_token_allowed",
|
||||
"reason": f"egress DLP: {result.reason}",
|
||||
"proposal": proposal.id,
|
||||
**self._req_ctx(flow),
|
||||
}) + "\n")
|
||||
return True
|
||||
|
||||
if response is None:
|
||||
reason = (
|
||||
f"egress DLP: {result.reason}; supervisor approval timed out "
|
||||
f"after {self._token_allow_timeout:g}s"
|
||||
)
|
||||
else:
|
||||
reason = f"egress DLP: {result.reason}; supervisor rejected the request"
|
||||
self._block(flow, reason, ctx=self._req_ctx(flow))
|
||||
return False
|
||||
|
||||
async def _await_token_response(
|
||||
self,
|
||||
queue_dir: Path,
|
||||
proposal_id: str,
|
||||
) -> "_sv.Response | None":
|
||||
"""Poll the queue dir for the operator's response without blocking the
|
||||
proxy event loop. Returns the Response, or None on timeout."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + self._token_allow_timeout
|
||||
while True:
|
||||
try:
|
||||
return _sv.read_response(queue_dir, proposal_id)
|
||||
except (OSError, ValueError, KeyError):
|
||||
# Not written yet, or a partial/malformed write — retry until
|
||||
# the deadline, then fail closed.
|
||||
pass
|
||||
if loop.time() >= deadline:
|
||||
return None
|
||||
await asyncio.sleep(TOKEN_ALLOW_POLL_INTERVAL_SECONDS)
|
||||
|
||||
def response(self, flow: http.HTTPFlow) -> None:
|
||||
"""DLP inbound scan on response headers and body."""
|
||||
route = match_route(self.config.routes, flow.request.pretty_host)
|
||||
@@ -272,7 +508,12 @@ class EgressAddon:
|
||||
message = flow.websocket.messages[-1] # type: ignore[union-attr]
|
||||
content = message.content.decode("utf-8", errors="replace")
|
||||
if message.from_client:
|
||||
result = scan_outbound(route, content, os.environ)
|
||||
# A WebSocket data frame is not an HTTP request line, so CRLF is
|
||||
# not an injection vector here — scan only for credential leakage.
|
||||
result = scan_outbound(
|
||||
route, content, os.environ,
|
||||
safe_tokens=self.safe_tokens, crlf_text="",
|
||||
)
|
||||
if result is not None and result.severity == "block":
|
||||
sys.stderr.write(f"egress DLP: {result.reason}\n")
|
||||
flow.kill() # type: ignore[union-attr]
|
||||
@@ -286,4 +527,23 @@ class EgressAddon:
|
||||
sys.stderr.write(f"egress DLP warn: {result.reason}\n")
|
||||
|
||||
|
||||
def _token_allow_timeout_from_env(env: "os._Environ[str]") -> float:
|
||||
"""Read EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS; fall back to the default on an
|
||||
unset or invalid value (a bad value should not wedge egress at boot)."""
|
||||
raw = env.get("EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS", "").strip()
|
||||
if not raw:
|
||||
return DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
value = 0.0
|
||||
if value <= 0:
|
||||
sys.stderr.write(
|
||||
"egress: invalid EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS="
|
||||
f"{raw!r}; using default {DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS:g}s\n"
|
||||
)
|
||||
return DEFAULT_TOKEN_ALLOW_TIMEOUT_SECONDS
|
||||
return value
|
||||
|
||||
|
||||
addons = [EgressAddon()]
|
||||
|
||||
+159
-22
@@ -34,9 +34,18 @@ VALID_METHODS = frozenset({
|
||||
"CONNECT",
|
||||
})
|
||||
|
||||
OUTBOUND_DETECTOR_NAMES = frozenset({"token_patterns", "known_secrets"})
|
||||
OUTBOUND_DETECTOR_NAMES = frozenset({"token_patterns", "known_secrets", "entropy"})
|
||||
INBOUND_DETECTOR_NAMES = frozenset({"naive_injection_detection"})
|
||||
|
||||
# Per-route policy for what the proxy does when an outbound DLP detector
|
||||
# matches a token (PRD 0062).
|
||||
ON_MATCH_BLOCK = "block" # hard 403, never overridable
|
||||
ON_MATCH_REDACT = "redact" # scrub the matched value, forward the request
|
||||
ON_MATCH_SUPERVISE = "supervise" # queue for operator approval, hold the request
|
||||
OUTBOUND_ON_MATCH_VALUES = (ON_MATCH_BLOCK, ON_MATCH_REDACT, ON_MATCH_SUPERVISE)
|
||||
# Unset resolves to supervise (fall back to block when supervise is not wired).
|
||||
DEFAULT_OUTBOUND_ON_MATCH = ON_MATCH_SUPERVISE
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PathMatch:
|
||||
@@ -69,6 +78,8 @@ class Route:
|
||||
git_fetch: bool = False
|
||||
outbound_detectors: tuple[str, ...] | None = None
|
||||
inbound_detectors: tuple[str, ...] | None = None
|
||||
# "" means unset → DEFAULT_OUTBOUND_ON_MATCH. See OUTBOUND_ON_MATCH_VALUES.
|
||||
outbound_on_match: str = ""
|
||||
|
||||
|
||||
LOG_OFF = 0 # no logging
|
||||
@@ -95,6 +106,11 @@ class ScanResult:
|
||||
reason: str
|
||||
location: str = "" # where the match was found, e.g. "body", "authorization header"
|
||||
context: str = "" # surrounding text with the match replaced by REDACT
|
||||
# Raw substring the detector matched. Used inside the sidecar to key the
|
||||
# supervisor-approved "safe tokens" set (PRD 0062); never logged or written
|
||||
# to a proposal file. Empty for structural detectors (CRLF) that carry no
|
||||
# safelist-able value.
|
||||
matched: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -218,12 +234,12 @@ def _parse_detectors(
|
||||
idx: int,
|
||||
host: str,
|
||||
raw_dict: dict[str, object],
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None]:
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None, str]:
|
||||
"""Parse the optional `dlp` block on a route, returning
|
||||
(outbound_detectors, inbound_detectors)."""
|
||||
(outbound_detectors, inbound_detectors, outbound_on_match)."""
|
||||
dlp_raw = raw_dict.get("dlp")
|
||||
if dlp_raw is None:
|
||||
return None, None
|
||||
return None, None, ""
|
||||
label = f"route[{idx}] ({host})"
|
||||
if not isinstance(dlp_raw, dict):
|
||||
raise ValueError(f"{label}: 'dlp' must be an object")
|
||||
@@ -260,13 +276,24 @@ def _parse_detectors(
|
||||
outbound = _parse_detector_field("outbound_detectors", OUTBOUND_DETECTOR_NAMES)
|
||||
inbound = _parse_detector_field("inbound_detectors", INBOUND_DETECTOR_NAMES)
|
||||
|
||||
on_match = ""
|
||||
on_match_raw = dlp.get("outbound_on_match")
|
||||
if on_match_raw is not None:
|
||||
if not isinstance(on_match_raw, str) or on_match_raw not in OUTBOUND_ON_MATCH_VALUES:
|
||||
raise ValueError(
|
||||
f"{label}: dlp.outbound_on_match must be one of "
|
||||
f"{', '.join(OUTBOUND_ON_MATCH_VALUES)} (got {on_match_raw!r})"
|
||||
)
|
||||
on_match = on_match_raw
|
||||
|
||||
for k in dlp:
|
||||
if k not in ("outbound_detectors", "inbound_detectors"):
|
||||
if k not in ("outbound_detectors", "inbound_detectors", "outbound_on_match"):
|
||||
raise ValueError(
|
||||
f"{label}: dlp has unknown key {k!r}; accepted keys "
|
||||
f"are 'outbound_detectors', 'inbound_detectors'"
|
||||
f"are 'outbound_detectors', 'inbound_detectors', "
|
||||
f"'outbound_on_match'"
|
||||
)
|
||||
return outbound, inbound
|
||||
return outbound, inbound, on_match
|
||||
|
||||
|
||||
def parse_routes(payload: object) -> tuple[Route, ...]:
|
||||
@@ -337,7 +364,7 @@ def _parse_one(idx: int, raw: object) -> Route:
|
||||
)
|
||||
|
||||
# dlp detectors
|
||||
outbound_detectors, inbound_detectors = _parse_detectors(
|
||||
outbound_detectors, inbound_detectors, outbound_on_match = _parse_detectors(
|
||||
idx, host, raw_dict,
|
||||
)
|
||||
|
||||
@@ -356,16 +383,60 @@ def _parse_one(idx: int, raw: object) -> Route:
|
||||
git_fetch=git_fetch,
|
||||
outbound_detectors=outbound_detectors,
|
||||
inbound_detectors=inbound_detectors,
|
||||
outbound_on_match=outbound_on_match,
|
||||
)
|
||||
|
||||
|
||||
def load_routes(text: str) -> tuple[Route, ...]:
|
||||
"""Parse YAML text → routes."""
|
||||
try:
|
||||
payload = parse_yaml_subset(text)
|
||||
except YamlSubsetError as e:
|
||||
raise ValueError(f"routes payload: invalid YAML: {e}") from e
|
||||
return parse_routes(payload)
|
||||
def _path_match_to_dict(pm: PathMatch) -> dict[str, object]:
|
||||
d: dict[str, object] = {"value": pm.value}
|
||||
if pm.type != "prefix":
|
||||
d["type"] = pm.type
|
||||
return d
|
||||
|
||||
|
||||
def _header_match_to_dict(hm: HeaderMatch) -> dict[str, object]:
|
||||
d: dict[str, object] = {"name": hm.name, "value": hm.value}
|
||||
if hm.type != "exact":
|
||||
d["type"] = hm.type
|
||||
return d
|
||||
|
||||
|
||||
def _match_entry_to_dict(me: MatchEntry) -> dict[str, object]:
|
||||
d: dict[str, object] = {}
|
||||
if me.paths:
|
||||
d["paths"] = [_path_match_to_dict(p) for p in me.paths]
|
||||
if me.methods:
|
||||
d["methods"] = list(me.methods)
|
||||
if me.headers:
|
||||
d["headers"] = [_header_match_to_dict(h) for h in me.headers]
|
||||
return d
|
||||
|
||||
|
||||
def route_to_yaml_dict(r: Route) -> dict[str, object]:
|
||||
"""Serialize a Route to YAML-schema-compatible dict.
|
||||
|
||||
Uses the same field names the YAML parser accepts, so the output
|
||||
can be round-tripped directly into an `allow` or `egress-block`
|
||||
proposal without translation. Fields that are empty/default are
|
||||
omitted so the agent doesn't copy irrelevant keys."""
|
||||
d: dict[str, object] = {"host": r.host}
|
||||
if r.auth_scheme:
|
||||
d["auth_scheme"] = r.auth_scheme
|
||||
d["token_env"] = r.token_env
|
||||
if r.matches:
|
||||
d["matches"] = [_match_entry_to_dict(m) for m in r.matches]
|
||||
if r.git_fetch:
|
||||
d["git"] = {"fetch": True}
|
||||
dlp: dict[str, object] = {}
|
||||
if r.outbound_detectors is not None:
|
||||
dlp["outbound_detectors"] = list(r.outbound_detectors)
|
||||
if r.inbound_detectors is not None:
|
||||
dlp["inbound_detectors"] = list(r.inbound_detectors)
|
||||
if r.outbound_on_match:
|
||||
dlp["outbound_on_match"] = r.outbound_on_match
|
||||
if dlp:
|
||||
d["dlp"] = dlp
|
||||
return d
|
||||
|
||||
|
||||
def parse_config(payload: object) -> "Config":
|
||||
@@ -640,43 +711,103 @@ def scan_outbound(
|
||||
route: Route,
|
||||
body: str | bytes,
|
||||
environ: typing.Mapping[str, str],
|
||||
*,
|
||||
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||
crlf_text: str | None = None,
|
||||
) -> ScanResult | None:
|
||||
# Lazy import to avoid circular deps and keep dlp_detectors optional
|
||||
# at import time (the sidecar copies it flat alongside this file).
|
||||
try:
|
||||
from dlp_detectors import ( # type: ignore[import-not-found]
|
||||
scan_crlf_injection,
|
||||
scan_entropy,
|
||||
scan_known_secrets,
|
||||
scan_token_patterns,
|
||||
)
|
||||
except ImportError: # pragma: no cover - host-side path
|
||||
from .dlp_detectors import ( # type: ignore[import-not-found]
|
||||
scan_crlf_injection,
|
||||
scan_entropy,
|
||||
scan_known_secrets,
|
||||
scan_token_patterns,
|
||||
)
|
||||
|
||||
text = body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
||||
# Binary bodies: latin-1 is a bijective byte↔codepoint mapping that
|
||||
# preserves every byte value, so ASCII-range secret strings remain
|
||||
# findable by str.find / regex. Prefer strict UTF-8 for valid text bodies.
|
||||
if isinstance(body, bytes):
|
||||
try:
|
||||
text = body.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = body.decode("latin-1")
|
||||
else:
|
||||
text = body
|
||||
|
||||
# CRLF injection is never legitimate — runs unconditionally, not gated
|
||||
# by outbound_detectors config.
|
||||
result = scan_crlf_injection(text)
|
||||
# CRLF injection is only an attack in the request line + headers, never the
|
||||
# body: an HTTP body is delimited by Content-Length, so CRLF bytes there
|
||||
# cannot split the request. Scanning the body produces false positives on
|
||||
# legitimate form-encoded / multi-line content. Callers pass the
|
||||
# body-excluded surfaces as `crlf_text`; `None` falls back to the full text
|
||||
# for backward-compatible callers (host-side tests, websocket frames).
|
||||
crlf_target = text if crlf_text is None else crlf_text
|
||||
result = scan_crlf_injection(crlf_target)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
if _detector_enabled(route.outbound_detectors, "token_patterns"):
|
||||
result = scan_token_patterns(text, location="body")
|
||||
result = scan_token_patterns(text, location="body", safe_tokens=safe_tokens)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
if _detector_enabled(route.outbound_detectors, "known_secrets"):
|
||||
result = scan_known_secrets(text, location="body", env=environ)
|
||||
# BOT_BOTTLE_SENSITIVE_PREFIXES lets operators add extra env prefixes
|
||||
# beyond EGRESS_TOKEN_* without changing the manifest schema.
|
||||
extra_raw = environ.get("BOT_BOTTLE_SENSITIVE_PREFIXES", "")
|
||||
extra = tuple(p for p in extra_raw.split(",") if p)
|
||||
sensitive_prefixes = ("EGRESS_TOKEN_",) + extra
|
||||
result = scan_known_secrets(
|
||||
text, location="body", env=environ,
|
||||
sensitive_prefixes=sensitive_prefixes, safe_tokens=safe_tokens,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# Entropy scanning requires explicit opt-in: it is NOT part of the
|
||||
# default "all detectors" set because it produces false positives on
|
||||
# legitimate base64 / binary payloads. Routes must list "entropy" in
|
||||
# dlp.outbound_detectors to enable it.
|
||||
if (
|
||||
route.outbound_detectors is not None
|
||||
and "entropy" in route.outbound_detectors
|
||||
):
|
||||
result = scan_entropy(text, location="body")
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def build_token_allow_payload(
|
||||
host: str,
|
||||
method: str,
|
||||
path: str,
|
||||
result: ScanResult,
|
||||
) -> str:
|
||||
"""Render the human-readable supervisor proposal body for an outbound
|
||||
token block (PRD 0062). Carries the host/method/path, the detector
|
||||
reason, and the redacted context snippet — never the raw token value."""
|
||||
lines = [
|
||||
"egress blocked an outbound request carrying a detected token",
|
||||
f"host: {host}",
|
||||
f"method: {method}",
|
||||
f"path: {path}",
|
||||
f"detector: {result.reason}",
|
||||
]
|
||||
if result.context:
|
||||
lines.append(f"context: {result.context}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def scan_inbound(
|
||||
route: Route,
|
||||
body: str | bytes,
|
||||
@@ -698,8 +829,14 @@ def scan_inbound(
|
||||
|
||||
__all__ = [
|
||||
"LOG_BLOCKS",
|
||||
"route_to_yaml_dict",
|
||||
"LOG_FULL",
|
||||
"LOG_OFF",
|
||||
"ON_MATCH_BLOCK",
|
||||
"ON_MATCH_REDACT",
|
||||
"ON_MATCH_SUPERVISE",
|
||||
"OUTBOUND_ON_MATCH_VALUES",
|
||||
"DEFAULT_OUTBOUND_ON_MATCH",
|
||||
"Config",
|
||||
"Decision",
|
||||
"HeaderMatch",
|
||||
@@ -709,13 +846,13 @@ __all__ = [
|
||||
"ScanResult",
|
||||
"build_inbound_scan_text",
|
||||
"build_outbound_scan_text",
|
||||
"build_token_allow_payload",
|
||||
"decide",
|
||||
"decide_git_fetch",
|
||||
"evaluate_matches",
|
||||
"is_git_push_request",
|
||||
"is_git_fetch_request",
|
||||
"load_config",
|
||||
"load_routes",
|
||||
"match_route",
|
||||
"outbound_scan_headers",
|
||||
"parse_config",
|
||||
|
||||
+2
-2
@@ -114,7 +114,7 @@ def _read_secret_silent(name: str, prompt_body: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
|
||||
def resolve_env(manifest: Manifest) -> ResolvedEnv:
|
||||
"""Iterate the agent's env entries:
|
||||
- secret: prompt at runtime; carry value in forwarded
|
||||
- interpolated: read $HOST_VAR from os.environ; carry value in forwarded
|
||||
@@ -124,7 +124,7 @@ def resolve_env(manifest: Manifest, agent: str) -> ResolvedEnv:
|
||||
backend injects forwarded values via its launcher's env parameter."""
|
||||
forwarded: dict[str, str] = {}
|
||||
literals: dict[str, str] = {}
|
||||
bottle = manifest.bottle_for(agent)
|
||||
bottle = manifest.bottle
|
||||
for name, raw in bottle.env.items():
|
||||
if not name:
|
||||
continue
|
||||
|
||||
+200
-22
@@ -43,10 +43,10 @@ from .manifest import ManifestBottle, ManifestGitEntry
|
||||
# Short network alias for git-gate inside the sidecar bundle. The
|
||||
# agent's `.gitconfig` insteadOf rewrites resolve through this name.
|
||||
GIT_GATE_HOSTNAME = "git-gate"
|
||||
# Bound half-open git client sessions. If an agent/tool runner is
|
||||
# interrupted during push, git daemon should reap the receive-pack
|
||||
# child instead of keeping the gate wedged indefinitely.
|
||||
GIT_GATE_DAEMON_TIMEOUT_SECS = 15
|
||||
# Shared timeout (seconds) for all git-gate subprocess and CGI calls:
|
||||
# git daemon (--timeout/--init-timeout), the access-hook subprocess in
|
||||
# git_http_backend, and the git http-backend CGI subprocess.
|
||||
GIT_GATE_TIMEOUT_SECS = 15
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -112,6 +112,15 @@ def git_gate_upstreams_for_bottle(bottle: ManifestBottle) -> tuple[GitGateUpstre
|
||||
)
|
||||
|
||||
|
||||
def _gitconfig_validate_value(field: str, value: str) -> None:
|
||||
"""Raise ValueError if value contains characters that break gitconfig line syntax."""
|
||||
if "\n" in value or "\r" in value:
|
||||
raise ValueError(
|
||||
f"git-gate: {field} contains a newline, which would inject "
|
||||
f"arbitrary gitconfig keys; rejecting manifest entry"
|
||||
)
|
||||
|
||||
|
||||
def git_gate_render_gitconfig(
|
||||
entries: tuple[ManifestGitEntry, ...], gate_host: str, *, scheme: str = "git",
|
||||
) -> str:
|
||||
@@ -136,6 +145,7 @@ def git_gate_render_gitconfig(
|
||||
"# fetch-from-upstream-before-every-upload-pack via access-hook).\n",
|
||||
]
|
||||
for entry in entries:
|
||||
_gitconfig_validate_value(f"repos[{entry.Name!r}].url", entry.Upstream)
|
||||
out.append(f'[url "{scheme}://{gate_host}/{entry.Name}.git"]\n')
|
||||
out.append(f"\tinsteadOf = {entry.Upstream}\n")
|
||||
if entry.RemoteKey and entry.RemoteKey != entry.UpstreamHost:
|
||||
@@ -148,6 +158,7 @@ def git_gate_render_gitconfig(
|
||||
f"ssh://{entry.UpstreamUser}@{entry.RemoteKey}{port}/"
|
||||
f"{entry.UpstreamPath}"
|
||||
)
|
||||
_gitconfig_validate_value(f"repos[{entry.Name!r}].url (resolved alias)", alias)
|
||||
out.append(f"\tinsteadOf = {alias}\n")
|
||||
return "".join(out)
|
||||
|
||||
@@ -217,8 +228,8 @@ def git_gate_render_entrypoint(upstreams: tuple[GitGateUpstream, ...]) -> str:
|
||||
"",
|
||||
"exec git daemon \\",
|
||||
" --reuseaddr \\",
|
||||
f" --timeout={GIT_GATE_DAEMON_TIMEOUT_SECS} \\",
|
||||
f" --init-timeout={GIT_GATE_DAEMON_TIMEOUT_SECS} \\",
|
||||
f" --timeout={GIT_GATE_TIMEOUT_SECS} \\",
|
||||
f" --init-timeout={GIT_GATE_TIMEOUT_SECS} \\",
|
||||
" --base-path=/git \\",
|
||||
" --export-all \\",
|
||||
" --enable=receive-pack \\",
|
||||
@@ -247,6 +258,164 @@ cat > "$refs_file"
|
||||
|
||||
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.
|
||||
while IFS=' ' read -r old new ref; do
|
||||
[ -z "$ref" ] && continue
|
||||
@@ -268,6 +437,9 @@ while IFS=' ' read -r old new ref; do
|
||||
echo "git-gate: gitleaks rejected push to $ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! supervise_gitleaks_allow "$log_opts" "$ref"; then
|
||||
exit 1
|
||||
fi
|
||||
done < "$refs_file"
|
||||
|
||||
# Phase 2: forward each ref to the upstream (`origin`, configured
|
||||
@@ -389,13 +561,12 @@ def _provision_dynamic_key(
|
||||
Returns the host-side path to the private key file so the caller
|
||||
can inject it into the GitGateUpstream as `identity_file`."""
|
||||
from .deploy_key_provisioner import get_provisioner
|
||||
pk = entry.ProvisionedKey
|
||||
assert pk is not None
|
||||
token = os.environ.get(pk.token_env)
|
||||
pk = entry.Key
|
||||
token = os.environ.get(pk.forge_token_env)
|
||||
if token is None:
|
||||
raise RuntimeError(
|
||||
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
|
||||
f" = {pk.token_env!r}: env var is not set"
|
||||
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
||||
f" = {pk.forge_token_env!r}: env var is not set"
|
||||
)
|
||||
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||
provisioner = get_provisioner(pk.provider, token, api_url)
|
||||
@@ -428,18 +599,18 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
|
||||
address manually."""
|
||||
from .deploy_key_provisioner import get_provisioner
|
||||
for entry in bottle.git:
|
||||
if entry.ProvisionedKey is None:
|
||||
if entry.Key.provider != "gitea":
|
||||
continue
|
||||
pk = entry.ProvisionedKey
|
||||
pk = entry.Key
|
||||
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
|
||||
if not id_file.exists():
|
||||
continue
|
||||
key_id = id_file.read_text().strip()
|
||||
token = os.environ.get(pk.token_env)
|
||||
token = os.environ.get(pk.forge_token_env)
|
||||
if token is None:
|
||||
raise RuntimeError(
|
||||
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
|
||||
f" = {pk.token_env!r}: env var is not set;"
|
||||
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
||||
f" = {pk.forge_token_env!r}: env var is not set;"
|
||||
f" cannot revoke deploy key {key_id}"
|
||||
)
|
||||
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||
@@ -452,6 +623,14 @@ 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}]")
|
||||
|
||||
|
||||
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):
|
||||
"""The per-agent git-gate. Encapsulates the host-side prepare
|
||||
(upstream lift + entrypoint/hook render); the sidecar's
|
||||
@@ -463,7 +642,7 @@ class GitGate(ABC):
|
||||
entrypoint, pre-receive hook, and access-hook scripts (mode
|
||||
600) under `stage_dir`. Pure host-side, no docker subprocess.
|
||||
|
||||
For `provisioned_key` entries, also generates and registers
|
||||
For `gitea` key entries, also generates and registers
|
||||
a fresh deploy key via the forge API and writes the private key
|
||||
+ key ID to `stage_dir`.
|
||||
|
||||
@@ -472,11 +651,10 @@ class GitGate(ABC):
|
||||
before passing the plan to `.start`."""
|
||||
upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
|
||||
for i, entry in enumerate(bottle.git):
|
||||
if entry.ProvisionedKey is not None:
|
||||
key_file = _provision_dynamic_key(entry, slug, stage_dir)
|
||||
upstreams_list[i] = dataclasses.replace(
|
||||
upstreams_list[i], identity_file=key_file
|
||||
)
|
||||
upstreams_list[i] = dataclasses.replace(
|
||||
upstreams_list[i],
|
||||
identity_file=_resolve_identity_file(entry, slug, stage_dir),
|
||||
)
|
||||
upstreams = tuple(upstreams_list)
|
||||
entrypoint = stage_dir / "git_gate_entrypoint.sh"
|
||||
entrypoint.write_text(git_gate_render_entrypoint(upstreams))
|
||||
|
||||
@@ -16,6 +16,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .git_gate import GIT_GATE_TIMEOUT_SECS
|
||||
|
||||
|
||||
DEFAULT_PORT = 9420
|
||||
|
||||
@@ -47,6 +49,7 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
||||
[hook_path, "upload-pack", str(repo_dir), peer, peer],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=GIT_GATE_TIMEOUT_SECS,
|
||||
)
|
||||
if hook.returncode != 0:
|
||||
detail = (hook.stderr or hook.stdout).decode(
|
||||
@@ -110,6 +113,7 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
||||
env=env,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=GIT_GATE_TIMEOUT_SECS,
|
||||
)
|
||||
self._write_cgi_response(proc.stdout)
|
||||
|
||||
@@ -148,7 +152,13 @@ class GitHttpHandler(BaseHTTPRequestHandler):
|
||||
key, _, value = line.decode("latin1").partition(":")
|
||||
value = value.strip()
|
||||
if key.lower() == "status":
|
||||
status = int(value.split()[0])
|
||||
try:
|
||||
status = int(value.split()[0])
|
||||
except (ValueError, IndexError):
|
||||
self.log_message(
|
||||
"malformed CGI Status header %r; using 500", value,
|
||||
)
|
||||
status = 500
|
||||
else:
|
||||
headers.append((key, value))
|
||||
self.send_response(status)
|
||||
|
||||
+96
-10
@@ -1,21 +1,107 @@
|
||||
"""Tiny logging wrappers. All output goes to stderr."""
|
||||
"""Tiny logging wrappers. All output goes to stderr.
|
||||
|
||||
Two capabilities layer onto the bare wrappers (issue #252):
|
||||
|
||||
- **Levels.** `debug` / `info` / `warn` / `error` carry an ordered
|
||||
severity. Output is gated by `BOT_BOTTLE_LOG_LEVEL` (debug | info |
|
||||
warn | error; default `info`). A message emits when its severity is
|
||||
at or above the threshold, so `debug` is silent by default and
|
||||
`error` always surfaces (nothing sits above it) — which keeps the
|
||||
fatal `die` path visible regardless of the configured level.
|
||||
|
||||
- **Context.** Every wrapper takes an optional `context` mapping that
|
||||
renders as a parseable ` [k=v ...]` suffix (keys sorted; values with
|
||||
whitespace/quotes are quoted), so failures can be filtered and
|
||||
correlated instead of being flat strings.
|
||||
|
||||
With no `context` and the default level, output is byte-identical to the
|
||||
original `bot-bottle: <msg>` / `bot-bottle: warning: <msg>` /
|
||||
`bot-bottle: error: <msg>` lines — the 100+ existing call sites are
|
||||
unaffected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import NoReturn
|
||||
from typing import Mapping, NoReturn
|
||||
|
||||
# Ordered severities. Gaps left between values so intermediate levels
|
||||
# can be added later without renumbering.
|
||||
DEBUG = 10
|
||||
INFO = 20
|
||||
WARN = 30
|
||||
ERROR = 40
|
||||
|
||||
_LEVEL_NAMES: dict[str, int] = {
|
||||
"debug": DEBUG,
|
||||
"info": INFO,
|
||||
"warn": WARN,
|
||||
"warning": WARN,
|
||||
"error": ERROR,
|
||||
}
|
||||
|
||||
# Default threshold when BOT_BOTTLE_LOG_LEVEL is unset or unrecognised.
|
||||
_DEFAULT_THRESHOLD = INFO
|
||||
|
||||
_LOG_LEVEL_ENV = "BOT_BOTTLE_LOG_LEVEL"
|
||||
|
||||
|
||||
def info(msg: str) -> None:
|
||||
print(f"bot-bottle: {msg}", file=sys.stderr)
|
||||
def _threshold() -> int:
|
||||
"""Resolve the active level threshold from the environment.
|
||||
|
||||
Read per-call (not cached) so the level can be changed at runtime
|
||||
and so tests can patch `os.environ` without a reload. Unknown values
|
||||
fall back to the default rather than raising — logging must never be
|
||||
the thing that crashes the process."""
|
||||
raw = os.environ.get(_LOG_LEVEL_ENV, "")
|
||||
return _LEVEL_NAMES.get(raw.strip().lower(), _DEFAULT_THRESHOLD)
|
||||
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
print(f"bot-bottle: warning: {msg}", file=sys.stderr)
|
||||
def _format_context(context: Mapping[str, object] | None) -> str:
|
||||
"""Render a context mapping as a ` [k=v k2=v2]` suffix.
|
||||
|
||||
Keys are sorted for stable, diffable output. Values that are empty or
|
||||
contain whitespace or a quote are wrapped in double quotes (with inner
|
||||
quotes escaped) so each `k=v` pair stays parseable. Empty/None context
|
||||
renders as the empty string."""
|
||||
if not context:
|
||||
return ""
|
||||
parts: list[str] = []
|
||||
for key in sorted(context):
|
||||
value = str(context[key])
|
||||
if value == "" or any(ch.isspace() for ch in value) or '"' in value:
|
||||
value = '"' + value.replace('"', '\\"') + '"'
|
||||
parts.append(f"{key}={value}")
|
||||
return " [" + " ".join(parts) + "]"
|
||||
|
||||
|
||||
def error(msg: str) -> None:
|
||||
print(f"bot-bottle: error: {msg}", file=sys.stderr)
|
||||
def _emit(
|
||||
level: int,
|
||||
label: str,
|
||||
msg: str,
|
||||
context: Mapping[str, object] | None,
|
||||
) -> None:
|
||||
if level < _threshold():
|
||||
return
|
||||
prefix = f"{label}: " if label else ""
|
||||
sys.stderr.write(f"bot-bottle: {prefix}{msg}{_format_context(context)}\n")
|
||||
|
||||
|
||||
def debug(msg: str, *, context: Mapping[str, object] | None = None) -> None:
|
||||
_emit(DEBUG, "debug", msg, context)
|
||||
|
||||
|
||||
def info(msg: str, *, context: Mapping[str, object] | None = None) -> None:
|
||||
_emit(INFO, "", msg, context)
|
||||
|
||||
|
||||
def warn(msg: str, *, context: Mapping[str, object] | None = None) -> None:
|
||||
_emit(WARN, "warning", msg, context)
|
||||
|
||||
|
||||
def error(msg: str, *, context: Mapping[str, object] | None = None) -> None:
|
||||
_emit(ERROR, "error", msg, context)
|
||||
|
||||
|
||||
class Die(SystemExit):
|
||||
@@ -31,6 +117,6 @@ class Die(SystemExit):
|
||||
self.message = message
|
||||
|
||||
|
||||
def die(msg: str) -> NoReturn:
|
||||
error(msg)
|
||||
def die(msg: str, *, context: Mapping[str, object] | None = None) -> NoReturn:
|
||||
error(msg, context=context)
|
||||
raise Die(1, msg)
|
||||
|
||||
+290
-107
@@ -19,7 +19,7 @@ Bottle schema (frontmatter):
|
||||
repos: { <name>: <git-gate-entry>, ... } # optional
|
||||
egress: { routes: [ <egress-route>, ... ] }
|
||||
# route keys: host, matches, auth, role, dlp
|
||||
supervise: <bool> # optional
|
||||
supervise: <bool> # optional (default true)
|
||||
|
||||
Agent schema (frontmatter):
|
||||
bottle: <bottle-name> # required
|
||||
@@ -36,10 +36,23 @@ Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a
|
||||
warn at load time and contributes nothing. The trust boundary is
|
||||
expressed as filesystem layout rather than resolver logic.
|
||||
|
||||
Validation runs once at load. Manifest.from_json_obj is preserved
|
||||
as a programmatic entry point (used by tests) that takes a dict
|
||||
with the same field names — useful for building manifests without
|
||||
on-disk files.
|
||||
Two types are exported:
|
||||
|
||||
ManifestIndex — the multi-agent/bottle collection returned by
|
||||
resolve() and from_json_obj(). Used for agent
|
||||
selection (all_agent_names), validation
|
||||
(require_agent), and lazy loading (load_for_agent).
|
||||
This is the pre-preflight form.
|
||||
|
||||
Manifest — a single-agent/bottle value type holding exactly
|
||||
one agent: ManifestAgent and one bottle:
|
||||
ManifestBottle (with the agent's git-gate.user
|
||||
already overlaid). Returned by load_for_agent().
|
||||
This is the post-preflight form passed to backends.
|
||||
|
||||
ManifestIndex.from_json_obj is preserved as a programmatic entry
|
||||
point (used by tests) that takes a dict with the same field names —
|
||||
useful for building manifests without on-disk files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -56,7 +69,7 @@ from .manifest_egress import (
|
||||
ManifestEgressConfig,
|
||||
ManifestEgressRoute,
|
||||
)
|
||||
from .manifest_git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config
|
||||
from .manifest_git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig, parse_git_gate_config
|
||||
from .manifest_schema import BOTTLE_KEYS
|
||||
|
||||
# Re-export everything that callers currently import from this module.
|
||||
@@ -64,12 +77,14 @@ __all__ = [
|
||||
"ManifestError",
|
||||
"ManifestGitEntry",
|
||||
"ManifestGitUser",
|
||||
"ManifestKeyConfig",
|
||||
"ManifestAgentProvider",
|
||||
"EGRESS_AUTH_SCHEMES",
|
||||
"ManifestEgressRoute",
|
||||
"ManifestEgressConfig",
|
||||
"ManifestAgent",
|
||||
"ManifestBottle",
|
||||
"ManifestIndex",
|
||||
"Manifest",
|
||||
]
|
||||
|
||||
@@ -96,13 +111,11 @@ class ManifestBottle:
|
||||
# identity without any git-gate.repos upstreams, and vice versa.
|
||||
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
||||
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
||||
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
|
||||
# the launch step brings up a supervise sidecar that exposes MCP
|
||||
# tools to the agent (egress-block, capability-block) plus mounts
|
||||
# the current-config dir read-only into the agent at
|
||||
# /etc/bot-bottle/current-config. False (the default) skips the
|
||||
# sidecar and mount.
|
||||
supervise: bool = False
|
||||
# Per-bottle stuck-recovery sidecar (PRD 0013). When true (the
|
||||
# default, issue #249), the launch step brings up a supervise
|
||||
# sidecar that exposes egress MCP tools to the agent. Set
|
||||
# `supervise: false` to skip the sidecar.
|
||||
supervise: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
||||
@@ -175,7 +188,7 @@ class ManifestBottle:
|
||||
else ManifestEgressConfig()
|
||||
)
|
||||
|
||||
supervise_raw = d.get("supervise", False)
|
||||
supervise_raw = d.get("supervise", True)
|
||||
if not isinstance(supervise_raw, bool):
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' supervise must be a boolean "
|
||||
@@ -188,14 +201,123 @@ class ManifestBottle:
|
||||
)
|
||||
|
||||
|
||||
def _merge_git_user(
|
||||
agent_user: ManifestGitUser, base_user: ManifestGitUser
|
||||
) -> ManifestGitUser:
|
||||
"""Merge the agent's git.user over the bottle's, agent-wins-on-non-empty."""
|
||||
if agent_user.is_empty():
|
||||
return base_user
|
||||
return ManifestGitUser(
|
||||
name=agent_user.name or base_user.name,
|
||||
email=agent_user.email or base_user.email,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_effective_bottle_eager(
|
||||
agent_name: str,
|
||||
agent: "ManifestAgent",
|
||||
bottle_names: "tuple[str, ...]",
|
||||
bottles: "Mapping[str, ManifestBottle]",
|
||||
) -> "ManifestBottle":
|
||||
"""Return the effective ManifestBottle for the eager (from_json_obj) path.
|
||||
|
||||
When bottle_names is non-empty they are merged in order. When empty, falls
|
||||
back to agent.bottle. Raises ManifestError when neither is set."""
|
||||
from .manifest_extends import merge_bottles_runtime
|
||||
|
||||
if bottle_names:
|
||||
resolved: list[ManifestBottle] = []
|
||||
for bn in bottle_names:
|
||||
if bn not in bottles:
|
||||
available = ", ".join(sorted(bottles.keys())) or "(none)"
|
||||
raise ManifestError(
|
||||
f"bottle '{bn}' not defined. Available: {available}"
|
||||
)
|
||||
resolved.append(bottles[bn])
|
||||
return merge_bottles_runtime(resolved)
|
||||
|
||||
if not agent.bottle:
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' has no 'bottle' field and no bottles were "
|
||||
f"selected at launch. Select at least one bottle or add "
|
||||
f"'bottle: <name>' to the agent manifest."
|
||||
)
|
||||
return bottles[agent.bottle]
|
||||
|
||||
|
||||
def _resolve_effective_bottle_lazy(
|
||||
agent_name: str,
|
||||
agent_bottle: str,
|
||||
bottle_names: "tuple[str, ...]",
|
||||
bottles_dir: "Path",
|
||||
) -> "ManifestBottle":
|
||||
"""Return the effective ManifestBottle for the lazy (from_md_dirs) path.
|
||||
|
||||
When bottle_names is non-empty they are resolved from disk and merged in
|
||||
order. When empty, falls back to agent_bottle. Raises ManifestError when
|
||||
neither is set."""
|
||||
from .manifest_extends import merge_bottles_runtime
|
||||
from .manifest_loader import load_bottle_chain_from_dir
|
||||
|
||||
if bottle_names:
|
||||
resolved = [load_bottle_chain_from_dir(bn, bottles_dir) for bn in bottle_names]
|
||||
return merge_bottles_runtime(resolved)
|
||||
|
||||
if not agent_bottle:
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' has no 'bottle' field and no bottles were "
|
||||
f"selected at launch. Select at least one bottle or add "
|
||||
f"'bottle: <name>' to the agent manifest."
|
||||
)
|
||||
return load_bottle_chain_from_dir(agent_bottle, bottles_dir)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Manifest:
|
||||
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
|
||||
|
||||
`bottle` is the effective bottle with the agent's git-gate.user already
|
||||
overlaid per-field (agent wins on non-empty). Backends and provisioners
|
||||
use this directly — no agent_name lookup needed."""
|
||||
|
||||
agent: ManifestAgent
|
||||
bottle: ManifestBottle
|
||||
|
||||
def git_identity_summary(self) -> str | None:
|
||||
"""One-line effective git identity with per-field provenance, e.g.
|
||||
`name=claude (agent), email=eric@dideric.is (bottle)`.
|
||||
Returns None when neither agent nor bottle sets an identity."""
|
||||
over = self.agent.git_user # agent's declared git_user (pre-merge)
|
||||
merged = self.bottle.git_user # effective git_user (post-merge)
|
||||
if merged.is_empty():
|
||||
return None
|
||||
parts: list[str] = []
|
||||
if merged.name:
|
||||
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
|
||||
if merged.email:
|
||||
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestIndex:
|
||||
"""Multi-agent/bottle collection. The pre-preflight form.
|
||||
|
||||
In lazy mode (from resolve()/from_md_dirs()) only filenames are scanned;
|
||||
no file content is read. In eager mode (from from_json_obj()) all agents
|
||||
and bottles are pre-parsed. Call load_for_agent() to get a single-value
|
||||
Manifest ready for backend use."""
|
||||
|
||||
bottles: Mapping[str, ManifestBottle]
|
||||
agents: Mapping[str, ManifestAgent]
|
||||
# Set by from_md_dirs; None in from_json_obj (test/programmatic) mode.
|
||||
# Stores the manifest root dirs so load_for_agent can locate files later.
|
||||
home_md: Path | None = field(default=None)
|
||||
cwd_md: Path | None = field(default=None)
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "Manifest":
|
||||
"""Walk the per-file manifest tree and build a Manifest.
|
||||
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex":
|
||||
"""Walk the per-file manifest tree and build a ManifestIndex.
|
||||
|
||||
Layout (PRD 0011):
|
||||
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
|
||||
@@ -208,7 +330,7 @@ class Manifest:
|
||||
boundary.
|
||||
|
||||
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
|
||||
returns an empty manifest instead of dying. This is for
|
||||
returns an empty index instead of dying. This is for
|
||||
passive UI surfaces like the dashboard, which can still
|
||||
monitor already-running agents without launch config.
|
||||
|
||||
@@ -247,25 +369,16 @@ class Manifest:
|
||||
cls,
|
||||
home_dir: Path,
|
||||
cwd_dir: Path | None,
|
||||
) -> "Manifest":
|
||||
"""Programmatic entry point. Loads bottles from
|
||||
`<home_dir>/bottles/`, home agents from `<home_dir>/agents/`,
|
||||
and (if `cwd_dir` is passed) cwd agents from
|
||||
`<cwd_dir>/agents/`. Cwd agents override home agents on
|
||||
name collision. A `bottles/` subdir under `cwd_dir` is
|
||||
logged as a warning and ignored.
|
||||
) -> "ManifestIndex":
|
||||
"""Return a names-only ManifestIndex. No file content is read; only
|
||||
filenames are scanned for the agent selector. Full parsing happens
|
||||
later, per-agent, via `load_for_agent`.
|
||||
|
||||
Used by tests to build a Manifest from fixture directories
|
||||
A `bottles/` subdir under `cwd_dir` is logged as a warning and
|
||||
ignored — the filesystem layout IS the trust boundary.
|
||||
|
||||
Used by tests to build a ManifestIndex from fixture directories
|
||||
without touching `os.environ`."""
|
||||
bottles_dir = home_dir / "bottles"
|
||||
from .manifest_loader import load_agents_from_dir, load_bottles_from_dir
|
||||
|
||||
bottles = load_bottles_from_dir(bottles_dir)
|
||||
|
||||
bottle_names = set(bottles.keys())
|
||||
agents_dir = home_dir / "agents"
|
||||
agents = load_agents_from_dir(agents_dir, bottle_names, source="$HOME")
|
||||
|
||||
if cwd_dir is not None:
|
||||
stale_bottles = cwd_dir / "bottles"
|
||||
if stale_bottles.is_dir():
|
||||
@@ -279,17 +392,11 @@ class Manifest:
|
||||
f"live under $HOME/.bot-bottle/bottles/ "
|
||||
f"(PRD 0011). Move them or delete."
|
||||
)
|
||||
cwd_agents_dir = cwd_dir / "agents"
|
||||
cwd_agents = load_agents_from_dir(
|
||||
cwd_agents_dir, bottle_names, source="$CWD"
|
||||
)
|
||||
agents = {**agents, **cwd_agents}
|
||||
|
||||
return cls(bottles=bottles, agents=agents)
|
||||
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
|
||||
|
||||
@classmethod
|
||||
def from_json_obj(cls, obj: object) -> "Manifest":
|
||||
"""Validate and build a Manifest from a raw JSON-like dict."""
|
||||
def from_json_obj(cls, obj: object) -> "ManifestIndex":
|
||||
"""Validate and build a ManifestIndex from a raw JSON-like dict."""
|
||||
d = as_json_object(obj, "manifest")
|
||||
raw_bottles_obj = _section_dict(d.get("bottles"), "manifest 'bottles'")
|
||||
raw_agents = _section_dict(d.get("agents"), "manifest 'agents'")
|
||||
@@ -310,75 +417,151 @@ class Manifest:
|
||||
}
|
||||
return cls(bottles=bottles, agents=agents)
|
||||
|
||||
@property
|
||||
def all_bottle_names(self) -> list[str]:
|
||||
"""Sorted list of all discoverable bottle names.
|
||||
|
||||
In names-only mode (from resolve/from_md_dirs) this scans bottle
|
||||
filenames without reading their content. In eager mode (from
|
||||
from_json_obj) it returns the pre-parsed bottles' names."""
|
||||
if self.home_md is not None:
|
||||
from .manifest_loader import scan_bottle_names
|
||||
return scan_bottle_names(self.home_md / "bottles")
|
||||
return sorted(self.bottles.keys())
|
||||
|
||||
@property
|
||||
def all_agent_names(self) -> list[str]:
|
||||
"""Sorted list of all discoverable agent names.
|
||||
|
||||
In names-only mode (from resolve/from_md_dirs) this scans agent
|
||||
filenames without reading their content. In eager mode (from
|
||||
from_json_obj) it returns the pre-parsed agents' names."""
|
||||
if self.home_md is not None:
|
||||
from .manifest_loader import scan_agent_names
|
||||
home_names = set(scan_agent_names(self.home_md / "agents").keys())
|
||||
cwd_names: set[str] = set()
|
||||
if self.cwd_md is not None:
|
||||
cwd_names = set(scan_agent_names(self.cwd_md / "agents").keys())
|
||||
return sorted(home_names | cwd_names)
|
||||
return sorted(self.agents.keys())
|
||||
|
||||
def load_for_agent(
|
||||
self,
|
||||
agent_name: str,
|
||||
bottle_names: "tuple[str, ...] | None" = None,
|
||||
) -> "Manifest":
|
||||
"""Parse the named agent and its bottle; return a single-value Manifest.
|
||||
|
||||
`bottle_names` is an ordered list of bottles selected at launch time.
|
||||
When non-empty they are resolved and merged in order (index 0 = base;
|
||||
later entries override). When empty or None, falls back to the agent's
|
||||
own `bottle:` field. Raises ManifestError when neither is set.
|
||||
|
||||
In lazy mode (from resolve/from_md_dirs) the agent file and its
|
||||
bottle chain are read from disk for the first time here. In eager
|
||||
mode (from_json_obj) the data is already parsed; this just filters
|
||||
down to the requested agent and its bottle.
|
||||
|
||||
The returned Manifest.bottle has the agent's git-gate.user already
|
||||
overlaid (agent wins on non-empty, per-field).
|
||||
|
||||
Always raises ManifestError if the agent is unknown or invalid.
|
||||
Backends call this at preflight inside _validate."""
|
||||
effective_bottle_names: tuple[str, ...] = bottle_names or ()
|
||||
|
||||
if self.home_md is None:
|
||||
# Eager manifest (from_json_obj): data already parsed; filter to
|
||||
# the one requested agent and its bottle so the returned Manifest
|
||||
# always holds exactly one agent and one bottle regardless of path.
|
||||
if agent_name not in self.agents:
|
||||
available = ", ".join(sorted(self.agents.keys())) or "(none)"
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' not defined. Available: {available}"
|
||||
)
|
||||
agent = self.agents[agent_name]
|
||||
raw_bottle = _resolve_effective_bottle_eager(
|
||||
agent_name, agent, effective_bottle_names, self.bottles
|
||||
)
|
||||
merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
|
||||
bottle = raw_bottle if merged == raw_bottle.git_user else replace(raw_bottle, git_user=merged)
|
||||
return Manifest(agent=agent, bottle=bottle)
|
||||
|
||||
from .manifest_loader import scan_agent_names
|
||||
from .manifest_schema import validate_agent_frontmatter_keys
|
||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||
|
||||
# Locate the agent file; cwd wins over home on name collision.
|
||||
home_agents = scan_agent_names(self.home_md / "agents")
|
||||
cwd_agents: dict[str, Path] = {}
|
||||
if self.cwd_md is not None:
|
||||
cwd_agents = scan_agent_names(self.cwd_md / "agents")
|
||||
merged_agents = {**home_agents, **cwd_agents}
|
||||
|
||||
if agent_name not in merged_agents:
|
||||
available = ", ".join(sorted(merged_agents.keys())) or "(none)"
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' not defined. Available: {available}"
|
||||
)
|
||||
|
||||
agent_path = merged_agents[agent_name]
|
||||
try:
|
||||
fm, body = parse_frontmatter(agent_path.read_text())
|
||||
except OSError as e:
|
||||
raise ManifestError(f"could not read {agent_path}: {e}") from e
|
||||
except YamlSubsetError as e:
|
||||
raise ManifestError(f"{agent_path}: {e}") from e
|
||||
|
||||
validate_agent_frontmatter_keys(agent_path, fm.keys())
|
||||
|
||||
# Determine the effective bottle name(s).
|
||||
agent_bottle = fm.get("bottle") or ""
|
||||
bottles_dir = self.home_md / "bottles"
|
||||
raw_bottle = _resolve_effective_bottle_lazy(
|
||||
agent_name, str(agent_bottle), effective_bottle_names, bottles_dir
|
||||
)
|
||||
effective_bottle_name = (
|
||||
effective_bottle_names[-1] if effective_bottle_names
|
||||
else str(agent_bottle)
|
||||
)
|
||||
|
||||
# Build and validate the full ManifestAgent.
|
||||
agent_dict: dict[str, object] = {
|
||||
"skills": fm.get("skills", []),
|
||||
"prompt": body.strip(),
|
||||
}
|
||||
if agent_bottle:
|
||||
agent_dict["bottle"] = agent_bottle
|
||||
if "git-gate" in fm:
|
||||
agent_dict["git-gate"] = fm["git-gate"]
|
||||
# Pass the effective bottle name as the known-bottles set so agents
|
||||
# that have bottle: set are validated; agents without bottle: pass {}
|
||||
# since bottle_names were already resolved above.
|
||||
known = {effective_bottle_name} if effective_bottle_name else set()
|
||||
agent = ManifestAgent.from_dict(agent_name, agent_dict, known)
|
||||
|
||||
merged_user = _merge_git_user(agent.git_user, raw_bottle.git_user)
|
||||
bottle = raw_bottle if merged_user == raw_bottle.git_user else replace(raw_bottle, git_user=merged_user)
|
||||
return Manifest(agent=agent, bottle=bottle)
|
||||
|
||||
def has_agent(self, name: str) -> bool:
|
||||
return name in self.agents
|
||||
|
||||
def require_agent(self, name: str) -> None:
|
||||
"""Check that `name` is a discoverable agent. In names-only mode
|
||||
this checks whether the .md file exists; in eager mode it checks
|
||||
the pre-parsed agents dict. Does NOT parse file content."""
|
||||
if self.has_agent(name):
|
||||
return
|
||||
available = ", ".join(self.agents.keys())
|
||||
if available:
|
||||
msg = f"agent '{name}' not defined in bot-bottle.json. Available: {available}"
|
||||
raise ManifestError(msg)
|
||||
raise ManifestError(
|
||||
f"agent '{name}' not defined in bot-bottle.json (manifest is empty)."
|
||||
)
|
||||
|
||||
def has_bottle(self, name: str) -> bool:
|
||||
return name in self.bottles
|
||||
|
||||
def require_bottle(self, name: str) -> None:
|
||||
if self.has_bottle(name):
|
||||
return
|
||||
available = ", ".join(self.bottles.keys())
|
||||
if available:
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' not defined in bot-bottle.json. "
|
||||
f"Available bottles: {available}"
|
||||
if self.home_md is not None:
|
||||
# Names-only mode: check file existence without parsing.
|
||||
home_path = self.home_md / "agents" / f"{name}.md"
|
||||
cwd_path = (
|
||||
self.cwd_md / "agents" / f"{name}.md"
|
||||
if self.cwd_md else None
|
||||
)
|
||||
raise ManifestError(f"bottle '{name}' not defined in bot-bottle.json (no bottles defined).")
|
||||
|
||||
def _effective_git_user(self, agent_name: str) -> ManifestGitUser:
|
||||
"""Merge the agent's git.user over the referenced bottle's,
|
||||
per-field, agent-wins-on-non-empty (issue #94). Same overlay
|
||||
the `extends:` resolver applies between bottles
|
||||
(`_merge_bottles`)."""
|
||||
agent = self.agents[agent_name]
|
||||
base = self.bottles[agent.bottle].git_user
|
||||
over = agent.git_user
|
||||
if over.is_empty():
|
||||
return base
|
||||
return ManifestGitUser(
|
||||
name=over.name or base.name,
|
||||
email=over.email or base.email,
|
||||
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
|
||||
return
|
||||
available = ", ".join(self.all_agent_names) or "(none)"
|
||||
raise ManifestError(
|
||||
f"agent '{name}' not defined. Available: {available}"
|
||||
)
|
||||
|
||||
def bottle_for(self, agent_name: str) -> ManifestBottle:
|
||||
"""Resolve the Bottle the named agent references, with the
|
||||
agent's git.user overlaid on top. The validator guarantees both
|
||||
lookups succeed for a manifest built via from_json_obj.
|
||||
|
||||
The overlay lives here, the single point both backends call to
|
||||
resolve an agent's bottle, so the docker / smolmachines git
|
||||
provisioners pick up the merged identity unchanged."""
|
||||
bottle = self.bottles[self.agents[agent_name].bottle]
|
||||
merged = self._effective_git_user(agent_name)
|
||||
if merged == bottle.git_user:
|
||||
return bottle
|
||||
return replace(bottle, git_user=merged)
|
||||
|
||||
def git_identity_summary(self, agent_name: str) -> str | None:
|
||||
"""One-line effective git identity with per-field provenance
|
||||
for launch summaries, e.g.
|
||||
`name=claude (agent), email=eric@dideric.is (bottle)`.
|
||||
Returns None when neither agent nor bottle sets an identity."""
|
||||
over = self.agents[agent_name].git_user
|
||||
merged = self._effective_git_user(agent_name)
|
||||
if merged.is_empty():
|
||||
return None
|
||||
parts: list[str] = []
|
||||
if merged.name:
|
||||
parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
|
||||
if merged.email:
|
||||
parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
|
||||
return ", ".join(parts)
|
||||
|
||||
@@ -109,7 +109,8 @@ class ManifestAgentProvider:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestAgent:
|
||||
bottle: str
|
||||
# Optional: when empty the operator selects bottles at launch time.
|
||||
bottle: str = ""
|
||||
skills: tuple[str, ...] = ()
|
||||
prompt: str = ""
|
||||
# Per-agent git identity (issue #94). Overlays the referenced
|
||||
@@ -129,18 +130,20 @@ class ManifestAgent:
|
||||
f"allowed keys are {allowed}."
|
||||
)
|
||||
|
||||
bottle = d.get("bottle")
|
||||
if not isinstance(bottle, str) or not bottle:
|
||||
raise ManifestError(
|
||||
f"agent '{name}' must declare a 'bottle' field naming a "
|
||||
f"defined bottle"
|
||||
)
|
||||
if bottle not in bottle_names:
|
||||
available = ", ".join(sorted(bottle_names)) or "(none defined)"
|
||||
raise ManifestError(
|
||||
f"agent '{name}' references bottle '{bottle}', which is not defined. "
|
||||
f"Available: {available}"
|
||||
)
|
||||
bottle_raw = d.get("bottle")
|
||||
bottle = ""
|
||||
if bottle_raw is not None:
|
||||
if not isinstance(bottle_raw, str) or not bottle_raw:
|
||||
raise ManifestError(
|
||||
f"agent '{name}' bottle must be a non-empty string when declared"
|
||||
)
|
||||
if bottle_raw not in bottle_names:
|
||||
available = ", ".join(sorted(bottle_names)) or "(none defined)"
|
||||
raise ManifestError(
|
||||
f"agent '{name}' references bottle '{bottle_raw}', which is not defined. "
|
||||
f"Available: {available}"
|
||||
)
|
||||
bottle = bottle_raw
|
||||
|
||||
skills: tuple[str, ...] = ()
|
||||
skills_raw = d.get("skills")
|
||||
@@ -199,13 +202,10 @@ def _parse_provider_settings(
|
||||
) -> dict[str, object]:
|
||||
if raw is None:
|
||||
return {}
|
||||
if template != "pi":
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings is only "
|
||||
"supported for template 'pi'"
|
||||
)
|
||||
settings = as_json_object(raw, f"bottle '{bottle_name}' agent_provider.settings")
|
||||
allowed = {
|
||||
|
||||
common_allowed = {"startup_args"}
|
||||
pi_allowed = {
|
||||
"provider",
|
||||
"base_url",
|
||||
"api",
|
||||
@@ -218,12 +218,37 @@ def _parse_provider_settings(
|
||||
"supports_developer_role",
|
||||
"supports_reasoning_effort",
|
||||
}
|
||||
if template == "pi":
|
||||
allowed = common_allowed | pi_allowed
|
||||
elif template in ("claude", "codex"):
|
||||
allowed = common_allowed
|
||||
elif template not in PROVIDER_TEMPLATES:
|
||||
return dict(settings)
|
||||
else:
|
||||
allowed = common_allowed
|
||||
|
||||
for key in settings:
|
||||
if key not in allowed:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings has unknown "
|
||||
f"key {key!r}; allowed: {', '.join(sorted(allowed))}"
|
||||
)
|
||||
startup_args = settings.get("startup_args")
|
||||
if startup_args is not None:
|
||||
if not isinstance(startup_args, list):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings.startup_args "
|
||||
f"must be an array of strings"
|
||||
)
|
||||
for i, arg in enumerate(startup_args):
|
||||
if not isinstance(arg, str) or not arg:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' agent_provider.settings."
|
||||
f"startup_args[{i}] must be a non-empty string"
|
||||
)
|
||||
if template != "pi":
|
||||
return dict(settings)
|
||||
|
||||
for key in ("provider", "base_url", "api", "api_key", "api_key_env"):
|
||||
value = settings.get(key)
|
||||
if value is not None and (not isinstance(value, str) or not value):
|
||||
|
||||
@@ -21,6 +21,9 @@ VALID_METHODS = frozenset({
|
||||
OUTBOUND_DETECTOR_NAMES = frozenset({"token_patterns", "known_secrets"})
|
||||
INBOUND_DETECTOR_NAMES = frozenset({"naive_injection_detection"})
|
||||
|
||||
# What the proxy does on an outbound token match (PRD 0062).
|
||||
OUTBOUND_ON_MATCH_VALUES = ("block", "redact", "supervise")
|
||||
|
||||
|
||||
def validate_egress_routes(
|
||||
bottle_name: str,
|
||||
@@ -67,6 +70,7 @@ class ManifestEgressRoute:
|
||||
GitFetch: bool = False
|
||||
OutboundDetectors: tuple[str, ...] | None = None
|
||||
InboundDetectors: tuple[str, ...] | None = None
|
||||
OutboundOnMatch: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, idx: int, raw: object) -> "ManifestEgressRoute":
|
||||
@@ -161,8 +165,9 @@ class ManifestEgressRoute:
|
||||
# --- dlp ---
|
||||
outbound_detectors: tuple[str, ...] | None = None
|
||||
inbound_detectors: tuple[str, ...] | None = None
|
||||
outbound_on_match = ""
|
||||
if "dlp" in d:
|
||||
outbound_detectors, inbound_detectors = _parse_dlp_block(
|
||||
outbound_detectors, inbound_detectors, outbound_on_match = _parse_dlp_block(
|
||||
label, d.get("dlp"),
|
||||
)
|
||||
|
||||
@@ -201,6 +206,7 @@ class ManifestEgressRoute:
|
||||
GitFetch=git_fetch,
|
||||
OutboundDetectors=outbound_detectors,
|
||||
InboundDetectors=inbound_detectors,
|
||||
OutboundOnMatch=outbound_on_match,
|
||||
)
|
||||
|
||||
|
||||
@@ -323,7 +329,7 @@ def _parse_header_match(
|
||||
def _parse_dlp_block(
|
||||
route_label: str,
|
||||
raw: object,
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None]:
|
||||
) -> tuple[tuple[str, ...] | None, tuple[str, ...] | None, str]:
|
||||
label = f"{route_label} dlp"
|
||||
d = as_json_object(raw, label)
|
||||
|
||||
@@ -358,13 +364,24 @@ def _parse_dlp_block(
|
||||
outbound = _parse_field("outbound_detectors", OUTBOUND_DETECTOR_NAMES)
|
||||
inbound = _parse_field("inbound_detectors", INBOUND_DETECTOR_NAMES)
|
||||
|
||||
on_match = ""
|
||||
on_match_raw = d.get("outbound_on_match")
|
||||
if on_match_raw is not None:
|
||||
if not isinstance(on_match_raw, str) or on_match_raw not in OUTBOUND_ON_MATCH_VALUES:
|
||||
raise ManifestError(
|
||||
f"{label} outbound_on_match must be one of "
|
||||
f"{', '.join(OUTBOUND_ON_MATCH_VALUES)} (got {on_match_raw!r})"
|
||||
)
|
||||
on_match = on_match_raw
|
||||
|
||||
for k in d:
|
||||
if k not in ("outbound_detectors", "inbound_detectors"):
|
||||
if k not in ("outbound_detectors", "inbound_detectors", "outbound_on_match"):
|
||||
raise ManifestError(
|
||||
f"{label} has unknown key {k!r}; accepted keys are "
|
||||
f"'outbound_detectors', 'inbound_detectors'"
|
||||
f"'outbound_detectors', 'inbound_detectors', "
|
||||
f"'outbound_on_match'"
|
||||
)
|
||||
return outbound, inbound
|
||||
return outbound, inbound, on_match
|
||||
|
||||
|
||||
LOG_LEVELS = frozenset({0, 1, 2})
|
||||
|
||||
+224
-32
@@ -5,16 +5,72 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import ManifestBottle, ManifestGitEntry
|
||||
from .manifest import ManifestBottle
|
||||
from .manifest_egress import ManifestEgressConfig
|
||||
|
||||
|
||||
def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
||||
"""Merge an ordered list of pre-resolved ManifestBottle objects.
|
||||
|
||||
Index 0 is the base; each subsequent entry is applied on top using
|
||||
the same field-merge rules as the file-based extends machinery:
|
||||
env: dict merge, later wins; git_user: per-field overlay, later
|
||||
wins on non-empty; git (repos): union by name, later wins; egress
|
||||
routes: concatenate; agent_provider, supervise: later replaces.
|
||||
"""
|
||||
if not bottles:
|
||||
raise ValueError("merge_bottles_runtime requires at least one bottle")
|
||||
result = bottles[0]
|
||||
for override in bottles[1:]:
|
||||
result = _merge_two_bottles_runtime(result, override)
|
||||
return result
|
||||
|
||||
|
||||
def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle") -> "ManifestBottle":
|
||||
from .manifest import ManifestBottle, ManifestGitUser
|
||||
from .manifest_egress import ManifestEgressConfig
|
||||
|
||||
merged_env = {**base.env, **override.env}
|
||||
|
||||
merged_git_user = ManifestGitUser(
|
||||
name=override.git_user.name or base.git_user.name,
|
||||
email=override.git_user.email or base.git_user.email,
|
||||
)
|
||||
|
||||
# git repos: union keyed by Name, override wins per-name.
|
||||
base_repos_by_name = {entry.Name: entry for entry in base.git}
|
||||
override_repos_by_name = {entry.Name: entry for entry in override.git}
|
||||
merged_repos_names = list(base_repos_by_name) + [
|
||||
n for n in override_repos_by_name if n not in base_repos_by_name
|
||||
]
|
||||
merged_git = tuple(
|
||||
override_repos_by_name.get(n, base_repos_by_name[n])
|
||||
for n in merged_repos_names
|
||||
)
|
||||
|
||||
merged_routes = base.egress.routes + override.egress.routes
|
||||
merged_egress = ManifestEgressConfig(routes=merged_routes, Log=override.egress.Log)
|
||||
|
||||
return ManifestBottle(
|
||||
env=merged_env,
|
||||
agent_provider=override.agent_provider,
|
||||
git=merged_git,
|
||||
git_user=merged_git_user,
|
||||
egress=merged_egress,
|
||||
supervise=override.supervise,
|
||||
)
|
||||
|
||||
|
||||
def resolve_bottles(raws: dict[str, dict[str, object]]) -> dict[str, ManifestBottle]:
|
||||
"""Apply `extends:` chains and return resolved ManifestBottle objects."""
|
||||
cache: dict[str, ManifestBottle] = {}
|
||||
# Per-bottle effective git-gate.repos, as raw dicts keyed by repo name.
|
||||
# Threaded alongside `cache` so a child can field-merge against its
|
||||
# parent's repos without reconstructing them from parsed entries.
|
||||
repos_cache: dict[str, dict[str, object]] = {}
|
||||
for name in raws:
|
||||
if name not in cache:
|
||||
_resolve_one_bottle(name, raws, cache, ())
|
||||
_resolve_one_bottle(name, raws, cache, repos_cache, ())
|
||||
return cache
|
||||
|
||||
|
||||
@@ -22,6 +78,7 @@ def _resolve_one_bottle(
|
||||
name: str,
|
||||
raws: dict[str, dict[str, object]],
|
||||
cache: dict[str, ManifestBottle],
|
||||
repos_cache: dict[str, dict[str, object]],
|
||||
seen: tuple[str, ...],
|
||||
) -> ManifestBottle:
|
||||
from .manifest import ManifestBottle, ManifestError
|
||||
@@ -41,39 +98,145 @@ def _resolve_one_bottle(
|
||||
if parent_name_raw is None:
|
||||
bottle = ManifestBottle.from_dict(name, child_raw)
|
||||
cache[name] = bottle
|
||||
repos_cache[name] = _resolve_repos_raw({}, child_raw)
|
||||
return bottle
|
||||
|
||||
if not isinstance(parent_name_raw, str):
|
||||
# Normalize to list, accepting both str and list[str].
|
||||
raw_list: list[object]
|
||||
if isinstance(parent_name_raw, str):
|
||||
raw_list = [parent_name_raw]
|
||||
elif isinstance(parent_name_raw, list):
|
||||
raw_list = parent_name_raw
|
||||
else:
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends must be a string "
|
||||
f"bottle '{name}' extends must be a string or list of strings "
|
||||
f"(was {type(parent_name_raw).__name__})"
|
||||
)
|
||||
parent_name: str = parent_name_raw
|
||||
if parent_name == name:
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends itself; remove the "
|
||||
f"self-reference"
|
||||
)
|
||||
if parent_name not in raws:
|
||||
avail = ", ".join(sorted(raws.keys())) or "(none)"
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends '{parent_name}' which is not "
|
||||
f"defined. Available bottles: {avail}"
|
||||
)
|
||||
parent = _resolve_one_bottle(parent_name, raws, cache, seen + (name,))
|
||||
bottle = _merge_bottles(parent, child_raw, name)
|
||||
|
||||
# Validate each entry before resolving any of them.
|
||||
parent_names: list[str] = []
|
||||
for i, pname in enumerate(raw_list):
|
||||
if not isinstance(pname, str):
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends[{i}] must be a string "
|
||||
f"(was {type(pname).__name__})"
|
||||
)
|
||||
parent_names.append(pname)
|
||||
if pname == name:
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends itself; remove the self-reference"
|
||||
)
|
||||
if pname not in raws:
|
||||
avail = ", ".join(sorted(raws.keys())) or "(none)"
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' extends '{pname}' which is not "
|
||||
f"defined. Available bottles: {avail}"
|
||||
)
|
||||
|
||||
combined_parent, combined_repos_raw = _fold_parents(
|
||||
parent_names, raws, cache, repos_cache, seen + (name,)
|
||||
)
|
||||
merged_repos_raw = _resolve_repos_raw(combined_repos_raw, child_raw)
|
||||
bottle = _merge_bottles(combined_parent, child_raw, merged_repos_raw, name)
|
||||
cache[name] = bottle
|
||||
repos_cache[name] = merged_repos_raw
|
||||
return bottle
|
||||
|
||||
|
||||
def _fold_parents(
|
||||
parent_names: list[str],
|
||||
raws: dict[str, dict[str, object]],
|
||||
cache: dict[str, ManifestBottle],
|
||||
repos_cache: dict[str, dict[str, object]],
|
||||
seen: tuple[str, ...],
|
||||
) -> tuple[ManifestBottle, dict[str, object]]:
|
||||
"""Resolve each parent and fold them left-to-right.
|
||||
|
||||
Later parents win over earlier ones on conflict. The `seen` tuple
|
||||
carries the current bottle's name so cycle detection works across
|
||||
every parent edge in the multi-parent graph."""
|
||||
first = parent_names[0]
|
||||
effective = _resolve_one_bottle(first, raws, cache, repos_cache, seen)
|
||||
effective_repos_raw = repos_cache[first]
|
||||
for pname in parent_names[1:]:
|
||||
later = _resolve_one_bottle(pname, raws, cache, repos_cache, seen)
|
||||
later_repos_raw = repos_cache[pname]
|
||||
effective, effective_repos_raw = _fold_two_bottles(
|
||||
effective, effective_repos_raw, later, later_repos_raw
|
||||
)
|
||||
return effective, effective_repos_raw
|
||||
|
||||
|
||||
def _fold_two_bottles(
|
||||
earlier: ManifestBottle,
|
||||
earlier_repos_raw: dict[str, object],
|
||||
later: ManifestBottle,
|
||||
later_repos_raw: dict[str, object],
|
||||
) -> tuple[ManifestBottle, dict[str, object]]:
|
||||
"""Combine two resolved parent bottles; later wins over earlier."""
|
||||
from .manifest import ManifestBottle, ManifestGitUser
|
||||
from .manifest_egress import ManifestEgressConfig
|
||||
from .manifest_git import parse_git_gate_config
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
merged_env = {**earlier.env, **later.env}
|
||||
|
||||
merged_git_user = ManifestGitUser(
|
||||
name=later.git_user.name or earlier.git_user.name,
|
||||
email=later.git_user.email or earlier.git_user.email,
|
||||
)
|
||||
|
||||
# Repos: union by name; for same-name entries, later wins per-field.
|
||||
# Unlike _resolve_repos_raw, an empty later_repos_raw means "no repos
|
||||
# declared" — it does NOT clear the earlier parent's repos.
|
||||
names = list(earlier_repos_raw) + [
|
||||
n for n in later_repos_raw if n not in earlier_repos_raw
|
||||
]
|
||||
merged_repos_raw: dict[str, object] = {
|
||||
n: {
|
||||
**as_json_object(earlier_repos_raw.get(n, {}), "earlier parent repo"),
|
||||
**as_json_object(later_repos_raw.get(n, {}), "later parent repo"),
|
||||
}
|
||||
for n in names
|
||||
}
|
||||
if merged_repos_raw:
|
||||
merged_git, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
|
||||
else:
|
||||
merged_git = ()
|
||||
|
||||
# Egress: routes concatenate; scalar fields use last-wins.
|
||||
merged_egress = ManifestEgressConfig(
|
||||
routes=earlier.egress.routes + later.egress.routes,
|
||||
Log=later.egress.Log,
|
||||
)
|
||||
|
||||
return ManifestBottle(
|
||||
env=merged_env,
|
||||
agent_provider=later.agent_provider,
|
||||
git=merged_git,
|
||||
git_user=merged_git_user,
|
||||
egress=merged_egress,
|
||||
supervise=later.supervise,
|
||||
), merged_repos_raw
|
||||
|
||||
|
||||
def _merge_bottles(
|
||||
parent: ManifestBottle,
|
||||
child_raw: dict[str, object],
|
||||
merged_repos_raw: dict[str, object],
|
||||
name: str,
|
||||
) -> ManifestBottle:
|
||||
"""Apply PRD 0025 merge rules."""
|
||||
from .manifest import ManifestBottle, ManifestGitUser
|
||||
from .manifest_egress import validate_egress_routes
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
# git-gate.repos: when the child declares repos, inject the already
|
||||
# name-merged repo set (computed by _resolve_repos_raw) so the child
|
||||
# parses with the full inherited+overridden list (issue #237).
|
||||
if _child_declares_git_gate_repos(child_raw):
|
||||
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
||||
child_raw = {**child_raw, "git-gate": {**git_raw, "repos": merged_repos_raw}}
|
||||
|
||||
# Parse the child's declared fields into a ManifestBottle (with the
|
||||
# usual defaults for anything missing). Validation runs the same
|
||||
@@ -92,11 +255,11 @@ def _merge_bottles(
|
||||
email=child.git_user.email or parent.git_user.email,
|
||||
)
|
||||
|
||||
# git-gate.repos: missing means inherit; an explicit empty object
|
||||
# clears; otherwise parent and child merge by UpstreamHost with
|
||||
# child entries replacing duplicate hosts.
|
||||
# git-gate.repos: when declared, child.git already holds the merged
|
||||
# set (an explicit empty dict clears parent, leaving child.git empty).
|
||||
# When omitted, the parent's entries are inherited verbatim.
|
||||
if _child_declares_git_gate_repos(child_raw):
|
||||
merged_git = _merge_git_remotes(parent.git, child.git) if child.git else ()
|
||||
merged_git = child.git
|
||||
else:
|
||||
merged_git = parent.git
|
||||
|
||||
@@ -130,6 +293,45 @@ def _merge_bottles(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_repos_raw(
|
||||
parent_repos: dict[str, object],
|
||||
child_raw: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""Compute a bottle's effective git-gate.repos as raw dicts.
|
||||
|
||||
Repos are keyed by name. When the child omits git-gate.repos it
|
||||
inherits the parent's set verbatim; an explicit empty dict clears it.
|
||||
Otherwise parent and child unite by name, with same-name entries
|
||||
field-merged (parent fields are defaults, child fields win)."""
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
if not _child_declares_git_gate_repos(child_raw):
|
||||
return parent_repos
|
||||
child_repos = _declared_repos_raw(child_raw)
|
||||
if not child_repos:
|
||||
return {}
|
||||
# Parent entries keep their order; child-only names are appended.
|
||||
names = list(parent_repos) + [n for n in child_repos if n not in parent_repos]
|
||||
return {
|
||||
name: {
|
||||
**as_json_object(parent_repos.get(name, {}), "parent git-gate repo"),
|
||||
**as_json_object(child_repos.get(name, {}), "child git-gate repo"),
|
||||
}
|
||||
for name in names
|
||||
}
|
||||
|
||||
|
||||
def _declared_repos_raw(child_raw: dict[str, object]) -> dict[str, object]:
|
||||
"""Return the child's explicitly declared git-gate.repos as raw dicts,
|
||||
or an empty dict when none are declared."""
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
if not _child_declares_git_gate_repos(child_raw):
|
||||
return {}
|
||||
git_raw = as_json_object(child_raw.get("git-gate", {}), "child git-gate")
|
||||
return as_json_object(git_raw.get("repos", {}), "child git-gate.repos")
|
||||
|
||||
|
||||
def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
||||
from .manifest_util import as_json_object
|
||||
|
||||
@@ -140,16 +342,6 @@ def _child_declares_git_gate_repos(child_raw: dict[str, object]) -> bool:
|
||||
return "repos" in git_obj
|
||||
|
||||
|
||||
def _merge_git_remotes(
|
||||
parent: tuple[ManifestGitEntry, ...],
|
||||
child: tuple[ManifestGitEntry, ...],
|
||||
) -> tuple[ManifestGitEntry, ...]:
|
||||
by_host = {entry.UpstreamHost: entry for entry in parent}
|
||||
for entry in child:
|
||||
by_host[entry.UpstreamHost] = entry
|
||||
return tuple(by_host.values())
|
||||
|
||||
|
||||
def _merge_egress(
|
||||
parent: ManifestEgressConfig,
|
||||
child: ManifestEgressConfig,
|
||||
|
||||
+73
-66
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from .manifest_util import ManifestError, as_json_object
|
||||
|
||||
@@ -13,6 +12,8 @@ from .manifest_util import ManifestError, as_json_object
|
||||
# defence; this regex is belt-and-suspenders and documents intent).
|
||||
_GIT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
|
||||
_KEY_PROVIDERS = {"static", "gitea"}
|
||||
|
||||
|
||||
def _opt_str(value: object, label: str) -> str:
|
||||
if value is None:
|
||||
@@ -69,20 +70,22 @@ def validate_unique_git_names(bottle_name: str, git: tuple[ManifestGitEntry, ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestProvisionedKeyConfig:
|
||||
"""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.
|
||||
class ManifestKeyConfig:
|
||||
"""Configuration for a repo's SSH key in git-gate.repos.
|
||||
|
||||
`provider` names the contrib sub-package to load (e.g. `gitea`).
|
||||
`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
|
||||
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` is either `"static"` (a pre-existing key on the host) or
|
||||
`"gitea"` (automatic deploy-key lifecycle via the Gitea API).
|
||||
|
||||
For `static`: `path` is the host-side absolute path to the SSH private key.
|
||||
|
||||
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
|
||||
token_env: str
|
||||
path: str = ""
|
||||
forge_token_env: str = ""
|
||||
api_url: str = ""
|
||||
|
||||
|
||||
@@ -99,15 +102,16 @@ class ManifestGitEntry:
|
||||
stashed in the `Upstream*` fields so the git-gate render step
|
||||
doesn't have to re-parse.
|
||||
|
||||
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). Exactly
|
||||
one of `identity` (static key path) or `provisioned_key` (automatic
|
||||
lifecycle) must be present. The internal field names are stable."""
|
||||
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). A `key`
|
||||
block is required; `key.provider` is `"static"` or `"gitea"`. For
|
||||
`static`, `IdentityFile` is populated at parse time from `key.path`.
|
||||
For `gitea`, `IdentityFile` is populated at provision time."""
|
||||
|
||||
Name: str
|
||||
Upstream: str
|
||||
Key: ManifestKeyConfig = ManifestKeyConfig(provider="")
|
||||
IdentityFile: str = ""
|
||||
KnownHostKey: str = ""
|
||||
ProvisionedKey: Optional[ManifestProvisionedKeyConfig] = None
|
||||
RemoteKey: str = ""
|
||||
UpstreamUser: str = ""
|
||||
UpstreamHost: str = ""
|
||||
@@ -120,8 +124,8 @@ class ManifestGitEntry:
|
||||
) -> "ManifestGitEntry":
|
||||
"""Parse one entry from `git-gate.repos.<repo_name>`.
|
||||
|
||||
YAML keys: `url` (required), exactly one of `identity` or
|
||||
`provisioned_key` (required), `host_key` (optional).
|
||||
YAML keys: `url` (required), `key` (required object with
|
||||
`provider`, and provider-specific fields), `host_key` (optional).
|
||||
The repo_name becomes `Name`."""
|
||||
if not repo_name:
|
||||
raise ManifestError(
|
||||
@@ -135,10 +139,10 @@ class ManifestGitEntry:
|
||||
label = f"git-gate.repos[{repo_name!r}]"
|
||||
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
|
||||
for k in d:
|
||||
if k not in {"url", "identity", "provisioned_key", "host_key"}:
|
||||
if k not in {"url", "key", "host_key"}:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
|
||||
f"allowed: url, identity, provisioned_key, host_key"
|
||||
f"allowed: url, key, host_key"
|
||||
)
|
||||
upstream = d.get("url")
|
||||
if not isinstance(upstream, str) or not upstream:
|
||||
@@ -146,32 +150,13 @@ class ManifestGitEntry:
|
||||
f"bottle '{bottle_name}' {label} missing required string field 'url'"
|
||||
)
|
||||
|
||||
has_identity = "identity" in d
|
||||
has_provisioned = "provisioned_key" in d
|
||||
if has_identity and has_provisioned:
|
||||
if "key" not in d:
|
||||
raise ManifestError(
|
||||
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."
|
||||
f"bottle '{bottle_name}' {label} missing required 'key' block"
|
||||
)
|
||||
key_config = _parse_key_config(bottle_name, label, d["key"])
|
||||
|
||||
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"]
|
||||
)
|
||||
ident = key_config.path if key_config.provider == "static" else ""
|
||||
|
||||
khk = _opt_str(
|
||||
d.get("host_key"),
|
||||
@@ -183,9 +168,9 @@ class ManifestGitEntry:
|
||||
return cls(
|
||||
Name=repo_name,
|
||||
Upstream=upstream,
|
||||
Key=key_config,
|
||||
IdentityFile=ident,
|
||||
KnownHostKey=khk,
|
||||
ProvisionedKey=provisioned_key,
|
||||
RemoteKey=host,
|
||||
UpstreamUser=user,
|
||||
UpstreamHost=host,
|
||||
@@ -194,38 +179,60 @@ class ManifestGitEntry:
|
||||
)
|
||||
|
||||
|
||||
def _parse_provisioned_key_config(
|
||||
def _parse_key_config(
|
||||
bottle_name: str, label: str, raw: object
|
||||
) -> ManifestProvisionedKeyConfig:
|
||||
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"
|
||||
)
|
||||
) -> ManifestKeyConfig:
|
||||
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.key")
|
||||
provider = d.get("provider")
|
||||
if not isinstance(provider, str) or not provider:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
|
||||
f"bottle '{bottle_name}' {label}.key missing required "
|
||||
f"string field 'provider'"
|
||||
)
|
||||
token_env = d.get("token_env")
|
||||
if not isinstance(token_env, str) or not token_env:
|
||||
if provider not in _KEY_PROVIDERS:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
|
||||
f"string field 'token_env'"
|
||||
f"bottle '{bottle_name}' {label}.key provider {provider!r} is unknown; "
|
||||
f"allowed: {', '.join(sorted(_KEY_PROVIDERS))}"
|
||||
)
|
||||
api_url_raw = d.get("api_url", "")
|
||||
if not isinstance(api_url_raw, str):
|
||||
|
||||
if provider == "gitea":
|
||||
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(
|
||||
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
|
||||
f"bottle '{bottle_name}' {label}.key missing required "
|
||||
f"string field 'path' for provider 'static'"
|
||||
)
|
||||
return ManifestProvisionedKeyConfig(
|
||||
provider=provider,
|
||||
token_env=token_env,
|
||||
api_url=api_url_raw,
|
||||
)
|
||||
return ManifestKeyConfig(provider=provider, path=path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -8,21 +8,19 @@ from typing import TYPE_CHECKING
|
||||
from .log import warn
|
||||
from .manifest_schema import (
|
||||
entity_name_from_path,
|
||||
validate_agent_frontmatter_keys,
|
||||
validate_bottle_frontmatter_keys,
|
||||
)
|
||||
from .manifest_util import ManifestError
|
||||
from .yaml_subset import YamlSubsetError, parse_frontmatter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .manifest import ManifestAgent, ManifestBottle
|
||||
from .manifest import ManifestBottle
|
||||
|
||||
|
||||
def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
||||
"""Die if `<dir_path>/bot-bottle.json` exists but `md_dir` does
|
||||
not. The manifest format changed in PRD 0011 and we do not want
|
||||
to silently leave the JSON content unused."""
|
||||
from .manifest import ManifestError
|
||||
|
||||
legacy = dir_path / "bot-bottle.json"
|
||||
if legacy.is_file() and not md_dir.exists():
|
||||
raise ManifestError(
|
||||
@@ -34,15 +32,13 @@ def check_stale_json(dir_path: Path, md_dir: Path, label: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
|
||||
"""Walk `<bottles_dir>/*.md`, parse each as a bottle, and return
|
||||
`{name: Bottle}`. Missing dir returns an empty dict."""
|
||||
from .manifest import ManifestError
|
||||
from .manifest_extends import resolve_bottles
|
||||
def scan_bottle_names(bottles_dir: Path) -> list[str]:
|
||||
"""Scan `<bottles_dir>/*.md` for valid filenames and return sorted bottle names.
|
||||
|
||||
raws: dict[str, dict[str, object]] = {}
|
||||
No file content is read. Invalid filenames are skipped with a warning."""
|
||||
result: list[str] = []
|
||||
if not bottles_dir.is_dir():
|
||||
return {}
|
||||
return result
|
||||
for path in sorted(bottles_dir.glob("*.md")):
|
||||
name = entity_name_from_path(path)
|
||||
if name is None:
|
||||
@@ -51,31 +47,17 @@ def load_bottles_from_dir(bottles_dir: Path) -> dict[str, ManifestBottle]:
|
||||
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
fm, _body = parse_frontmatter(path.read_text())
|
||||
except OSError as e:
|
||||
raise ManifestError(f"could not read {path}: {e}") from e
|
||||
except YamlSubsetError as e:
|
||||
raise ManifestError(f"{path}: {e}") from e
|
||||
validate_bottle_frontmatter_keys(path, fm.keys())
|
||||
raws[name] = fm
|
||||
return resolve_bottles(raws)
|
||||
result.append(name)
|
||||
return result
|
||||
|
||||
|
||||
def load_agents_from_dir(
|
||||
agents_dir: Path,
|
||||
bottle_names: set[str],
|
||||
*,
|
||||
source: str, # noqa: F841 — unused, but required by interface
|
||||
) -> dict[str, ManifestAgent]:
|
||||
"""Walk `<agents_dir>/*.md`, parse each as an agent, and return
|
||||
`{name: Agent}`. The Markdown body becomes the agent's prompt.
|
||||
Missing dir returns an empty dict."""
|
||||
from .manifest import ManifestAgent, ManifestError
|
||||
def scan_agent_names(agents_dir: Path) -> dict[str, Path]:
|
||||
"""Scan `<agents_dir>/*.md` for valid filenames and return `{name: path}`.
|
||||
|
||||
out: dict[str, ManifestAgent] = {}
|
||||
No file content is read. Invalid filenames are skipped with a warning."""
|
||||
result: dict[str, Path] = {}
|
||||
if not agents_dir.is_dir():
|
||||
return out
|
||||
return result
|
||||
for path in sorted(agents_dir.glob("*.md")):
|
||||
name = entity_name_from_path(path)
|
||||
if name is None:
|
||||
@@ -84,22 +66,47 @@ def load_agents_from_dir(
|
||||
f"[a-z][a-z0-9-]*.md (got {path.name!r})"
|
||||
)
|
||||
continue
|
||||
result[name] = path
|
||||
return result
|
||||
|
||||
|
||||
def load_bottle_chain_from_dir(
|
||||
bottle_name: str, bottles_dir: Path
|
||||
) -> ManifestBottle:
|
||||
"""Load `bottle_name` and its full `extends:` chain from `bottles_dir`,
|
||||
returning the resolved ManifestBottle.
|
||||
|
||||
Only the files in the extends chain are read — unrelated bottle files
|
||||
are never touched. Raises ManifestError on parse or validation failure."""
|
||||
from .manifest_extends import resolve_bottles
|
||||
|
||||
raws: dict[str, dict[str, object]] = {}
|
||||
to_load = [bottle_name]
|
||||
while to_load:
|
||||
name = to_load.pop()
|
||||
if name in raws:
|
||||
continue
|
||||
path = bottles_dir / f"{name}.md"
|
||||
if not path.is_file():
|
||||
avail = ", ".join(
|
||||
p.stem for p in sorted(bottles_dir.glob("*.md")) if p.is_file()
|
||||
) or "(none)"
|
||||
raise ManifestError(
|
||||
f"bottle '{name}' not found at {path}. "
|
||||
f"Available: {avail}"
|
||||
)
|
||||
try:
|
||||
fm, body = parse_frontmatter(path.read_text())
|
||||
fm, _body = parse_frontmatter(path.read_text())
|
||||
except OSError as e:
|
||||
raise ManifestError(f"could not read {path}: {e}") from e
|
||||
except YamlSubsetError as e:
|
||||
raise ManifestError(f"{path}: {e}") from e
|
||||
validate_agent_frontmatter_keys(path, fm.keys())
|
||||
# Build the dict Agent.from_dict expects. The body becomes
|
||||
# prompt; Claude Code passthrough fields stay in fm and get
|
||||
# ignored by Agent.from_dict (reads bottle/skills/git-gate/prompt).
|
||||
agent_dict: dict[str, object] = {
|
||||
"bottle": fm.get("bottle"),
|
||||
"skills": fm.get("skills", []),
|
||||
"prompt": body.strip(),
|
||||
}
|
||||
if "git-gate" in fm:
|
||||
agent_dict["git-gate"] = fm["git-gate"]
|
||||
out[name] = ManifestAgent.from_dict(name, agent_dict, bottle_names)
|
||||
return out
|
||||
validate_bottle_frontmatter_keys(path, fm.keys())
|
||||
raws[name] = dict(fm)
|
||||
parent = fm.get("extends")
|
||||
if isinstance(parent, str):
|
||||
to_load.append(parent)
|
||||
elif isinstance(parent, list):
|
||||
to_load.extend(p for p in parent if isinstance(p, str))
|
||||
|
||||
return resolve_bottles(raws)[bottle_name]
|
||||
|
||||
@@ -18,8 +18,8 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||
BOTTLE_KEYS = frozenset(
|
||||
{"env", "extends", "agent_provider", "git-gate", "egress", "supervise"}
|
||||
)
|
||||
AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
||||
AGENT_KEYS_OPTIONAL = frozenset({"skills", "git-gate"})
|
||||
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
|
||||
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"})
|
||||
|
||||
# Claude Code subagent fields bot-bottle ignores at launch but does
|
||||
# not reject. This lets the same file double as
|
||||
|
||||
+34
-49
@@ -2,11 +2,10 @@
|
||||
|
||||
The supervise plane is the per-bottle MCP sidecar plus its host-side
|
||||
queue/audit support. The sidecar (bot_bottle.supervise_server)
|
||||
sits on the bottle's internal network and exposes three MCP tools the
|
||||
agent calls when it hits a stuck-recovery category:
|
||||
sits on the bottle's internal network and exposes MCP tools the agent
|
||||
calls when it needs an operator-reviewed egress change:
|
||||
|
||||
* egress-block — agent proposes a new routes.yaml
|
||||
* capability-block — agent proposes a new agent Dockerfile
|
||||
* egress-block / allow — agent proposes a new routes.yaml
|
||||
|
||||
Each tool call: the agent passes the full proposed file plus a
|
||||
justification text. The sidecar validates the proposal syntactically,
|
||||
@@ -48,28 +47,35 @@ from pathlib import Path
|
||||
SUPERVISE_HOSTNAME = "supervise"
|
||||
SUPERVISE_PORT = 9100
|
||||
|
||||
TOOL_CAPABILITY_BLOCK = "capability-block"
|
||||
TOOL_EGRESS_BLOCK = "egress-block"
|
||||
TOOL_EGRESS_ALLOW = "egress-allow"
|
||||
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
|
||||
# Written directly by the egress addon (not an agent-facing MCP tool) when an
|
||||
# outbound DLP token block is routed to the operator for override (PRD 0062).
|
||||
TOOL_EGRESS_TOKEN_ALLOW = "egress-token-allow"
|
||||
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
||||
TOOLS: tuple[str, ...] = (
|
||||
TOOL_CAPABILITY_BLOCK,
|
||||
TOOL_EGRESS_ALLOW,
|
||||
TOOL_EGRESS_BLOCK,
|
||||
TOOL_GITLEAKS_ALLOW,
|
||||
TOOL_EGRESS_TOKEN_ALLOW,
|
||||
TOOL_LIST_EGRESS_ROUTES,
|
||||
)
|
||||
|
||||
# The supervise sidecar uses these to query egress's
|
||||
# introspection endpoint for the `list-egress-routes` MCP
|
||||
# tool. The hostname + port match egress's docker network
|
||||
# alias + listen port (see bot_bottle.egress.EGRESS_HOSTNAME
|
||||
# and backend.docker.egress.EGRESS_PORT — the values
|
||||
# are inlined here so the in-container supervise_server doesn't
|
||||
# need to import the egress package).
|
||||
EGRESS_FORWARD_PROXY = "http://egress:9099"
|
||||
# listen port (see backend.docker.egress.EGRESS_PORT). The supervise
|
||||
# daemon runs inside the sidecar bundle alongside egress, so loopback
|
||||
# is the stable address across docker, smolmachines, and Apple
|
||||
# Container backends.
|
||||
EGRESS_FORWARD_PROXY = "http://127.0.0.1:9099"
|
||||
EGRESS_INTROSPECT_URL = "http://_egress.local/allowlist"
|
||||
|
||||
# capability-block has no on-disk config the operator edits in place
|
||||
# (the Dockerfile is rebuilt, not patched), so it has no audit log
|
||||
# here — those changes are captured by git history + the rebuild
|
||||
# record laid down in PRD 0016. egress-block was removed in issue #198.
|
||||
COMPONENT_FOR_TOOL: dict[str, str] = {}
|
||||
COMPONENT_FOR_TOOL: dict[str, str] = {
|
||||
TOOL_EGRESS_ALLOW: "egress",
|
||||
TOOL_EGRESS_BLOCK: "egress",
|
||||
}
|
||||
|
||||
STATUS_APPROVED = "approved"
|
||||
STATUS_MODIFIED = "modified"
|
||||
@@ -81,8 +87,6 @@ STATUSES: tuple[str, ...] = (STATUS_APPROVED, STATUS_MODIFIED, STATUS_REJECTED)
|
||||
ACTION_OPERATOR_EDIT = "operator-edit"
|
||||
|
||||
QUEUE_DIR_IN_CONTAINER = "/run/supervise/queue"
|
||||
CURRENT_CONFIG_DIR_IN_AGENT = "/etc/bot-bottle/current-config"
|
||||
|
||||
DEFAULT_POLL_INTERVAL_SEC = 0.5
|
||||
|
||||
|
||||
@@ -425,59 +429,39 @@ def sha256_hex(content: str) -> str:
|
||||
# --- Sidecar plan + abstract lifecycle -------------------------------------
|
||||
|
||||
|
||||
# Filename of the staged Dockerfile inside the agent's read-only
|
||||
# current-config mount. The capability-block tool's description
|
||||
# points the agent at this exact path so it can read the current
|
||||
# Dockerfile and propose modifications.
|
||||
#
|
||||
# routes.yaml + allowlist used to live here too; PRD 0017 chunk 3
|
||||
# moved them behind the `list-egress-routes` MCP tool (live
|
||||
# state from egress's introspection endpoint) so the agent
|
||||
# always sees current data rather than a launch-time snapshot.
|
||||
CURRENT_CONFIG_DOCKERFILE = "Dockerfile"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupervisePlan:
|
||||
"""Output of Supervise.prepare; consumed by .start.
|
||||
|
||||
`queue_dir` is the host directory bind-mounted into the sidecar
|
||||
at /run/supervise/queue. `current_config_dir` is the host
|
||||
directory bind-mounted (read-only) into the *agent* container
|
||||
at /etc/bot-bottle/current-config — currently holds only the
|
||||
Dockerfile snapshot (routes.yaml + allowlist moved to the
|
||||
`list-egress-routes` MCP tool). `internal_network` is
|
||||
empty at prepare time; the backend's launch step fills it via
|
||||
dataclasses.replace before calling .start."""
|
||||
at /run/supervise/queue. `internal_network` is empty at prepare
|
||||
time; the backend's launch step fills it via dataclasses.replace
|
||||
before calling .start."""
|
||||
|
||||
slug: str
|
||||
queue_dir: Path
|
||||
current_config_dir: Path
|
||||
internal_network: str = ""
|
||||
|
||||
|
||||
class Supervise(ABC):
|
||||
"""Per-bottle supervise sidecar. Encapsulates the host-side
|
||||
prepare (queue dir + current-config staging); the sidecar's
|
||||
start/stop lifecycle is backend-specific."""
|
||||
prepare (queue dir staging); the sidecar's start/stop lifecycle
|
||||
is backend-specific."""
|
||||
|
||||
def prepare(
|
||||
self,
|
||||
slug: str,
|
||||
stage_dir: Path,
|
||||
) -> SupervisePlan:
|
||||
"""Stage the per-bottle queue dir on the host and the
|
||||
current-config dir under `stage_dir`. Returns the plan;
|
||||
`internal_network` must be set by the launch step before
|
||||
"""Stage the per-bottle queue dir on the host. Returns the
|
||||
plan; `internal_network` must be set by the launch step before
|
||||
.start runs."""
|
||||
del stage_dir
|
||||
queue_dir = queue_dir_for_slug(slug)
|
||||
queue_dir.mkdir(parents=True, exist_ok=True)
|
||||
current_config_dir = stage_dir / "current-config"
|
||||
current_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
return SupervisePlan(
|
||||
slug=slug,
|
||||
queue_dir=queue_dir,
|
||||
current_config_dir=current_config_dir,
|
||||
)
|
||||
|
||||
# --- Helpers ---------------------------------------------------------------
|
||||
@@ -528,8 +512,6 @@ __all__ = [
|
||||
"ACTION_OPERATOR_EDIT",
|
||||
"AuditEntry",
|
||||
"COMPONENT_FOR_TOOL",
|
||||
"CURRENT_CONFIG_DIR_IN_AGENT",
|
||||
"CURRENT_CONFIG_DOCKERFILE",
|
||||
"DEFAULT_POLL_INTERVAL_SEC",
|
||||
"Proposal",
|
||||
"QUEUE_DIR_IN_CONTAINER",
|
||||
@@ -545,7 +527,10 @@ __all__ = [
|
||||
"TOOLS",
|
||||
"EGRESS_FORWARD_PROXY",
|
||||
"EGRESS_INTROSPECT_URL",
|
||||
"TOOL_CAPABILITY_BLOCK",
|
||||
"TOOL_EGRESS_ALLOW",
|
||||
"TOOL_EGRESS_BLOCK",
|
||||
"TOOL_GITLEAKS_ALLOW",
|
||||
"TOOL_EGRESS_TOKEN_ALLOW",
|
||||
"TOOL_LIST_EGRESS_ROUTES",
|
||||
"archive_proposal",
|
||||
"audit_dir",
|
||||
|
||||
+148
-52
@@ -1,8 +1,8 @@
|
||||
"""Supervise sidecar HTTP server (PRD 0013).
|
||||
|
||||
Per-bottle MCP server exposing tools the agent calls to propose config
|
||||
changes when stuck. The egress-block tool was removed in issue #198;
|
||||
the remaining tools are `capability-block` and `list-egress-routes`.
|
||||
Per-bottle MCP server exposing tools the agent calls to propose egress
|
||||
config changes when stuck. The tools are `egress-allow`,
|
||||
`egress-block`, and `list-egress-routes`.
|
||||
|
||||
Each queued tool call:
|
||||
|
||||
@@ -44,9 +44,15 @@ import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# Same-directory import inside the bundle container; `supervise.py`
|
||||
# is COPYed alongside this file by Dockerfile.sidecars.
|
||||
import supervise as _sv
|
||||
try:
|
||||
# Same-directory imports inside the bundle container; these files are
|
||||
# COPYed flat under /app by Dockerfile.sidecars.
|
||||
from egress_addon_core import LOG_OFF, load_config
|
||||
import supervise as _sv
|
||||
except ModuleNotFoundError:
|
||||
# Package imports for host-side tests and tooling.
|
||||
from .egress_addon_core import LOG_OFF, load_config
|
||||
from . import supervise as _sv
|
||||
|
||||
|
||||
# --- JSON-RPC / MCP plumbing ----------------------------------------------
|
||||
@@ -84,19 +90,19 @@ def parse_jsonrpc(body: bytes) -> JsonRpcRequest:
|
||||
try:
|
||||
raw = json.loads(body)
|
||||
except json.JSONDecodeError as e:
|
||||
raise _RpcError(ERR_PARSE, f"parse error: {e}") from e
|
||||
raise _RpcClientError(ERR_PARSE, f"parse error: {e}") from e
|
||||
if not isinstance(raw, dict):
|
||||
raise _RpcError(ERR_INVALID_REQUEST, "request must be a JSON object")
|
||||
raise _RpcClientError(ERR_INVALID_REQUEST, "request must be a JSON object")
|
||||
if raw.get("jsonrpc") != JSONRPC_VERSION:
|
||||
raise _RpcError(ERR_INVALID_REQUEST, "jsonrpc field must be '2.0'")
|
||||
raise _RpcClientError(ERR_INVALID_REQUEST, "jsonrpc field must be '2.0'")
|
||||
method = raw.get("method")
|
||||
if not isinstance(method, str):
|
||||
raise _RpcError(ERR_INVALID_REQUEST, "method must be a string")
|
||||
raise _RpcClientError(ERR_INVALID_REQUEST, "method must be a string")
|
||||
params = raw.get("params", {})
|
||||
if params is None:
|
||||
params = {}
|
||||
if not isinstance(params, dict):
|
||||
raise _RpcError(ERR_INVALID_PARAMS, "params must be an object")
|
||||
raise _RpcClientError(ERR_INVALID_PARAMS, "params must be an object")
|
||||
rpc_id = raw.get("id", _NO_ID)
|
||||
is_notification = rpc_id is _NO_ID
|
||||
return JsonRpcRequest(
|
||||
@@ -111,12 +117,23 @@ _NO_ID = object()
|
||||
|
||||
|
||||
class _RpcError(Exception):
|
||||
"""Base class for all typed RPC errors that surface as JSON-RPC error responses."""
|
||||
def __init__(self, code: int, message: str):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
|
||||
|
||||
class _RpcClientError(_RpcError):
|
||||
"""Caller sent a bad request; returned verbatim, no server-side logging."""
|
||||
|
||||
|
||||
class _RpcInternalError(_RpcError):
|
||||
"""Server-side fault; logged at ERROR with cause, always returns ERR_INTERNAL."""
|
||||
def __init__(self, message: str) -> None:
|
||||
super().__init__(ERR_INTERNAL, message)
|
||||
|
||||
|
||||
def jsonrpc_result(request_id: object, result: object) -> bytes:
|
||||
payload = {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result}
|
||||
return (json.dumps(payload) + "\n").encode("utf-8")
|
||||
@@ -142,8 +159,9 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
||||
"allowlist. Returns JSON with one entry per allowed host, "
|
||||
"each carrying its matches rules (if any) and whether "
|
||||
"the proxy injects Authorization for the route. Use this "
|
||||
"before composing an `egress-block` proposal so the new "
|
||||
"routes file extends the live one rather than replacing it."
|
||||
"before composing an `egress-allow` or `egress-block` proposal so "
|
||||
"the new routes file extends the live one rather than "
|
||||
"replacing it."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
@@ -152,41 +170,97 @@ TOOL_DEFINITIONS: list[dict[str, object]] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": _sv.TOOL_CAPABILITY_BLOCK,
|
||||
"name": _sv.TOOL_EGRESS_ALLOW,
|
||||
"description": (
|
||||
"Call when the bottle is missing a tool, skill, permission, "
|
||||
"or env var you need — something that lives in the agent "
|
||||
"Dockerfile rather than in the egress routes. "
|
||||
"Read the current Dockerfile from "
|
||||
"/etc/bot-bottle/current-config/Dockerfile, compose a "
|
||||
"modified version, and pass the full new file plus a "
|
||||
"justification. On approval the supervisor rebuilds the "
|
||||
"bottle from the new Dockerfile and starts a replacement on "
|
||||
"the same branch (wired in PRD 0016; v1 acknowledges only)."
|
||||
"Request operator approval to change the bottle's egress "
|
||||
"allowlist. Pass the full proposed routes.yaml content, not "
|
||||
"just the new host, plus a justification. Use "
|
||||
"`list-egress-routes` first so the proposal preserves existing "
|
||||
"routes."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dockerfile": {
|
||||
"routes_yaml": {
|
||||
"type": "string",
|
||||
"description": "Full proposed Dockerfile content.",
|
||||
"description": (
|
||||
"Full proposed /etc/egress/routes.yaml content. "
|
||||
"Each route entry accepts these keys:\n"
|
||||
" host: <hostname> (required)\n"
|
||||
" auth_scheme: Bearer|token (must pair with token_env)\n"
|
||||
" token_env: <ENV_VAR_NAME> (must pair with auth_scheme)\n"
|
||||
" matches: (optional list of match entries)\n"
|
||||
" - paths: [{type: prefix|exact|regex, value: /...}]\n"
|
||||
" methods: [GET, POST, ...]\n"
|
||||
" headers: [{name: X-Hdr, value: val, type: exact|regex}]\n"
|
||||
" git: (optional; omit to block git clone/fetch)\n"
|
||||
" fetch: true\n"
|
||||
" dlp: (optional DLP scanner overrides)\n"
|
||||
" outbound_detectors: [token_patterns, known_secrets]\n"
|
||||
" inbound_detectors: [naive_injection_detection]\n"
|
||||
" outbound_on_match: block|redact|supervise (default supervise)\n"
|
||||
"Omit any key that should use its default. "
|
||||
"`list-egress-routes` returns routes in this same format."
|
||||
),
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Why this capability is needed.",
|
||||
"description": "Why this egress route is needed.",
|
||||
},
|
||||
},
|
||||
"required": ["dockerfile", "justification"],
|
||||
"required": ["routes_yaml", "justification"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": _sv.TOOL_EGRESS_BLOCK,
|
||||
"description": (
|
||||
"Request operator approval to change the bottle's egress "
|
||||
"allowlist after a blocked outbound request. Pass the full "
|
||||
"proposed routes.yaml content plus a justification. Use "
|
||||
"`list-egress-routes` first so the proposal preserves existing "
|
||||
"routes."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"routes_yaml": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Full proposed /etc/egress/routes.yaml content. "
|
||||
"Each route entry accepts these keys:\n"
|
||||
" host: <hostname> (required)\n"
|
||||
" auth_scheme: Bearer|token (must pair with token_env)\n"
|
||||
" token_env: <ENV_VAR_NAME> (must pair with auth_scheme)\n"
|
||||
" matches: (optional list of match entries)\n"
|
||||
" - paths: [{type: prefix|exact|regex, value: /...}]\n"
|
||||
" methods: [GET, POST, ...]\n"
|
||||
" headers: [{name: X-Hdr, value: val, type: exact|regex}]\n"
|
||||
" git: (optional; omit to block git clone/fetch)\n"
|
||||
" fetch: true\n"
|
||||
" dlp: (optional DLP scanner overrides)\n"
|
||||
" outbound_detectors: [token_patterns, known_secrets]\n"
|
||||
" inbound_detectors: [naive_injection_detection]\n"
|
||||
" outbound_on_match: block|redact|supervise (default supervise)\n"
|
||||
"Omit any key that should use its default. "
|
||||
"`list-egress-routes` returns routes in this same format."
|
||||
),
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Why this egress route is needed.",
|
||||
},
|
||||
},
|
||||
"required": ["routes_yaml", "justification"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Map each non-egress tool to the input field that carries the agent's
|
||||
# payload (stored in Proposal.proposed_file). egress-block builds its
|
||||
# payload from structured input fields in `handle_egress_block`.
|
||||
# Map each proposal tool to the input field that carries the agent's
|
||||
# payload (stored in Proposal.proposed_file).
|
||||
PROPOSED_FILE_FIELD: dict[str, str] = {
|
||||
_sv.TOOL_CAPABILITY_BLOCK: "dockerfile",
|
||||
_sv.TOOL_EGRESS_ALLOW: "routes_yaml",
|
||||
_sv.TOOL_EGRESS_BLOCK: "routes_yaml",
|
||||
}
|
||||
|
||||
|
||||
@@ -198,13 +272,22 @@ def validate_proposed_file(tool: str, content: str) -> None:
|
||||
catches obvious paste-errors / wrong-tool selections before they
|
||||
enter the queue."""
|
||||
if not content.strip():
|
||||
raise _RpcError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty")
|
||||
if tool == _sv.TOOL_CAPABILITY_BLOCK:
|
||||
# Dockerfiles are too varied to validate syntactically beyond
|
||||
# non-empty. The operator reads the diff in the TUI.
|
||||
pass
|
||||
raise _RpcClientError(ERR_INVALID_PARAMS, f"{tool}: proposed file is empty")
|
||||
if tool in (_sv.TOOL_EGRESS_ALLOW, _sv.TOOL_EGRESS_BLOCK):
|
||||
try:
|
||||
config = load_config(content)
|
||||
except ValueError as e:
|
||||
raise _RpcClientError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: proposed routes.yaml is not valid: {e}",
|
||||
) from e
|
||||
if config.log != LOG_OFF:
|
||||
raise _RpcClientError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{tool}: proposed routes.yaml must not change egress logging",
|
||||
)
|
||||
else:
|
||||
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}")
|
||||
raise _RpcClientError(ERR_INVALID_PARAMS, f"unknown tool {tool!r}")
|
||||
|
||||
|
||||
# --- MCP handlers ----------------------------------------------------------
|
||||
@@ -277,17 +360,17 @@ def handle_tools_call(
|
||||
doesn't need operator approval."""
|
||||
name = params.get("name")
|
||||
if not isinstance(name, str):
|
||||
raise _RpcError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
|
||||
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call missing 'name'")
|
||||
if name == _sv.TOOL_LIST_EGRESS_ROUTES:
|
||||
return handle_list_egress_routes(typing.cast(dict[str, object], params.get("arguments", {})), config)
|
||||
|
||||
args_raw = params.get("arguments", {})
|
||||
if not isinstance(args_raw, dict):
|
||||
raise _RpcError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
|
||||
raise _RpcClientError(ERR_INVALID_PARAMS, "tools/call 'arguments' must be an object")
|
||||
|
||||
justification = args_raw.get("justification")
|
||||
if not isinstance(justification, str) or not justification.strip():
|
||||
raise _RpcError(
|
||||
raise _RpcClientError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{name}: 'justification' is required and must be a non-empty string",
|
||||
)
|
||||
@@ -296,13 +379,13 @@ def handle_tools_call(
|
||||
file_field = PROPOSED_FILE_FIELD[name]
|
||||
proposed_file = args_raw.get(file_field)
|
||||
if not isinstance(proposed_file, str):
|
||||
raise _RpcError(
|
||||
raise _RpcClientError(
|
||||
ERR_INVALID_PARAMS,
|
||||
f"{name}: '{file_field}' is required and must be a string",
|
||||
)
|
||||
validate_proposed_file(name, proposed_file)
|
||||
else:
|
||||
raise _RpcError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
|
||||
raise _RpcClientError(ERR_INVALID_PARAMS, f"unknown tool {name!r}")
|
||||
|
||||
proposal = _sv.Proposal.new(
|
||||
bottle_slug=config.bottle_slug,
|
||||
@@ -311,7 +394,10 @@ def handle_tools_call(
|
||||
justification=justification,
|
||||
current_file_hash=_sv.sha256_hex(proposed_file),
|
||||
)
|
||||
_sv.write_proposal(config.queue_dir, proposal)
|
||||
try:
|
||||
_sv.write_proposal(config.queue_dir, proposal)
|
||||
except OSError as e:
|
||||
raise _RpcInternalError(f"failed to write proposal to queue: {e}") from e
|
||||
sys.stderr.write(
|
||||
f"supervise: queued proposal {proposal.id} ({name}) "
|
||||
f"for bottle {config.bottle_slug}; waiting for operator...\n"
|
||||
@@ -331,7 +417,10 @@ def handle_tools_call(
|
||||
"content": [{"type": "text", "text": text}],
|
||||
"isError": False,
|
||||
}
|
||||
_sv.archive_proposal(config.queue_dir, proposal.id)
|
||||
try:
|
||||
_sv.archive_proposal(config.queue_dir, proposal.id)
|
||||
except OSError as e:
|
||||
raise _RpcInternalError(f"failed to archive proposal: {e}") from e
|
||||
|
||||
text = format_response_text(response)
|
||||
return {
|
||||
@@ -365,9 +454,8 @@ def format_pending_response_text(timeout_seconds: float) -> str:
|
||||
# --- HTTP transport --------------------------------------------------------
|
||||
|
||||
|
||||
# Max request body the server accepts. Generous because Dockerfile
|
||||
# proposals can be a few KB; routes.json is small. 1 MB is well above
|
||||
# any realistic config file.
|
||||
# Max request body the server accepts. 1 MB is well above any realistic
|
||||
# routes.yaml proposal.
|
||||
MAX_BODY_BYTES = 1 * 1024 * 1024
|
||||
|
||||
|
||||
@@ -407,7 +495,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
try:
|
||||
req = parse_jsonrpc(body)
|
||||
except _RpcError as e:
|
||||
except _RpcClientError as e:
|
||||
self._write_jsonrpc(jsonrpc_error(None, e.code, e.message))
|
||||
return
|
||||
|
||||
@@ -415,11 +503,19 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
||||
|
||||
try:
|
||||
result = self._dispatch(req, config)
|
||||
except _RpcError as e:
|
||||
except _RpcClientError as e:
|
||||
self._write_jsonrpc(jsonrpc_error(req.id, e.code, e.message))
|
||||
return
|
||||
except Exception as e: # noqa: W0718 — catch-all for RPC dispatch errors
|
||||
sys.stderr.write(f"supervise: internal error: {e}\n")
|
||||
except _RpcInternalError as e:
|
||||
cause = e.__cause__
|
||||
detail = f": {cause}" if cause else ""
|
||||
sys.stderr.write(f"supervise: internal error: {e.message}{detail}\n")
|
||||
sys.stderr.flush()
|
||||
self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error"))
|
||||
return
|
||||
except Exception as e: # noqa: W0718 — unexpected errors
|
||||
sys.stderr.write(f"supervise: unexpected error: {type(e).__name__}: {e}\n")
|
||||
sys.stderr.flush()
|
||||
self._write_jsonrpc(jsonrpc_error(req.id, ERR_INTERNAL, "internal error"))
|
||||
return
|
||||
|
||||
@@ -438,7 +534,7 @@ class MCPHandler(http.server.BaseHTTPRequestHandler):
|
||||
return handle_tools_list(req.params)
|
||||
if method == "tools/call":
|
||||
return handle_tools_call(req.params, config)
|
||||
raise _RpcError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
|
||||
raise _RpcClientError(ERR_METHOD_NOT_FOUND, f"method not found: {method}")
|
||||
|
||||
def _write_jsonrpc(self, body: bytes) -> None:
|
||||
self.send_response(200)
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# PRD prd-new: macOS Container backend
|
||||
# PRD 0059: macOS Container backend
|
||||
|
||||
- **Status:** Draft
|
||||
- **Status:** Active
|
||||
- **Author:** Codex
|
||||
- **Created:** 2026-06-10
|
||||
- **Issue:** #220
|
||||
@@ -0,0 +1,159 @@
|
||||
# PRD 0060: Commit bottle state to an image
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-06-20
|
||||
- **Issue:** #194
|
||||
|
||||
## Summary
|
||||
|
||||
Add a `commit` CLI command that freezes a running bottle's state to a
|
||||
resumable local artifact. Docker bottles are stored as Docker images;
|
||||
smolmachines bottles are stored as `.smolmachine` artifacts. Operators
|
||||
can then resume the bottle from that exact filesystem snapshot, or
|
||||
export the artifact to migrate work to a different host.
|
||||
|
||||
## Problem
|
||||
|
||||
When a long-running agent session is interrupted — by a host reboot, a
|
||||
network failure, or a planned infrastructure migration — the in-progress
|
||||
container state is lost. `cli.py resume` rebuilds the agent image from
|
||||
the Dockerfile and reprovi-sions the bottle, but that returns the guest
|
||||
to its initial state, not to wherever the agent was mid-task.
|
||||
|
||||
There is no mechanism today to capture "what's installed / configured
|
||||
inside the running container right now" and make it reproducible. The
|
||||
`capability-block` flow writes a new Dockerfile and marks the bottle for
|
||||
resume, but that only applies when the agent itself has requested a
|
||||
capability change; it doesn't help the operator who wants to take a
|
||||
snapshot before a planned host reboot or hardware migration.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `./cli.py commit [<slug>]` takes a snapshot of the running agent and
|
||||
stores it as a local artifact.
|
||||
- Without a slug argument the command shows the same interactive picker
|
||||
as `start` (the list of active slugs).
|
||||
- The committed artifact reference is stored in per-bottle state so
|
||||
that the next `./cli.py resume <slug>` automatically uses the
|
||||
snapshot instead of rebuilding from the Dockerfile.
|
||||
- `mark_preserved` is called so the state dir survives the normal
|
||||
session-end cleanup.
|
||||
- A backend-specific export hint is printed so operators know how to
|
||||
migrate the snapshot.
|
||||
- The command errors clearly on unsupported backends.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- macOS-container backend support.
|
||||
- Automatic commit on agent exit.
|
||||
- Image push to a remote registry.
|
||||
- Storing the image tag in the manifest or sharing it between operators.
|
||||
|
||||
## Design
|
||||
|
||||
### Docker image tag
|
||||
|
||||
`bot-bottle-committed-<slug>:latest` — namespaced under `bot-bottle-`
|
||||
to match existing image naming conventions; `committed` distinguishes it
|
||||
from the build-time image (`bot-bottle-claude:latest`) and the
|
||||
capability-block rebuild image (`bot-bottle-rebuilt-<identity>:latest`).
|
||||
|
||||
### State storage
|
||||
|
||||
A new plain-text file `committed-image` is added to the per-bottle state
|
||||
directory:
|
||||
|
||||
```
|
||||
~/.bot-bottle/state/<identity>/
|
||||
metadata.json
|
||||
Dockerfile (capability-block override; optional)
|
||||
committed-image (committed artifact reference; optional)
|
||||
transcript/
|
||||
```
|
||||
|
||||
`bottle_state.committed_image_path(identity)` returns the path.
|
||||
`write_committed_image` / `read_committed_image` are the read/write
|
||||
helpers, matching the existing `per_bottle_dockerfile` pattern. Docker
|
||||
stores a Docker tag in this file; smolmachines stores the absolute path
|
||||
to the committed `.smolmachine` artifact.
|
||||
|
||||
### `commit` command
|
||||
|
||||
```
|
||||
./cli.py commit [<slug>]
|
||||
```
|
||||
|
||||
1. Resolve slug (arg or interactive picker from `enumerate_active_agents`).
|
||||
2. Check metadata and branch by backend.
|
||||
3. For Docker, derive container name `bot-bottle-<slug>` and run
|
||||
`docker commit <container> bot-bottle-committed-<slug>:latest`.
|
||||
4. For smolmachines, derive machine name `bot-bottle-<slug>` and run
|
||||
`smolvm pack create --from-vm <machine> -o ~/.bot-bottle/state/<slug>/committed-smolmachine`.
|
||||
5. Write the Docker image tag or smolmachine artifact path to
|
||||
`~/.bot-bottle/state/<slug>/committed-image`.
|
||||
6. Call `mark_preserved(<slug>)` so the state dir survives session-end.
|
||||
7. Print the resume hint and a backend-specific export example.
|
||||
|
||||
### Resume from committed image
|
||||
|
||||
`bot_bottle/backend/docker/launch.py` already rebuilds the agent image
|
||||
at the top of the `launch` context manager. The change is a check
|
||||
immediately before that step:
|
||||
|
||||
```python
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and docker_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
plan = dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||
)
|
||||
else:
|
||||
docker_mod.build_image(plan.image, _REPO_DIR, dockerfile=plan.dockerfile_path)
|
||||
```
|
||||
|
||||
Replacing `agent_provision.image` propagates to `plan.image` (a
|
||||
property) and from there to the Compose spec renderer's `_agent_service`
|
||||
→ `image:` field, so the container boots from the committed snapshot.
|
||||
The build step is skipped entirely when a committed image is found and
|
||||
exists locally.
|
||||
|
||||
If the committed image has been deleted from the local daemon (e.g.
|
||||
after `docker rmi` or a `docker system prune`), the launch falls back
|
||||
to a normal Dockerfile build, matching the pre-commit behavior.
|
||||
|
||||
### Resume from committed smolmachine
|
||||
|
||||
`bot_bottle/backend/smolmachines/launch.py` checks the committed
|
||||
reference before the normal Docker build -> pack cache path:
|
||||
|
||||
```python
|
||||
committed = read_committed_image(plan.slug)
|
||||
if committed and Path(committed).is_file():
|
||||
return Path(committed)
|
||||
return _ensure_smolmachine(plan.agent_image, dockerfile=plan.agent_dockerfile_path)
|
||||
```
|
||||
|
||||
The returned path is passed to `smolvm machine create --from`, so the
|
||||
resumed VM boots from the committed snapshot. If the artifact has been
|
||||
deleted, launch falls back to the normal build and pack flow.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- Unit tests for `write_committed_image` / `read_committed_image` in
|
||||
`tests/unit/test_bottle_state.py`, using the existing `_FakeHomeMixin`
|
||||
pattern.
|
||||
- Unit tests for `commit_container` in `tests/unit/test_docker_util_image.py`,
|
||||
mocking `subprocess.run` and asserting on the `docker commit` argv.
|
||||
- Unit tests for `cmd_commit` argument parsing, Docker commit,
|
||||
smolmachines pack, and the unsupported backend error path, mocking
|
||||
`enumerate_active_agents`, `commit_container`, and
|
||||
`pack_create_from_vm`.
|
||||
- Unit tests for the launch-step committed-image branch: patch
|
||||
`read_committed_image` to return a tag, patch `image_exists` to return
|
||||
True, and assert that `build_image` is not called and `plan.image` is
|
||||
overridden.
|
||||
- Unit tests for the smolmachines launch-step committed-artifact branch:
|
||||
patch `read_committed_image` to return an existing path and assert the
|
||||
normal `_ensure_smolmachine` path is skipped.
|
||||
@@ -0,0 +1,101 @@
|
||||
# PRD 0061: 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.
|
||||
@@ -0,0 +1,210 @@
|
||||
# PRD 0062: Supervisor override for egress token blocks
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-06-24
|
||||
- **Issue:** #261
|
||||
|
||||
## Summary
|
||||
|
||||
Give each egress route a policy for what happens when an outbound DLP detector
|
||||
matches a token, via `dlp.outbound_on_match: block | redact | supervise`
|
||||
(default `supervise`):
|
||||
|
||||
- **`supervise`** (default) — route the block through the existing supervisor
|
||||
approval queue instead of returning `403` immediately. The proxy holds the
|
||||
request open until the operator approves or rejects it. On approval the
|
||||
matched token is added to an in-memory "safe tokens" set so the request — and
|
||||
any later request carrying the same token — flows through without
|
||||
re-prompting.
|
||||
- **`redact`** — scrub the matched value(s) from the request and forward it,
|
||||
no operator in the loop. For routes where a token-shaped value is noise the
|
||||
upstream doesn't need (telemetry/log sinks). Fails closed if a match lands on
|
||||
a surface redaction can't rewrite (the hostname).
|
||||
- **`block`** — the original hard `403`; never overridable. For routes where a
|
||||
detected token must always stop.
|
||||
|
||||
The motivating goal is reducing friction from false positives without weakening
|
||||
the default-deny posture: supervise keeps a human in the loop, redact is an
|
||||
explicit per-route opt-in, and block stays available for sensitive routes.
|
||||
|
||||
## Problem
|
||||
|
||||
The outbound DLP detectors (`token_patterns`, `known_secrets`) are
|
||||
deliberately aggressive: any string that looks like a credential is blocked
|
||||
before it leaves the bottle. That is the right default, but it produces false
|
||||
positives — a token-shaped value that is not actually a secret, or a credential
|
||||
the agent legitimately needs to send to a declared host. Today the only
|
||||
recovery is for the operator to notice the `egress DLP` 403 in the logs and
|
||||
hand-edit the route's `dlp.outbound_detectors`, which disables the detector for
|
||||
the whole route rather than allowing the one value.
|
||||
|
||||
The operator has no in-the-loop signal that a token block happened and no
|
||||
fine-grained way to say "this specific value is fine."
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. An outbound DLP **token** block (a `ScanResult` carrying a matched secret
|
||||
value) creates a supervisor proposal instead of an immediate `403`.
|
||||
2. The egress proxy holds the blocked request open, polling for the operator's
|
||||
response up to a bounded timeout.
|
||||
3. The proposal shows the operator the host, method, path, the detector reason,
|
||||
and a **redacted** context snippet — never the raw token value.
|
||||
4. On `approved`/`modified`, the matched token value is added to an in-memory
|
||||
safe-tokens set and the request proceeds normally; later requests carrying
|
||||
the same value skip the block.
|
||||
5. On `rejected`, timeout, malformed response, or missing supervisor wiring,
|
||||
the request fails closed with the same `403` as today.
|
||||
6. Structural blocks that carry no token value (CRLF injection) and the
|
||||
route-not-allowlisted / git blocks are unchanged — they stay hard `403`s and
|
||||
keep their existing agent-driven `allow` / `egress-block` MCP path.
|
||||
7. The proxy event loop is not stalled while waiting: the wait is asynchronous,
|
||||
so other flows keep being served.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Persisting the safe-tokens set across egress restarts. It lives in process
|
||||
memory only; a restart re-prompts. (The issue explicitly defers persistence.)
|
||||
- Supervising inbound (prompt-injection) blocks or WebSocket frame blocks.
|
||||
WebSocket frames still honour the safe-tokens set for already-approved values
|
||||
but cannot wait for approval (there is no response surface after upgrade).
|
||||
- Generalising an approved secret across encodings. The safe-tokens set matches
|
||||
the exact value the detector found.
|
||||
- Replacing the per-route `dlp.outbound_detectors` override. That remains the
|
||||
way to turn a detector off wholesale.
|
||||
- Making `redact` the default. Silent redaction of a true false positive
|
||||
corrupts legitimate data, so it is opt-in per route; `supervise` (human in
|
||||
the loop) stays the default.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
The minimum cut that ships, in build order:
|
||||
|
||||
1. **Core** — `ScanResult.matched`; thread `safe_tokens` through
|
||||
`scan_outbound` / the token detectors; `build_token_allow_payload`.
|
||||
2. **Supervise + TUI** — `TOOL_EGRESS_TOKEN_ALLOW`; TUI suffix, modify guard,
|
||||
required approval reason.
|
||||
3. **Addon glue** — async `request`, safe-tokens set, proposal write + async
|
||||
poll, allow/block decision; pass `safe_tokens` into the WebSocket path.
|
||||
4. **On-match policy** — `dlp.outbound_on_match` through manifest → render →
|
||||
addon; `redact` surface scrub with fail-closed re-scan; policy dispatch in
|
||||
the addon's outbound handler.
|
||||
5. **Tests + docs** — core/supervise/TUI/manifest/render unit tests; README
|
||||
egress + supervisor notes.
|
||||
|
||||
### Out of scope
|
||||
|
||||
The deferrals enumerated under **Non-goals** — restart persistence, inbound /
|
||||
WebSocket-frame supervision, cross-encoding generalisation, replacing
|
||||
`dlp.outbound_detectors`, and making `redact` the default.
|
||||
|
||||
## Proposed Design
|
||||
|
||||
### New services / components
|
||||
|
||||
A new proposal tool constant `egress-token-allow` (`TOOL_EGRESS_TOKEN_ALLOW`)
|
||||
is added to `supervise.TOOLS`, and the egress addon gains an in-memory
|
||||
safe-tokens set plus the policy-dispatch path that drives it.
|
||||
|
||||
On an outbound block the addon dispatches on the resolved policy:
|
||||
|
||||
- **Structural blocks always 403.** A `ScanResult` with no `matched` value
|
||||
(CRLF injection) is a hard `403` regardless of policy — there is nothing to
|
||||
redact or safelist.
|
||||
- **`redact`** runs `redact_tokens` over the body, non-`host` header values,
|
||||
and path/query, then re-scans. If the re-scan is clean the (rewritten)
|
||||
request is forwarded; if a block-severity match remains (e.g. in the
|
||||
hostname, or a unicode-evasion token redaction can't reach) it fails closed
|
||||
with a `403`.
|
||||
- **`block`** writes the `403` immediately.
|
||||
- **`supervise`** runs the queue-and-wait loop, falling back to `block` when
|
||||
supervise isn't wired for the bottle.
|
||||
|
||||
For `supervise`, the addon writes the proposal directly to
|
||||
`SUPERVISE_QUEUE_DIR` (the queue is bind-mounted into the sidecar bundle and
|
||||
shared by every daemon, exactly as git-gate's `gitleaks-allow` proposal in PRD
|
||||
0061 does). The proposal's `proposed_file` is a human-readable text payload
|
||||
built by `build_token_allow_payload`:
|
||||
|
||||
```
|
||||
egress blocked an outbound request carrying a detected token
|
||||
host: api.example.com
|
||||
method: POST
|
||||
path: /v1/ingest
|
||||
detector: OpenAI API key found in body
|
||||
context: ...before ******** after...
|
||||
```
|
||||
|
||||
The justification tells the operator to approve only if the value is a false
|
||||
positive or a credential the request legitimately needs. The addon then polls
|
||||
`<proposal-id>.response.json` for `EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS` (default
|
||||
300). `approved`/`modified` allow the request and add the value to the
|
||||
safe-tokens set; `rejected`, malformed responses, and timeout fail the request
|
||||
closed. The proposal + response are archived to `processed/` after a decision.
|
||||
Because the wait happens inside mitmproxy's asyncio loop, the addon's `request`
|
||||
hook is async and polls with `asyncio.sleep`, so concurrent flows are
|
||||
unaffected.
|
||||
|
||||
### Existing code touched
|
||||
|
||||
- **Policy threading.** `dlp.outbound_on_match` is a per-route enum threaded
|
||||
from the bottle manifest (`manifest_egress`) through the resolved route
|
||||
(`egress.EgressRoute`), the rendered `routes.yaml` (`egress_render_routes`),
|
||||
and the addon's `Route` (`egress_addon_core`). Unset renders nothing and
|
||||
resolves to `supervise` at request time. The `list-egress-routes`
|
||||
introspection endpoint round-trips it so the agent's proposals preserve it.
|
||||
- **Provider-route default.** Agent-provider routes (the agent talking to its
|
||||
own LLM API — `api.anthropic.com`, the Codex backend, etc.) are the worst
|
||||
source of token-shaped false positives because the whole conversation payload
|
||||
flows through them. `egress_routes_for_bottle` fills `outbound_on_match=redact`
|
||||
on any provider route that doesn't set it explicitly; a provider that sets the
|
||||
policy keeps its choice, and manifest routes are unaffected (they default to
|
||||
`supervise`).
|
||||
- **Scanners.** `scan_outbound` (and the token detectors `scan_token_patterns`
|
||||
/ `scan_known_secrets` it calls) accept a `safe_tokens` set. A match whose
|
||||
value is in `safe_tokens` is skipped, so an approved token no longer blocks;
|
||||
the scanners keep searching past a safelisted match so a second, un-approved
|
||||
secret in the same request is still caught. The WebSocket path is passed the
|
||||
same `safe_tokens` set.
|
||||
- **Supervisor UI.** `cli/supervise.py` renders `egress-token-allow` like
|
||||
`gitleaks-allow`: the text payload is shown, modify is unavailable (there is
|
||||
no file patch to edit), and approval prompts for a non-empty reason recorded
|
||||
in the response notes. There is no on-disk config diff, so — like
|
||||
`gitleaks-allow` and `capability-block` — it writes no egress audit-log entry.
|
||||
- **Failure handling.** If `SUPERVISE_QUEUE_DIR` / `SUPERVISE_BOTTLE_SLUG` are
|
||||
unset (supervise disabled for the bottle), the addon skips the queue and
|
||||
returns the existing `403`. Any error writing the proposal or reading the
|
||||
response also fails closed.
|
||||
|
||||
### Data model changes
|
||||
|
||||
- New per-route manifest field `dlp.outbound_on_match: block | redact |
|
||||
supervise`, rendered into `routes.yaml` (omitted when unset).
|
||||
- `ScanResult` gains a `matched: str = ""` field carrying the raw substring the
|
||||
detector matched. The token detectors populate it; the structural CRLF
|
||||
detector leaves it empty. The value stays inside the egress sidecar process —
|
||||
never written to a log line (logs use the redacted `context`) nor to the
|
||||
proposal file.
|
||||
- Proposal text payload (above) plus `<proposal-id>.response.json` in
|
||||
`SUPERVISE_QUEUE_DIR`, archived to `processed/` after a decision.
|
||||
- New env var `EGRESS_TOKEN_ALLOW_TIMEOUT_SECONDS` (default 300).
|
||||
|
||||
### External dependencies
|
||||
|
||||
None. Reuses the existing supervisor queue (`SUPERVISE_QUEUE_DIR`) and the
|
||||
mitmproxy addon framework already in the egress sidecar.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Should `known_secrets` (provisioned `EGRESS_TOKEN_*` exfiltration) be
|
||||
override-able at all, or only `token_patterns`? This PRD allows both —
|
||||
approval is an explicit operator decision and the safe-tokens set matches the
|
||||
exact found value — but a future revision could restrict `known_secrets` to
|
||||
reject-only.
|
||||
|
||||
## References
|
||||
|
||||
- Issue #261
|
||||
- PRD 0061 — `gitleaks-allow` supervisor proposal pattern this reuses.
|
||||
@@ -0,0 +1,189 @@
|
||||
# PRD 0063: Strengthen outbound exfiltration detection
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-06-25
|
||||
- **Issue:** #259
|
||||
|
||||
## Summary
|
||||
|
||||
The current DLP system has two detection tiers: weak (vendor token regexes)
|
||||
and strong (exact matching of provisioned `EGRESS_TOKEN_*` values in multiple
|
||||
encodings). This PRD adds four complementary hardening layers in ranked order
|
||||
of impact:
|
||||
|
||||
1. **Canary tokens** — inject a per-session fake secret into the agent's
|
||||
environment; any outbound appearance is a zero-false-positive exfil signal.
|
||||
2. **Broadened known-value scanning** — extend the strong tier beyond
|
||||
`EGRESS_TOKEN_*` via a `BOT_BOTTLE_SENSITIVE_PREFIXES` sidecar env var.
|
||||
3. **Fragmentation-resistant matching** — catch separator injection and
|
||||
long-substring leaks via alnum projection and sliding-window substring
|
||||
search.
|
||||
4. **Entropy scoring** — warn-only flag on high-entropy blobs as a
|
||||
complementary signal for encrypted exfil.
|
||||
|
||||
## Problem
|
||||
|
||||
After PRD 0056 the outbound scan covers all HTTP request surfaces (host, path,
|
||||
query, headers, body). But the content-based strong tier only matches
|
||||
`EGRESS_TOKEN_*` values and their encodings. Three gaps remain:
|
||||
|
||||
| Gap | How an agent exploits it |
|
||||
|-----|--------------------------|
|
||||
| No canary | There is no "can't be false-positive" signal |
|
||||
| Narrow prefix filter | Sensitive values provisioned under other env prefixes (MCP keys, API keys injected via the agent provider) are invisible to `scan_known_secrets` |
|
||||
| Fragmentation blindness | Inserting `-`, space, or `\n` between secret characters turns an exact-match into a miss |
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. Each launched bottle has a unique canary token in the agent's environment
|
||||
under a randomized `WORD_WORD_SECRET` env var name. The egress sidecar gets
|
||||
the same env var and registers that exact name through
|
||||
`BOT_BOTTLE_SENSITIVE_PREFIXES`. Any outbound appearance of the canary
|
||||
blocks the request as a known-secret match.
|
||||
2. `scan_known_secrets` accepts a `sensitive_prefixes` parameter (default:
|
||||
`("EGRESS_TOKEN_",)`). `scan_outbound` reads
|
||||
`BOT_BOTTLE_SENSITIVE_PREFIXES` from `environ` and merges those prefixes
|
||||
in, so operators can mark additional env vars as scanned values without
|
||||
changing the manifest schema.
|
||||
3. For every secret that passes exact-match, a secondary alnum-projection pass
|
||||
checks for the secret with all non-alphanumeric characters stripped. This
|
||||
catches separator-injection evasion (`MY-SECRET` → body contains
|
||||
`MY SECRET`).
|
||||
4. A sliding-window partial-match pass checks for long-enough contiguous
|
||||
substrings of the secret's alnum projection in the text's alnum projection.
|
||||
Any match ≥ `PARTIAL_MATCH_MIN_LEN` (12 chars) blocks with reason
|
||||
`"partial match"`.
|
||||
5. A new `scan_entropy` detector flags outbound text windows with Shannon
|
||||
entropy ≥ `ENTROPY_BLOCK_THRESHOLD` (5.5 bits/char) at **warn** severity
|
||||
only. It is registered under the new detector name `"entropy"` in
|
||||
`OUTBOUND_DETECTOR_NAMES` and disabled by default (routes must opt in).
|
||||
6. Binary request bodies are decoded via `latin-1` instead of
|
||||
`utf-8 errors="replace"`, preserving every byte value and allowing
|
||||
ASCII-range secrets to be found within binary payloads.
|
||||
7. All new behaviour is unit-tested; existing tests pass unchanged.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Rolling per-host buffer for split-across-requests detection (state in the
|
||||
stateless addon is complex; deferred).
|
||||
- Additional vendor regexes.
|
||||
- ML / embedding-based detection.
|
||||
- Entropy-based hard blocks (warn only per the issue).
|
||||
|
||||
## Design
|
||||
|
||||
### Canary token flow
|
||||
|
||||
```
|
||||
Egress.prepare()
|
||||
canary = secrets.token_urlsafe(32)
|
||||
canary_env = <random WORD_WORD_SECRET>
|
||||
EgressPlan(canary=canary, canary_env=canary_env, ...)
|
||||
|
||||
Docker compose render:
|
||||
sidecar env: <canary_env>=<canary>
|
||||
sidecar env: BOT_BOTTLE_SENSITIVE_PREFIXES=<canary_env>
|
||||
agent env: <canary_env>=<canary> ← visible to agent as a "secret"
|
||||
|
||||
macos-container launch: same literals added to sidecar + agent env entries
|
||||
```
|
||||
|
||||
The sidecar uses `BOT_BOTTLE_SENSITIVE_PREFIXES` to make the random canary env
|
||||
name part of the existing `scan_known_secrets` detector without adding a
|
||||
manifest schema field.
|
||||
|
||||
### Broadened known-value scanning
|
||||
|
||||
`scan_known_secrets` gains a `sensitive_prefixes` parameter:
|
||||
|
||||
```python
|
||||
def scan_known_secrets(
|
||||
text: str,
|
||||
*,
|
||||
location: str = "body",
|
||||
env: Mapping[str, str] | None = None,
|
||||
sensitive_prefixes: tuple[str, ...] = ("EGRESS_TOKEN_",),
|
||||
) -> ScanResult | None:
|
||||
```
|
||||
|
||||
`scan_outbound` reads `BOT_BOTTLE_SENSITIVE_PREFIXES` (comma-separated list
|
||||
of additional prefixes) from `environ` and appends them:
|
||||
|
||||
```python
|
||||
extra = tuple(
|
||||
p for p in environ.get("BOT_BOTTLE_SENSITIVE_PREFIXES", "").split(",") if p
|
||||
)
|
||||
sensitive_prefixes = ("EGRESS_TOKEN_",) + extra
|
||||
```
|
||||
|
||||
`redact_tokens` receives the same treatment for consistent redaction.
|
||||
|
||||
### Fragmentation-resistant matching
|
||||
|
||||
A new helper `_alnum_projection(text)` strips all non-alphanumeric characters.
|
||||
`scan_known_secrets` runs two passes per secret:
|
||||
|
||||
1. **Exact pass** — existing encoded-variant loop (unchanged).
|
||||
2. **Alnum-projection pass** — if the secret's alnum projection has ≥ 8 chars,
|
||||
check if it appears in the text's alnum projection. Match → block with
|
||||
`"fragmented match (separator injection)"` reason.
|
||||
3. **Partial-substring pass** — if the secret's alnum projection has ≥
|
||||
`PARTIAL_MATCH_MIN_LEN` chars (12), slide a window of that length across the
|
||||
secret's projection and look for each window in the text's alnum projection.
|
||||
First match → block with `"partial match"` reason.
|
||||
|
||||
All three passes run only for the `"known_secrets"` detector; the token-pattern
|
||||
and entropy detectors are unchanged.
|
||||
|
||||
### Entropy scoring
|
||||
|
||||
New public function:
|
||||
|
||||
```python
|
||||
def scan_entropy(
|
||||
text: str,
|
||||
*,
|
||||
location: str = "body",
|
||||
window: int = ENTROPY_WINDOW, # 64
|
||||
threshold: float = ENTROPY_BLOCK_THRESHOLD, # 5.5
|
||||
) -> ScanResult | None:
|
||||
```
|
||||
|
||||
Slides a window of `window` characters across `text` in steps of `window // 2`.
|
||||
If any window's Shannon entropy exceeds `threshold`, returns a **warn**-severity
|
||||
`ScanResult`. Never blocks.
|
||||
|
||||
`OUTBOUND_DETECTOR_NAMES` gains `"entropy"`. Routes opt in via their `dlp`
|
||||
block; entropy scanning is **off by default** to avoid false-positive noise on
|
||||
legitimate binary payloads.
|
||||
|
||||
### Binary body handling
|
||||
|
||||
In `scan_outbound`, the bytes → str decoding changes from:
|
||||
|
||||
```python
|
||||
body.decode("utf-8", errors="replace")
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```python
|
||||
body.decode("utf-8") if body is str else body.decode("latin-1")
|
||||
```
|
||||
|
||||
`latin-1` is a bijective byte↔codepoint mapping; every byte value is preserved
|
||||
as its corresponding Latin-1 code point, so ASCII-range secret strings remain
|
||||
intact and `str.find` / regex still locate them correctly. The fallback from
|
||||
strict UTF-8 is tried first so valid UTF-8 bodies are decoded faithfully.
|
||||
|
||||
## Implementation
|
||||
|
||||
Delivered in three commits on the same branch:
|
||||
|
||||
1. **DLP detector changes** — `_alnum_projection`, fragmentation passes,
|
||||
`scan_entropy`, broadened `scan_known_secrets`, updated `scan_outbound` and
|
||||
`redact_tokens`; all accompanying unit tests.
|
||||
2. **Canary injection** — `EgressPlan.canary`, `Egress.prepare()`,
|
||||
Docker compose + macos-container backend injection.
|
||||
3. **PRD flip** — `Status: Draft → Active`.
|
||||
@@ -0,0 +1,85 @@
|
||||
# PRD 0064: LOG_FULL egress logging credential redaction
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-06-25
|
||||
- **Issue:** #257
|
||||
|
||||
## Summary
|
||||
|
||||
The `LOG_FULL` egress logging path (`_log_request` and `_log_response` in `egress_addon.py`) writes request/response headers and bodies to stderr without redaction and includes the sidecar-injected upstream `Authorization` header verbatim. This PR applies `redact_tokens` to header values and bodies in both log functions and strips the injected `Authorization` header from request logs entirely.
|
||||
|
||||
## Problem
|
||||
|
||||
`LOG_FULL` (log level 2) is intended for debugging egress traffic. When active it calls `_log_request` and `_log_response`. Both functions have two related bugs:
|
||||
|
||||
1. **Injected `Authorization` header exposure.** `_log_request` is called *after* the sidecar injects upstream credentials (`flow.request.headers["authorization"] = decision.inject_authorization`). The full header dict — including the live credential — is serialized to stderr. Any log collector that ingests the egress container's stderr will receive the upstream bearer token in plaintext.
|
||||
|
||||
2. **Unredacted bodies and header values.** Neither `_log_request` nor `_log_response` passes body or header values through `redact_tokens`. By contrast, `_req_ctx` (used for block/warn events) already calls `redact_tokens` on path and host. Any provisioned secret or recognized token pattern that appears in a request body, response body, or non-Authorization header value will be logged verbatim under `LOG_FULL`.
|
||||
|
||||
These two bugs compose: an agent that enables `LOG_FULL` and simultaneously triggers a request that carries a known token gains a write path from credentials → egress logs.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `_log_request` never logs the `authorization` header in any form.
|
||||
- `_log_request` applies `redact_tokens(value, env=os.environ)` to every other header value before serializing.
|
||||
- `_log_request` applies `redact_tokens(body, env=os.environ)` to the request body before logging.
|
||||
- `_log_response` applies `redact_tokens(value, env=os.environ)` to every response header value before logging.
|
||||
- `_log_response` applies `redact_tokens(body, env=os.environ)` to the response body before logging.
|
||||
- Unit tests cover each of the five cases above.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Redacting host or path in the full-log path (already covered by `_req_ctx` for block/warn events; `_log_request` already calls `redact_tokens` on host and path).
|
||||
- Suppressing `LOG_FULL` or adding a new log level.
|
||||
- Changing the outbound DLP scan logic.
|
||||
|
||||
## Design
|
||||
|
||||
### `_log_request`
|
||||
|
||||
```python
|
||||
def _log_request(self, flow: http.HTTPFlow) -> None:
|
||||
headers = {
|
||||
k: redact_tokens(v, env=os.environ)
|
||||
for k, v in flow.request.headers.items()
|
||||
if k.lower() != "authorization"
|
||||
}
|
||||
body = redact_tokens(flow.request.get_text(strict=False) or "", env=os.environ)
|
||||
sys.stderr.write(
|
||||
json.dumps({
|
||||
"event": "egress_request",
|
||||
"host": redact_tokens(flow.request.pretty_host, env=os.environ),
|
||||
"method": flow.request.method,
|
||||
"path": redact_tokens(flow.request.path, env=os.environ),
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
})
|
||||
+ "\n"
|
||||
)
|
||||
```
|
||||
|
||||
The `authorization` key is excluded because by the time `_log_request` is called the sidecar has already injected the upstream credential (`decision.inject_authorization`). Logging it would write a live bearer token to stderr on every allowed request. There is no safe subset to log — the value is always a live credential or empty.
|
||||
|
||||
### `_log_response`
|
||||
|
||||
```python
|
||||
def _log_response(self, flow: http.HTTPFlow) -> None:
|
||||
headers = {
|
||||
k: redact_tokens(v, env=os.environ)
|
||||
for k, v in flow.response.headers.items()
|
||||
}
|
||||
body = redact_tokens(flow.response.get_text(strict=False) or "", env=os.environ)
|
||||
sys.stderr.write(
|
||||
json.dumps({
|
||||
"event": "egress_response",
|
||||
"host": flow.request.pretty_host,
|
||||
"status": flow.response.status_code,
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
})
|
||||
+ "\n"
|
||||
)
|
||||
```
|
||||
|
||||
Response headers don't carry injected credentials, so no header name is suppressed — only the values are scrubbed by `redact_tokens`.
|
||||
@@ -0,0 +1,166 @@
|
||||
# PRD 0065: Multi-parent `extends:` for bottles
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-06-25
|
||||
- **Issue:** #268
|
||||
- **Extends:** PRD 0025 (`0025-bottle-extends.md`)
|
||||
|
||||
## Summary
|
||||
|
||||
Allow a bottle's `extends:` field to accept either a single bottle name (existing
|
||||
behavior) or a list of bottle names (new). Multiple parents are resolved
|
||||
independently and folded left-to-right into a single effective parent before the
|
||||
child is merged on top. This lets orthogonal concerns (base env, networking/egress,
|
||||
agent provider) live in separate bottles and be composed without forcing them into a
|
||||
linear chain.
|
||||
|
||||
## Problem
|
||||
|
||||
PRD 0025 shipped single-parent `extends:` and listed "No multi-parent inheritance"
|
||||
as a non-goal. In practice, users want to compose multiple orthogonal bottles — a
|
||||
base environment, a networking profile, and an agent-provider override — without
|
||||
creating a three-level linear chain that couples unrelated parents to each other.
|
||||
The linear chain workaround has two problems:
|
||||
|
||||
1. **Ordering constraint.** `networking extends base` works, but then
|
||||
`agent extends networking` can't also pick up `base` without going through
|
||||
`networking`, coupling two unrelated concerns.
|
||||
|
||||
2. **Quadratic duplication.** N orthogonal bottles require O(N²) chain variants
|
||||
(one chain per permutation of applied concerns).
|
||||
|
||||
Multi-parent `extends:` removes both constraints: each orthogonal concern stays in
|
||||
its own bottle, and the child bottle is the only place that names the combination.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `extends:` accepts a list of strings in addition to a plain string.
|
||||
- Backward compat: existing single-string `extends:` is unchanged.
|
||||
- Parents are resolved left-to-right; later entries win on conflict.
|
||||
- Child wins over all parents (unchanged from PRD 0025).
|
||||
- Cycle detection covers multi-parent graphs, not just linear chains.
|
||||
- Diamond inheritance: a shared ancestor is resolved once (via the existing cache).
|
||||
- Invalid list entries (non-string, undefined bottle, self-reference) die at parse
|
||||
with clear messages.
|
||||
- `manifest_loader.py`'s `load_bottle_chain_from_dir` enqueues all parents from a
|
||||
list `extends:` so the resolver sees every bottle in the graph.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No change to the agent-vs-bottle trust boundary (PRD 0025 "Alternatives
|
||||
considered" option 2 stays rejected).
|
||||
- No MRO / C3 linearization. Left-to-right fold is sufficient for the expected use
|
||||
cases.
|
||||
- No preflight display of per-field provenance across multiple parents (same open
|
||||
question as PRD 0025; remains a follow-up).
|
||||
|
||||
## Design
|
||||
|
||||
### Schema
|
||||
|
||||
`extends:` now accepts either form:
|
||||
|
||||
```yaml
|
||||
# single parent (unchanged)
|
||||
extends: base
|
||||
|
||||
# multiple parents (new)
|
||||
extends: [base, networking]
|
||||
```
|
||||
|
||||
Both forms are normalized to a list internally. A list with one element behaves
|
||||
identically to the string form.
|
||||
|
||||
### Merge rules for multi-parent fold
|
||||
|
||||
Parents are folded pairwise left-to-right before the child merge. For each step in
|
||||
the fold, the "earlier" bottle is the running accumulator and the "later" bottle is
|
||||
the next parent. Rules per field:
|
||||
|
||||
| Field | Fold rule |
|
||||
|--------------------|--------------------------------------------------------------|
|
||||
| `env` | dict merge; later wins on key collision |
|
||||
| `git-gate.user` | per-field overlay; later's non-empty fields win |
|
||||
| `git-gate.repos` | union by name; for same-name entries, later wins per-field |
|
||||
| `egress.routes` | concatenate (earlier first, later appended) |
|
||||
| `egress.log` | later wins (last-wins) |
|
||||
| `agent_provider` | later wins (last-wins) |
|
||||
| `supervise` | later wins (last-wins) |
|
||||
|
||||
After the fold, the combined parent is merged against the child using the existing
|
||||
PRD 0025 rules (child always wins). The child's `egress.routes` appends to the
|
||||
combined parent's concatenated routes; `validate_egress_routes` runs once on the
|
||||
final merged set and catches duplicate hosts.
|
||||
|
||||
### Algorithm
|
||||
|
||||
```
|
||||
extends: [p1, p2, p3]
|
||||
|
||||
fold:
|
||||
combined = resolve(p1)
|
||||
combined = fold_two(combined, resolve(p2))
|
||||
combined = fold_two(combined, resolve(p3))
|
||||
|
||||
merge:
|
||||
result = _merge_bottles(combined, child_raw, name)
|
||||
```
|
||||
|
||||
`fold_two(earlier, later)` applies the rules in the table above. Cycle detection
|
||||
(the `seen` tuple) is passed to each parent resolution call unchanged — if any
|
||||
parent's chain circles back to the current bottle, it is caught. The `cache` dict
|
||||
ensures a shared ancestor is only resolved once across all parents.
|
||||
|
||||
### Error cases
|
||||
|
||||
| Condition | Error message shape |
|
||||
|----------------------------------------|------------------------------------------------------------------|
|
||||
| `extends` is not a string or list | `extends must be a string or list of strings (was <type>)` |
|
||||
| A list entry is not a string | `extends[<i>] must be a string (was <type>)` |
|
||||
| A list entry names an undefined bottle | `extends '<name>' which is not defined. Available bottles: ...` |
|
||||
| A list entry is the bottle itself | `extends itself; remove the self-reference` |
|
||||
| Cycle through any parent edge | `is in an extends cycle: <chain>` |
|
||||
|
||||
## Implementation
|
||||
|
||||
### `bot_bottle/manifest_extends.py`
|
||||
|
||||
- `_resolve_one_bottle`: accept `str | list[str]` for `extends`; normalize to list;
|
||||
validate each entry; for a single-entry list fall through to the existing
|
||||
single-parent path; for multiple entries call `_fold_parents` then
|
||||
`_merge_bottles`.
|
||||
- `_fold_parents(parent_names, raws, cache, repos_cache, seen)`: resolve each
|
||||
parent and fold pairwise left-to-right; return `(effective_bottle,
|
||||
effective_repos_raw)`.
|
||||
- `_fold_two_bottles(earlier, earlier_repos_raw, later, later_repos_raw)`: apply
|
||||
the fold rules above; return `(folded_bottle, folded_repos_raw)`.
|
||||
|
||||
### `bot_bottle/manifest_loader.py`
|
||||
|
||||
- `load_bottle_chain_from_dir`: when `extends` is a list, enqueue all parent names
|
||||
for loading (previously only `isinstance(parent, str)` was handled).
|
||||
|
||||
### `tests/unit/test_manifest_extends.py`
|
||||
|
||||
- `TestExtendsErrors.test_non_string_extends_dies`: update to use an integer
|
||||
`extends` value (a list is now valid).
|
||||
- New class `TestExtendsMultiParent` covering all cases listed in the issue.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
Unit tests via `ManifestIndex.from_json_obj` (same resolver surface used by all
|
||||
paths). No integration test changes needed — downstream code consumes the already-
|
||||
merged bottle and is unchanged.
|
||||
|
||||
Test cases:
|
||||
- Two-parent list: env union, egress routes concat, git repos union
|
||||
- Last-parent-wins on scalar (supervise, agent_provider)
|
||||
- Child wins over all parents on conflict
|
||||
- Diamond: two parents share an ancestor; ancestor resolved once
|
||||
- Single-element list: identical to string form
|
||||
- Non-string extends value → ManifestError
|
||||
- Non-string list entry → ManifestError
|
||||
- Undefined bottle in list → ManifestError
|
||||
- Self-reference in list → ManifestError
|
||||
- Cycle through multi-parent edge → ManifestError
|
||||
@@ -0,0 +1,216 @@
|
||||
# PRD 0066: Separate agent and bottle selection
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** claude
|
||||
- **Created:** 2026-06-25
|
||||
- **Issue:** #269
|
||||
|
||||
## Summary
|
||||
|
||||
Agents and bottles are two separate concerns: agents carry a system prompt and
|
||||
skills; bottles carry infrastructure configuration (egress, git-gate, env,
|
||||
agent provider). Today an agent's manifest file hard-codes a single `bottle:`
|
||||
reference, which prevents the same agent prompt from being reused across
|
||||
projects that need different bottle configurations. This PRD decouples them: at
|
||||
launch time, after choosing the agent, the operator picks an ordered list of
|
||||
bottles via a multi-select picker. The selected bottles are merged in order
|
||||
(later entries override earlier ones) to produce the effective bottle for the
|
||||
session.
|
||||
|
||||
## Problem
|
||||
|
||||
The current `bottle: <name>` field on an agent manifest file binds the agent
|
||||
permanently to one bottle. To use the same system prompt with a different bottle
|
||||
(e.g. `claude-implementer` at home vs. at a client site that needs a different
|
||||
egress policy), the operator must duplicate the agent file and change the
|
||||
`bottle:` field. Duplicate agent files drift out of sync.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
1. `bottle:` in an agent's frontmatter becomes optional. Existing manifests with
|
||||
`bottle:` continue to work unchanged (backward compat).
|
||||
2. After selecting an agent (via the existing single-select picker), a new
|
||||
multi-select bottle picker appears showing all available bottles.
|
||||
3. The multi-select picker pre-populates with the agent's `bottle:` value when
|
||||
present.
|
||||
4. Confirming with one or more bottles selected uses those bottles, merged in
|
||||
selection order, as the effective bottle for the session.
|
||||
5. Confirming with an empty selection falls back to the agent's `bottle:` field.
|
||||
If neither is set, a ManifestError is raised pointing the operator at the fix.
|
||||
6. The ordered bottle list is stored in launch metadata so `./cli.py resume`
|
||||
uses the same bottles.
|
||||
7. The preflight summary (`y/N` screen) shows the effective bottle name(s).
|
||||
8. The multi-select picker supports incremental filtering, Space/Enter to toggle
|
||||
selection, an ordered "Selected: ..." summary line, Ctrl-D to confirm, and
|
||||
Esc/q to cancel the whole start operation.
|
||||
9. Unit tests cover: multi-select widget (filter, toggle, confirm, cancel),
|
||||
the `cmd_start` bottle-picker step, and the manifest `load_for_agent`
|
||||
runtime-bottle-merge path.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Reordering the selection list from within the picker (order = insertion order;
|
||||
drag-and-drop is out of scope).
|
||||
- Storing bottle selection history / MRU.
|
||||
- Changes to `./cli.py edit`, `./cli.py list`, or `./cli.py info`.
|
||||
- Removing the `bottle:` key from the agent schema (it stays, now optional).
|
||||
|
||||
## Design
|
||||
|
||||
### `bot_bottle/cli/tui.py` — `filter_multiselect`
|
||||
|
||||
```python
|
||||
def filter_multiselect(
|
||||
items: list[str],
|
||||
*,
|
||||
title: str = "",
|
||||
initial: list[str] | None = None,
|
||||
tty_path: str = "/dev/tty",
|
||||
) -> list[str] | None:
|
||||
"""Multi-select variant of filter_select.
|
||||
|
||||
Returns the ordered list of selected items, or None on cancel.
|
||||
Press Space/Enter to toggle the item under the cursor.
|
||||
Press Ctrl-D to confirm. Press Esc/q to cancel.
|
||||
"""
|
||||
```
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
Select bottles
|
||||
Filter: _
|
||||
─────────────────────────────────────────
|
||||
> [*] claude
|
||||
[ ] dev
|
||||
[ ] codex
|
||||
─────────────────────────────────────────
|
||||
Selected (in order): claude
|
||||
─────────────────────────────────────────
|
||||
[↑↓/jk] move [Space] toggle [Ctrl-D] done [Esc] cancel
|
||||
```
|
||||
|
||||
`initial` pre-populates the ordered selection. `None` means no pre-selection.
|
||||
Items added are appended in insertion order; items removed leave the remaining
|
||||
order unchanged.
|
||||
|
||||
### `bot_bottle/manifest_schema.py` — optional `bottle:`
|
||||
|
||||
`bottle` moves from `AGENT_KEYS_REQUIRED` to `AGENT_KEYS_OPTIONAL`.
|
||||
|
||||
### `bot_bottle/manifest_agent.py` — optional `bottle:`
|
||||
|
||||
`ManifestAgent.bottle` changes from `str` (required) to `str = ""`.
|
||||
`from_dict` no longer requires the key to be present; the bottle-exists
|
||||
validation is skipped when the key is absent.
|
||||
|
||||
### `bot_bottle/manifest_loader.py` — `scan_bottle_names`
|
||||
|
||||
```python
|
||||
def scan_bottle_names(bottles_dir: Path) -> list[str]:
|
||||
"""Scan <bottles_dir>/*.md and return sorted bottle names."""
|
||||
```
|
||||
|
||||
### `bot_bottle/manifest.py` — `ManifestIndex` changes
|
||||
|
||||
**`all_bottle_names` property** — analogous to `all_agent_names`; scans
|
||||
`home_md / "bottles"` in lazy mode, returns `sorted(self.bottles.keys())` in
|
||||
eager mode.
|
||||
|
||||
**`load_for_agent(agent_name, bottle_names: tuple[str, ...] = ())`** — new
|
||||
`bottle_names` parameter. When non-empty, the listed bottles are resolved and
|
||||
merged in order (index 0 is the base; each subsequent bottle is applied on top
|
||||
using the same field-merge rules as `extends:`). The result replaces the bottle
|
||||
that `agent.bottle` would have provided. When empty, falls back to `agent.bottle`.
|
||||
Raises ManifestError if neither `bottle_names` nor `agent.bottle` is set.
|
||||
|
||||
### `bot_bottle/manifest_extends.py` — `merge_bottles_runtime`
|
||||
|
||||
```python
|
||||
def merge_bottles_runtime(bottles: list[ManifestBottle]) -> ManifestBottle:
|
||||
"""Merge an ordered list of pre-resolved ManifestBottle objects.
|
||||
|
||||
Index 0 is the base; each subsequent entry overrides the previous using
|
||||
the same rules as the file-based extends machinery:
|
||||
- env: dict merge, later wins
|
||||
- git_user: per-field overlay, later wins on non-empty
|
||||
- git (repos): union by name, later wins per-name
|
||||
- egress.routes: concatenate
|
||||
- agent_provider, supervise: later bottle's value replaces earlier
|
||||
"""
|
||||
```
|
||||
|
||||
This function operates on already-parsed `ManifestBottle` objects, so it does
|
||||
not need to touch the raw-dict path.
|
||||
|
||||
### `bot_bottle/backend/__init__.py` — `BottleSpec` + `_validate`
|
||||
|
||||
`BottleSpec` gains `bottle_names: tuple[str, ...] = ()`.
|
||||
|
||||
`BottleBackend._validate` passes `spec.bottle_names` to `load_for_agent`:
|
||||
|
||||
```python
|
||||
manifest = spec.manifest.load_for_agent(spec.agent_name, spec.bottle_names)
|
||||
```
|
||||
|
||||
The preflight print updates `info(f"bottle: {agent.bottle}")` to display the
|
||||
effective bottle name(s). When `spec.bottle_names` is non-empty those are
|
||||
shown; when empty and `agent.bottle` is set, the agent's `bottle:` is shown.
|
||||
|
||||
### `bot_bottle/bottle_state.py` — persist bottle names
|
||||
|
||||
`BottleMetadata` gains `bottle_names: tuple[str, ...] = ()`. `read_metadata`
|
||||
reads this from JSON (default `()`). `write_launch_metadata` passes
|
||||
`spec.bottle_names` through.
|
||||
|
||||
### `bot_bottle/cli/start.py` — bottle multiselect step
|
||||
|
||||
After agent selection, before the name/color modal:
|
||||
|
||||
```python
|
||||
available_bottle_names = manifest.all_bottle_names
|
||||
# Peek at agent's bottle default for pre-population
|
||||
initial_bottle = _peek_agent_bottle(manifest, agent_name)
|
||||
initial = [initial_bottle] if initial_bottle else []
|
||||
|
||||
bottle_names_list = tui.filter_multiselect(
|
||||
available_bottle_names,
|
||||
title="Select bottles",
|
||||
initial=initial,
|
||||
)
|
||||
if bottle_names_list is None:
|
||||
return 0 # user cancelled
|
||||
bottle_names = tuple(bottle_names_list)
|
||||
```
|
||||
|
||||
`_peek_agent_bottle` reads the agent file's frontmatter without full parsing,
|
||||
returning the `bottle:` value or `""` when absent.
|
||||
|
||||
`BottleSpec` is built with `bottle_names=bottle_names`.
|
||||
|
||||
### `bot_bottle/cli/resume.py` — bottle names from metadata
|
||||
|
||||
```python
|
||||
spec = BottleSpec(
|
||||
...
|
||||
bottle_names=tuple(metadata.bottle_names),
|
||||
)
|
||||
```
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
1. **Schema + model** — `manifest_schema.py`, `manifest_agent.py` (optional
|
||||
`bottle:`), `manifest_loader.py` (`scan_bottle_names`), `manifest.py`
|
||||
(`all_bottle_names`, `load_for_agent` signature), `manifest_extends.py`
|
||||
(`merge_bottles_runtime`), `bottle_state.py` (`bottle_names` field),
|
||||
`resolve_common.py` (thread through).
|
||||
2. **Backend** — `BottleSpec.bottle_names`, `_validate`, preflight print.
|
||||
3. **TUI** — `filter_multiselect` in `tui.py` + unit tests.
|
||||
4. **CLI wiring** — `start.py` bottle picker step, `resume.py` metadata load.
|
||||
5. **Tests** — `test_cli_start_selector.py` bottle-picker cases,
|
||||
`test_manifest_agent.py` optional-bottle cases, new
|
||||
`test_manifest_bottle_merge.py` for `merge_bottles_runtime`.
|
||||
|
||||
## Open questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,227 @@
|
||||
# PRD prd-new: smolmachines backend on Linux
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-06-25
|
||||
- **Issue:** #283
|
||||
|
||||
## Summary
|
||||
|
||||
Make the `smolmachines` backend (PRD 0023) runnable on Linux, not
|
||||
just macOS. `smolvm` already supports Linux via KVM (`/dev/kvm`);
|
||||
the gap is entirely in bot-bottle's host-side glue, which hard-codes
|
||||
macOS assumptions in three places:
|
||||
|
||||
1. **Preflight** only checks that `smolvm` is on `PATH` — it never
|
||||
checks the Linux KVM prerequisite, so a misconfigured host fails
|
||||
deep in the launch flow with an opaque `smolvm` error.
|
||||
2. **The TSI allowlist enforcement** (`force_allowlist`) — the
|
||||
security property that confines the agent VM to its sidecar
|
||||
bundle's `/32` — **no-ops on Linux today, failing _open_**. The
|
||||
smolvm state-DB path it patches is hard-coded to macOS's
|
||||
`~/Library/Application Support/...`.
|
||||
3. **Per-bottle loopback scoping** (`allocate`) returns the shared
|
||||
`127.0.0.1` on Linux, which would let the agent VM reach every
|
||||
service on host loopback — a downgrade from the per-bottle alias
|
||||
isolation macOS gets.
|
||||
|
||||
This PRD closes all three so a bottle launched with
|
||||
`BOT_BOTTLE_BACKEND=smolmachines` on Linux gets the same isolation
|
||||
guarantee it gets on macOS, and documents the Linux/NixOS host
|
||||
setup. The primary validation target is NixOS, but the changes are
|
||||
distro-agnostic.
|
||||
|
||||
## Problem
|
||||
|
||||
The smolmachines backend runs each bottle's agent inside a libkrun
|
||||
microVM via `smolvm`, with egress confined by TSI's `--allow-cidr`
|
||||
allowlist set to a single `/32` — the sidecar bundle's loopback
|
||||
address. Everything else (host loopback, LAN, internet) is denied at
|
||||
the VMM layer. That security property is the entire reason the
|
||||
backend exists.
|
||||
|
||||
libkrun runs on Hypervisor.framework (macOS) **and** KVM (Linux), and
|
||||
`smolvm` ships Linux x86_64 / aarch64 builds that require `/dev/kvm`.
|
||||
So the microVM layer already works on Linux. What does not work is
|
||||
bot-bottle's host integration, which PRD 0023 explicitly scoped to
|
||||
macOS-only for v1. Three concrete blockers:
|
||||
|
||||
- **No KVM preflight.** On a Linux host without `/dev/kvm` (kernel
|
||||
module not loaded) or without access to it (user not in the `kvm`
|
||||
group), the failure surfaces as a cryptic `smolvm` non-zero exit
|
||||
mid-launch instead of an actionable message.
|
||||
|
||||
- **TSI enforcement fails open on Linux.** `force_allowlist`
|
||||
early-returns on non-macOS. It exists because `smolvm` 0.8.0
|
||||
silently drops `--allow-cidr` when combined with `--from`, so the
|
||||
allowlist has to be patched into smolvm's persisted state DB before
|
||||
`machine start`. On Linux that patch never runs **and** the DB path
|
||||
is the macOS path, so the booted VM's TSI allowlist is whatever
|
||||
smolvm defaulted to — potentially all of `127.0.0.0/8`. That is the
|
||||
exact sandbox-escape the backend is supposed to prevent.
|
||||
|
||||
- **No per-bottle loopback isolation on Linux.** `allocate` returns
|
||||
`127.0.0.1` on Linux. Even with a correct allowlist, `127.0.0.1/32`
|
||||
is shared by every service on host loopback, so the agent could
|
||||
reach other bottles' published ports and host services. On macOS
|
||||
this is solved with per-bottle `127.0.0.16..31` aliases added via
|
||||
`sudo ifconfig lo0 alias`. On Linux the whole `127.0.0.0/8` is
|
||||
already routed to `lo`, so docker can publish to `127.0.0.<N>`
|
||||
with **no `ifconfig`/sudo step at all** — the isolation is actually
|
||||
cheaper to achieve than on macOS.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `BOT_BOTTLE_BACKEND=smolmachines ./cli.py start <agent>` launches,
|
||||
runs, and tears down a bottle on a Linux host with `/dev/kvm`.
|
||||
- The TSI allowlist is enforced on Linux: PRD 0022's
|
||||
`tests/integration/test_sandbox_escape.py` passes against
|
||||
`BOT_BOTTLE_BACKEND=smolmachines` on Linux (the acceptance gate).
|
||||
- Each Linux bottle is scoped to its own `127.0.0.<N>/32`, matching
|
||||
the macOS per-bottle isolation property.
|
||||
- A clear, actionable preflight error when `/dev/kvm` is missing or
|
||||
inaccessible, with remediation (load `kvm-intel`/`kvm-amd`, join the
|
||||
`kvm` group).
|
||||
- **Fail-closed:** if bot-bottle cannot positively confirm the TSI
|
||||
allowlist was persisted for a machine (DB missing, row missing,
|
||||
patch didn't take), it `die()`s before `machine start` rather than
|
||||
booting a VM with an unverified allowlist.
|
||||
- macOS behavior is unchanged.
|
||||
- README documents Linux + NixOS host setup.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Rootless / non-KVM fallbacks (e.g. software emulation). Linux
|
||||
smolmachines requires `/dev/kvm`, full stop.
|
||||
- Removing Docker as a host dependency — the sidecar bundle and
|
||||
image-build pipeline still use Docker on Linux, same as macOS.
|
||||
- Auto-installing `smolvm` or configuring KVM on the operator's
|
||||
behalf. Preflight reports; the operator remediates.
|
||||
- Nested-virtualization tuning for running the runner itself inside a
|
||||
VM (documented as a caveat, not solved here).
|
||||
|
||||
## Design
|
||||
|
||||
### Platform detection
|
||||
|
||||
Reuse the existing `platform.system()` check already in
|
||||
`loopback_alias.py` (`_is_macos()`). "Linux" is "not macOS" for every
|
||||
branch below; no new third-platform path.
|
||||
|
||||
### Preflight: KVM gate (`util.smolmachines_preflight`)
|
||||
|
||||
After the existing `smolvm`-on-`PATH` check, add a Linux-only gate:
|
||||
|
||||
- `/dev/kvm` must exist → else `die()` with "enable KVM
|
||||
(`kvm-intel`/`kvm-amd` kernel module)".
|
||||
- `/dev/kvm` must be readable + writable by the current user
|
||||
(`os.access(..., R_OK | W_OK)`) → else `die()` with "add your user
|
||||
to the `kvm` group (and re-login)".
|
||||
|
||||
macOS is unaffected (Hypervisor.framework needs no device node).
|
||||
|
||||
### smolvm state-DB path (platform-aware)
|
||||
|
||||
`loopback_alias._SMOLVM_DB_PATH` becomes platform-derived:
|
||||
|
||||
- macOS: `~/Library/Application Support/smolvm/server/smolvm.db`
|
||||
(unchanged).
|
||||
- Linux: `$XDG_DATA_HOME/smolvm/server/smolvm.db`, defaulting to
|
||||
`~/.local/share/smolvm/server/smolvm.db`.
|
||||
|
||||
> **Verification note:** the Linux DB location is inferred from
|
||||
> smolvm's documented `~/.local/share` install layout and the XDG
|
||||
> base-dir spec. It must be confirmed on a real Linux smolvm install;
|
||||
> if smolvm uses a different path or schema, the fail-closed check
|
||||
> below turns that into a clear `die()` at launch rather than a silent
|
||||
> escape.
|
||||
|
||||
### TSI enforcement: cross-platform + fail-closed (`force_allowlist`)
|
||||
|
||||
Rework `force_allowlist(machine_name, allowed_cidrs)` to run on
|
||||
**both** platforms and to fail closed:
|
||||
|
||||
1. Resolve the state DB; if the file is missing, `die()` (cannot
|
||||
confirm enforcement → refuse to launch).
|
||||
2. Read the machine's persisted row; if the row is missing, `die()`.
|
||||
3. If the row's `allowed_cidrs` already equals the requested list
|
||||
(e.g. a newer `smolvm` that honors `--allow-cidr` at create), do
|
||||
nothing — no write.
|
||||
4. Otherwise patch `allowed_cidrs` (the existing BLOB-encoded write)
|
||||
and re-read.
|
||||
5. If, after the patch, `allowed_cidrs` still does not equal the
|
||||
requested list, `die()`.
|
||||
|
||||
This is robust across smolvm versions: it works whether `--allow-cidr`
|
||||
is silently dropped (0.8.0) or honored (newer), and it never boots a
|
||||
VM whose persisted allowlist it could not confirm. It is a strict
|
||||
improvement on macOS too (today's code writes unconditionally and
|
||||
never verifies).
|
||||
|
||||
> The persisted-row check confirms our write took, not that smolvm's
|
||||
> runtime TSI enforces it. The runtime guarantee is covered by the
|
||||
> sandbox-escape acceptance test; the persisted check is the cheap
|
||||
> fail-closed guard at launch.
|
||||
|
||||
### Per-bottle loopback scoping on Linux (`allocate`)
|
||||
|
||||
`allocate` runs the same docker-state-driven allocation on Linux as on
|
||||
macOS (`_allocate_locked`, the file lock, and `_aliases_in_use` via
|
||||
`docker inspect` are all already cross-platform). The only macOS-only
|
||||
step, `ensure_pool` (the `sudo ifconfig lo0 alias` dance), stays
|
||||
macOS-only: on Linux `127.0.0.0/8` is already loopback, so docker can
|
||||
publish bundle ports directly on `127.0.0.<N>` with no setup.
|
||||
|
||||
Net effect: Linux bottles get per-bottle `127.0.0.16..31/32` scoping
|
||||
identical to macOS, without sudo.
|
||||
|
||||
### Launch flow
|
||||
|
||||
`launch.py` needs no structural change — `_allocate_resources` already
|
||||
calls `ensure_pool()` (now a Linux no-op) then `allocate()` (now
|
||||
per-bottle on Linux), and `_launch_vm` already calls
|
||||
`force_allowlist()` (now active on Linux). Only the macOS-specific
|
||||
docstrings are updated to describe the cross-platform behavior.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
1. **Preflight KVM gate** — `util.smolmachines_preflight` +
|
||||
unit tests for the missing-device and no-access branches.
|
||||
2. **Platform-aware DB path + fail-closed `force_allowlist`** —
|
||||
`loopback_alias.py`; update/extend `TestForceAllowlist`.
|
||||
3. **Cross-platform `allocate`** — drop the Linux early-return; update
|
||||
`TestAllocate` / `TestAllocateLock` for the new Linux behavior.
|
||||
4. **Docstring + comment cleanup** in `launch.py` and module headers.
|
||||
5. **Docs** — README requirements + a Linux/NixOS host-setup section.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit (CI, any OS):** the suite mocks `platform.system()` /
|
||||
`subprocess` and patches `_SMOLVM_DB_PATH`, so the new Linux
|
||||
branches are testable on the macOS/Linux CI runner without `smolvm`
|
||||
or KVM. Covers: KVM preflight branches, fail-closed `force_allowlist`
|
||||
(DB missing, row missing, patch-doesn't-take), per-bottle Linux
|
||||
allocation + locking, platform-derived DB path.
|
||||
- **Integration (Linux host with KVM — the acceptance gate):**
|
||||
`tests/integration/test_sandbox_escape.py` against
|
||||
`BOT_BOTTLE_BACKEND=smolmachines`. This cannot run on the macOS dev
|
||||
box and must be executed on NixOS before merge.
|
||||
|
||||
## Open questions / verification pending
|
||||
|
||||
- **Confirm the Linux smolvm state-DB path and schema** on a real
|
||||
install (the `~/.local/share/...` inference above).
|
||||
- **Confirm whether the current smolvm Linux build still drops
|
||||
`--allow-cidr` with `--from`** (the 0.8.0 bug). The fail-closed
|
||||
design handles either answer, but knowing lets us drop the DB patch
|
||||
if upstream fixed it.
|
||||
- **Confirm docker publishing to `127.0.0.<N>` on Linux** behaves as
|
||||
expected end-to-end with TSI (high confidence; standard loopback
|
||||
behavior, but unverified on the target host).
|
||||
|
||||
## References
|
||||
|
||||
- PRD 0023 — smolmachines bottle backend (macOS v1).
|
||||
- PRD 0022 — `test_sandbox_escape.py` acceptance gate.
|
||||
- PRD 0024 — sidecar bundle image.
|
||||
- smolvm: https://github.com/smol-machines/smolvm
|
||||
@@ -0,0 +1,360 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,476 @@
|
||||
# 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.
|
||||
@@ -22,7 +22,7 @@ escapes**, and **whether credentials are short-lived and scoped**.
|
||||
- Outbound: Docker containers have full internet access by default; no egress monitoring on most home networks
|
||||
- Lateral movement: compromised container can reach the LAN — NAS, other machines, internal services
|
||||
- Notable: CVE-2025-59536 (CVSS 8.7, Feb 2026) — a poisoned `.claude/settings.json` in a repo gives RCE when Claude Code opens it. `--dangerously-skip-permissions` removes the last gate.
|
||||
- Supply chain: MCP servers, skills, and npm packages pulled during agent execution. ~20% of ClawHub skills were found malicious in early 2026.
|
||||
- Supply chain: MCP servers, skills, and npm packages pulled during agent execution. A Jan 2026 large-scale empirical study of a 98,380-skill snapshot confirmed 157 malicious skills, ~71% of them credential harvesters. Exfiltration was overwhelmingly naive — plaintext HTTP to hardcoded endpoints; under 10% used any code obfuscation, and concealment was mostly at the documentation level, not the code level. ([Malicious Agent Skills in the Wild](https://arxiv.org/html/2602.06547v1), arXiv:2602.06547)
|
||||
|
||||
**What local topology protects:**
|
||||
- No inbound attack surface — nothing listening on a public port
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
agent_provider:
|
||||
template: claude
|
||||
|
||||
egress:
|
||||
routes:
|
||||
- host: api.anthropic.com
|
||||
role: claude_code_oauth
|
||||
auth:
|
||||
scheme: Bearer
|
||||
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||
# auth_token names the host env var holding the Claude OAuth token. The
|
||||
# provider injects a provider-owned api.anthropic.com egress route that
|
||||
# re-injects this token as the Bearer header; the agent only ever sees a
|
||||
# placeholder CLAUDE_CODE_OAUTH_TOKEN. DLP defaults (token_patterns,
|
||||
# known_secrets outbound; naive_injection_detection inbound) apply to
|
||||
# that route. To scan additional hosts, declare them under egress.routes
|
||||
# with per-route matches/dlp (see README "Egress route fields").
|
||||
auth_token: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||
---
|
||||
|
||||
Common Claude provider boundary. Drop this file into
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
|
||||
pylint>=3.0.0
|
||||
pyright>=1.1.300
|
||||
coverage>=7.0.0
|
||||
|
||||
+9
-9
@@ -10,7 +10,7 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
|
||||
def fixture_minimal_dict() -> dict[str, Any]:
|
||||
@@ -46,12 +46,12 @@ def fixture_with_git_dict() -> dict[str, Any]:
|
||||
"repos": {
|
||||
"bot-bottle": {
|
||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||
"identity": "/dev/null",
|
||||
"key": {"provider": "static", "path": "/dev/null"},
|
||||
"host_key": "ssh-ed25519 AAAA...",
|
||||
},
|
||||
"foo": {
|
||||
"url": "ssh://git@github.com/didericis/foo.git",
|
||||
"identity": "/dev/null",
|
||||
"key": {"provider": "static", "path": "/dev/null"},
|
||||
"host_key": "ssh-ed25519 BBBB...",
|
||||
},
|
||||
},
|
||||
@@ -62,16 +62,16 @@ def fixture_with_git_dict() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def fixture_minimal() -> Manifest:
|
||||
return Manifest.from_json_obj(fixture_minimal_dict())
|
||||
def fixture_minimal() -> ManifestIndex:
|
||||
return ManifestIndex.from_json_obj(fixture_minimal_dict())
|
||||
|
||||
|
||||
def fixture_with_egress() -> Manifest:
|
||||
return Manifest.from_json_obj(fixture_with_egress_dict())
|
||||
def fixture_with_egress() -> ManifestIndex:
|
||||
return ManifestIndex.from_json_obj(fixture_with_egress_dict())
|
||||
|
||||
|
||||
def fixture_with_git() -> Manifest:
|
||||
return Manifest.from_json_obj(fixture_with_git_dict())
|
||||
def fixture_with_git() -> ManifestIndex:
|
||||
return ManifestIndex.from_json_obj(fixture_with_git_dict())
|
||||
|
||||
|
||||
def write_fixture(fn: Callable[[], dict[str, Any]]) -> Path:
|
||||
|
||||
@@ -29,7 +29,7 @@ from bot_bottle.backend.macos_container.util import (
|
||||
dns_server as _container_dns_server,
|
||||
is_available as _container_available,
|
||||
)
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
|
||||
_AGENT_PROMPT = "You are a launch smoke-test agent. Be brief."
|
||||
@@ -52,8 +52,8 @@ def _minimal_agent_dockerfile(path: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _minimal_manifest(dockerfile: Path) -> Manifest:
|
||||
return Manifest.from_json_obj({
|
||||
def _minimal_manifest(dockerfile: Path) -> ManifestIndex:
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"agent_provider": {
|
||||
|
||||
@@ -31,7 +31,7 @@ from pathlib import Path
|
||||
|
||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
||||
from bot_bottle.bottle_state import cleanup_state
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
@@ -92,17 +92,16 @@ class TestSandboxEscape(unittest.TestCase):
|
||||
"on PATH: curl -sSL https://smolmachines.com/install.sh | sh"
|
||||
)
|
||||
|
||||
# Throwaway "identity file" so the manifest's _validate_git_entries
|
||||
# passes (it only checks `os.path.isfile`, not that the content is
|
||||
# a real SSH key). Test 5 reaches gitleaks before any SSH attempt
|
||||
# anyway.
|
||||
# Throwaway static key for the git-gate fixture. It need not
|
||||
# be a real SSH key: test 5 reaches gitleaks before any SSH
|
||||
# attempt anyway.
|
||||
fd, kp = tempfile.mkstemp(prefix="sandbox-test-key.")
|
||||
os.close(fd)
|
||||
cls._key_path = Path(kp)
|
||||
cls._key_path.write_text("placeholder\n")
|
||||
cls._key_path.chmod(0o600)
|
||||
|
||||
manifest = Manifest.from_json_obj({
|
||||
manifest = ManifestIndex.from_json_obj({
|
||||
"bottles": {
|
||||
"dev": {
|
||||
# Three fake secrets — different shapes — land
|
||||
@@ -124,7 +123,10 @@ class TestSandboxEscape(unittest.TestCase):
|
||||
"git-gate": {"repos": {
|
||||
"throwaway": {
|
||||
"url": "ssh://git@unreachable.invalid:22/throwaway.git",
|
||||
"identity": str(cls._key_path),
|
||||
"key": {
|
||||
"provider": "static",
|
||||
"path": str(cls._key_path),
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
|
||||
@@ -22,15 +22,15 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
def _manifest() -> Manifest:
|
||||
def _manifest() -> ManifestIndex:
|
||||
"""Bottle with supervise on so the bundle exercises egress +
|
||||
supervise. Git is off because a meaningful git-gate test needs
|
||||
a real upstream and SSH keys — out of scope for a bundle smoke."""
|
||||
return Manifest.from_json_obj({
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"supervise": True,
|
||||
|
||||
@@ -35,15 +35,15 @@ from pathlib import Path
|
||||
|
||||
from bot_bottle.backend import BottleSpec, get_bottle_backend
|
||||
from bot_bottle.backend.smolmachines.smolvm import is_available as _smolvm_available
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
from tests._docker import skip_unless_docker
|
||||
|
||||
|
||||
_AGENT_PROMPT = "You are demo. Be brief."
|
||||
|
||||
|
||||
def _minimal_manifest() -> Manifest:
|
||||
return Manifest.from_json_obj({
|
||||
def _minimal_manifest() -> ManifestIndex:
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"egress": {
|
||||
@@ -198,6 +198,7 @@ class TestSmolmachinesLaunch(unittest.TestCase):
|
||||
# connect fails, which is the property chunk 3 will
|
||||
# preserve once egress is actually running.
|
||||
r = self.bottle.exec(
|
||||
"env -u HTTPS_PROXY -u HTTP_PROXY -u https_proxy -u http_proxy "
|
||||
f"curl -s --show-error --max-time 3 http://{self.plan.bundle_ip}:9099 "
|
||||
"2>&1 || true"
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
instance_name="bot-bottle-test",
|
||||
prompt_file=prompt_file,
|
||||
label="review-api",
|
||||
color="bright-cyan",
|
||||
color="cyan",
|
||||
)
|
||||
prompt = prompt_file.read_text()
|
||||
config = Path(tmp, "codex-config.toml").read_text()
|
||||
@@ -168,6 +168,34 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
self.assertEqual("~/.claude/statusline.sh", settings["statusLine"]["command"])
|
||||
self.assertEqual("custom:bot-bottle-research-ui", settings["theme"])
|
||||
|
||||
def test_claude_plan_uses_startup_args_from_provider_settings(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = build_agent_provision_plan(
|
||||
template="claude",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
instance_name="bot-bottle-test",
|
||||
prompt_file=Path(tmp) / "prompt.txt",
|
||||
provider_settings={
|
||||
"startup_args": ["--model", "opus"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(("--model", "opus"), plan.startup_args)
|
||||
|
||||
def test_codex_plan_uses_startup_args_from_provider_settings(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = build_agent_provision_plan(
|
||||
template="codex",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
instance_name="bot-bottle-test",
|
||||
prompt_file=Path(tmp) / "prompt.txt",
|
||||
provider_settings={
|
||||
"startup_args": ["--model", "gpt-5-codex"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(("--model", "gpt-5-codex"), plan.startup_args)
|
||||
|
||||
def test_codex_forward_host_credentials_populates_egress_routes(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
home = Path(tmp) / "host-codex"
|
||||
@@ -394,6 +422,24 @@ class TestAgentProviderRuntime(unittest.TestCase):
|
||||
self.assertNotIn("OPENROUTER_API_KEY", plan.guest_env)
|
||||
self.assertTrue(provider["compat"]["supportsReasoningEffort"])
|
||||
|
||||
def test_pi_plan_appends_startup_args_from_provider_settings(self):
|
||||
with tempfile.TemporaryDirectory(prefix="bb-provider.") as tmp:
|
||||
plan = build_agent_provision_plan(
|
||||
template="pi",
|
||||
dockerfile="",
|
||||
state_dir=Path(tmp),
|
||||
instance_name="bot-bottle-test",
|
||||
prompt_file=Path(tmp) / "prompt.txt",
|
||||
provider_settings={
|
||||
"models": ["qwen3:14b"],
|
||||
"startup_args": ["--no-stream"],
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
("--models", "ollama/qwen3:14b", "--no-stream"),
|
||||
plan.startup_args,
|
||||
)
|
||||
|
||||
def test_pi_prompt_mode_appends_system_prompt_interactively(self):
|
||||
self.assertEqual(
|
||||
["--append-system-prompt", "/home/node/.bot-bottle-prompt.txt"],
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Unit: Freezer class hierarchy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle import supervise, bottle_state
|
||||
from bot_bottle.backend import ActiveAgent
|
||||
from bot_bottle.backend.freeze import get_freezer
|
||||
from bot_bottle.backend.docker.freezer import DockerFreezer
|
||||
from bot_bottle.backend.macos_container.freezer import MacosContainerFreezer
|
||||
from bot_bottle.backend.smolmachines.freezer import SmolmachinesFreezer
|
||||
|
||||
|
||||
class _FakeHomeMixin:
|
||||
def _setup_fake_home(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="freezer-test.")
|
||||
original = supervise.bot_bottle_root
|
||||
|
||||
def fake_root() -> Path:
|
||||
return Path(self._tmp.name) / ".bot-bottle"
|
||||
|
||||
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
|
||||
self._restore = lambda: setattr(supervise, "bot_bottle_root", original)
|
||||
|
||||
def _teardown_fake_home(self):
|
||||
self._restore()
|
||||
self._tmp.cleanup()
|
||||
|
||||
|
||||
def _make_agent(slug: str, backend: str = "docker") -> ActiveAgent:
|
||||
return ActiveAgent(
|
||||
backend_name=backend,
|
||||
slug=slug,
|
||||
agent_name="dev",
|
||||
started_at="t",
|
||||
services=(),
|
||||
)
|
||||
|
||||
|
||||
class TestGetFreezer(unittest.TestCase):
|
||||
def test_docker(self):
|
||||
self.assertIsInstance(get_freezer("docker"), DockerFreezer)
|
||||
|
||||
def test_empty_backend_gives_docker(self):
|
||||
self.assertIsInstance(get_freezer(""), DockerFreezer)
|
||||
|
||||
def test_macos_container(self):
|
||||
self.assertIsInstance(get_freezer("macos-container"), MacosContainerFreezer)
|
||||
|
||||
def test_smolmachines(self):
|
||||
self.assertIsInstance(get_freezer("smolmachines"), SmolmachinesFreezer)
|
||||
|
||||
def test_unknown_backend_dies(self):
|
||||
with patch("bot_bottle.backend.freeze.die", side_effect=SystemExit("die")):
|
||||
with self.assertRaises(SystemExit):
|
||||
get_freezer("unknown-backend")
|
||||
|
||||
|
||||
class TestFreezerBaseCommit(_FakeHomeMixin, unittest.TestCase):
|
||||
"""The base Freezer.commit() owns the shared post-freeze steps."""
|
||||
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_writes_committed_image_and_marks_preserved(self):
|
||||
slug = "dev-abc12"
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="docker",
|
||||
))
|
||||
freezer = get_freezer("docker")
|
||||
agent = _make_agent(slug)
|
||||
|
||||
with patch.object(freezer, "_freeze", return_value="bot-bottle-committed-dev-abc12:latest"), \
|
||||
patch("bot_bottle.backend.freeze.info"):
|
||||
freezer.commit(agent)
|
||||
|
||||
self.assertEqual(
|
||||
"bot-bottle-committed-dev-abc12:latest",
|
||||
bottle_state.read_committed_image(slug),
|
||||
)
|
||||
self.assertTrue(bottle_state.is_preserved(slug))
|
||||
|
||||
def test_commit_slug_passes_correct_slug_to_freeze(self):
|
||||
slug = "dev-abc12"
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="docker",
|
||||
))
|
||||
freezer = get_freezer("docker")
|
||||
captured = {}
|
||||
|
||||
def capture_freeze(agent: ActiveAgent) -> str:
|
||||
captured["slug"] = agent.slug
|
||||
return "some-ref"
|
||||
|
||||
with patch.object(freezer, "_freeze", side_effect=capture_freeze), \
|
||||
patch("bot_bottle.backend.freeze.info"):
|
||||
freezer.commit_slug(slug)
|
||||
|
||||
self.assertEqual(slug, captured["slug"])
|
||||
|
||||
|
||||
class TestDockerFreezer(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_commits_container_and_records_image(self):
|
||||
slug = "dev-abc12"
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="docker",
|
||||
))
|
||||
freezer = DockerFreezer()
|
||||
agent = _make_agent(slug)
|
||||
|
||||
with patch("bot_bottle.backend.docker.freezer.commit_container") as mock_commit, \
|
||||
patch("bot_bottle.backend.freeze.info"), \
|
||||
patch("bot_bottle.backend.docker.freezer.info"):
|
||||
freezer.commit(agent)
|
||||
|
||||
mock_commit.assert_called_once_with(
|
||||
f"bot-bottle-{slug}",
|
||||
f"bot-bottle-committed-{slug}:latest",
|
||||
)
|
||||
self.assertEqual(
|
||||
f"bot-bottle-committed-{slug}:latest",
|
||||
bottle_state.read_committed_image(slug),
|
||||
)
|
||||
self.assertTrue(bottle_state.is_preserved(slug))
|
||||
|
||||
|
||||
class TestMacosContainerFreezer(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def _write_meta(self, slug: str) -> None:
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="macos-container",
|
||||
))
|
||||
|
||||
def test_commits_running_container_without_stopping(self):
|
||||
"""Commit should exec-tar the running container, not stop it."""
|
||||
slug = "dev-abc12"
|
||||
self._write_meta(slug)
|
||||
freezer = MacosContainerFreezer()
|
||||
agent = _make_agent(slug, "macos-container")
|
||||
|
||||
with patch("bot_bottle.backend.macos_container.freezer.commit_container") as mock_commit, \
|
||||
patch("bot_bottle.backend.freeze.info"), \
|
||||
patch("bot_bottle.backend.macos_container.freezer.info"):
|
||||
freezer.commit(agent)
|
||||
|
||||
mock_commit.assert_called_once_with(
|
||||
f"bot-bottle-{slug}",
|
||||
f"bot-bottle-committed-{slug}:latest",
|
||||
)
|
||||
self.assertEqual(
|
||||
f"bot-bottle-committed-{slug}:latest",
|
||||
bottle_state.read_committed_image(slug),
|
||||
)
|
||||
self.assertTrue(bottle_state.is_preserved(slug))
|
||||
|
||||
|
||||
class TestSmolmachinesFreezer(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def _write_meta(self, slug: str) -> None:
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend="smolmachines",
|
||||
))
|
||||
|
||||
def test_snapshots_running_vm_without_stopping(self):
|
||||
"""Commit should exec-tar the running VM, not stop it."""
|
||||
slug = "dev-abc12"
|
||||
self._write_meta(slug)
|
||||
freezer = SmolmachinesFreezer()
|
||||
agent = _make_agent(slug, "smolmachines")
|
||||
|
||||
with patch("bot_bottle.backend.smolmachines.freezer._snapshot_running_vm") as mock_snap, \
|
||||
patch("bot_bottle.backend.freeze.info"), \
|
||||
patch("bot_bottle.backend.smolmachines.freezer.info"):
|
||||
freezer.commit(agent)
|
||||
|
||||
expected_binary = bottle_state.bottle_state_dir(slug) / "committed-smolmachine"
|
||||
mock_snap.assert_called_once_with(
|
||||
f"bot-bottle-{slug}",
|
||||
f"bot-bottle-committed-{slug}:latest",
|
||||
expected_binary,
|
||||
)
|
||||
expected_sidecar = str(expected_binary.with_suffix(".smolmachine"))
|
||||
self.assertEqual(expected_sidecar, bottle_state.read_committed_image(slug))
|
||||
self.assertTrue(bottle_state.is_preserved(slug))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -16,12 +16,13 @@ from bot_bottle import bottle_state
|
||||
from bot_bottle import supervise
|
||||
from bot_bottle.backend import BottleSpec
|
||||
from bot_bottle.backend.docker import DockerBottleBackend
|
||||
from bot_bottle.backend.resolve_common import mint_slug
|
||||
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
|
||||
def _manifest() -> Manifest:
|
||||
return Manifest.from_json_obj({
|
||||
def _manifest() -> ManifestIndex:
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"env": {
|
||||
@@ -115,5 +116,36 @@ class TestSmolmachinesPrepare(_FakeStateMixin, unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestMintSlug(unittest.TestCase):
|
||||
def _spec(self, *, label: str = "", identity: str = "") -> BottleSpec:
|
||||
manifest = _manifest()
|
||||
return BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd="/tmp",
|
||||
label=label,
|
||||
identity=identity,
|
||||
)
|
||||
|
||||
def test_no_label_uses_agent_name_with_random_suffix(self) -> None:
|
||||
slug = mint_slug(self._spec(label=""))
|
||||
self.assertTrue(slug.startswith("demo-"), slug)
|
||||
# random suffix present — slug is longer than just "demo"
|
||||
self.assertGreater(len(slug), len("demo-"))
|
||||
|
||||
def test_label_becomes_exact_slug(self) -> None:
|
||||
slug = mint_slug(self._spec(label="my-run"))
|
||||
self.assertEqual("my-run", slug)
|
||||
|
||||
def test_label_with_spaces_slugified_no_suffix(self) -> None:
|
||||
slug = mint_slug(self._spec(label="My Feature Run"))
|
||||
self.assertEqual("my-feature-run", slug)
|
||||
|
||||
def test_identity_takes_precedence_over_label(self) -> None:
|
||||
slug = mint_slug(self._spec(label="my-run", identity="fixed-id"))
|
||||
self.assertEqual("fixed-id", slug)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -11,14 +11,14 @@ class TestPalettePrintf(unittest.TestCase):
|
||||
def test_known_color_returns_printf(self):
|
||||
cmd = palette_printf("red")
|
||||
self.assertTrue(cmd.startswith("printf '"))
|
||||
self.assertIn("\\033]4;1;", cmd) # normal red
|
||||
self.assertIn("\\033]4;9;", cmd) # bright red
|
||||
self.assertIn("\\033]4;9;", cmd) # bright-red slot
|
||||
self.assertIn("\\033]4;1;", cmd) # normal-red slot
|
||||
self.assertIn("\\033]11;", cmd) # default background tint
|
||||
|
||||
def test_bright_variant_sets_both_slots(self):
|
||||
cmd = palette_printf("bright-blue")
|
||||
self.assertIn("\\033]4;12;", cmd) # bright-blue
|
||||
self.assertIn("\\033]4;4;", cmd) # blue
|
||||
def test_color_sets_both_palette_slots(self):
|
||||
cmd = palette_printf("blue")
|
||||
self.assertIn("\\033]4;12;", cmd) # bright-blue slot
|
||||
self.assertIn("\\033]4;4;", cmd) # normal-blue slot
|
||||
|
||||
def test_unknown_color_returns_empty(self):
|
||||
self.assertEqual("", palette_printf(""))
|
||||
@@ -26,10 +26,7 @@ class TestPalettePrintf(unittest.TestCase):
|
||||
|
||||
def test_all_named_colors_produce_output(self):
|
||||
colors = [
|
||||
"black", "red", "green", "yellow",
|
||||
"blue", "magenta", "cyan", "white",
|
||||
"bright-black", "bright-red", "bright-green", "bright-yellow",
|
||||
"bright-blue", "bright-magenta", "bright-cyan", "bright-white",
|
||||
"red", "green", "yellow", "blue", "magenta",
|
||||
]
|
||||
for color in colors:
|
||||
with self.subTest(color=color):
|
||||
@@ -65,7 +62,7 @@ class TestExecShellScript(unittest.TestCase):
|
||||
self.assertFalse(agent_part.startswith("exec "))
|
||||
|
||||
def test_title_and_color_both_appear(self):
|
||||
script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="cyan")
|
||||
script = exec_shell_script(self._ARGV, terminal_title="bot", terminal_color="magenta")
|
||||
assert script is not None
|
||||
self.assertIn("bot", script)
|
||||
self.assertIn("\\033]4;", script)
|
||||
|
||||
@@ -17,11 +17,11 @@ from bot_bottle import supervise
|
||||
from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||
from bot_bottle.backend.docker import DockerBottleBackend
|
||||
from bot_bottle.backend.smolmachines import SmolmachinesBottleBackend
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
|
||||
def _manifest() -> Manifest:
|
||||
return Manifest.from_json_obj({
|
||||
def _manifest() -> ManifestIndex:
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {
|
||||
"demo": {
|
||||
|
||||
@@ -115,8 +115,8 @@ class TestBottleIdentity(unittest.TestCase):
|
||||
|
||||
|
||||
class TestPreserveMarker(_FakeHomeMixin, unittest.TestCase):
|
||||
"""The .preserve marker is how capability_apply tells cli.py's
|
||||
session-end cleanup to keep the state dir instead of removing it."""
|
||||
"""The .preserve marker tells cli.py's session-end cleanup to keep
|
||||
the state dir instead of removing it."""
|
||||
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
@@ -277,5 +277,56 @@ class TestBottleMetadataBackend(_FakeHomeMixin, unittest.TestCase):
|
||||
self.assertEqual("", loaded.backend)
|
||||
|
||||
|
||||
class TestCommittedImage(_FakeHomeMixin, unittest.TestCase):
|
||||
"""write_committed_image / read_committed_image round-trip."""
|
||||
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_returns_none_when_absent(self):
|
||||
self.assertIsNone(bottle_state.read_committed_image("dev"))
|
||||
|
||||
def test_write_then_read_roundtrip(self):
|
||||
bottle_state.write_committed_image("dev", "bot-bottle-committed-dev:latest")
|
||||
self.assertEqual(
|
||||
"bot-bottle-committed-dev:latest",
|
||||
bottle_state.read_committed_image("dev"),
|
||||
)
|
||||
|
||||
def test_strips_trailing_newline_on_read(self):
|
||||
path = bottle_state.committed_image_path("dev")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("bot-bottle-committed-dev:latest\n\n")
|
||||
self.assertEqual(
|
||||
"bot-bottle-committed-dev:latest",
|
||||
bottle_state.read_committed_image("dev"),
|
||||
)
|
||||
|
||||
def test_isolated_per_slug(self):
|
||||
bottle_state.write_committed_image("dev", "bot-bottle-committed-dev:latest")
|
||||
bottle_state.write_committed_image("api", "bot-bottle-committed-api:latest")
|
||||
self.assertEqual(
|
||||
"bot-bottle-committed-dev:latest",
|
||||
bottle_state.read_committed_image("dev"),
|
||||
)
|
||||
self.assertEqual(
|
||||
"bot-bottle-committed-api:latest",
|
||||
bottle_state.read_committed_image("api"),
|
||||
)
|
||||
|
||||
def test_path_under_state_dir(self):
|
||||
path = bottle_state.committed_image_path("dev")
|
||||
self.assertTrue(str(path).endswith("/.bot-bottle/state/dev/committed-image"))
|
||||
|
||||
def test_empty_content_returns_none(self):
|
||||
path = bottle_state.committed_image_path("dev")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(" \n")
|
||||
self.assertIsNone(bottle_state.read_committed_image("dev"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Unit: cli.py commit command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.cli.commit import cmd_commit
|
||||
from bot_bottle import supervise
|
||||
from bot_bottle import bottle_state
|
||||
from bot_bottle.backend.freeze import CommitCancelled
|
||||
|
||||
|
||||
class _FakeHomeMixin:
|
||||
def _setup_fake_home(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="cli-commit-test.")
|
||||
original = supervise.bot_bottle_root
|
||||
|
||||
def fake_root() -> Path:
|
||||
return Path(self._tmp.name) / ".bot-bottle"
|
||||
|
||||
supervise.bot_bottle_root = fake_root # type: ignore[assignment]
|
||||
self._restore = lambda: setattr(supervise, "bot_bottle_root", original)
|
||||
|
||||
def _teardown_fake_home(self):
|
||||
self._restore()
|
||||
self._tmp.cleanup()
|
||||
|
||||
|
||||
class TestCmdCommitSlugArg(_FakeHomeMixin, unittest.TestCase):
|
||||
"""cmd_commit with an explicit slug delegates to get_freezer."""
|
||||
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def _write_meta(self, slug: str, backend: str) -> None:
|
||||
bottle_state.write_metadata(bottle_state.BottleMetadata(
|
||||
identity=slug, agent_name="dev", cwd="", copy_cwd=False,
|
||||
started_at="t", backend=backend,
|
||||
))
|
||||
|
||||
def test_commits_docker_bottle(self):
|
||||
slug = "dev-abc12"
|
||||
self._write_meta(slug, "docker")
|
||||
|
||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
||||
mock_freezer = MagicMock()
|
||||
mock_gf.return_value = mock_freezer
|
||||
rc = cmd_commit([slug])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
mock_gf.assert_called_once_with("docker")
|
||||
mock_freezer.commit_slug.assert_called_once_with(slug)
|
||||
|
||||
def test_empty_backend_passed_to_get_freezer(self):
|
||||
"""Old state dirs without a backend field pass '' to get_freezer."""
|
||||
slug = "dev-abc12"
|
||||
self._write_meta(slug, "")
|
||||
|
||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
||||
mock_freezer = MagicMock()
|
||||
mock_gf.return_value = mock_freezer
|
||||
rc = cmd_commit([slug])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
mock_gf.assert_called_once_with("")
|
||||
|
||||
def test_commits_macos_container_bottle(self):
|
||||
slug = "dev-abc12"
|
||||
self._write_meta(slug, "macos-container")
|
||||
|
||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
||||
mock_freezer = MagicMock()
|
||||
mock_gf.return_value = mock_freezer
|
||||
rc = cmd_commit([slug])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
mock_gf.assert_called_once_with("macos-container")
|
||||
mock_freezer.commit_slug.assert_called_once_with(slug)
|
||||
|
||||
def test_commits_smolmachines_bottle(self):
|
||||
slug = "dev-abc12"
|
||||
self._write_meta(slug, "smolmachines")
|
||||
|
||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
||||
mock_freezer = MagicMock()
|
||||
mock_gf.return_value = mock_freezer
|
||||
rc = cmd_commit([slug])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
mock_gf.assert_called_once_with("smolmachines")
|
||||
|
||||
def test_returns_zero_on_commit_cancelled(self):
|
||||
slug = "dev-abc12"
|
||||
self._write_meta(slug, "macos-container")
|
||||
|
||||
with patch("bot_bottle.cli.commit.get_freezer") as mock_gf:
|
||||
mock_freezer = MagicMock()
|
||||
mock_freezer.commit_slug.side_effect = CommitCancelled
|
||||
mock_gf.return_value = mock_freezer
|
||||
rc = cmd_commit([slug])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
|
||||
class TestCmdCommitNoActiveBottles(_FakeHomeMixin, unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
def tearDown(self):
|
||||
self._teardown_fake_home()
|
||||
|
||||
def test_dies_when_no_active_bottles_and_no_slug(self):
|
||||
with patch(
|
||||
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[],
|
||||
), patch(
|
||||
"bot_bottle.cli.commit.die", side_effect=SystemExit("die"),
|
||||
) as mock_die:
|
||||
with self.assertRaises(SystemExit):
|
||||
cmd_commit([])
|
||||
|
||||
mock_die.assert_called_once()
|
||||
|
||||
def test_returns_zero_when_picker_cancelled(self):
|
||||
active = MagicMock()
|
||||
active.slug = "dev-abc12"
|
||||
with patch(
|
||||
"bot_bottle.cli.commit.enumerate_active_agents", return_value=[active],
|
||||
), patch(
|
||||
"bot_bottle.cli.commit.tui.filter_select", return_value=None,
|
||||
):
|
||||
rc = cmd_commit([])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Unit: cmd_start selector dispatch (PRD 0051).
|
||||
"""Unit: cmd_start selector dispatch (PRD 0051, issue #269).
|
||||
|
||||
Tests that cmd_start calls filter_select only when the agent name is
|
||||
absent, skips it when the agent is explicit, and returns 0 on cancel.
|
||||
absent, shows the bottle multiselect after agent selection, and skips
|
||||
pickers when both are explicitly set.
|
||||
|
||||
All actual launch work is stubbed so no container is created.
|
||||
"""
|
||||
@@ -10,15 +11,24 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from collections.abc import Mapping, Sequence
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import bot_bottle.cli.start as start_mod
|
||||
import bot_bottle.cli.tui as tui_mod
|
||||
from bot_bottle.backend import ActiveAgent
|
||||
|
||||
|
||||
def _make_manifest(agent_names: list[str]):
|
||||
def _make_manifest(
|
||||
agent_names: list[str],
|
||||
bottle_names: list[str] | None = None,
|
||||
agent_bottle: str = "",
|
||||
):
|
||||
manifest = MagicMock()
|
||||
manifest.agents = {name: MagicMock() for name in agent_names}
|
||||
manifest.agents = {name: MagicMock(bottle=agent_bottle) for name in agent_names}
|
||||
manifest.all_agent_names = sorted(agent_names)
|
||||
manifest.all_bottle_names = sorted(bottle_names or [])
|
||||
manifest.home_md = None # eager mode so _peek_agent_bottle uses agents dict
|
||||
return manifest
|
||||
|
||||
|
||||
@@ -26,27 +36,27 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
"""Drive cmd_start with a minimal set of stubs."""
|
||||
|
||||
def setUp(self):
|
||||
# Stub Manifest.resolve so no on-disk manifest is needed.
|
||||
self._manifest = _make_manifest(["researcher", "implementer"])
|
||||
self._manifest = _make_manifest(["researcher", "implementer"], ["claude", "dev"])
|
||||
self._resolve_patch = patch(
|
||||
"bot_bottle.cli.start.Manifest.resolve",
|
||||
"bot_bottle.cli.start.ManifestIndex.resolve",
|
||||
return_value=self._manifest,
|
||||
)
|
||||
self._resolve_patch.start()
|
||||
|
||||
# Stub _launch_bottle so no real container work happens.
|
||||
self._launch_patch = patch(
|
||||
"bot_bottle.cli.start._launch_bottle",
|
||||
return_value=0,
|
||||
)
|
||||
self._launch_mock = self._launch_patch.start()
|
||||
|
||||
# Stub filter_select to avoid opening /dev/tty.
|
||||
self._tui_patch = patch.object(tui_mod, "filter_select")
|
||||
self._tui_mock = self._tui_patch.start()
|
||||
# Stub filter_select (agent picker) and filter_multiselect (bottle picker).
|
||||
self._agent_picker_patch = patch.object(tui_mod, "filter_select")
|
||||
self._agent_picker_mock = self._agent_picker_patch.start()
|
||||
|
||||
self._bottle_picker_patch = patch.object(tui_mod, "filter_multiselect")
|
||||
self._bottle_picker_mock = self._bottle_picker_patch.start()
|
||||
self._bottle_picker_mock.return_value = ["claude"] # default: one bottle selected
|
||||
|
||||
# Ensure BOT_BOTTLE_BACKEND is absent so omitted --backend
|
||||
# flows through to the resolver default.
|
||||
self._env_patch = patch.dict(os.environ, {}, clear=False)
|
||||
self._env_patch.start()
|
||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
||||
@@ -54,50 +64,108 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self._resolve_patch.stop()
|
||||
self._launch_patch.stop()
|
||||
self._tui_patch.stop()
|
||||
self._agent_picker_patch.stop()
|
||||
self._bottle_picker_patch.stop()
|
||||
self._env_patch.stop()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Both explicit — no picker shown
|
||||
# Agent explicit — agent picker skipped; bottle picker always shown
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_both_explicit_skips_picker(self):
|
||||
self._tui_mock.return_value = "researcher"
|
||||
def test_explicit_agent_skips_agent_picker(self):
|
||||
rc = start_mod.cmd_start(["--backend=docker", "researcher"])
|
||||
self.assertEqual(0, rc)
|
||||
self._tui_mock.assert_not_called()
|
||||
self._agent_picker_mock.assert_not_called()
|
||||
self._bottle_picker_mock.assert_called_once()
|
||||
self._launch_mock.assert_called_once()
|
||||
_, kwargs = self._launch_mock.call_args
|
||||
self.assertEqual("docker", kwargs["backend_name"])
|
||||
|
||||
def test_explicit_agent_bottle_picker_shows_available_bottles(self):
|
||||
start_mod.cmd_start(["researcher"])
|
||||
call_kwargs = self._bottle_picker_mock.call_args
|
||||
self.assertEqual(["claude", "dev"], call_kwargs[0][0])
|
||||
self.assertIn("bottle", call_kwargs[1]["title"].lower())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Agent absent → agent picker fires; backend explicit
|
||||
# Agent absent → agent picker fires; bottle picker always follows
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_agent_absent_shows_agent_picker(self):
|
||||
self._tui_mock.return_value = "researcher"
|
||||
self._agent_picker_mock.return_value = "researcher"
|
||||
rc = start_mod.cmd_start(["--backend=docker"])
|
||||
self.assertEqual(0, rc)
|
||||
self._tui_mock.assert_called_once()
|
||||
call_kwargs = self._tui_mock.call_args
|
||||
self._agent_picker_mock.assert_called_once()
|
||||
call_kwargs = self._agent_picker_mock.call_args
|
||||
self.assertEqual(["implementer", "researcher"], call_kwargs[0][0])
|
||||
self.assertIn("agent", call_kwargs[1]["title"].lower())
|
||||
# Bottle picker must also fire after agent selection.
|
||||
self._bottle_picker_mock.assert_called_once()
|
||||
|
||||
def test_agent_picker_cancel_returns_0(self):
|
||||
self._tui_mock.return_value = None
|
||||
def test_agent_picker_cancel_skips_bottle_picker(self):
|
||||
self._agent_picker_mock.return_value = None
|
||||
rc = start_mod.cmd_start(["--backend=docker"])
|
||||
self.assertEqual(0, rc)
|
||||
self._bottle_picker_mock.assert_not_called()
|
||||
self._launch_mock.assert_not_called()
|
||||
|
||||
def test_bottle_picker_cancel_returns_0(self):
|
||||
self._bottle_picker_mock.return_value = None
|
||||
rc = start_mod.cmd_start(["researcher"])
|
||||
self.assertEqual(0, rc)
|
||||
self._launch_mock.assert_not_called()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Agent explicit, backend absent → no picker
|
||||
# Bottle selection is forwarded to BottleSpec
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_backend_absent_uses_default_without_picker(self):
|
||||
rc = start_mod.cmd_start(["researcher"])
|
||||
self.assertEqual(0, rc)
|
||||
self._tui_mock.assert_not_called()
|
||||
def test_selected_bottles_forwarded_to_spec(self):
|
||||
self._bottle_picker_mock.return_value = ["claude", "dev"]
|
||||
start_mod.cmd_start(["researcher"])
|
||||
self._launch_mock.assert_called_once()
|
||||
spec = self._launch_mock.call_args[0][0]
|
||||
self.assertEqual(("claude", "dev"), spec.bottle_names)
|
||||
|
||||
def test_empty_bottle_selection_forwarded(self):
|
||||
self._bottle_picker_mock.return_value = []
|
||||
start_mod.cmd_start(["researcher"])
|
||||
self._launch_mock.assert_called_once()
|
||||
spec = self._launch_mock.call_args[0][0]
|
||||
self.assertEqual((), spec.bottle_names)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Agent default bottle pre-populates the picker
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_agent_bottle_prepopulates_bottle_picker(self):
|
||||
manifest = _make_manifest(
|
||||
["implementer"], ["claude", "dev"], agent_bottle="claude"
|
||||
)
|
||||
with patch(
|
||||
"bot_bottle.cli.start.ManifestIndex.resolve", return_value=manifest
|
||||
):
|
||||
start_mod.cmd_start(["implementer"])
|
||||
call_kwargs = self._bottle_picker_mock.call_args
|
||||
self.assertEqual(["claude"], call_kwargs[1]["initial"])
|
||||
|
||||
def test_no_agent_bottle_empty_initial(self):
|
||||
manifest = _make_manifest(["researcher"], ["claude", "dev"], agent_bottle="")
|
||||
with patch(
|
||||
"bot_bottle.cli.start.ManifestIndex.resolve", return_value=manifest
|
||||
):
|
||||
start_mod.cmd_start(["researcher"])
|
||||
call_kwargs = self._bottle_picker_mock.call_args
|
||||
self.assertEqual([], call_kwargs[1]["initial"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Backend wiring
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_explicit_backend_forwarded(self):
|
||||
start_mod.cmd_start(["--backend=docker", "researcher"])
|
||||
_, kwargs = self._launch_mock.call_args
|
||||
self.assertEqual("docker", kwargs["backend_name"])
|
||||
|
||||
def test_absent_backend_uses_default(self):
|
||||
start_mod.cmd_start(["researcher"])
|
||||
_, kwargs = self._launch_mock.call_args
|
||||
self.assertIsNone(kwargs["backend_name"])
|
||||
|
||||
@@ -108,30 +176,185 @@ class TestCmdStartSelector(unittest.TestCase):
|
||||
finally:
|
||||
os.environ.pop("BOT_BOTTLE_BACKEND", None)
|
||||
self.assertEqual(0, rc)
|
||||
self._tui_mock.assert_not_called()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Both absent → only agent picker
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_both_absent_shows_only_agent_picker(self):
|
||||
self._tui_mock.return_value = "researcher"
|
||||
def test_both_absent_shows_agent_picker_then_bottle_picker(self):
|
||||
self._agent_picker_mock.return_value = "researcher"
|
||||
rc = start_mod.cmd_start([])
|
||||
self.assertEqual(0, rc)
|
||||
self._tui_mock.assert_called_once()
|
||||
title = self._tui_mock.call_args[1]["title"].lower()
|
||||
self.assertIn("agent", title)
|
||||
self._agent_picker_mock.assert_called_once()
|
||||
self._bottle_picker_mock.assert_called_once()
|
||||
self._launch_mock.assert_called_once()
|
||||
_, kwargs = self._launch_mock.call_args
|
||||
self.assertIsNone(kwargs["backend_name"])
|
||||
|
||||
def test_both_absent_agent_cancel_skips_backend_picker(self):
|
||||
self._tui_mock.side_effect = [None]
|
||||
def test_both_absent_agent_cancel_skips_bottle_and_launch(self):
|
||||
self._agent_picker_mock.return_value = None
|
||||
rc = start_mod.cmd_start([])
|
||||
self.assertEqual(0, rc)
|
||||
self.assertEqual(1, self._tui_mock.call_count)
|
||||
self._agent_picker_mock.assert_called_once()
|
||||
self._bottle_picker_mock.assert_not_called()
|
||||
self._launch_mock.assert_not_called()
|
||||
|
||||
|
||||
def _active_agent(slug: str) -> ActiveAgent:
|
||||
return ActiveAgent(
|
||||
backend_name="docker",
|
||||
slug=slug,
|
||||
agent_name="demo",
|
||||
started_at="2026-01-01T00:00:00+00:00",
|
||||
services=(),
|
||||
)
|
||||
|
||||
|
||||
class TestCmdStartLabelCollision(unittest.TestCase):
|
||||
"""cmd_start re-prompts when the label's slug is already running."""
|
||||
|
||||
def setUp(self):
|
||||
self._manifest = _make_manifest(["researcher"], ["claude"])
|
||||
patch("bot_bottle.cli.start.ManifestIndex.resolve", return_value=self._manifest).start()
|
||||
self._launch_mock = patch(
|
||||
"bot_bottle.cli.start._launch_bottle", return_value=0,
|
||||
).start()
|
||||
# Stub the bottle picker to always return a selection.
|
||||
patch.object(tui_mod, "filter_multiselect", return_value=["claude"]).start()
|
||||
self.addCleanup(patch.stopall)
|
||||
|
||||
def test_no_collision_proceeds_without_reprompt(self):
|
||||
with (
|
||||
patch.object(tui_mod, "name_color_modal", return_value=("researcher", "")) as modal,
|
||||
patch("bot_bottle.cli.start.enumerate_active_agents", return_value=[]),
|
||||
):
|
||||
rc = start_mod.cmd_start(["researcher"])
|
||||
self.assertEqual(0, rc)
|
||||
modal.assert_called_once()
|
||||
self._launch_mock.assert_called_once()
|
||||
|
||||
def test_collision_reprompts_with_disclaimer(self):
|
||||
collision_agent = _active_agent("researcher")
|
||||
call_count = 0
|
||||
|
||||
def _modal(default_label: str, *, disclaimer: str = "", **_kw: object) -> tuple[str, str]:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return "researcher", ""
|
||||
return "researcher-2", ""
|
||||
|
||||
with (
|
||||
patch.object(tui_mod, "name_color_modal", side_effect=_modal) as modal,
|
||||
patch(
|
||||
"bot_bottle.cli.start.enumerate_active_agents",
|
||||
side_effect=[[collision_agent], []],
|
||||
),
|
||||
):
|
||||
rc = start_mod.cmd_start(["researcher"])
|
||||
|
||||
self.assertEqual(0, rc)
|
||||
self.assertEqual(2, modal.call_count)
|
||||
second_call_kwargs = modal.call_args_list[1][1]
|
||||
self.assertIn("researcher", second_call_kwargs.get("disclaimer", ""))
|
||||
self.assertIn("already in use", second_call_kwargs.get("disclaimer", ""))
|
||||
|
||||
|
||||
class TestBottleLineage(unittest.TestCase):
|
||||
"""Unit tests for _bottle_lineage."""
|
||||
|
||||
def test_returns_empty_in_eager_mode(self):
|
||||
manifest = _make_manifest(["agent"], ["base", "dev"])
|
||||
# home_md is None in eager mode → no file reads, returns {}
|
||||
result = start_mod._bottle_lineage(manifest)
|
||||
self.assertEqual({}, result)
|
||||
|
||||
def test_reads_extends_chain_from_files(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bottles_dir = Path(tmp) / "bottles"
|
||||
bottles_dir.mkdir()
|
||||
(bottles_dir / "base.md").write_text("---\n{}\n---\n")
|
||||
(bottles_dir / "mid.md").write_text("---\nextends: base\n---\n")
|
||||
(bottles_dir / "leaf.md").write_text("---\nextends: mid\n---\n")
|
||||
|
||||
manifest = MagicMock()
|
||||
manifest.home_md = Path(tmp)
|
||||
|
||||
result = start_mod._bottle_lineage(manifest)
|
||||
|
||||
self.assertNotIn("base", result) # no parent → not in map
|
||||
self.assertEqual("base -> mid", result["mid"])
|
||||
self.assertEqual("base -> mid -> leaf", result["leaf"])
|
||||
|
||||
def test_cycle_protection(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
bottles_dir = Path(tmp) / "bottles"
|
||||
bottles_dir.mkdir()
|
||||
(bottles_dir / "a.md").write_text("---\nextends: b\n---\n")
|
||||
(bottles_dir / "b.md").write_text("---\nextends: a\n---\n")
|
||||
|
||||
manifest = MagicMock()
|
||||
manifest.home_md = Path(tmp)
|
||||
|
||||
result = start_mod._bottle_lineage(manifest)
|
||||
|
||||
# Cycle must not hang; each should get a two-element chain.
|
||||
for name in ("a", "b"):
|
||||
self.assertIn(name, result)
|
||||
self.assertIn("->", result[name])
|
||||
|
||||
|
||||
class TestManifestToYaml(unittest.TestCase):
|
||||
"""Unit tests for _manifest_to_yaml."""
|
||||
|
||||
def _make_manifest_obj(
|
||||
self,
|
||||
*,
|
||||
skills: Sequence[str] = (),
|
||||
env: Mapping[str, str] | None = None,
|
||||
supervise: bool = True,
|
||||
agent_provider_template: str = "claude",
|
||||
):
|
||||
from bot_bottle.manifest import Manifest, ManifestBottle
|
||||
from bot_bottle.manifest_agent import ManifestAgent, ManifestAgentProvider
|
||||
|
||||
agent = ManifestAgent(skills=tuple(skills))
|
||||
bottle = ManifestBottle(
|
||||
env=env or {},
|
||||
supervise=supervise,
|
||||
agent_provider=ManifestAgentProvider(template=agent_provider_template),
|
||||
)
|
||||
return Manifest(agent=agent, bottle=bottle)
|
||||
|
||||
def test_includes_agent_section(self):
|
||||
m = self._make_manifest_obj(skills=["researcher"])
|
||||
yaml = start_mod._manifest_to_yaml(m)
|
||||
self.assertIn("agent:", yaml)
|
||||
self.assertIn("- researcher", yaml)
|
||||
|
||||
def test_includes_bottle_section(self):
|
||||
m = self._make_manifest_obj(env={"FOO": "bar"})
|
||||
yaml = start_mod._manifest_to_yaml(m)
|
||||
self.assertIn("bottle:", yaml)
|
||||
self.assertIn("FOO: bar", yaml)
|
||||
|
||||
def test_supervise_rendered(self):
|
||||
m_true = self._make_manifest_obj(supervise=True)
|
||||
m_false = self._make_manifest_obj(supervise=False)
|
||||
self.assertIn("supervise: true", start_mod._manifest_to_yaml(m_true))
|
||||
self.assertIn("supervise: false", start_mod._manifest_to_yaml(m_false))
|
||||
|
||||
def test_non_claude_provider_shown(self):
|
||||
m = self._make_manifest_obj(agent_provider_template="codex")
|
||||
yaml = start_mod._manifest_to_yaml(m)
|
||||
self.assertIn("agent_provider:", yaml)
|
||||
self.assertIn("template: codex", yaml)
|
||||
|
||||
def test_default_claude_provider_omitted(self):
|
||||
m = self._make_manifest_obj(agent_provider_template="claude")
|
||||
yaml = start_mod._manifest_to_yaml(m)
|
||||
self.assertNotIn("agent_provider:", yaml)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -29,8 +29,8 @@ class _FakeHomeMixin:
|
||||
|
||||
|
||||
class TestCaptureSessionState(_FakeHomeMixin, unittest.TestCase):
|
||||
# snapshot_transcript is commented out (capability_apply is disabled);
|
||||
# capture_claude_session_state now only handles the preserve marker.
|
||||
# capture_claude_session_state handles the preserve marker for
|
||||
# non-zero agent exits.
|
||||
def setUp(self):
|
||||
self._setup_fake_home()
|
||||
|
||||
@@ -102,6 +102,27 @@ class TestAttachAgent(unittest.TestCase):
|
||||
bottle.argv,
|
||||
)
|
||||
|
||||
def test_remote_control_is_provider_startup_arg(self):
|
||||
class Bottle:
|
||||
argv: list[str] = []
|
||||
|
||||
def exec_agent(self, argv: list[str], *, tty: bool = True) -> int:
|
||||
self.argv = list(argv)
|
||||
return 0
|
||||
|
||||
bottle = Bottle()
|
||||
exit_code = start_mod.attach_agent(
|
||||
bottle, # type: ignore[arg-type]
|
||||
agent_provider_template="codex",
|
||||
startup_args=("remote-control",),
|
||||
)
|
||||
|
||||
self.assertEqual(0, exit_code)
|
||||
self.assertEqual(
|
||||
["--dangerously-bypass-approvals-and-sandbox", "remote-control"],
|
||||
bottle.argv,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+128
-2
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for bot_bottle.cli.tui — filter_select internals.
|
||||
"""Unit tests for bot_bottle.cli.tui — filter_select and filter_multiselect.
|
||||
|
||||
We test the pure-Python logic (_filter_items, cursor movement, confirm,
|
||||
cancel) by exercising the internal helpers directly, without spinning up
|
||||
@@ -8,8 +8,15 @@ a real curses session (which requires a TTY).
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from typing import Any, Optional
|
||||
|
||||
from bot_bottle.cli.tui import _filter_items, filter_select
|
||||
from bot_bottle.cli.tui import _filter_items, _multiselect_loop, filter_multiselect, filter_select
|
||||
|
||||
_KEY_SPACE = 32
|
||||
_KEY_ENTER = 10
|
||||
|
||||
_KEY_ESC = 27
|
||||
_KEY_CTRL_D = 4
|
||||
|
||||
|
||||
class TestFilterItems(unittest.TestCase):
|
||||
@@ -46,5 +53,124 @@ class TestFilterSelectEmptyItems(unittest.TestCase):
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestFilterMultiselectEmptyItems(unittest.TestCase):
|
||||
def test_returns_empty_list_for_empty_items(self):
|
||||
# No TTY needed — short-circuits before opening tty.
|
||||
result = filter_multiselect([], title="Select", tty_path="/dev/null")
|
||||
self.assertEqual([], result)
|
||||
|
||||
def test_returns_none_when_tty_unavailable(self):
|
||||
result = filter_multiselect(["a", "b"], tty_path="/nonexistent/tty")
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestMultiselectLoopReordering(unittest.TestCase):
|
||||
"""Exercise _multiselect_loop key handling without a real curses terminal.
|
||||
|
||||
We drive the loop via a fake screen that feeds a pre-recorded key sequence
|
||||
and records what was drawn — we only need the return value, so the fake
|
||||
screen's getch() raises StopIteration after the key list is exhausted, and
|
||||
the loop is expected to return before that via Ctrl-D.
|
||||
"""
|
||||
|
||||
def _run(self, keys: list[int], items: list[str], initial: list[str]) -> Optional[list[str]]:
|
||||
"""Run _multiselect_loop with a synthetic screen feeding `keys`."""
|
||||
key_iter = iter(keys)
|
||||
|
||||
class FakeScreen:
|
||||
def erase(self) -> None: pass
|
||||
def getmaxyx(self) -> tuple[int, int]: return (40, 80)
|
||||
def refresh(self) -> None: pass
|
||||
def getch(self) -> int: return next(key_iter)
|
||||
def addstr(self, *a: Any) -> None: pass
|
||||
def keypad(self, *a: Any) -> None: pass
|
||||
|
||||
return _multiselect_loop(FakeScreen(), items, title="", initial=initial) # type: ignore[arg-type]
|
||||
|
||||
def test_ctrl_d_confirms_initial_selection(self):
|
||||
result = self._run([_KEY_CTRL_D], ["a", "b", "c"], ["a", "b"])
|
||||
self.assertEqual(["a", "b"], result)
|
||||
|
||||
def test_esc_cancels(self):
|
||||
result = self._run([_KEY_ESC], ["a", "b"], ["a"])
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_tab_then_K_moves_item_up(self):
|
||||
# Start: selected = ["a", "b", "c"]
|
||||
# Tab → order mode (order_cursor=0 on "a")
|
||||
# ↓ → order_cursor=1 (on "b")
|
||||
# K → swap b and a → ["b", "a", "c"], order_cursor=0
|
||||
# Ctrl-D → confirm
|
||||
DOWN = ord("j")
|
||||
result = self._run(
|
||||
[ord("\t"), DOWN, ord("K"), _KEY_CTRL_D],
|
||||
["a", "b", "c"],
|
||||
["a", "b", "c"],
|
||||
)
|
||||
self.assertEqual(["b", "a", "c"], result)
|
||||
|
||||
def test_tab_then_J_moves_item_down(self):
|
||||
# selected = ["a", "b", "c"], focus order, cursor=0
|
||||
# J → swap a and b → ["b", "a", "c"], cursor=1
|
||||
# Ctrl-D → confirm
|
||||
result = self._run(
|
||||
[ord("\t"), ord("J"), _KEY_CTRL_D],
|
||||
["a", "b", "c"],
|
||||
["a", "b", "c"],
|
||||
)
|
||||
self.assertEqual(["b", "a", "c"], result)
|
||||
|
||||
def test_K_at_top_is_no_op(self):
|
||||
# cursor already at 0, K should not change order
|
||||
result = self._run(
|
||||
[ord("\t"), ord("K"), _KEY_CTRL_D],
|
||||
["a", "b"],
|
||||
["a", "b"],
|
||||
)
|
||||
self.assertEqual(["a", "b"], result)
|
||||
|
||||
def test_J_at_bottom_is_no_op(self):
|
||||
DOWN = ord("j")
|
||||
result = self._run(
|
||||
[ord("\t"), DOWN, ord("J"), _KEY_CTRL_D],
|
||||
["a", "b"],
|
||||
["a", "b"],
|
||||
)
|
||||
self.assertEqual(["a", "b"], result)
|
||||
|
||||
def test_tab_back_to_filter_then_confirm(self):
|
||||
# Tab → order, Tab → filter, Ctrl-D confirms unchanged
|
||||
result = self._run(
|
||||
[ord("\t"), ord("\t"), _KEY_CTRL_D],
|
||||
["a", "b"],
|
||||
["a", "b"],
|
||||
)
|
||||
self.assertEqual(["a", "b"], result)
|
||||
|
||||
def test_space_toggles_item_on(self):
|
||||
# Space on an unselected item selects it; Ctrl-D confirms.
|
||||
result = self._run([_KEY_SPACE, _KEY_CTRL_D], ["a", "b"], [])
|
||||
self.assertEqual(["a"], result)
|
||||
|
||||
def test_space_toggles_item_off(self):
|
||||
# Space on a selected item deselects it; Ctrl-D confirms empty.
|
||||
result = self._run([_KEY_SPACE, _KEY_CTRL_D], ["a", "b"], ["a"])
|
||||
self.assertEqual([], result)
|
||||
|
||||
def test_enter_confirms_without_toggle(self):
|
||||
# Enter immediately confirms the current selection without toggling.
|
||||
result = self._run([_KEY_ENTER], ["a", "b"], ["a"])
|
||||
self.assertEqual(["a"], result)
|
||||
|
||||
def test_enter_confirms_empty_selection(self):
|
||||
result = self._run([_KEY_ENTER], ["a", "b"], [])
|
||||
self.assertEqual([], result)
|
||||
|
||||
def test_space_then_enter_confirms(self):
|
||||
# Space selects "a", Enter confirms.
|
||||
result = self._run([_KEY_SPACE, _KEY_ENTER], ["a", "b"], [])
|
||||
self.assertEqual(["a"], result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+39
-29
@@ -31,7 +31,7 @@ from bot_bottle.egress import (
|
||||
EgressRoute,
|
||||
)
|
||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
from bot_bottle.supervise import SupervisePlan
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ STAGE = Path("/tmp/cb-stage")
|
||||
STATE = Path("/tmp/cb-state")
|
||||
|
||||
|
||||
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest:
|
||||
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex:
|
||||
"""Minimal manifest with the toggles the chunk-1 matrix needs.
|
||||
The renderer only reads from the plan, not the manifest, so this
|
||||
is just here to back BottleSpec."""
|
||||
@@ -51,7 +51,7 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
|
||||
bottle["git-gate"] = {"repos": {
|
||||
"upstream": {
|
||||
"url": "ssh://git@example.com:22/x/y.git",
|
||||
"identity": "/etc/hostname", # any existing file
|
||||
"key": {"provider": "static", "path": "/etc/hostname"},
|
||||
},
|
||||
}}
|
||||
if with_egress:
|
||||
@@ -61,22 +61,12 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
|
||||
"auth": {"scheme": "Bearer", "token_ref": "TOK"},
|
||||
}],
|
||||
}
|
||||
return Manifest.from_json_obj({
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": bottle},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
|
||||
|
||||
def _spec(*, supervise: bool, with_git: bool, with_egress: bool) -> BottleSpec:
|
||||
return BottleSpec(
|
||||
manifest=_manifest(
|
||||
supervise=supervise, with_git=with_git, with_egress=with_egress,
|
||||
),
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd="/tmp/x",
|
||||
)
|
||||
|
||||
|
||||
def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
|
||||
return GitGatePlan(
|
||||
@@ -90,7 +80,11 @@ def _git_gate_plan(upstreams: tuple[GitGateUpstream, ...] = ()) -> GitGatePlan:
|
||||
)
|
||||
|
||||
|
||||
def _egress_plan(routes: tuple[EgressRoute, ...] = ()) -> EgressPlan:
|
||||
def _egress_plan(
|
||||
routes: tuple[EgressRoute, ...] = (),
|
||||
*,
|
||||
canary: bool = False,
|
||||
) -> EgressPlan:
|
||||
token_env_map = {
|
||||
r.token_env: r.token_ref
|
||||
for r in routes
|
||||
@@ -105,6 +99,8 @@ def _egress_plan(routes: tuple[EgressRoute, ...] = ()) -> EgressPlan:
|
||||
egress_network=f"bot-bottle-egress-{SLUG}",
|
||||
mitmproxy_ca_host_path=STATE / "egress-ca" / "mitmproxy-ca.pem",
|
||||
mitmproxy_ca_cert_only_host_path=STATE / "egress-ca" / "ca.pem",
|
||||
canary="fake-canary-value" if canary else "",
|
||||
canary_env="CANON_ALPHA_SECRET" if canary else "",
|
||||
)
|
||||
|
||||
|
||||
@@ -112,7 +108,6 @@ def _supervise_plan() -> SupervisePlan:
|
||||
return SupervisePlan(
|
||||
slug=SLUG,
|
||||
queue_dir=STATE / "supervise" / "queue",
|
||||
current_config_dir=STATE / "supervise" / "current-config",
|
||||
internal_network=f"bot-bottle-net-{SLUG}",
|
||||
)
|
||||
|
||||
@@ -122,6 +117,7 @@ def _plan(
|
||||
with_git: bool = False,
|
||||
with_egress: bool = False,
|
||||
supervise: bool = False,
|
||||
canary: bool = False,
|
||||
) -> DockerBottlePlan:
|
||||
"""Build a fully-resolved DockerBottlePlan. Toggles cover the
|
||||
matrix the renderer's conditional-service logic branches on."""
|
||||
@@ -146,14 +142,21 @@ def _plan(
|
||||
roles=(),
|
||||
),)
|
||||
|
||||
spec = _spec(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
||||
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
||||
spec = BottleSpec(
|
||||
manifest=index,
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd="/tmp/x",
|
||||
)
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
manifest=index.load_for_agent("demo"),
|
||||
stage_dir=STAGE,
|
||||
slug=SLUG,
|
||||
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
|
||||
git_gate_plan=_git_gate_plan(upstreams),
|
||||
egress_plan=_egress_plan(routes),
|
||||
egress_plan=_egress_plan(routes, canary=canary),
|
||||
supervise_plan=_supervise_plan() if supervise else None,
|
||||
use_runsc=False,
|
||||
agent_provision=AgentProvisionPlan(
|
||||
@@ -267,18 +270,11 @@ class TestAgentAlwaysPresent(unittest.TestCase):
|
||||
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
|
||||
self.assertEqual(["sidecars"], s["depends_on"])
|
||||
|
||||
def test_agent_current_config_mount_only_with_supervise(self):
|
||||
def test_agent_has_no_current_config_mount_with_supervise(self):
|
||||
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
|
||||
self.assertTrue(any(
|
||||
v["target"] == "/etc/bot-bottle/current-config"
|
||||
for v in with_sv.get("volumes", [])
|
||||
))
|
||||
self.assertNotIn("volumes", with_sv)
|
||||
without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"]
|
||||
# Either no volumes key at all, or no current-config target.
|
||||
self.assertFalse(any(
|
||||
v["target"] == "/etc/bot-bottle/current-config"
|
||||
for v in without_sv.get("volumes", [])
|
||||
))
|
||||
self.assertNotIn("volumes", without_sv)
|
||||
|
||||
|
||||
class TestSidecarBundleShape(unittest.TestCase):
|
||||
@@ -378,6 +374,20 @@ class TestSidecarBundleShape(unittest.TestCase):
|
||||
env_strings = sc["environment"]
|
||||
self.assertNotIn("EGRESS_TOKEN_0", env_strings)
|
||||
|
||||
def test_canary_env_registered_as_sensitive_in_sidecar(self):
|
||||
sc = self._render(canary=True)["services"]["sidecars"]
|
||||
env_strings = sc["environment"]
|
||||
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", env_strings)
|
||||
self.assertIn(
|
||||
"BOT_BOTTLE_SENSITIVE_PREFIXES=CANON_ALPHA_SECRET",
|
||||
env_strings,
|
||||
)
|
||||
|
||||
def test_canary_env_visible_to_agent(self):
|
||||
agent = self._render(canary=True)["services"]["agent"]
|
||||
env_strings = agent["environment"]
|
||||
self.assertIn("CANON_ALPHA_SECRET=fake-canary-value", env_strings)
|
||||
|
||||
def test_supervise_env_present_when_active(self):
|
||||
sc = self._render(supervise=True)["services"]["sidecars"]
|
||||
env_strings = sc["environment"]
|
||||
@@ -395,7 +405,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
||||
"services"]["sidecars"]
|
||||
targets = {v["target"] for v in sc["volumes"]}
|
||||
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
||||
self.assertIn("/etc/egress/routes.yaml", targets)
|
||||
self.assertIn("/etc/egress", targets)
|
||||
self.assertIn("/git-gate-entrypoint.sh", targets)
|
||||
self.assertIn("/git-gate/creds/upstream-known_hosts", targets)
|
||||
self.assertTrue(any("supervise/queue" in t or t.startswith("/run/supervise")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user