Compare commits
24 Commits
0e823d2aff
..
pr-211
| Author | SHA1 | Date | |
|---|---|---|---|
| df469b2f47 | |||
| d1d9e7a105 | |||
| 7a124d7d25 | |||
| f00c567469 | |||
| 6f0e5b4589 | |||
| 5da4d05bf2 | |||
| 1a8718ca9d | |||
| c1c225aa05 | |||
| dc7c10d6fe | |||
| a827b0841e | |||
| a9c93ea9df | |||
| bb69af31f8 | |||
| 7644da4280 | |||
| 13e4af421d | |||
| f2d5307573 | |||
| bc9a22b46a | |||
| 932e71c0bf | |||
| d3b0b330aa | |||
| 5e927bcd13 | |||
| 890a146413 | |||
| afdf0779a1 | |||
| eb7cae1fea | |||
| fe82dc7f2b | |||
| b00b0ba4aa |
@@ -5,7 +5,7 @@
|
|||||||
# bot-bottle
|
# bot-bottle
|
||||||
|
|
||||||
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
[](https://gitea.dideric.is/didericis/bot-bottle/actions?workflow=test.yml)
|
||||||
[](https://github.com/PyCQA/pylint)
|
[](https://github.com/PyCQA/pylint)
|
||||||
[](https://github.com/microsoft/pyright)
|
[](https://github.com/microsoft/pyright)
|
||||||
|
|
||||||
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
**Problem:** Developer wants to run a coding agent without supervision, but they don't want a prompt injected or misbehaving agent wrecking their environment or exfiltrating sensitive data.
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist 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.
|
||||||
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
|
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
|
||||||
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
|
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
|
||||||
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
||||||
@@ -106,8 +106,15 @@ egress:
|
|||||||
routes:
|
routes:
|
||||||
- host: gitea.dideric.is
|
- host: gitea.dideric.is
|
||||||
auth:
|
auth:
|
||||||
scheme: token
|
scheme: token # Bearer | token
|
||||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||||
|
matches: # optional — restrict to specific paths/methods/headers
|
||||||
|
- paths:
|
||||||
|
- {type: prefix, value: /api/v1/}
|
||||||
|
methods: [GET, POST, PATCH, DELETE]
|
||||||
|
dlp: # optional — per-route detector overrides (default: all on)
|
||||||
|
outbound_detectors: [token_patterns, known_secrets]
|
||||||
|
inbound_detectors: false # disable response scanning for this host
|
||||||
---
|
---
|
||||||
|
|
||||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
||||||
@@ -126,6 +133,23 @@ skills:
|
|||||||
You help maintain Gitea-hosted projects.
|
You help maintain Gitea-hosted projects.
|
||||||
````
|
````
|
||||||
|
|
||||||
|
**Egress route fields:**
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `host` | yes | Hostname to allowlist. One entry per host. |
|
||||||
|
| `role` | no | Provider-specific role string (e.g. `claude_code_oauth`). Wires built-in auth flows; set by provider templates, not manually. |
|
||||||
|
| `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. |
|
||||||
|
| `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. |
|
||||||
|
| `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. |
|
||||||
|
| `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. |
|
||||||
|
| `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. |
|
||||||
|
| `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. |
|
||||||
|
| `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). |
|
||||||
|
| `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). |
|
||||||
|
| `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). |
|
||||||
|
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
|
||||||
|
|
||||||
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ def require_container() -> None:
|
|||||||
info("Apple Container is required but was not found on PATH.")
|
info("Apple Container is required but was not found on PATH.")
|
||||||
info("Install: https://github.com/apple/container/releases")
|
info("Install: https://github.com/apple/container/releases")
|
||||||
die("container not found")
|
die("container not found")
|
||||||
|
_require_container_service()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_container_service() -> None:
|
||||||
|
result = subprocess.run(
|
||||||
|
[_CONTAINER, "system", "status"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
info("Apple Container system service is not running.")
|
||||||
|
info("Start it with: container system start")
|
||||||
|
die("container system service not running")
|
||||||
|
|
||||||
|
|
||||||
def dns_server() -> str:
|
def dns_server() -> str:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ RUN apt-get update \
|
|||||||
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
# build (`claude --version` returns 2.1.126). Bump deliberately when
|
||||||
# rolling forward; an unpinned install would mean rebuilds silently pick
|
# rolling forward; an unpinned install would mean rebuilds silently pick
|
||||||
# up new behavior.
|
# up new behavior.
|
||||||
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.126 \
|
RUN npm install -g --no-fund --no-audit @anthropic-ai/claude-code@2.1.172 \
|
||||||
&& npm cache clean --force
|
&& npm cache clean --force
|
||||||
|
|
||||||
# Run as a non-root user. The node image already provides a `node` user
|
# Run as a non-root user. The node image already provides a `node` user
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
|
Generates ed25519 keypairs via `ssh-keygen` and registers / deletes
|
||||||
them using the Gitea deploy-key HTTP API. No new Python dependencies —
|
them using the Gitea deploy-key HTTP API. No new Python dependencies —
|
||||||
only stdlib `urllib.request` and `subprocess`."""
|
only stdlib `urllib.request` and `subprocess`.
|
||||||
|
|
||||||
|
Required token permissions (Gitea "Applications" → "Generate Token"):
|
||||||
|
- Repository: Read & Write
|
||||||
|
Grants POST /api/v1/repos/{owner}/{repo}/keys (create deploy key)
|
||||||
|
and DELETE /api/v1/repos/{owner}/{repo}/keys/{id} (revoke deploy key).
|
||||||
|
No other scopes are needed."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
+22
-14
@@ -300,6 +300,8 @@ while IFS=' ' read -r old new ref; do
|
|||||||
[ -z "$ref" ] && continue
|
[ -z "$ref" ] && continue
|
||||||
if [ "$new" = "$zero" ]; then
|
if [ "$new" = "$zero" ]; then
|
||||||
refspec=":$ref"
|
refspec=":$ref"
|
||||||
|
elif [ "$old" != "$zero" ] && ! git merge-base --is-ancestor "$old" "$new" 2>/dev/null; then
|
||||||
|
refspec="+$new:$ref"
|
||||||
else
|
else
|
||||||
refspec="$new:$ref"
|
refspec="$new:$ref"
|
||||||
fi
|
fi
|
||||||
@@ -387,13 +389,12 @@ def _provision_dynamic_key(
|
|||||||
Returns the host-side path to the private key file so the caller
|
Returns the host-side path to the private key file so the caller
|
||||||
can inject it into the GitGateUpstream as `identity_file`."""
|
can inject it into the GitGateUpstream as `identity_file`."""
|
||||||
from .deploy_key_provisioner import get_provisioner
|
from .deploy_key_provisioner import get_provisioner
|
||||||
pk = entry.ProvisionedKey
|
pk = entry.Key
|
||||||
assert pk is not None
|
token = os.environ.get(pk.forge_token_env)
|
||||||
token = os.environ.get(pk.token_env)
|
|
||||||
if token is None:
|
if token is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
|
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
||||||
f" = {pk.token_env!r}: env var is not set"
|
f" = {pk.forge_token_env!r}: env var is not set"
|
||||||
)
|
)
|
||||||
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||||
provisioner = get_provisioner(pk.provider, token, api_url)
|
provisioner = get_provisioner(pk.provider, token, api_url)
|
||||||
@@ -426,18 +427,18 @@ def revoke_git_gate_provisioned_keys(bottle: ManifestBottle, stage_dir: Path) ->
|
|||||||
address manually."""
|
address manually."""
|
||||||
from .deploy_key_provisioner import get_provisioner
|
from .deploy_key_provisioner import get_provisioner
|
||||||
for entry in bottle.git:
|
for entry in bottle.git:
|
||||||
if entry.ProvisionedKey is None:
|
if entry.Key.provider != "gitea":
|
||||||
continue
|
continue
|
||||||
pk = entry.ProvisionedKey
|
pk = entry.Key
|
||||||
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
|
id_file = stage_dir / f"{entry.Name}-deploy-key-id"
|
||||||
if not id_file.exists():
|
if not id_file.exists():
|
||||||
continue
|
continue
|
||||||
key_id = id_file.read_text().strip()
|
key_id = id_file.read_text().strip()
|
||||||
token = os.environ.get(pk.token_env)
|
token = os.environ.get(pk.forge_token_env)
|
||||||
if token is None:
|
if token is None:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"git-gate.repos[{entry.Name!r}] provisioned_key.token_env"
|
f"git-gate.repos[{entry.Name!r}] key.forge_token_env"
|
||||||
f" = {pk.token_env!r}: env var is not set;"
|
f" = {pk.forge_token_env!r}: env var is not set;"
|
||||||
f" cannot revoke deploy key {key_id}"
|
f" cannot revoke deploy key {key_id}"
|
||||||
)
|
)
|
||||||
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
api_url = pk.api_url or f"https://{entry.UpstreamHost}"
|
||||||
@@ -450,6 +451,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}]")
|
info(f"revoked deploy key {key_id} for git-gate.repos[{entry.Name!r}]")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_identity_file(entry: ManifestGitEntry, slug: str, stage_dir: Path) -> str:
|
||||||
|
"""Return the host-side SSH identity file path for this entry.
|
||||||
|
For gitea entries, provisions a fresh deploy key first."""
|
||||||
|
if entry.Key.provider == "gitea":
|
||||||
|
return _provision_dynamic_key(entry, slug, stage_dir)
|
||||||
|
return entry.IdentityFile
|
||||||
|
|
||||||
|
|
||||||
class GitGate(ABC):
|
class GitGate(ABC):
|
||||||
"""The per-agent git-gate. Encapsulates the host-side prepare
|
"""The per-agent git-gate. Encapsulates the host-side prepare
|
||||||
(upstream lift + entrypoint/hook render); the sidecar's
|
(upstream lift + entrypoint/hook render); the sidecar's
|
||||||
@@ -461,7 +470,7 @@ class GitGate(ABC):
|
|||||||
entrypoint, pre-receive hook, and access-hook scripts (mode
|
entrypoint, pre-receive hook, and access-hook scripts (mode
|
||||||
600) under `stage_dir`. Pure host-side, no docker subprocess.
|
600) under `stage_dir`. Pure host-side, no docker subprocess.
|
||||||
|
|
||||||
For `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
|
a fresh deploy key via the forge API and writes the private key
|
||||||
+ key ID to `stage_dir`.
|
+ key ID to `stage_dir`.
|
||||||
|
|
||||||
@@ -470,10 +479,9 @@ class GitGate(ABC):
|
|||||||
before passing the plan to `.start`."""
|
before passing the plan to `.start`."""
|
||||||
upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
|
upstreams_list = list(git_gate_upstreams_for_bottle(bottle))
|
||||||
for i, entry in enumerate(bottle.git):
|
for i, entry in enumerate(bottle.git):
|
||||||
if entry.ProvisionedKey is not None:
|
|
||||||
key_file = _provision_dynamic_key(entry, slug, stage_dir)
|
|
||||||
upstreams_list[i] = dataclasses.replace(
|
upstreams_list[i] = dataclasses.replace(
|
||||||
upstreams_list[i], identity_file=key_file
|
upstreams_list[i],
|
||||||
|
identity_file=_resolve_identity_file(entry, slug, stage_dir),
|
||||||
)
|
)
|
||||||
upstreams = tuple(upstreams_list)
|
upstreams = tuple(upstreams_list)
|
||||||
entrypoint = stage_dir / "git_gate_entrypoint.sh"
|
entrypoint = stage_dir / "git_gate_entrypoint.sh"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ from .manifest_egress import (
|
|||||||
ManifestEgressConfig,
|
ManifestEgressConfig,
|
||||||
ManifestEgressRoute,
|
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
|
from .manifest_schema import BOTTLE_KEYS
|
||||||
|
|
||||||
# Re-export everything that callers currently import from this module.
|
# Re-export everything that callers currently import from this module.
|
||||||
@@ -64,6 +64,7 @@ __all__ = [
|
|||||||
"ManifestError",
|
"ManifestError",
|
||||||
"ManifestGitEntry",
|
"ManifestGitEntry",
|
||||||
"ManifestGitUser",
|
"ManifestGitUser",
|
||||||
|
"ManifestKeyConfig",
|
||||||
"ManifestAgentProvider",
|
"ManifestAgentProvider",
|
||||||
"EGRESS_AUTH_SCHEMES",
|
"EGRESS_AUTH_SCHEMES",
|
||||||
"ManifestEgressRoute",
|
"ManifestEgressRoute",
|
||||||
|
|||||||
+68
-61
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from .manifest_util import ManifestError, as_json_object
|
from .manifest_util import ManifestError, as_json_object
|
||||||
|
|
||||||
@@ -13,6 +12,8 @@ from .manifest_util import ManifestError, as_json_object
|
|||||||
# defence; this regex is belt-and-suspenders and documents intent).
|
# defence; this regex is belt-and-suspenders and documents intent).
|
||||||
_GIT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
_GIT_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||||
|
|
||||||
|
_KEY_PROVIDERS = {"static", "gitea"}
|
||||||
|
|
||||||
|
|
||||||
def _opt_str(value: object, label: str) -> str:
|
def _opt_str(value: object, label: str) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -69,20 +70,22 @@ def validate_unique_git_names(bottle_name: str, git: tuple[ManifestGitEntry, ...
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ManifestProvisionedKeyConfig:
|
class ManifestKeyConfig:
|
||||||
"""Configuration for automatic deploy-key lifecycle management
|
"""Configuration for a repo's SSH key in git-gate.repos.
|
||||||
(PRD 0048). Used when a git-gate.repos entry opts out of a
|
|
||||||
static identity file and instead wants a fresh SSH keypair
|
|
||||||
generated at spin-up and revoked at teardown.
|
|
||||||
|
|
||||||
`provider` names the contrib sub-package to load (e.g. `gitea`).
|
`provider` is either `"static"` (a pre-existing key on the host) or
|
||||||
`token_env` is the name of a host-side env var carrying the API
|
`"gitea"` (automatic deploy-key lifecycle via 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
|
For `static`: `path` is the host-side absolute path to the SSH private key.
|
||||||
derived from the upstream URL's host at provision time."""
|
|
||||||
|
For `gitea`: `forge_token_env` is the name of a host-side env var
|
||||||
|
carrying the Gitea API token; the value is read at provision time,
|
||||||
|
never stored on the plan. `api_url` is the forge's HTTP API root; if
|
||||||
|
empty, it is derived from the upstream URL's host at provision time."""
|
||||||
|
|
||||||
provider: str
|
provider: str
|
||||||
token_env: str
|
path: str = ""
|
||||||
|
forge_token_env: str = ""
|
||||||
api_url: str = ""
|
api_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -99,15 +102,16 @@ class ManifestGitEntry:
|
|||||||
stashed in the `Upstream*` fields so the git-gate render step
|
stashed in the `Upstream*` fields so the git-gate render step
|
||||||
doesn't have to re-parse.
|
doesn't have to re-parse.
|
||||||
|
|
||||||
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). Exactly
|
Manifest source: `git-gate.repos.<Name>` (PRD 0047/0048). A `key`
|
||||||
one of `identity` (static key path) or `provisioned_key` (automatic
|
block is required; `key.provider` is `"static"` or `"gitea"`. For
|
||||||
lifecycle) must be present. The internal field names are stable."""
|
`static`, `IdentityFile` is populated at parse time from `key.path`.
|
||||||
|
For `gitea`, `IdentityFile` is populated at provision time."""
|
||||||
|
|
||||||
Name: str
|
Name: str
|
||||||
Upstream: str
|
Upstream: str
|
||||||
|
Key: ManifestKeyConfig = ManifestKeyConfig(provider="")
|
||||||
IdentityFile: str = ""
|
IdentityFile: str = ""
|
||||||
KnownHostKey: str = ""
|
KnownHostKey: str = ""
|
||||||
ProvisionedKey: Optional[ManifestProvisionedKeyConfig] = None
|
|
||||||
RemoteKey: str = ""
|
RemoteKey: str = ""
|
||||||
UpstreamUser: str = ""
|
UpstreamUser: str = ""
|
||||||
UpstreamHost: str = ""
|
UpstreamHost: str = ""
|
||||||
@@ -120,8 +124,8 @@ class ManifestGitEntry:
|
|||||||
) -> "ManifestGitEntry":
|
) -> "ManifestGitEntry":
|
||||||
"""Parse one entry from `git-gate.repos.<repo_name>`.
|
"""Parse one entry from `git-gate.repos.<repo_name>`.
|
||||||
|
|
||||||
YAML keys: `url` (required), exactly one of `identity` or
|
YAML keys: `url` (required), `key` (required object with
|
||||||
`provisioned_key` (required), `host_key` (optional).
|
`provider`, and provider-specific fields), `host_key` (optional).
|
||||||
The repo_name becomes `Name`."""
|
The repo_name becomes `Name`."""
|
||||||
if not repo_name:
|
if not repo_name:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
@@ -135,10 +139,10 @@ class ManifestGitEntry:
|
|||||||
label = f"git-gate.repos[{repo_name!r}]"
|
label = f"git-gate.repos[{repo_name!r}]"
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
|
d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
|
||||||
for k in d:
|
for k in d:
|
||||||
if k not in {"url", "identity", "provisioned_key", "host_key"}:
|
if k not in {"url", "key", "host_key"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
|
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
|
||||||
f"allowed: url, identity, provisioned_key, host_key"
|
f"allowed: url, key, host_key"
|
||||||
)
|
)
|
||||||
upstream = d.get("url")
|
upstream = d.get("url")
|
||||||
if not isinstance(upstream, str) or not upstream:
|
if not isinstance(upstream, str) or not upstream:
|
||||||
@@ -146,32 +150,13 @@ class ManifestGitEntry:
|
|||||||
f"bottle '{bottle_name}' {label} missing required string field 'url'"
|
f"bottle '{bottle_name}' {label} missing required string field 'url'"
|
||||||
)
|
)
|
||||||
|
|
||||||
has_identity = "identity" in d
|
if "key" not in d:
|
||||||
has_provisioned = "provisioned_key" in d
|
|
||||||
if has_identity and has_provisioned:
|
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label} must set exactly one of "
|
f"bottle '{bottle_name}' {label} missing required 'key' block"
|
||||||
f"'identity' or 'provisioned_key'; got both."
|
|
||||||
)
|
|
||||||
if not has_identity and not has_provisioned:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{bottle_name}' {label} must set exactly one of "
|
|
||||||
f"'identity' or 'provisioned_key'; got neither."
|
|
||||||
)
|
)
|
||||||
|
key_config = _parse_key_config(bottle_name, label, d["key"])
|
||||||
|
|
||||||
ident = ""
|
ident = key_config.path if key_config.provider == "static" else ""
|
||||||
provisioned_key: Optional[ManifestProvisionedKeyConfig] = None
|
|
||||||
if has_identity:
|
|
||||||
raw_ident = d.get("identity")
|
|
||||||
if not isinstance(raw_ident, str) or not raw_ident:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{bottle_name}' {label} 'identity' must be a non-empty string"
|
|
||||||
)
|
|
||||||
ident = raw_ident
|
|
||||||
else:
|
|
||||||
provisioned_key = _parse_provisioned_key_config(
|
|
||||||
bottle_name, label, d["provisioned_key"]
|
|
||||||
)
|
|
||||||
|
|
||||||
khk = _opt_str(
|
khk = _opt_str(
|
||||||
d.get("host_key"),
|
d.get("host_key"),
|
||||||
@@ -183,9 +168,9 @@ class ManifestGitEntry:
|
|||||||
return cls(
|
return cls(
|
||||||
Name=repo_name,
|
Name=repo_name,
|
||||||
Upstream=upstream,
|
Upstream=upstream,
|
||||||
|
Key=key_config,
|
||||||
IdentityFile=ident,
|
IdentityFile=ident,
|
||||||
KnownHostKey=khk,
|
KnownHostKey=khk,
|
||||||
ProvisionedKey=provisioned_key,
|
|
||||||
RemoteKey=host,
|
RemoteKey=host,
|
||||||
UpstreamUser=user,
|
UpstreamUser=user,
|
||||||
UpstreamHost=host,
|
UpstreamHost=host,
|
||||||
@@ -194,39 +179,61 @@ class ManifestGitEntry:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_provisioned_key_config(
|
def _parse_key_config(
|
||||||
bottle_name: str, label: str, raw: object
|
bottle_name: str, label: str, raw: object
|
||||||
) -> ManifestProvisionedKeyConfig:
|
) -> ManifestKeyConfig:
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.provisioned_key")
|
d = as_json_object(raw, f"bottle '{bottle_name}' {label}.key")
|
||||||
for k in d:
|
|
||||||
if k not in {"provider", "token_env", "api_url"}:
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{bottle_name}' {label}.provisioned_key has unknown key {k!r}; "
|
|
||||||
f"allowed: provider, token_env, api_url"
|
|
||||||
)
|
|
||||||
provider = d.get("provider")
|
provider = d.get("provider")
|
||||||
if not isinstance(provider, str) or not provider:
|
if not isinstance(provider, str) or not provider:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
|
f"bottle '{bottle_name}' {label}.key missing required "
|
||||||
f"string field 'provider'"
|
f"string field 'provider'"
|
||||||
)
|
)
|
||||||
token_env = d.get("token_env")
|
if provider not in _KEY_PROVIDERS:
|
||||||
if not isinstance(token_env, str) or not token_env:
|
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label}.provisioned_key missing required "
|
f"bottle '{bottle_name}' {label}.key provider {provider!r} is unknown; "
|
||||||
f"string field 'token_env'"
|
f"allowed: {', '.join(sorted(_KEY_PROVIDERS))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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", "")
|
api_url_raw = d.get("api_url", "")
|
||||||
if not isinstance(api_url_raw, str):
|
if not isinstance(api_url_raw, str):
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' {label}.provisioned_key 'api_url' must be a string"
|
f"bottle '{bottle_name}' {label}.key 'api_url' must be a string"
|
||||||
)
|
)
|
||||||
return ManifestProvisionedKeyConfig(
|
return ManifestKeyConfig(
|
||||||
provider=provider,
|
provider=provider,
|
||||||
token_env=token_env,
|
forge_token_env=forge_token_env,
|
||||||
api_url=api_url_raw,
|
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}.key missing required "
|
||||||
|
f"string field 'path' for provider 'static'"
|
||||||
|
)
|
||||||
|
return ManifestKeyConfig(provider=provider, path=path)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ManifestGitUser:
|
class ManifestGitUser:
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
# PRD prd-new: macOS Container backend
|
# PRD 0059: macOS Container backend
|
||||||
|
|
||||||
- **Status:** Draft
|
- **Status:** Active
|
||||||
- **Author:** Codex
|
- **Author:** Codex
|
||||||
- **Created:** 2026-06-10
|
- **Created:** 2026-06-10
|
||||||
- **Issue:** #220
|
- **Issue:** #220
|
||||||
@@ -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.
|
||||||
@@ -5,10 +5,15 @@ agent_provider:
|
|||||||
egress:
|
egress:
|
||||||
routes:
|
routes:
|
||||||
- host: api.anthropic.com
|
- host: api.anthropic.com
|
||||||
role: claude_code_oauth
|
role: claude_code_oauth # wires Claude Code OAuth; do not change
|
||||||
auth:
|
auth:
|
||||||
scheme: Bearer
|
scheme: Bearer
|
||||||
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||||
|
# dlp is omitted → all detectors on by default (token_patterns,
|
||||||
|
# known_secrets outbound; naive_injection_detection inbound).
|
||||||
|
# To disable inbound scanning for this route:
|
||||||
|
# dlp:
|
||||||
|
# inbound_detectors: false
|
||||||
---
|
---
|
||||||
|
|
||||||
Common Claude provider boundary. Drop this file into
|
Common Claude provider boundary. Drop this file into
|
||||||
|
|||||||
+2
-2
@@ -46,12 +46,12 @@ def fixture_with_git_dict() -> dict[str, Any]:
|
|||||||
"repos": {
|
"repos": {
|
||||||
"bot-bottle": {
|
"bot-bottle": {
|
||||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
"host_key": "ssh-ed25519 AAAA...",
|
"host_key": "ssh-ed25519 AAAA...",
|
||||||
},
|
},
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/didericis/foo.git",
|
"url": "ssh://git@github.com/didericis/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
"host_key": "ssh-ed25519 BBBB...",
|
"host_key": "ssh-ed25519 BBBB...",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> Manifest
|
|||||||
bottle["git-gate"] = {"repos": {
|
bottle["git-gate"] = {"repos": {
|
||||||
"upstream": {
|
"upstream": {
|
||||||
"url": "ssh://git@example.com:22/x/y.git",
|
"url": "ssh://git@example.com:22/x/y.git",
|
||||||
"identity": "/etc/hostname", # any existing file
|
"key": {"provider": "static", "path": "/etc/hostname"},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
if with_egress:
|
if with_egress:
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ class TestHookRender(unittest.TestCase):
|
|||||||
self.assertIn("BatchMode=yes", hook)
|
self.assertIn("BatchMode=yes", hook)
|
||||||
self.assertIn("ConnectTimeout=", hook)
|
self.assertIn("ConnectTimeout=", hook)
|
||||||
|
|
||||||
|
def test_force_push_uses_plus_refspec(self):
|
||||||
|
# A non-fast-forward push (old != zero, new not a descendant of old)
|
||||||
|
# must forward +$new:$ref so the upstream accepts the force push.
|
||||||
|
hook = git_gate_render_hook()
|
||||||
|
self.assertIn('git merge-base --is-ancestor "$old" "$new"', hook)
|
||||||
|
self.assertIn('refspec="+$new:$ref"', hook)
|
||||||
|
|
||||||
def test_forward_preserves_push_options(self):
|
def test_forward_preserves_push_options(self):
|
||||||
# Git exposes push options to pre-receive hooks as
|
# Git exposes push options to pre-receive hooks as
|
||||||
# GIT_PUSH_OPTION_COUNT + indexed GIT_PUSH_OPTION_N variables.
|
# GIT_PUSH_OPTION_COUNT + indexed GIT_PUSH_OPTION_N variables.
|
||||||
@@ -277,7 +284,7 @@ class TestPrepare(unittest.TestCase):
|
|||||||
"bottles": {"dev": {"git-gate": {"repos": {
|
"bottles": {"dev": {"git-gate": {"repos": {
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/didericis/foo.git",
|
"url": "ssh://git@github.com/didericis/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}}}},
|
}}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
|||||||
class TestAgentGitUserRejections(unittest.TestCase):
|
class TestAgentGitUserRejections(unittest.TestCase):
|
||||||
def test_agent_repos_dies_bottle_only(self):
|
def test_agent_repos_dies_bottle_only(self):
|
||||||
msg = _error_message(_manifest, agent_git={
|
msg = _error_message(_manifest, agent_git={
|
||||||
"repos": {"r": {"url": "ssh://git@x/y.git", "identity": "/dev/null"}},
|
"repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}},
|
||||||
})
|
})
|
||||||
self.assertIn("git-gate.repos", msg)
|
self.assertIn("git-gate.repos", msg)
|
||||||
self.assertIn("bottle-only", msg)
|
self.assertIn("bottle-only", msg)
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ class TestExtendsGitMerge(unittest.TestCase):
|
|||||||
"""git-gate.user overlays by field; git-gate.repos merges by upstream
|
"""git-gate.user overlays by field; git-gate.repos merges by upstream
|
||||||
host, with child entries replacing duplicate hosts."""
|
host, with child entries replacing duplicate hosts."""
|
||||||
|
|
||||||
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "identity": "/dev/null"}
|
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
|
||||||
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "identity": "/dev/null"}
|
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}}
|
||||||
|
|
||||||
def test_child_git_repos_merge_with_parent(self):
|
def test_child_git_repos_merge_with_parent(self):
|
||||||
m = _build(
|
m = _build(
|
||||||
@@ -131,7 +131,7 @@ class TestExtendsGitMerge(unittest.TestCase):
|
|||||||
self.assertEqual(["a", "b"], names)
|
self.assertEqual(["a", "b"], names)
|
||||||
|
|
||||||
def test_child_git_repo_replaces_same_host(self):
|
def test_child_git_repo_replaces_same_host(self):
|
||||||
replacement = {"url": "ssh://git@host-a/replacement.git", "identity": "/dev/null"}
|
replacement = {"url": "ssh://git@host-a/replacement.git", "key": {"provider": "static", "path": "/dev/null"}}
|
||||||
m = _build(
|
m = _build(
|
||||||
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
||||||
child={
|
child={
|
||||||
|
|||||||
+105
-75
@@ -17,7 +17,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
m = Manifest.from_json_obj(_manifest({
|
m = Manifest.from_json_obj(_manifest({
|
||||||
"bot-bottle": {
|
"bot-bottle": {
|
||||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
entries = m.bottles["dev"].git
|
entries = m.bottles["dev"].git
|
||||||
@@ -33,7 +33,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
m = Manifest.from_json_obj(_manifest({
|
m = Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/didericis/foo.git",
|
"url": "ssh://git@github.com/didericis/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
e = m.bottles["dev"].git[0]
|
e = m.bottles["dev"].git[0]
|
||||||
@@ -44,7 +44,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
m = Manifest.from_json_obj(_manifest({
|
m = Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey)
|
self.assertEqual("", m.bottles["dev"].git[0].KnownHostKey)
|
||||||
@@ -53,7 +53,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
m = Manifest.from_json_obj(_manifest({
|
m = Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
"host_key": "ssh-ed25519 AAAA",
|
"host_key": "ssh-ed25519 AAAA",
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -63,7 +63,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
m = Manifest.from_json_obj(_manifest({
|
m = Manifest.from_json_obj(_manifest({
|
||||||
"my-repo": {
|
"my-repo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
self.assertEqual("my-repo", m.bottles["dev"].git[0].Name)
|
self.assertEqual("my-repo", m.bottles["dev"].git[0].Name)
|
||||||
@@ -71,10 +71,10 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
def test_missing_url_dies(self):
|
def test_missing_url_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {"identity": "/dev/null"},
|
"foo": {"key": {"provider": "static", "path": "/dev/null"}},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
def test_missing_identity_dies(self):
|
def test_missing_key_block_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {"url": "ssh://git@github.com/foo.git"},
|
"foo": {"url": "ssh://git@github.com/foo.git"},
|
||||||
@@ -85,7 +85,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
"IdentityFile": "/dev/null", # old PascalCase key
|
"IdentityFile": "/dev/null", # old PascalCase key
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -95,7 +95,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "https://github.com/didericis/foo.git",
|
"url": "https://github.com/didericis/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "git@github.com:didericis/foo.git",
|
"url": "git@github.com:didericis/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://github.com/foo.git",
|
"url": "ssh://github.com/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com",
|
"url": "ssh://git@github.com",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com:notaport/foo.git",
|
"url": "ssh://git@github.com:notaport/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ class TestGitEntryParsing(unittest.TestCase):
|
|||||||
m = Manifest.from_json_obj(_manifest({
|
m = Manifest.from_json_obj(_manifest({
|
||||||
"bot-bottle": {
|
"bot-bottle": {
|
||||||
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
e = m.bottles["dev"].git[0]
|
e = m.bottles["dev"].git[0]
|
||||||
@@ -156,11 +156,11 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
"bottles": {"dev": {"git-gate": {"repos": {
|
"bottles": {"dev": {"git-gate": {"repos": {
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@a.example/x.git",
|
"url": "ssh://git@a.example/x.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
"bar": {
|
"bar": {
|
||||||
"url": "ssh://git@b.example/y.git",
|
"url": "ssh://git@b.example/y.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}}}},
|
}}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
@@ -190,7 +190,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"o'reilly": {
|
"o'reilly": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"my repo": {
|
"my repo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo;bar": {
|
"foo;bar": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo$bar": {
|
"foo$bar": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
m = Manifest.from_json_obj(_manifest({
|
m = Manifest.from_json_obj(_manifest({
|
||||||
"my.repo-name_1": {
|
"my.repo-name_1": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
self.assertEqual("my.repo-name_1", m.bottles["dev"].git[0].Name)
|
self.assertEqual("my.repo-name_1", m.bottles["dev"].git[0].Name)
|
||||||
@@ -243,111 +243,141 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
self.assertIn("PRD 0047", msg)
|
self.assertIn("PRD 0047", msg)
|
||||||
|
|
||||||
|
|
||||||
class TestProvisionedKey(unittest.TestCase):
|
class TestStaticKey(unittest.TestCase):
|
||||||
"""git-gate.repos entries that use provisioned_key (PRD 0048)."""
|
"""git-gate.repos entries with key.provider = "static"."""
|
||||||
|
|
||||||
def test_provisioned_key_minimal(self):
|
def test_static_key_minimal(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = Manifest.from_json_obj(_manifest({
|
||||||
"bot-bottle": {
|
"bot-bottle": {
|
||||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||||
"provisioned_key": {
|
"key": {"provider": "static", "path": "/home/user/.ssh/id_ed25519"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
e = m.bottles["dev"].git[0]
|
||||||
|
self.assertEqual("bot-bottle", e.Name)
|
||||||
|
self.assertEqual("static", e.Key.provider)
|
||||||
|
self.assertEqual("/home/user/.ssh/id_ed25519", e.Key.path)
|
||||||
|
self.assertEqual("/home/user/.ssh/id_ed25519", e.IdentityFile)
|
||||||
|
|
||||||
|
def test_static_key_sets_identity_file_at_parse_time(self):
|
||||||
|
m = Manifest.from_json_obj(_manifest({
|
||||||
|
"foo": {
|
||||||
|
"url": "ssh://git@github.com/foo.git",
|
||||||
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
self.assertEqual("/dev/null", m.bottles["dev"].git[0].IdentityFile)
|
||||||
|
|
||||||
|
def test_static_key_missing_path_dies(self):
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
Manifest.from_json_obj(_manifest({
|
||||||
|
"foo": {
|
||||||
|
"url": "ssh://git@github.com/foo.git",
|
||||||
|
"key": {"provider": "static"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
def test_static_key_unknown_field_dies(self):
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
Manifest.from_json_obj(_manifest({
|
||||||
|
"foo": {
|
||||||
|
"url": "ssh://git@github.com/foo.git",
|
||||||
|
"key": {"provider": "static", "path": "/dev/null", "api_url": "x"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGiteaKey(unittest.TestCase):
|
||||||
|
"""git-gate.repos entries with key.provider = "gitea"."""
|
||||||
|
|
||||||
|
def test_gitea_key_minimal(self):
|
||||||
|
m = Manifest.from_json_obj(_manifest({
|
||||||
|
"bot-bottle": {
|
||||||
|
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||||
|
"key": {
|
||||||
"provider": "gitea",
|
"provider": "gitea",
|
||||||
"token_env": "GITEA_TOKEN",
|
"forge_token_env": "GITEA_TOKEN",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
e = m.bottles["dev"].git[0]
|
e = m.bottles["dev"].git[0]
|
||||||
self.assertEqual("bot-bottle", e.Name)
|
self.assertEqual("bot-bottle", e.Name)
|
||||||
self.assertIsNotNone(e.ProvisionedKey)
|
self.assertEqual("gitea", e.Key.provider)
|
||||||
assert e.ProvisionedKey is not None
|
self.assertEqual("GITEA_TOKEN", e.Key.forge_token_env)
|
||||||
self.assertEqual("gitea", e.ProvisionedKey.provider)
|
self.assertEqual("", e.Key.api_url)
|
||||||
self.assertEqual("GITEA_TOKEN", e.ProvisionedKey.token_env)
|
|
||||||
self.assertEqual("", e.ProvisionedKey.api_url)
|
|
||||||
self.assertEqual("", e.IdentityFile)
|
self.assertEqual("", e.IdentityFile)
|
||||||
|
|
||||||
def test_provisioned_key_with_api_url(self):
|
def test_gitea_key_with_api_url(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
m = Manifest.from_json_obj(_manifest({
|
||||||
"repo": {
|
"repo": {
|
||||||
"url": "ssh://git@gitea.example.com/org/repo.git",
|
"url": "ssh://git@gitea.example.com/org/repo.git",
|
||||||
"provisioned_key": {
|
"key": {
|
||||||
"provider": "gitea",
|
"provider": "gitea",
|
||||||
"token_env": "MY_TOKEN",
|
"forge_token_env": "MY_TOKEN",
|
||||||
"api_url": "https://gitea.example.com",
|
"api_url": "https://gitea.example.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
pk = m.bottles["dev"].git[0].ProvisionedKey
|
self.assertEqual("https://gitea.example.com", m.bottles["dev"].git[0].Key.api_url)
|
||||||
assert pk is not None
|
|
||||||
self.assertEqual("https://gitea.example.com", pk.api_url)
|
|
||||||
|
|
||||||
def test_both_identity_and_provisioned_key_dies(self):
|
def test_gitea_key_has_no_identity_file_at_parse_time(self):
|
||||||
with self.assertRaises(ManifestError) as ctx:
|
m = Manifest.from_json_obj(_manifest({
|
||||||
Manifest.from_json_obj(_manifest({
|
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/didericis/foo.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "gitea", "forge_token_env": "T"},
|
||||||
"provisioned_key": {"provider": "gitea", "token_env": "T"},
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
self.assertIn("exactly one of", str(ctx.exception))
|
self.assertEqual("", m.bottles["dev"].git[0].IdentityFile)
|
||||||
self.assertIn("got both", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_neither_identity_nor_provisioned_key_dies(self):
|
def test_gitea_key_missing_forge_token_env_dies(self):
|
||||||
with self.assertRaises(ManifestError) as ctx:
|
|
||||||
Manifest.from_json_obj(_manifest({
|
|
||||||
"foo": {"url": "ssh://git@github.com/foo.git"},
|
|
||||||
}))
|
|
||||||
self.assertIn("exactly one of", str(ctx.exception))
|
|
||||||
self.assertIn("got neither", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_unknown_key_in_provisioned_key_block_dies(self):
|
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"provisioned_key": {
|
"key": {"provider": "gitea"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
def test_gitea_key_unknown_field_dies(self):
|
||||||
|
with self.assertRaises(ManifestError):
|
||||||
|
Manifest.from_json_obj(_manifest({
|
||||||
|
"foo": {
|
||||||
|
"url": "ssh://git@github.com/foo.git",
|
||||||
|
"key": {
|
||||||
"provider": "gitea",
|
"provider": "gitea",
|
||||||
"token_env": "T",
|
"forge_token_env": "T",
|
||||||
"key_type": "rsa", # not allowed
|
"key_type": "rsa", # not allowed
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyBlockValidation(unittest.TestCase):
|
||||||
|
"""Validation rules on the key block shared across providers."""
|
||||||
|
|
||||||
def test_missing_provider_dies(self):
|
def test_missing_provider_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"provisioned_key": {"token_env": "T"},
|
"key": {"path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
def test_missing_token_env_dies(self):
|
def test_unknown_provider_dies(self):
|
||||||
with self.assertRaises(ManifestError):
|
with self.assertRaises(ManifestError):
|
||||||
Manifest.from_json_obj(_manifest({
|
Manifest.from_json_obj(_manifest({
|
||||||
"foo": {
|
"foo": {
|
||||||
"url": "ssh://git@github.com/foo.git",
|
"url": "ssh://git@github.com/foo.git",
|
||||||
"provisioned_key": {"provider": "gitea"},
|
"key": {"provider": "github"},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
def test_provisioned_key_entry_has_no_identity_file(self):
|
def test_missing_key_block_dies(self):
|
||||||
m = Manifest.from_json_obj(_manifest({
|
with self.assertRaises(ManifestError):
|
||||||
"foo": {
|
Manifest.from_json_obj(_manifest({
|
||||||
"url": "ssh://git@github.com/didericis/foo.git",
|
"foo": {"url": "ssh://git@github.com/foo.git"},
|
||||||
"provisioned_key": {"provider": "gitea", "token_env": "T"},
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
self.assertEqual("", m.bottles["dev"].git[0].IdentityFile)
|
|
||||||
|
|
||||||
def test_identity_entry_has_no_provisioned_key(self):
|
|
||||||
m = Manifest.from_json_obj(_manifest({
|
|
||||||
"foo": {
|
|
||||||
"url": "ssh://git@github.com/foo.git",
|
|
||||||
"identity": "/dev/null",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
self.assertIsNone(m.bottles["dev"].git[0].ProvisionedKey)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEmptyGitGateField(unittest.TestCase):
|
class TestEmptyGitGateField(unittest.TestCase):
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class TestGitGateGitconfigRender(unittest.TestCase):
|
|||||||
"bottles": {"dev": {"git-gate": {"repos": {
|
"bottles": {"dev": {"git-gate": {"repos": {
|
||||||
"bot-bottle": {
|
"bot-bottle": {
|
||||||
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
||||||
"identity": "/dev/null",
|
"key": {"provider": "static", "path": "/dev/null"},
|
||||||
},
|
},
|
||||||
}}}},
|
}}}},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from bot_bottle.backend.smolmachines.launch import _bundle_launch_spec
|
|||||||
from bot_bottle.backend.util import AGENT_CA_PATH
|
from bot_bottle.backend.util import AGENT_CA_PATH
|
||||||
from bot_bottle.egress import EgressPlan, EgressRoute
|
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||||
from bot_bottle.manifest import ManifestGitEntry, Manifest
|
from bot_bottle.manifest import ManifestGitEntry, ManifestKeyConfig, Manifest
|
||||||
from bot_bottle.supervise import SupervisePlan
|
from bot_bottle.supervise import SupervisePlan
|
||||||
|
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ def _plan(
|
|||||||
git_gate_json["repos"] = {
|
git_gate_json["repos"] = {
|
||||||
g.Name: {
|
g.Name: {
|
||||||
"url": g.Upstream,
|
"url": g.Upstream,
|
||||||
"identity": g.IdentityFile,
|
"key": {"provider": g.Key.provider or "static", "path": g.Key.path or g.IdentityFile},
|
||||||
}
|
}
|
||||||
for g in git
|
for g in git
|
||||||
}
|
}
|
||||||
@@ -360,6 +360,7 @@ class TestProvisionGit(unittest.TestCase):
|
|||||||
git=[ManifestGitEntry(
|
git=[ManifestGitEntry(
|
||||||
Name="bot-bottle",
|
Name="bot-bottle",
|
||||||
Upstream="ssh://git@host/repo.git",
|
Upstream="ssh://git@host/repo.git",
|
||||||
|
Key=ManifestKeyConfig(provider="static", path="~/.ssh/id_ed25519"),
|
||||||
IdentityFile="~/.ssh/id_ed25519",
|
IdentityFile="~/.ssh/id_ed25519",
|
||||||
)],
|
)],
|
||||||
stage_dir=self.stage,
|
stage_dir=self.stage,
|
||||||
|
|||||||
Reference in New Issue
Block a user