Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76037f36a6 | |||
| 4b17e6d683 | |||
| 7a48ea2b0c | |||
| ec953ceda7 | |||
| ed0f95f445 | |||
| 794e4e662d | |||
| f2fe1f9b2d |
@@ -102,20 +102,6 @@ jobs:
|
||||
python3 --version
|
||||
python3 cli.py backend status --backend=docker
|
||||
|
||||
- name: Preflight — clear any leftover poisoned gateway network
|
||||
run: |
|
||||
# The gateway network has a fixed name and persists across jobs on
|
||||
# this shared runner. A pre-fix or concurrent launch can leave it with
|
||||
# a malformed IPv6 subnet that trips docker's own ParseAddr in
|
||||
# `network inspect` (see PR #515); the code now self-heals it, but the
|
||||
# heal can't run if `network inspect` is what's broken on some daemon
|
||||
# versions. Drop the network here so this run recreates it IPv4-only.
|
||||
# Remove the attached gateway container first (else `network rm` fails
|
||||
# on active endpoints); both are recreated by ensure_running. Harmless
|
||||
# when absent.
|
||||
docker rm --force bot-bottle-orch-gateway 2>/dev/null || true
|
||||
docker network rm bot-bottle-gateway 2>/dev/null || true
|
||||
|
||||
- name: Run integration tests (docker) with coverage
|
||||
env:
|
||||
BOT_BOTTLE_BACKEND: docker
|
||||
@@ -298,7 +284,7 @@ jobs:
|
||||
- name: Combined coverage (unit + docker integration)
|
||||
run: PYTHON=python3 bash scripts/coverage.sh aggregate critical
|
||||
|
||||
- name: Diff-coverage gate (changed lines >= 80%)
|
||||
- name: Diff-coverage gate (changed lines >= 90%)
|
||||
run: |
|
||||
git fetch --no-tags origin main:refs/remotes/origin/main
|
||||
python3 scripts/diff_coverage.py --base origin/main --min 80
|
||||
python3 scripts/diff_coverage.py --base origin/main --min 90
|
||||
|
||||
@@ -182,9 +182,7 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
|
||||
|
||||
## Manifest
|
||||
|
||||
Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Both bottles and agents are **home-only**: they live under `~/.bot-bottle/bottles/` and `~/.bot-bottle/agents/`. A `<repo>/.bot-bottle/agents/<name>.md` shipped by a workspace is ignored with a warning — since an agent may select a host identity and forge secret, checked-out content must not define one (PRD 0082). Keep repo-specific behavioral instructions in `AGENTS.md` instead.
|
||||
|
||||
Identity is **agent-owned**: the author name/email and named forge accounts live on the agent, not under `git-gate` (which now carries only Git transport policy). A bottle repo may optionally name one of the selected agent's forge aliases via `forge:`.
|
||||
Bottles and agents are Markdown files with YAML frontmatter under `~/.bot-bottle/`. The Markdown body is the system prompt. Bottles live in `~/.bot-bottle/bottles/`; agents may also be shipped by a repo at `<repo>/.bot-bottle/agents/<name>.md`.
|
||||
|
||||
**Bottle** (`~/.bot-bottle/bottles/gitea-dev.md`):
|
||||
|
||||
@@ -192,19 +190,37 @@ Identity is **agent-owned**: the author name/email and named forge accounts live
|
||||
---
|
||||
extends: claude # inherit the Claude provider boundary
|
||||
|
||||
git-gate:
|
||||
repos:
|
||||
bot-bottle:
|
||||
url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
||||
key:
|
||||
provider: gitea
|
||||
forge_token_env: GITEA_DEPLOY_TOKEN # deploy-key admin (push), PRD 0048
|
||||
host_key: "ssh-ed25519 AAAA..."
|
||||
forge: didericis-gitea # ← selects the agent's forge alias
|
||||
env:
|
||||
GIT_AUTHOR_NAME: didericis
|
||||
|
||||
git:
|
||||
user:
|
||||
name: "Eric Bauerfeld"
|
||||
email: "eric+claude@dideric.is"
|
||||
remotes:
|
||||
gitea.dideric.is:
|
||||
Name: bot-bottle
|
||||
Upstream: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git
|
||||
IdentityFile: /Users/didericis/.ssh/id_ed25519_gitea
|
||||
KnownHostKey: ssh-ed25519 AAAA...
|
||||
|
||||
egress:
|
||||
routes:
|
||||
- host: gitea.dideric.is
|
||||
inspect:
|
||||
auth:
|
||||
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]
|
||||
outbound_detectors: [token_patterns, known_secrets]
|
||||
inbound_detectors: false # disable response scanning for this host
|
||||
---
|
||||
|
||||
The `gitea-dev` bottle. Gitea over SSH for push; the API credential and
|
||||
workflow guidance come from the agent's `forge: didericis-gitea` association.
|
||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
||||
gitea over SSH for push, token over HTTPS for the API.
|
||||
````
|
||||
|
||||
**Agent** (`~/.bot-bottle/agents/gitea-helper.md`):
|
||||
@@ -214,27 +230,11 @@ workflow guidance come from the agent's `forge: didericis-gitea` association.
|
||||
bottle: gitea-dev
|
||||
skills:
|
||||
- init-prd
|
||||
author:
|
||||
name: didericis-claude
|
||||
email: eric+claude@dideric.is
|
||||
forge-accounts:
|
||||
didericis-gitea:
|
||||
url: https://gitea.dideric.is/api/v1
|
||||
auth:
|
||||
type: token
|
||||
token_secret: GITEA_CLAUDE_TOKEN # host env var; value never enters the bottle
|
||||
---
|
||||
|
||||
You help maintain Gitea-hosted projects.
|
||||
````
|
||||
|
||||
`author` populates the bottle's `git config user.name/user.email`. When a
|
||||
selected repo names a `forge` alias, bot-bottle resolves the alias's
|
||||
`token_secret` from the host env into the egress proxy only (never the bottle),
|
||||
adds a scoped, proxy-authenticated route to the Gitea API origin, and appends
|
||||
non-secret forge workflow guidance to the agent's system prompt. Neither the
|
||||
token value nor its `token_secret` name appears in the bottle env or prompt.
|
||||
|
||||
**Egress route fields:**
|
||||
|
||||
| Field | Required | Description |
|
||||
|
||||
@@ -140,43 +140,12 @@ class DockerGateway(Gateway):
|
||||
marker = inspected.stdout.strip()
|
||||
if marker in {"", self._subnet}:
|
||||
return
|
||||
# Inspectable but mislabelled: the stale auto-IPAM network created
|
||||
# by older releases. Replace it below.
|
||||
stale = True
|
||||
else:
|
||||
# inspect failed. Classify by stderr — do NOT assume "not absent"
|
||||
# implies "poisoned": a transient daemon/API error, permission
|
||||
# failure, timeout, or bad context also fails here, and destroying
|
||||
# the shared gateway on that guess would tear the network out from
|
||||
# under every live bottle.
|
||||
err = inspected.stderr.lower()
|
||||
if "no such network" in err or "not found" in err:
|
||||
# Absent: nothing to replace — create it below.
|
||||
stale = False
|
||||
elif "parseaddr" in err:
|
||||
# Present but poisoned. A daemon that default-enables IPv6
|
||||
# attaches an fdd0::/64 subnet whose `::1/64` gateway trips
|
||||
# docker's own netip.ParseAddr in `network inspect`/`ls`, so the
|
||||
# command exits non-zero with that signature. A fixed release
|
||||
# never *creates* such a network, but one can survive on a
|
||||
# shared host from an older or concurrent launch — and
|
||||
# `--ipv6=false` alone can't heal it, since the create below only
|
||||
# no-ops on "already exists". Force-replace it so later reads
|
||||
# (e.g. `_network_cidr` pinning a source IP) stop failing.
|
||||
stale = True
|
||||
else:
|
||||
# Unrecognized failure: no evidence the network is malformed.
|
||||
# Surface it rather than mutate shared state on a guess.
|
||||
raise GatewayError(
|
||||
f"gateway network {self.network} could not be inspected: "
|
||||
f"{inspected.stderr.strip()}"
|
||||
)
|
||||
if stale:
|
||||
# Migrate the stale/poisoned network. Removing the fixed gateway is
|
||||
# safe here: this launch recreates it.
|
||||
if inspected.returncode == 0:
|
||||
# Migrate the stale auto-IPAM network created by older releases.
|
||||
# Removing the fixed gateway is safe here: this launch recreates it.
|
||||
run_docker(["docker", "rm", "--force", self.name])
|
||||
removed = run_docker(["docker", "network", "rm", self.network])
|
||||
if removed.returncode != 0 and "no such network" not in removed.stderr.lower():
|
||||
if removed.returncode != 0:
|
||||
raise GatewayError(
|
||||
f"gateway network {self.network} needs explicit subnet "
|
||||
f"{self._subnet} but could not be replaced: "
|
||||
|
||||
@@ -166,9 +166,17 @@ def register_agent(
|
||||
# collide with this bottle's — and `by_source_ip` fail-closes on ambiguity,
|
||||
# which would resolve no policy at all and deny every host. Best-effort: a
|
||||
# reconciliation failure must not block an otherwise-fine launch.
|
||||
#
|
||||
# The orchestrator now pulls the live set from the broker itself, so this is
|
||||
# a bare trigger. Until macOS moves onto the host controller (the follow-up
|
||||
# that routes launch through the broker), the orchestrator's broker is the
|
||||
# stub, whose live set is the bottles it recorded launches for — so the sweep
|
||||
# only under-reaps (leaves an orphan a cycle longer), never reaps a healthy
|
||||
# bottle. The Apple-container enumeration below (`live_source_ips`) becomes
|
||||
# that host controller's `list_live` there.
|
||||
try:
|
||||
client.reconcile(live_source_ips(endpoint.network))
|
||||
except (OrchestratorClientError, EnumerationError) as e:
|
||||
client.reconcile()
|
||||
except OrchestratorClientError as e:
|
||||
info(f"registry reconciliation skipped: {e}")
|
||||
reg = provision_bottle(
|
||||
client, source_ip, egress_plan, git_gate_plan, MacosGatewayTransport(),
|
||||
|
||||
@@ -118,7 +118,7 @@ class BottlePreparationPlanner:
|
||||
slug=slug,
|
||||
resolved_env=resolved_env,
|
||||
agent_provision_plan=provision,
|
||||
egress_plan=prepare_egress(manifest, slug, provision),
|
||||
egress_plan=prepare_egress(bottle, slug, provision),
|
||||
git_gate_plan=prepare_git_gate(bottle, slug),
|
||||
supervise_plan=prepare_supervise(bottle, slug),
|
||||
)
|
||||
|
||||
@@ -24,11 +24,10 @@ from ..bottle_state import (
|
||||
supervise_state_dir,
|
||||
write_metadata,
|
||||
)
|
||||
from ..egress import Egress, EgressPlan, egress_forge_routes
|
||||
from ..egress import Egress, EgressPlan
|
||||
from ..git_gate import GitGate, GitGatePlan
|
||||
from ..log import die
|
||||
from ..manifest import Manifest, ManifestBottle
|
||||
from ..manifest.forge import render_forge_guidance
|
||||
from ..supervisor.plan import SupervisePlan
|
||||
from ..orchestrator.supervisor import Supervisor
|
||||
from ..util import slugify
|
||||
@@ -72,21 +71,12 @@ def write_launch_metadata(
|
||||
|
||||
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).
|
||||
|
||||
For repositories associated with a forge, appends generated, non-secret
|
||||
provider-specific workflow guidance to the prompt (PRD
|
||||
0082). The guidance carries neither the
|
||||
token value nor its `token_secret` name."""
|
||||
Returns (agent_dir, prompt_file)."""
|
||||
agent = manifest.agent
|
||||
agent_dir = agent_state_dir(slug)
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
prompt_file = agent_dir / "prompt.txt"
|
||||
prompt = agent.prompt or ""
|
||||
guidance = render_forge_guidance(manifest.forge_associations)
|
||||
if guidance:
|
||||
prompt = f"{prompt.rstrip()}\n\n{guidance}" if prompt.strip() else guidance
|
||||
prompt_file.write_text(prompt)
|
||||
prompt_file.write_text(agent.prompt or "")
|
||||
prompt_file.chmod(0o600)
|
||||
return agent_dir, prompt_file
|
||||
|
||||
@@ -98,18 +88,11 @@ def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan:
|
||||
|
||||
|
||||
def prepare_egress(
|
||||
manifest: Manifest, slug: str, provision: AgentProvisionPlan,
|
||||
bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan,
|
||||
) -> EgressPlan:
|
||||
"""Build the egress plan, adding a scoped, proxy-held Gitea API route for
|
||||
each forge alias referenced by a selected git-gate repo (PRD
|
||||
0082). The token is resolved from the host
|
||||
env at launch and never enters the bottle."""
|
||||
egress_dir = egress_state_dir(slug)
|
||||
egress_dir.mkdir(parents=True, exist_ok=True)
|
||||
forge_routes = egress_forge_routes(manifest.forge_associations)
|
||||
return Egress().prepare(
|
||||
manifest.bottle, slug, egress_dir, provision.egress_routes, forge_routes,
|
||||
)
|
||||
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
|
||||
|
||||
|
||||
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
|
||||
|
||||
@@ -128,7 +128,7 @@ def cmd_start(argv: list[str]) -> int:
|
||||
if not manifest.all_agent_names:
|
||||
print(
|
||||
"bot-bottle: no agents defined. "
|
||||
"Add an agent to ~/.bot-bottle/agents/ to get started.",
|
||||
"Add an agent to ~/.bot-bottle/agents/ or ./bot-bottle/agents/ to get started.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
@@ -383,9 +383,12 @@ def _peek_agent_bottle(manifest: ManifestIndex, agent_name: str) -> str:
|
||||
from ...manifest.loader import scan_agent_names
|
||||
from ...yaml_subset import YamlSubsetError, parse_frontmatter
|
||||
|
||||
# Agents are home-only (PRD 0082).
|
||||
home_agents = scan_agent_names(manifest.home_md / "agents")
|
||||
path = home_agents.get(agent_name)
|
||||
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:
|
||||
@@ -485,19 +488,13 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
|
||||
lines.append(" skills:")
|
||||
for s in agent.skills:
|
||||
lines.append(f" - {s}")
|
||||
if agent.author is not None:
|
||||
lines.append(" author:")
|
||||
lines.append(f" name: {agent.author.name}")
|
||||
lines.append(f" email: {agent.author.email}")
|
||||
if agent.forge_accounts:
|
||||
lines.append(" forge-accounts:")
|
||||
for alias, acct in sorted(agent.forge_accounts.items()):
|
||||
lines.append(f" {alias}:")
|
||||
lines.append(f" url: {acct.url}")
|
||||
lines.append(" auth:")
|
||||
lines.append(f" type: {acct.auth_type}")
|
||||
# token_secret name is host config; show the name, never a value.
|
||||
lines.append(f" token_secret: {acct.token_secret}")
|
||||
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:")
|
||||
@@ -513,14 +510,20 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
|
||||
for k, v in sorted(bottle.env.items()):
|
||||
lines.append(f" {k}: {v}")
|
||||
|
||||
if bottle.git:
|
||||
has_git_gate = not bottle.git_user.is_empty() or bottle.git
|
||||
if has_git_gate:
|
||||
lines.append(" git-gate:")
|
||||
lines.append(" repos:")
|
||||
for entry in bottle.git:
|
||||
lines.append(f" {entry.Name}:")
|
||||
lines.append(f" url: {entry.Upstream}")
|
||||
if entry.Forge:
|
||||
lines.append(f" forge: {entry.Forge}")
|
||||
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:")
|
||||
|
||||
@@ -32,7 +32,6 @@ if TYPE_CHECKING:
|
||||
EGRESS_ROUTES_IN_CONTAINER,
|
||||
Egress,
|
||||
egress_agent_env_entries,
|
||||
egress_forge_routes,
|
||||
egress_gateway_env_entries,
|
||||
egress_manifest_routes,
|
||||
egress_render_routes,
|
||||
@@ -52,7 +51,6 @@ _LAZY: dict[str, str] = {
|
||||
"EGRESS_ROUTES_FILENAME": ".service",
|
||||
"EGRESS_ROUTES_IN_CONTAINER": ".service",
|
||||
"egress_agent_env_entries": ".service",
|
||||
"egress_forge_routes": ".service",
|
||||
"egress_gateway_env_entries": ".service",
|
||||
"egress_manifest_routes": ".service",
|
||||
"egress_render_routes": ".service",
|
||||
@@ -82,7 +80,6 @@ __all__ = [
|
||||
"Egress",
|
||||
"EgressPlan",
|
||||
"EgressRoute",
|
||||
"egress_forge_routes",
|
||||
"egress_manifest_routes",
|
||||
"egress_render_routes",
|
||||
"egress_resolve_token_values",
|
||||
|
||||
@@ -26,7 +26,7 @@ from ..log import die
|
||||
from .plan import EgressPlan, EgressRoute
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..manifest import ManifestBottle, ResolvedForgeAssociation
|
||||
from ..manifest import ManifestBottle
|
||||
|
||||
|
||||
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
|
||||
@@ -119,61 +119,15 @@ def egress_manifest_routes(
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def egress_forge_routes(
|
||||
associations: "tuple[ResolvedForgeAssociation, ...]",
|
||||
) -> tuple[EgressRoute, ...]:
|
||||
"""Synthesize one inspected, token-authenticated egress route per distinct
|
||||
forge alias referenced by a selected git-gate repo (PRD
|
||||
0082).
|
||||
|
||||
The route is scoped to the canonical forge origin (host) and API prefix
|
||||
(`/api/v1`): the proxy injects the Gitea `token` scheme using the value of
|
||||
the host env var named by the account's `token_secret`, resolved at launch
|
||||
from the host environment — the token never enters the bottle. Aliases that
|
||||
canonicalize to the same host share a single route (deduplicated).
|
||||
|
||||
Composition (`resolve_forge_associations`) has already rejected referenced
|
||||
aliases that share a host but disagree on origin/auth/token_secret, so this
|
||||
host-dedup only ever collapses genuinely identical credentials — it never
|
||||
silently discards a distinct one."""
|
||||
out: list[EgressRoute] = []
|
||||
seen_hosts: set[str] = set()
|
||||
for assoc in associations:
|
||||
acct = assoc.account
|
||||
host_key = acct.host.lower()
|
||||
if host_key in seen_hosts:
|
||||
continue
|
||||
seen_hosts.add(host_key)
|
||||
out.append(EgressRoute(
|
||||
host=acct.host,
|
||||
matches=(CoreMatchEntry(
|
||||
paths=(CorePathMatch(type="prefix", value=acct.api_prefix),),
|
||||
),),
|
||||
auth_scheme=acct.auth_type,
|
||||
token_ref=acct.token_secret,
|
||||
inspect=True,
|
||||
))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def egress_routes_for_bottle(
|
||||
bottle: ManifestBottle,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
forge_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> tuple[EgressRoute, ...]:
|
||||
manifest = egress_manifest_routes(bottle)
|
||||
# Provider routes (LLM API) default to redact-on-match; forge routes are
|
||||
# host-injected but keep the default DLP policy. Both take precedence over
|
||||
# a manifest route to the same host.
|
||||
reserved_hosts = (
|
||||
{pr.host.lower() for pr in provider_routes}
|
||||
| {fr.host.lower() for fr in forge_routes}
|
||||
)
|
||||
merged = (
|
||||
list(_default_provider_on_match(provider_routes))
|
||||
+ list(forge_routes)
|
||||
+ [r for r in manifest if r.host.lower() not in reserved_hosts]
|
||||
)
|
||||
provisioned_hosts = {pr.host.lower() for pr in 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)
|
||||
|
||||
|
||||
@@ -413,9 +367,8 @@ class Egress:
|
||||
slug: str,
|
||||
stage_dir: Path,
|
||||
provider_routes: tuple[EgressRoute, ...] = (),
|
||||
forge_routes: tuple[EgressRoute, ...] = (),
|
||||
) -> EgressPlan:
|
||||
routes = egress_routes_for_bottle(bottle, provider_routes, forge_routes)
|
||||
routes = egress_routes_for_bottle(bottle, provider_routes)
|
||||
log = bottle.egress.Log
|
||||
routes_path = stage_dir / EGRESS_ROUTES_FILENAME
|
||||
routes_path.write_text(egress_render_routes(routes, log=log))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Manifest dataclasses (PRD 0011 layout).
|
||||
|
||||
Reads the per-file manifest tree (home-only —
|
||||
PRD 0082):
|
||||
Reads the per-file manifest tree:
|
||||
|
||||
$HOME/.bot-bottle/bottles/<name>.md — one bottle per file
|
||||
$HOME/.bot-bottle/agents/<name>.md — agents
|
||||
$HOME/.bot-bottle/agents/<name>.md — home-resident agents
|
||||
$CWD/.bot-bottle/agents/<name>.md — cwd-supplied agents
|
||||
|
||||
Each file is Markdown with YAML frontmatter. The frontmatter holds
|
||||
the structured config (see schema below); for agents the body is
|
||||
@@ -15,38 +15,27 @@ Bottle schema (frontmatter):
|
||||
extends: <bottle-name> # optional (PRD 0025)
|
||||
env: { <NAME>: <env-entry>, ... }
|
||||
git-gate: # optional (PRD 0047)
|
||||
user: { name: <str>, email: <str> } # optional
|
||||
repos: { <name>: <git-gate-entry>, ... } # optional
|
||||
# git-gate-entry keys: url, key, host_key, forge
|
||||
# `forge`: optional alias into the selected agent's forge-accounts
|
||||
egress: { routes: [ <egress-route>, ... ] }
|
||||
# route keys: host, matches, auth, role, dlp
|
||||
supervise: <bool> # optional (default true)
|
||||
nested_containers: <bool> # optional (default false)
|
||||
|
||||
Agent schema (frontmatter):
|
||||
bottle: <bottle-name> # optional
|
||||
bottle: <bottle-name> # required
|
||||
skills: [ <skill-name>, ... ] # optional
|
||||
author: # optional; agent git identity
|
||||
name: <str> # required when author is present
|
||||
email: <str> # required when author is present
|
||||
forge-accounts: # optional; alias -> forge account
|
||||
<alias>:
|
||||
url: <https Gitea /api/v1 base>
|
||||
auth: { type: token, token_secret: <host env var name> }
|
||||
git-gate:
|
||||
user: { name: <str>, email: <str> } # optional; overlays bottle
|
||||
# Claude Code subagent passthrough fields — accepted, ignored:
|
||||
name, description, model, color, memory
|
||||
|
||||
`author` populates the bottle's git user.name/user.email; `forge-accounts`
|
||||
maps a forge alias to a Gitea API origin plus a host token reference. Identity
|
||||
is agent-owned — `git-gate` is no longer accepted on an agent (git-gate.user
|
||||
moved to `author`; git-gate.repos is bottle-only).
|
||||
|
||||
The agent file's Markdown body is the system prompt (stripped).
|
||||
Unknown top-level frontmatter keys raise ManifestError with a hint.
|
||||
|
||||
Both bottles and agents can ONLY live under $HOME. An agents/ or 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.
|
||||
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.
|
||||
|
||||
Two types are exported:
|
||||
|
||||
@@ -77,11 +66,6 @@ if TYPE_CHECKING:
|
||||
from .agent import ManifestAgent, ManifestAgentProvider
|
||||
from .bottle import ManifestBottle
|
||||
from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute
|
||||
from .forge import (
|
||||
ManifestAuthor,
|
||||
ManifestForgeAccount,
|
||||
ResolvedForgeAssociation,
|
||||
)
|
||||
from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
|
||||
|
||||
|
||||
@@ -97,9 +81,6 @@ _LAZY_MODULES: dict[str, str] = {
|
||||
"EGRESS_AUTH_SCHEMES": "egress",
|
||||
"ManifestEgressRoute": "egress",
|
||||
"ManifestEgressConfig": "egress",
|
||||
"ManifestAuthor": "forge",
|
||||
"ManifestForgeAccount": "forge",
|
||||
"ResolvedForgeAssociation": "forge",
|
||||
"ManifestGitEntry": "git",
|
||||
"ManifestGitUser": "git",
|
||||
"ManifestKeyConfig": "git",
|
||||
@@ -134,7 +115,4 @@ __all__ = [
|
||||
"EGRESS_AUTH_SCHEMES",
|
||||
"ManifestEgressRoute",
|
||||
"ManifestEgressConfig",
|
||||
"ManifestAuthor",
|
||||
"ManifestForgeAccount",
|
||||
"ResolvedForgeAssociation",
|
||||
]
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Mapping, cast
|
||||
from typing import cast
|
||||
|
||||
from ..agent_provider import PROVIDER_TEMPLATES
|
||||
from .util import ManifestError, as_json_object
|
||||
from .forge import ManifestAuthor, ManifestForgeAccount
|
||||
from .git import ManifestGitUser
|
||||
from .schema import AGENT_MODEL_KEYS, is_valid_entity_name
|
||||
|
||||
|
||||
@@ -119,29 +119,15 @@ class ManifestAgent:
|
||||
bottle: str = ""
|
||||
skills: tuple[str, ...] = ()
|
||||
prompt: str = ""
|
||||
# Agent-owned identity (PRD 0082).
|
||||
# `author` populates the bottle's git user.name/user.email;
|
||||
# `forge_accounts` maps a forge alias to a canonical Gitea API origin and
|
||||
# a host token reference. Both live only on the agent — never under
|
||||
# `git-gate`, which is bottle-only transport policy.
|
||||
author: ManifestAuthor | None = None
|
||||
forge_accounts: Mapping[str, ManifestForgeAccount] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
# Per-agent git identity (issue #94). Overlays the referenced
|
||||
# bottle's git-gate.user per-field at `Manifest.bottle_for`. Only
|
||||
# `user` is allowed at the agent level; `repos` stays bottle-only
|
||||
# because it carries credentials and host trust.
|
||||
git_user: ManifestGitUser = ManifestGitUser()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent":
|
||||
d = as_json_object(raw, f"agent '{name}'")
|
||||
# git-gate is no longer accepted on an agent (checked before the
|
||||
# generic unknown-key error so the migration pointer is surfaced):
|
||||
# identity moved to `author`, and git-gate.repos is bottle-only.
|
||||
if "git-gate" in d:
|
||||
raise ManifestError(
|
||||
f"agent '{name}' has a 'git-gate' block, which is no longer "
|
||||
f"accepted on an agent (PRD 0082). "
|
||||
f"Move git-gate.user name/email into the 'author' block; "
|
||||
f"git-gate.repos stays on the bottle."
|
||||
)
|
||||
unknown = set(d.keys()) - AGENT_MODEL_KEYS
|
||||
if unknown:
|
||||
allowed = ", ".join(sorted(AGENT_MODEL_KEYS))
|
||||
@@ -205,30 +191,24 @@ class ManifestAgent:
|
||||
f"(was {type(prompt_raw).__name__})"
|
||||
)
|
||||
|
||||
# author: agent-owned git identity (optional; both fields required
|
||||
# when present). Populates the bottle's user.name/user.email.
|
||||
author = (
|
||||
ManifestAuthor.from_dict(name, d["author"])
|
||||
if "author" in d else None
|
||||
)
|
||||
# git-gate: agents may declare only `git-gate.user` (name/email).
|
||||
# `git-gate.repos` is bottle-only — it carries credentials and host trust.
|
||||
git_user = ManifestGitUser()
|
||||
git_raw = d.get("git-gate")
|
||||
if git_raw is not None:
|
||||
gd = as_json_object(git_raw, f"agent '{name}' git-gate")
|
||||
for k in gd:
|
||||
if k != "user":
|
||||
raise ManifestError(
|
||||
f"agent '{name}' git-gate.{k} is not allowed at the "
|
||||
f"agent level; only git-gate.user (name/email) may be "
|
||||
f"set on an agent. git-gate.repos is bottle-only "
|
||||
f"(it carries credentials and host trust)."
|
||||
)
|
||||
if "user" in gd:
|
||||
git_user = ManifestGitUser.from_dict(name, gd["user"])
|
||||
|
||||
# forge-accounts: alias -> Gitea API origin + host token reference.
|
||||
forge_accounts: dict[str, ManifestForgeAccount] = {}
|
||||
forge_raw = d.get("forge-accounts")
|
||||
if forge_raw is not None:
|
||||
forge_d = as_json_object(forge_raw, f"agent '{name}' forge-accounts")
|
||||
for alias, entry in forge_d.items():
|
||||
forge_accounts[alias] = ManifestForgeAccount.from_dict(
|
||||
name, alias, entry,
|
||||
)
|
||||
|
||||
return cls(
|
||||
bottle=bottle,
|
||||
skills=skills,
|
||||
prompt=prompt,
|
||||
author=author,
|
||||
forge_accounts=forge_accounts,
|
||||
)
|
||||
return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user)
|
||||
|
||||
|
||||
def _parse_provider_settings(
|
||||
|
||||
@@ -107,14 +107,11 @@ class ManifestBottle:
|
||||
)
|
||||
env[var] = value
|
||||
|
||||
# `git_user` is now an internal resolved carrier populated from the
|
||||
# selected agent's `author` at composition time — it is never parsed
|
||||
# from the bottle manifest (PRD 0082).
|
||||
git: tuple[ManifestGitEntry, ...] = ()
|
||||
git_user = ManifestGitUser()
|
||||
git_raw = d.get("git-gate")
|
||||
if git_raw is not None:
|
||||
git = parse_git_gate_config(name, git_raw)
|
||||
git, git_user = parse_git_gate_config(name, git_raw)
|
||||
|
||||
agent_provider = (
|
||||
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
||||
|
||||
@@ -210,7 +210,7 @@ def _fold_two_bottles(
|
||||
for n in names
|
||||
}
|
||||
if merged_repos_raw:
|
||||
merged_git = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
|
||||
merged_git, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
|
||||
else:
|
||||
merged_git = ()
|
||||
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
"""Agent-owned author identity and forge accounts (PRD 0082).
|
||||
|
||||
`ManifestAuthor` is the agent's git author identity (name/email) — it moved off
|
||||
`git-gate.user` (PRD 0027 / ADR 0002) and onto the trusted agent definition.
|
||||
|
||||
`ManifestForgeAccount` maps a **forge alias** to a canonical HTTPS Gitea API
|
||||
origin plus the *name* of a host env var holding the operator-provided API
|
||||
token. The token value never lives on the manifest; the host resolves it only
|
||||
when a selected bottle repository references the alias, and hands it to the
|
||||
egress proxy — never to the bottle.
|
||||
|
||||
`ResolvedForgeAssociation` is the composed view: one distinct forge alias that
|
||||
at least one selected git-gate repository points at, with the repository names
|
||||
that reference it. The proxy-provisioning and prompt-guidance steps consume it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .schema import is_valid_entity_name
|
||||
from .util import ManifestError, as_json_object
|
||||
|
||||
# Only the Gitea `/api/v1` base is supported today. A future provider adds a
|
||||
# validator + auth scheme + guidance renderer in code rather than accepting a
|
||||
# repository-supplied prompt or path (PRD non-goal: provider-generic prompts).
|
||||
GITEA_API_PREFIX = "/api/v1"
|
||||
|
||||
# Auth schemes an agent forge account may declare. Gitea uses `token`.
|
||||
FORGE_AUTH_TYPES = ("token",)
|
||||
|
||||
|
||||
def canonicalize_forge_url(
|
||||
agent_name: str, alias: str, url: object,
|
||||
) -> tuple[str, str, str, str]:
|
||||
"""Validate and canonicalize a forge account's API base URL.
|
||||
|
||||
Returns `(canonical, origin, host, api_prefix)` where `origin` is
|
||||
`https://host[:port]` and `canonical` is `origin + api_prefix`.
|
||||
|
||||
Fails closed on: non-string, non-`https`, embedded userinfo, query, or
|
||||
fragment, a missing host, or any path other than the supported Gitea API
|
||||
base (`/api/v1`, trailing slash tolerated)."""
|
||||
label = f"agent '{agent_name}' forge-accounts.{alias}.url"
|
||||
if not isinstance(url, str) or not url:
|
||||
raise ManifestError(f"{label} is required (non-empty string)")
|
||||
try:
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
except ValueError as e:
|
||||
raise ManifestError(f"{label} is not a valid URL ({e}): {url!r}") from e
|
||||
|
||||
if parts.scheme != "https":
|
||||
raise ManifestError(
|
||||
f"{label} must use https (got scheme {parts.scheme!r} in {url!r})"
|
||||
)
|
||||
if parts.username or parts.password:
|
||||
raise ManifestError(
|
||||
f"{label} must not embed userinfo (user:pass@); the token is held "
|
||||
f"host-side via auth.token_secret. Got {url!r}"
|
||||
)
|
||||
if parts.query:
|
||||
raise ManifestError(f"{label} must not contain a query string: {url!r}")
|
||||
if parts.fragment:
|
||||
raise ManifestError(f"{label} must not contain a fragment: {url!r}")
|
||||
|
||||
host = parts.hostname
|
||||
if not host:
|
||||
raise ManifestError(f"{label} must include a hostname: {url!r}")
|
||||
|
||||
path = parts.path.rstrip("/")
|
||||
if path != GITEA_API_PREFIX:
|
||||
raise ManifestError(
|
||||
f"{label} path must be the Gitea API base '{GITEA_API_PREFIX}' "
|
||||
f"(got {parts.path!r} in {url!r}); other providers and API paths "
|
||||
f"are not yet supported."
|
||||
)
|
||||
|
||||
host = host.lower()
|
||||
netloc = host if parts.port is None else f"{host}:{parts.port}"
|
||||
origin = f"https://{netloc}"
|
||||
return f"{origin}{path}", origin, host, path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestAuthor:
|
||||
"""The agent's git author identity. Both fields are required and
|
||||
non-empty — an author that is declared must fully identify the agent.
|
||||
Populates `user.name` / `user.email` inside the bottle."""
|
||||
|
||||
name: str
|
||||
email: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, agent_name: str, raw: object) -> "ManifestAuthor":
|
||||
d = as_json_object(raw, f"agent '{agent_name}' author")
|
||||
for k in d:
|
||||
if k not in {"name", "email"}:
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' author has unknown key {k!r}; "
|
||||
f"allowed: name, email"
|
||||
)
|
||||
name = d.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' author.name must be a non-empty string"
|
||||
)
|
||||
email = d.get("email")
|
||||
if not isinstance(email, str) or not email:
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' author.email must be a non-empty string"
|
||||
)
|
||||
# Git identity validation: reject values that would break the
|
||||
# `git config user.email` line or smuggle a second config directive.
|
||||
if any(c in email for c in ("\n", "\r", " ", "\t")):
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' author.email must not contain whitespace "
|
||||
f"or newlines (got {email!r})"
|
||||
)
|
||||
if any(c in name for c in ("\n", "\r")):
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' author.name must not contain newlines "
|
||||
f"(got {name!r})"
|
||||
)
|
||||
return cls(name=name, email=email)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestForgeAccount:
|
||||
"""One forge alias on an agent: a canonical Gitea API origin plus the
|
||||
host env var name that holds the agent-specific API token. The
|
||||
authenticated account is whoever owns that token — there is no separate
|
||||
account-name field."""
|
||||
|
||||
alias: str
|
||||
url: str # canonical https base incl. /api/v1, no trailing slash
|
||||
origin: str # https://host[:port]
|
||||
host: str # lowercased hostname
|
||||
api_prefix: str # /api/v1
|
||||
auth_type: str # "token"
|
||||
token_secret: str # host env var name holding the API token
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, agent_name: str, alias: str, raw: object,
|
||||
) -> "ManifestForgeAccount":
|
||||
if not is_valid_entity_name(alias):
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' forge-accounts key {alias!r} is not a "
|
||||
f"valid alias; must match [a-z][a-z0-9-]*"
|
||||
)
|
||||
label = f"agent '{agent_name}' forge-accounts.{alias}"
|
||||
d = as_json_object(raw, label)
|
||||
for k in d:
|
||||
if k not in {"url", "auth"}:
|
||||
raise ManifestError(
|
||||
f"{label} has unknown key {k!r}; allowed: url, auth"
|
||||
)
|
||||
|
||||
canonical, origin, host, api_prefix = canonicalize_forge_url(
|
||||
agent_name, alias, d.get("url"),
|
||||
)
|
||||
|
||||
if "auth" not in d:
|
||||
raise ManifestError(f"{label} missing required 'auth' block")
|
||||
auth = as_json_object(d.get("auth"), f"{label}.auth")
|
||||
for k in auth:
|
||||
if k not in {"type", "token_secret"}:
|
||||
raise ManifestError(
|
||||
f"{label}.auth has unknown key {k!r}; allowed: type, "
|
||||
f"token_secret"
|
||||
)
|
||||
auth_type = auth.get("type")
|
||||
if auth_type not in FORGE_AUTH_TYPES:
|
||||
raise ManifestError(
|
||||
f"{label}.auth.type must be one of "
|
||||
f"{', '.join(FORGE_AUTH_TYPES)} (got {auth_type!r})"
|
||||
)
|
||||
token_secret = auth.get("token_secret")
|
||||
if not isinstance(token_secret, str) or not token_secret:
|
||||
raise ManifestError(
|
||||
f"{label}.auth.token_secret must be a non-empty string (the "
|
||||
f"name of a host env var holding the API token)"
|
||||
)
|
||||
return cls(
|
||||
alias=alias,
|
||||
url=canonical,
|
||||
origin=origin,
|
||||
host=host,
|
||||
api_prefix=api_prefix,
|
||||
auth_type=str(auth_type),
|
||||
token_secret=token_secret,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedForgeAssociation:
|
||||
"""A distinct forge alias referenced by one or more selected git-gate
|
||||
repositories. `repo_names` lists the git-gate repo names pointing at
|
||||
`account.alias` (sorted, deduplicated)."""
|
||||
|
||||
account: ManifestForgeAccount
|
||||
repo_names: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def alias(self) -> str:
|
||||
return self.account.alias
|
||||
|
||||
|
||||
def resolve_forge_associations(
|
||||
agent_name: str,
|
||||
forge_accounts: "dict[str, ManifestForgeAccount]",
|
||||
git_entries: "tuple[object, ...]",
|
||||
) -> tuple[ResolvedForgeAssociation, ...]:
|
||||
"""Compose the agent's forge accounts with the effective bottle's
|
||||
git-gate repos. Each git entry that declares a `forge` alias must match a
|
||||
forge account on the selected agent — otherwise fail closed before launch.
|
||||
|
||||
Returns one association per distinct referenced alias, carrying the repo
|
||||
names that reference it. Unreferenced accounts produce nothing (no token
|
||||
is resolved and no route is created for them)."""
|
||||
by_alias: dict[str, list[str]] = {}
|
||||
for entry in git_entries:
|
||||
alias = getattr(entry, "Forge", "")
|
||||
if not alias:
|
||||
continue
|
||||
if alias not in forge_accounts:
|
||||
available = ", ".join(sorted(forge_accounts)) or "(none)"
|
||||
raise ManifestError(
|
||||
f"git-gate.repos['{getattr(entry, 'Name', '?')}'].forge "
|
||||
f"references forge alias {alias!r}, which is not defined on "
|
||||
f"agent '{agent_name}'. Available forge-accounts: {available}"
|
||||
)
|
||||
by_alias.setdefault(alias, []).append(getattr(entry, "Name", ""))
|
||||
|
||||
associations = tuple(
|
||||
ResolvedForgeAssociation(
|
||||
account=forge_accounts[alias],
|
||||
repo_names=tuple(sorted(set(names))),
|
||||
)
|
||||
for alias, names in sorted(by_alias.items())
|
||||
)
|
||||
|
||||
# The egress proxy routes by host, so two referenced aliases that resolve
|
||||
# to the same host must agree on the credential and target — otherwise the
|
||||
# generated plan would authenticate every call to that host as whichever
|
||||
# alias happened to win, silently acting as the wrong forge account. Fail
|
||||
# closed here (before the bottle is created) rather than dropping a
|
||||
# credential at route-synthesis time.
|
||||
by_host: dict[str, ManifestForgeAccount] = {}
|
||||
for assoc in associations:
|
||||
acct = assoc.account
|
||||
prev = by_host.get(acct.host)
|
||||
if prev is None:
|
||||
by_host[acct.host] = acct
|
||||
continue
|
||||
if (prev.origin, prev.api_prefix, prev.auth_type, prev.token_secret) != (
|
||||
acct.origin, acct.api_prefix, acct.auth_type, acct.token_secret
|
||||
):
|
||||
raise ManifestError(
|
||||
f"agent '{agent_name}' forge-accounts '{prev.alias}' and "
|
||||
f"'{acct.alias}' both resolve to host '{acct.host}' but differ "
|
||||
f"in origin/auth/token_secret. The egress proxy routes by host, "
|
||||
f"so the intended credential would be ambiguous — every request "
|
||||
f"to '{acct.host}' would authenticate as one account. Give the "
|
||||
f"aliases distinct hosts, or make their url/auth identical."
|
||||
)
|
||||
|
||||
return associations
|
||||
|
||||
|
||||
def render_forge_guidance(
|
||||
associations: tuple[ResolvedForgeAssociation, ...],
|
||||
) -> str:
|
||||
"""Render the non-secret, provider-specific forge workflow guidance
|
||||
appended to the agent's system prompt for associated repositories only.
|
||||
|
||||
Derived entirely from validated typed fields — never repository-supplied
|
||||
Markdown. Contains neither the token value nor its `token_secret` name.
|
||||
Returns "" when there are no associations (no section is generated)."""
|
||||
if not associations:
|
||||
return ""
|
||||
lines: list[str] = [
|
||||
"## Forge access (managed by bot-bottle)",
|
||||
"",
|
||||
"bot-bottle authenticates the forge API requests below for you "
|
||||
"through the egress proxy. Do not read, print, or manually attach "
|
||||
"an authorization token — the proxy injects it. Never place a token "
|
||||
"in a request.",
|
||||
]
|
||||
for assoc in associations:
|
||||
acct = assoc.account
|
||||
for repo in assoc.repo_names:
|
||||
lines.extend([
|
||||
"",
|
||||
f"### Repository `{repo}` (forge alias `{acct.alias}`)",
|
||||
f"- Forge API base URL: `{acct.url}`.",
|
||||
f"- This git-gate repository (`{repo}`) is tied to forge "
|
||||
f"`{acct.alias}`; use its API for forge actions on this repo.",
|
||||
f"- Call the API at `{acct.url}` over HTTPS through the proxy. "
|
||||
"The proxy adds authentication; you never supply a token "
|
||||
"yourself.",
|
||||
"- Git pushes still use the git-gate remote, not the API.",
|
||||
"- To propose changes: push a normal branch "
|
||||
"(`refs/heads/<branch>`) through the git-gate remote, then open "
|
||||
"a branch-backed pull request through the API.",
|
||||
"- Do NOT push AGit review refs (`refs/for/*`, `refs/draft/*`, "
|
||||
"`refs/for-review/*`); they are prohibited here.",
|
||||
"- Use the API for reviews and comments, and verify the "
|
||||
"returned object state before claiming the task is complete.",
|
||||
])
|
||||
return "\n".join(lines) + "\n"
|
||||
+12
-38
@@ -117,11 +117,6 @@ class ManifestGitEntry:
|
||||
UpstreamHost: str = ""
|
||||
UpstreamPort: str = ""
|
||||
UpstreamPath: str = ""
|
||||
# Optional forge alias (PRD 0082). When
|
||||
# set, it must match a `forge-accounts` alias on the selected agent; the
|
||||
# composition enables a scoped proxy-held API credential and forge
|
||||
# workflow guidance for this repo. Empty = no forge association.
|
||||
Forge: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_repos_entry(
|
||||
@@ -144,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", "key", "host_key", "forge"}:
|
||||
if k not in {"url", "key", "host_key"}:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
|
||||
f"allowed: url, key, host_key, forge"
|
||||
f"allowed: url, key, host_key"
|
||||
)
|
||||
upstream = d.get("url")
|
||||
if not isinstance(upstream, str) or not upstream:
|
||||
@@ -155,21 +150,6 @@ class ManifestGitEntry:
|
||||
f"bottle '{bottle_name}' {label} missing required string field 'url'"
|
||||
)
|
||||
|
||||
forge = d.get("forge", "")
|
||||
if not isinstance(forge, str):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label} forge must be a string "
|
||||
f"(was {type(forge).__name__})"
|
||||
)
|
||||
if forge and not _GIT_NAME_RE.match(forge):
|
||||
# forge aliases follow the kebab-case identifier grammar; the
|
||||
# cross-check against the agent's forge-accounts happens at
|
||||
# composition time (it needs the resolved agent).
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label} forge {forge!r} is not a "
|
||||
f"valid forge alias; allowed characters: A-Z a-z 0-9 . _ -"
|
||||
)
|
||||
|
||||
if "key" not in d:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' {label} missing required 'key' block"
|
||||
@@ -196,7 +176,6 @@ class ManifestGitEntry:
|
||||
UpstreamHost=host,
|
||||
UpstreamPort=port,
|
||||
UpstreamPath=path,
|
||||
Forge=forge,
|
||||
)
|
||||
|
||||
|
||||
@@ -307,26 +286,21 @@ class ManifestGitUser:
|
||||
def parse_git_gate_config(
|
||||
bottle_name: str,
|
||||
raw: object,
|
||||
) -> tuple[ManifestGitEntry, ...]:
|
||||
"""Parse `git-gate` on a bottle. Only `repos` is accepted; `git-gate.user`
|
||||
moved to the agent's `author` block (PRD 0082)."""
|
||||
) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
|
||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
|
||||
if "user" in d:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' git-gate.user is no longer supported "
|
||||
f"(PRD 0082). Move name/email into "
|
||||
f"the selected home agent's 'author' block:\n"
|
||||
f" author:\n name: <name>\n email: <email>\n"
|
||||
f"Identity is agent-owned; git-gate now carries only transport "
|
||||
f"policy (repos)."
|
||||
)
|
||||
for k in d:
|
||||
if k != "repos":
|
||||
if k not in {"user", "repos"}:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
|
||||
f"allowed: repos"
|
||||
f"allowed: user, repos"
|
||||
)
|
||||
|
||||
git_user = (
|
||||
ManifestGitUser.from_dict(bottle_name, d["user"])
|
||||
if "user" in d
|
||||
else ManifestGitUser()
|
||||
)
|
||||
|
||||
git: tuple[ManifestGitEntry, ...] = ()
|
||||
repos_raw = d.get("repos")
|
||||
if repos_raw is not None:
|
||||
@@ -337,4 +311,4 @@ def parse_git_gate_config(
|
||||
)
|
||||
validate_unique_git_names(bottle_name, git)
|
||||
|
||||
return git
|
||||
return git, git_user
|
||||
|
||||
@@ -19,7 +19,6 @@ from .util import ManifestError, as_json_object
|
||||
from .agent import ManifestAgent
|
||||
from .bottle import ManifestBottle
|
||||
from .extends import merge_bottles_runtime, resolve_bottles
|
||||
from .forge import ResolvedForgeAssociation, resolve_forge_associations
|
||||
from .git import ManifestGitUser
|
||||
from .loader import (
|
||||
check_stale_json,
|
||||
@@ -38,50 +37,30 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
|
||||
return as_json_object(value, label)
|
||||
|
||||
|
||||
def _warn_ignored_cwd_dir(cwd_dir: Path, kind: str, home_path: str) -> None:
|
||||
"""Warn (once) that manifest files of `kind` under `$CWD/.bot-bottle/`
|
||||
are ignored — the filesystem layout IS the trust boundary. `kind` is the
|
||||
subdir name (`bottles`/`agents`); `home_path` is where they belong."""
|
||||
stale = cwd_dir / kind
|
||||
if not stale.is_dir():
|
||||
return
|
||||
files = sorted(stale.glob("*.md"))
|
||||
if not files:
|
||||
return
|
||||
names = ", ".join(p.name for p in files)
|
||||
warn(
|
||||
f"ignoring {kind[:-1]} file(s) under {stale}: {names}. "
|
||||
f"{kind.capitalize()} can only live under {home_path} "
|
||||
f"(PRD 0082). Move them or delete."
|
||||
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 _compose_manifest(
|
||||
agent_name: str,
|
||||
agent: "ManifestAgent",
|
||||
raw_bottle: "ManifestBottle",
|
||||
def _manifest_with_merged_git_user(
|
||||
agent: "ManifestAgent", raw_bottle: "ManifestBottle"
|
||||
) -> "Manifest":
|
||||
"""Build the single-value Manifest from the selected agent and its
|
||||
effective bottle (PRD 0082):
|
||||
|
||||
- the agent's `author` populates the bottle's git user.name/user.email;
|
||||
- each git-gate repo's `forge` alias is resolved against the agent's
|
||||
`forge-accounts` (failing closed on an unknown alias) into the
|
||||
Manifest's forge associations.
|
||||
|
||||
Shared by the eager (from_json_obj) and lazy (from_md_dirs) paths."""
|
||||
identity = (
|
||||
ManifestGitUser(name=agent.author.name, email=agent.author.email)
|
||||
if agent.author is not None else ManifestGitUser()
|
||||
)
|
||||
"""Build the single-value Manifest, overlaying the agent's git-gate.user
|
||||
onto the bottle (agent wins on non-empty, per-field). Shared by the eager
|
||||
and lazy load_for_agent paths."""
|
||||
merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
|
||||
bottle = (
|
||||
raw_bottle if identity == raw_bottle.git_user
|
||||
else replace(raw_bottle, git_user=identity)
|
||||
raw_bottle if merged == raw_bottle.git_user
|
||||
else replace(raw_bottle, git_user=merged)
|
||||
)
|
||||
associations = resolve_forge_associations(
|
||||
agent_name, dict(agent.forge_accounts), bottle.git,
|
||||
)
|
||||
return Manifest(agent=agent, bottle=bottle, forge_associations=associations)
|
||||
return Manifest(agent=agent, bottle=bottle)
|
||||
|
||||
|
||||
def _resolve_effective_bottle_eager(
|
||||
@@ -142,28 +121,26 @@ def _resolve_effective_bottle_lazy(
|
||||
class Manifest:
|
||||
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
|
||||
|
||||
`bottle` is the effective bottle with the agent's `author` already
|
||||
populated into its git identity. `forge_associations` holds the distinct
|
||||
forge aliases referenced by the effective bottle's git-gate repos, resolved
|
||||
against the agent's `forge-accounts`. Backends and provisioners use this
|
||||
directly — no agent_name lookup needed."""
|
||||
`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
|
||||
forge_associations: tuple[ResolvedForgeAssociation, ...] = ()
|
||||
|
||||
def git_identity_summary(self) -> str | None:
|
||||
"""One-line effective git identity, e.g.
|
||||
`name=claude, email=eric@dideric.is`. Sourced from the agent's
|
||||
`author` block. Returns None when the agent declares no author."""
|
||||
gu = self.bottle.git_user
|
||||
if gu.is_empty():
|
||||
"""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 gu.name:
|
||||
parts.append(f"name={gu.name}")
|
||||
if gu.email:
|
||||
parts.append(f"email={gu.email}")
|
||||
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)
|
||||
|
||||
|
||||
@@ -187,15 +164,15 @@ class ManifestIndex:
|
||||
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex":
|
||||
"""Walk the per-file manifest tree and build a ManifestIndex.
|
||||
|
||||
Layout:
|
||||
Layout (PRD 0011):
|
||||
$HOME/.bot-bottle/bottles/<name>.md — bottles (home-only)
|
||||
$HOME/.bot-bottle/agents/<name>.md — agents (home-only)
|
||||
$HOME/.bot-bottle/agents/<name>.md — home agents
|
||||
$CWD/.bot-bottle/agents/<name>.md — cwd agents
|
||||
|
||||
Both agents and bottles are home-only
|
||||
(PRD 0082): a `bottles/` or `agents/`
|
||||
subdir under $CWD is logged as a warning and ignored — the filesystem
|
||||
layout IS the trust boundary, since an agent may now select a host
|
||||
identity and forge secret.
|
||||
Cwd agents merge into the home agents on the same name
|
||||
(cwd wins). A bottles/ subdir under $CWD is logged as a
|
||||
warning and ignored — the filesystem layout IS the trust
|
||||
boundary.
|
||||
|
||||
If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
|
||||
returns an empty index instead of dying. This is for
|
||||
@@ -246,12 +223,17 @@ class ManifestIndex:
|
||||
Used by tests to build a ManifestIndex from fixture directories
|
||||
without touching `os.environ`."""
|
||||
if cwd_dir is not None:
|
||||
_warn_ignored_cwd_dir(cwd_dir, "bottles", "$HOME/.bot-bottle/bottles/")
|
||||
# Agents became home-only in
|
||||
# PRD 0082: a cwd agent file that
|
||||
# once shadowed a home agent could select a host identity/secret,
|
||||
# so it is now ignored with a migration pointer.
|
||||
_warn_ignored_cwd_dir(cwd_dir, "agents", "$HOME/.bot-bottle/agents/")
|
||||
stale_bottles = cwd_dir / "bottles"
|
||||
if stale_bottles.is_dir():
|
||||
files = sorted(stale_bottles.glob("*.md"))
|
||||
if files:
|
||||
names = ", ".join(p.name for p in files)
|
||||
warn(
|
||||
f"ignoring bottle file(s) under "
|
||||
f"{stale_bottles}: {names}. Bottles can only "
|
||||
f"live under $HOME/.bot-bottle/bottles/ "
|
||||
f"(PRD 0011). Move them or delete."
|
||||
)
|
||||
return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
|
||||
|
||||
@classmethod
|
||||
@@ -293,12 +275,13 @@ class ManifestIndex:
|
||||
|
||||
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.
|
||||
|
||||
Agents are home-only (PRD 0082): cwd
|
||||
agent files never contribute names."""
|
||||
from_json_obj) it returns the pre-parsed agents' names."""
|
||||
if self.home_md is not None:
|
||||
return sorted(scan_agent_names(self.home_md / "agents").keys())
|
||||
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(
|
||||
@@ -343,7 +326,7 @@ class ManifestIndex:
|
||||
raw_bottle = _resolve_effective_bottle_eager(
|
||||
agent_name, agent, bottle_names, self.bottles
|
||||
)
|
||||
return _compose_manifest(agent_name, agent, raw_bottle)
|
||||
return _manifest_with_merged_git_user(agent, raw_bottle)
|
||||
|
||||
def _load_for_agent_lazy(
|
||||
self, agent_name: str, bottle_names: tuple[str, ...]
|
||||
@@ -351,17 +334,20 @@ class ManifestIndex:
|
||||
"""Lazy path (resolve/from_md_dirs): read and parse the agent file and
|
||||
its bottle chain from disk for the first time here."""
|
||||
assert self.home_md is not None # guaranteed by load_for_agent dispatch
|
||||
# Agents are home-only (PRD 0082):
|
||||
# a cwd agent file must not select a host identity or forge secret.
|
||||
# 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 home_agents:
|
||||
available = ", ".join(sorted(home_agents.keys())) or "(none)"
|
||||
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 = home_agents[agent_name]
|
||||
agent_path = merged_agents[agent_name]
|
||||
try:
|
||||
fm, body = parse_frontmatter(agent_path.read_text())
|
||||
except OSError as e:
|
||||
@@ -388,18 +374,15 @@ class ManifestIndex:
|
||||
}
|
||||
if agent_bottle:
|
||||
agent_dict["bottle"] = agent_bottle
|
||||
# Surface agent-owned identity keys (and any stale git-gate, so
|
||||
# ManifestAgent.from_dict raises the migration error).
|
||||
for key in ("author", "forge-accounts", "git-gate"):
|
||||
if key in fm:
|
||||
agent_dict[key] = fm[key]
|
||||
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)
|
||||
|
||||
return _compose_manifest(agent_name, agent, raw_bottle)
|
||||
return _manifest_with_merged_git_user(agent, raw_bottle)
|
||||
|
||||
def has_agent(self, name: str) -> bool:
|
||||
return name in self.agents
|
||||
@@ -411,9 +394,13 @@ class ManifestIndex:
|
||||
if self.has_agent(name):
|
||||
return
|
||||
if self.home_md is not None:
|
||||
# Names-only mode: check home file existence without parsing.
|
||||
# Agents are home-only; a cwd agent file is never selectable.
|
||||
if (self.home_md / "agents" / f"{name}.md").is_file():
|
||||
# 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
|
||||
)
|
||||
if home_path.is_file() or (cwd_path and cwd_path.is_file()):
|
||||
return
|
||||
available = ", ".join(self.all_agent_names) or "(none)"
|
||||
raise ManifestError(
|
||||
|
||||
@@ -22,10 +22,7 @@ BOTTLE_KEYS = frozenset(
|
||||
}
|
||||
)
|
||||
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
|
||||
# `author` / `forge-accounts` are agent-owned identity (PRD
|
||||
# 0082). `git-gate` is no longer accepted on an
|
||||
# agent: `git-gate.user` moved to `author`, and `git-gate.repos` is bottle-only.
|
||||
AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "author", "forge-accounts"})
|
||||
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
|
||||
|
||||
@@ -17,7 +17,10 @@ from pathlib import Path
|
||||
|
||||
from .. import log
|
||||
from .store.store_manager import StoreManager
|
||||
from .broker import LaunchBroker, StubBroker
|
||||
from ..paths import LAUNCH_BROKER_KEY_ENV
|
||||
from .broker import StubBroker, SubmitBroker
|
||||
from .broker_client import BrokerClient
|
||||
from .host_server import DEFAULT_PORT, broker_secret
|
||||
from .server import make_server
|
||||
from .docker_broker import DockerBroker
|
||||
from .store.registry_store import RegistryStore, default_db_path
|
||||
@@ -34,8 +37,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
help=f"registry DB path (default: {default_db_path()})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--broker", choices=("stub", "docker"), default="stub",
|
||||
help="launch broker: 'stub' records requests; 'docker' runs containers",
|
||||
"--broker", choices=("stub", "docker", "http"), default="stub",
|
||||
help="launch broker: 'stub' records requests; 'docker' runs containers "
|
||||
"in-process; 'http' relays signed requests to a host control server",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host-controller-url", default=f"http://127.0.0.1:{DEFAULT_PORT}",
|
||||
help="host control server URL (used only with --broker http)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
@@ -47,11 +55,27 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# operator reaches it over HTTP (never a second, disconnected DB).
|
||||
StoreManager(registry.db_path).migrate()
|
||||
|
||||
# An ephemeral signing secret ties the orchestrator (signer) to its
|
||||
# broker (verifier). 'stub' records launches instead of starting
|
||||
# anything; 'docker' runs real containers (firecracker drops in later).
|
||||
secret = secrets.token_bytes(32)
|
||||
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
|
||||
# A signing secret ties the orchestrator (signer) to its broker (verifier).
|
||||
# 'stub' records launches instead of starting anything; 'docker' runs real
|
||||
# containers in-process; 'http' relays signed requests to a separate host
|
||||
# control server, which verifies and launches. For 'stub'/'docker' the secret
|
||||
# is ephemeral (signer and verifier share this process). For 'http' it must be
|
||||
# the SAME key the host controller holds — and this process is the *guest*
|
||||
# (signer), so it must be given that key by injection, NOT mint its own
|
||||
# process-local one (which would diverge from the host's and 401 every launch).
|
||||
broker: SubmitBroker
|
||||
if args.broker == "http":
|
||||
secret = broker_secret() # env-injected only; no host-file fallback here
|
||||
if secret is None:
|
||||
parser.error(
|
||||
f"--broker http requires the launch-broker key injected as "
|
||||
f"${LAUNCH_BROKER_KEY_ENV} (the host controller owns/mints it); the "
|
||||
"orchestrator must not mint its own or it would diverge from the host's"
|
||||
)
|
||||
broker = BrokerClient(args.host_controller_url)
|
||||
else:
|
||||
secret = secrets.token_bytes(32)
|
||||
broker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret)
|
||||
orchestrator = OrchestratorCore(registry, broker, secret)
|
||||
|
||||
server = make_server(orchestrator, host=args.host, port=args.port)
|
||||
|
||||
@@ -29,29 +29,71 @@ import json
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
_JWT_HEADER = {"alg": "HS256", "typ": "JWT"}
|
||||
_ALLOWED_OPS = ("launch", "teardown")
|
||||
|
||||
# The closed op vocabulary, split by kind (PRD "ids + static flags" rule):
|
||||
# * mutation ops act on ONE bottle — they carry its id (+ static launch flags);
|
||||
# * query ops enumerate host state — they carry NO ids or flags at all.
|
||||
# Each new host-privileged op is added here deliberately; `verify_request`
|
||||
# enforces the per-kind shape so the privileged surface can't drift.
|
||||
_MUTATION_OPS = ("launch", "teardown")
|
||||
_QUERY_OPS = ("list_live",)
|
||||
_ALLOWED_OPS = _MUTATION_OPS + _QUERY_OPS
|
||||
|
||||
# Every claim key a signed request may carry — the request fields plus the two
|
||||
# per-signature envelope claims. A strict allow-list (open question 1, resolved
|
||||
# "yes"): an unknown key is rejected outright, so the schema can't silently widen
|
||||
# as ops are added.
|
||||
_ALLOWED_CLAIMS = frozenset(
|
||||
{"op", "bottle_id", "source_ip", "image_ref", "slot", "jti", "iat"}
|
||||
)
|
||||
|
||||
|
||||
class BrokerAuthError(Exception):
|
||||
"""A broker request failed provenance or schema verification —
|
||||
bad/absent signature, malformed token, or a payload that doesn't match
|
||||
the fixed launch-request shape. Fail-closed: the broker must not act."""
|
||||
the fixed launch-request shape. Fail-closed: the broker must not act.
|
||||
|
||||
A **definite** negative: nothing was launched, so a caller may safely roll
|
||||
back as if the op never happened."""
|
||||
|
||||
|
||||
class BrokerUnavailableError(Exception):
|
||||
"""A brokered request could not be carried to a verdict: the broker (or the
|
||||
wire to it) was unreachable, timed out, or dropped the response.
|
||||
|
||||
Crucially **ambiguous** — unlike `BrokerAuthError`, the op MAY already have
|
||||
taken effect on the backend before the response was lost, so a caller must
|
||||
NOT assume it did nothing (e.g. must not roll a registry row back as if no
|
||||
launch happened, which would orphan a running container). Only the in-process
|
||||
brokers never raise this; the out-of-process `BrokerClient` does."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaunchRequest:
|
||||
"""The structured, un-coercible launch/teardown request. Ids + flags
|
||||
only — `image_ref` is a content-addressed id, never a path/argv."""
|
||||
class BrokerRequest:
|
||||
"""A structured, un-coercible broker request. Ids + static flags only —
|
||||
`image_ref` is a content-addressed id, never a path/argv.
|
||||
|
||||
A **mutation** op (`launch`/`teardown`) names the bottle it acts on in
|
||||
`bottle_id` (plus launch's static flags). A **query** op (`list_live`) acts
|
||||
on no single bottle, so it carries nothing but its `op` name — `bottle_id`
|
||||
and every flag stay empty, and `verify_request` rejects a query that smuggles
|
||||
any in. `LaunchRequest` is kept as an alias for the launch/teardown callers."""
|
||||
|
||||
op: str # one of _ALLOWED_OPS
|
||||
bottle_id: str
|
||||
bottle_id: str = ""
|
||||
source_ip: str = ""
|
||||
image_ref: str = ""
|
||||
slot: int | None = None
|
||||
|
||||
|
||||
# Historical name — the request type predates the query ops. Kept so the launch
|
||||
# path (`OrchestratorCore`, `DockerBroker`) reads unchanged.
|
||||
LaunchRequest = BrokerRequest
|
||||
|
||||
|
||||
# --- minimal JWS/JWT (HS256), stdlib only ----------------------------------
|
||||
|
||||
def _b64url(data: bytes) -> str:
|
||||
@@ -103,26 +145,55 @@ def verify_request(token: str, secret: bytes) -> LaunchRequest:
|
||||
raise BrokerAuthError("unexpected header/alg")
|
||||
if not isinstance(claims, dict):
|
||||
raise BrokerAuthError("claims are not an object")
|
||||
# Strict schema (open question 1): a signed request may carry ONLY the known
|
||||
# claim keys, so the privileged surface can't widen by smuggling an extra
|
||||
# claim past the fixed field checks below.
|
||||
unknown = set(claims) - _ALLOWED_CLAIMS
|
||||
if unknown:
|
||||
raise BrokerAuthError(f"unknown claim(s): {', '.join(sorted(unknown))}")
|
||||
|
||||
op = claims.get("op")
|
||||
bottle_id = claims.get("bottle_id")
|
||||
if not isinstance(op, str) or op not in _ALLOWED_OPS:
|
||||
raise BrokerAuthError("request does not match the broker-request schema")
|
||||
bottle_id = claims.get("bottle_id", "")
|
||||
source_ip = claims.get("source_ip", "")
|
||||
image_ref = claims.get("image_ref", "")
|
||||
slot = claims.get("slot")
|
||||
if (
|
||||
not isinstance(op, str) or op not in _ALLOWED_OPS
|
||||
or not isinstance(bottle_id, str) or not bottle_id
|
||||
not isinstance(bottle_id, str)
|
||||
or not isinstance(source_ip, str) or not isinstance(image_ref, str)
|
||||
or not (slot is None or isinstance(slot, int))
|
||||
):
|
||||
raise BrokerAuthError("request does not match the launch-request schema")
|
||||
return LaunchRequest(
|
||||
raise BrokerAuthError("request does not match the broker-request schema")
|
||||
# Per-kind shape: a mutation names exactly one bottle; a query names none and
|
||||
# carries no flags (the "no arguments" rule for `list_live`). Enforcing both
|
||||
# halves keeps `bottle_id`/flags from being a coercible field on a query op.
|
||||
if op in _MUTATION_OPS:
|
||||
if not bottle_id:
|
||||
raise BrokerAuthError(f"{op} requires a bottle_id")
|
||||
elif bottle_id or source_ip or image_ref or slot is not None:
|
||||
raise BrokerAuthError(f"{op} is a query op and takes no ids or flags")
|
||||
return BrokerRequest(
|
||||
op=op, bottle_id=bottle_id, source_ip=source_ip, image_ref=image_ref, slot=slot
|
||||
)
|
||||
|
||||
|
||||
# --- the broker itself ------------------------------------------------------
|
||||
|
||||
class SubmitBroker(Protocol):
|
||||
"""The broker surface `OrchestratorCore` depends on. `submit` verifies a
|
||||
signed *mutation* token and performs its op, returning the verified request;
|
||||
`list_live` verifies a signed `list_live` token and returns the source IPs of
|
||||
the bottles the backend currently has running (reconcile's live set). Both the
|
||||
in-process `LaunchBroker` and the out-of-process `BrokerClient` (which relays
|
||||
the token to the host control server) satisfy it structurally, so the core is
|
||||
unchanged whether the backend is local or a real host service."""
|
||||
|
||||
def submit(self, token: str) -> LaunchRequest: ...
|
||||
|
||||
def list_live(self, token: str) -> list[str]: ...
|
||||
|
||||
|
||||
class LaunchBroker(abc.ABC):
|
||||
"""Verifies a signed request came from the orchestrator, then performs
|
||||
the backend-native launch/teardown. Subclasses implement `_launch` /
|
||||
@@ -132,15 +203,35 @@ class LaunchBroker(abc.ABC):
|
||||
self._secret = secret
|
||||
|
||||
def submit(self, token: str) -> LaunchRequest:
|
||||
"""Verify `token` and perform its op. Returns the verified request;
|
||||
raises `BrokerAuthError` if provenance/shape fails."""
|
||||
"""Verify `token` and perform its mutation op. Returns the verified
|
||||
request; raises `BrokerAuthError` if provenance/shape fails, or if the
|
||||
token is a non-mutation op (a `list_live` token routed here by mistake)."""
|
||||
req = verify_request(token, self._secret)
|
||||
if req.op == "launch":
|
||||
self._launch(req)
|
||||
else:
|
||||
elif req.op == "teardown":
|
||||
self._teardown(req)
|
||||
else:
|
||||
raise BrokerAuthError(f"{req.op} is not a submit op")
|
||||
return req
|
||||
|
||||
def list_live(self, token: str) -> list[str]:
|
||||
"""Verify a `list_live` `token` and return the source IPs of the bottles
|
||||
the backend currently has running. Raises `BrokerAuthError` on bad
|
||||
provenance/shape or a non-`list_live` op; a backend enumeration failure
|
||||
is converted to `BrokerUnavailableError` — the single "live set could not
|
||||
be determined" signal reconcile catches to skip the sweep rather than
|
||||
reaping healthy rows against a partial set (the out-of-process
|
||||
`BrokerClient` raises the same on an enumeration 502 / no response)."""
|
||||
req = verify_request(token, self._secret)
|
||||
if req.op != "list_live":
|
||||
raise BrokerAuthError(f"{req.op} is not a list_live op")
|
||||
try:
|
||||
return list(self._list_live())
|
||||
except Exception as e: # noqa: BLE001 — any enumeration failure means the
|
||||
# live set is unknown; reconcile must skip, never reap against it.
|
||||
raise BrokerUnavailableError(f"list_live enumeration failed: {e}") from e
|
||||
|
||||
@abc.abstractmethod
|
||||
def _launch(self, req: LaunchRequest) -> None:
|
||||
...
|
||||
@@ -149,15 +240,31 @@ class LaunchBroker(abc.ABC):
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
def _list_live(self) -> list[str]:
|
||||
"""Every running bottle's source IP, backend-native. Must raise (not
|
||||
return a partial list) if the live set can't be determined
|
||||
authoritatively, so reconcile skips rather than reaping healthy rows."""
|
||||
...
|
||||
|
||||
|
||||
class StubBroker(LaunchBroker):
|
||||
"""Dev-harness broker: records verified requests without launching
|
||||
anything. Exercises the full sign -> verify -> act contract in-process."""
|
||||
anything. Exercises the full sign -> verify -> act contract in-process.
|
||||
|
||||
Its live set (`_list_live`) is, by default, every bottle it recorded a
|
||||
launch for and no teardown since — the in-process analogue of enumerating
|
||||
the backend. Tests that need to simulate a bottle dying out from under the
|
||||
registry (the case reconcile exists for) set `live_source_ips` explicitly to
|
||||
override that derived set."""
|
||||
|
||||
def __init__(self, secret: bytes) -> None:
|
||||
super().__init__(secret)
|
||||
self.launched: list[LaunchRequest] = []
|
||||
self.torn_down: list[LaunchRequest] = []
|
||||
# When not None, the exact live set `_list_live` reports — lets a test
|
||||
# say "only these IPs are still up" regardless of what was launched.
|
||||
self.live_source_ips: list[str] | None = None
|
||||
|
||||
def _launch(self, req: LaunchRequest) -> None:
|
||||
self.launched.append(req)
|
||||
@@ -165,10 +272,20 @@ class StubBroker(LaunchBroker):
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
self.torn_down.append(req)
|
||||
|
||||
def _list_live(self) -> list[str]:
|
||||
if self.live_source_ips is not None:
|
||||
return list(self.live_source_ips)
|
||||
torn = {r.bottle_id for r in self.torn_down}
|
||||
return [r.source_ip for r in self.launched
|
||||
if r.bottle_id not in torn and r.source_ip]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BrokerAuthError",
|
||||
"BrokerUnavailableError",
|
||||
"BrokerRequest",
|
||||
"LaunchRequest",
|
||||
"SubmitBroker",
|
||||
"LaunchBroker",
|
||||
"StubBroker",
|
||||
"sign_request",
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Orchestrator-side broker transport (issue #468, chunk 1).
|
||||
|
||||
The signer's half of the launch-broker transport gap. `BrokerClient` satisfies
|
||||
the exact `submit(token)` contract `OrchestratorCore` already depends on (see
|
||||
`broker.SubmitBroker`), but instead of verifying and launching in-process it POSTs
|
||||
the signed token to the host control server over HTTP (stdlib `urllib`, like
|
||||
`orchestrator/client.py`). Because it is drop-in for that interface, wiring a real
|
||||
out-of-process backend does not change the core: it still signs a request and
|
||||
calls `submit()`; only the wire is new.
|
||||
|
||||
A provenance/schema rejection from the host controller (HTTP 401) is re-raised as
|
||||
the same `BrokerAuthError` the in-process broker raises, so the launch path's
|
||||
rollback-on-failure (`OrchestratorCore.launch_bottle`) behaves identically whether
|
||||
the broker is local or remote.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from .broker import BrokerAuthError, BrokerUnavailableError, LaunchRequest
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
class BrokerClientError(RuntimeError):
|
||||
"""The host control server *responded*, but with an unexpected status other
|
||||
than the fail-closed 401 (which surfaces as `BrokerAuthError`) — e.g. a 502
|
||||
backend failure or a malformed body. A definite negative: the host processed
|
||||
the request and it did not launch. (A *no-response* failure — unreachable /
|
||||
timeout / dropped — is the ambiguous `BrokerUnavailableError` instead.)"""
|
||||
|
||||
|
||||
class BrokerClient:
|
||||
"""Drop-in `submit(token)` that relays a signed request to the host control
|
||||
server. Holds no secret — provenance rides entirely in the signed token, so a
|
||||
caller that can reach this client still cannot forge a launch."""
|
||||
|
||||
def __init__(self, base_url: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> None:
|
||||
self._base = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
|
||||
def submit(self, token: str) -> LaunchRequest:
|
||||
"""POST the signed token to the host controller and return the request it
|
||||
verified and acted on.
|
||||
|
||||
Raises `BrokerAuthError` on a fail-closed 401 (bad provenance/schema —
|
||||
the same exception the in-process broker raises); `BrokerClientError` if
|
||||
the host *responds* with any other non-success status or a malformed
|
||||
body (a definite negative); or `BrokerUnavailableError` if no response is
|
||||
obtained (unreachable / timeout / dropped) — the **ambiguous** case, where
|
||||
the host may already have acted, so the caller must not roll back."""
|
||||
data = json.dumps({"token": token}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{self._base}/broker", data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
return _request_from(_json_object(resp.read()))
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = _error_detail(e)
|
||||
if e.code == 401:
|
||||
raise BrokerAuthError(
|
||||
detail or "host controller rejected the request"
|
||||
) from e
|
||||
raise BrokerClientError(
|
||||
f"POST /broker: HTTP {e.code} {detail}".rstrip()
|
||||
) from e
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
# No usable response — unreachable, timed out, or the connection
|
||||
# dropped mid-exchange. Ambiguous: the request may already have
|
||||
# launched the bottle, so this is NOT a definite failure.
|
||||
raise BrokerUnavailableError(f"POST /broker: {e}") from e
|
||||
|
||||
def list_live(self, token: str) -> list[str]:
|
||||
"""POST the signed `list_live` token to the host controller and return
|
||||
the source IPs of the bottles it enumerated as running.
|
||||
|
||||
Raises `BrokerAuthError` on a fail-closed 401 (bad provenance/schema);
|
||||
`BrokerClientError` on any other non-success status or a malformed body
|
||||
(a backend-enumeration 502 included); or `BrokerUnavailableError` if no
|
||||
response is obtained. A query has no backend side effect, so — unlike
|
||||
`submit` — every one of these is a *definite* "no live set"; reconcile
|
||||
catches all three and skips the sweep rather than reaping against an
|
||||
unknown or partial set."""
|
||||
data = json.dumps({"token": token}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{self._base}/broker/live", data=data, method="POST",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
||||
return _source_ips_from(_json_object(resp.read()))
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = _error_detail(e)
|
||||
if e.code == 401:
|
||||
raise BrokerAuthError(
|
||||
detail or "host controller rejected the request"
|
||||
) from e
|
||||
raise BrokerClientError(
|
||||
f"POST /broker/live: HTTP {e.code} {detail}".rstrip()
|
||||
) from e
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
raise BrokerUnavailableError(f"POST /broker/live: {e}") from e
|
||||
|
||||
|
||||
def _json_object(raw: bytes) -> dict[str, object]:
|
||||
"""Parse a JSON object, tolerating an empty or malformed body (→ {}), like
|
||||
the orchestrator client — a bad body becomes a clean 'missing field' error
|
||||
downstream rather than an opaque JSON crash."""
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
obj = json.loads(raw)
|
||||
except ValueError:
|
||||
return {}
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
|
||||
|
||||
def _error_detail(e: urllib.error.HTTPError) -> str:
|
||||
"""The `error` string from a structured error response, best-effort — an
|
||||
error body may be absent or unreadable, in which case there is no detail."""
|
||||
try:
|
||||
detail = _json_object(e.read()).get("error", "")
|
||||
except Exception: # noqa: BLE001 — the error body is advisory only
|
||||
return ""
|
||||
return detail if isinstance(detail, str) else ""
|
||||
|
||||
|
||||
def _source_ips_from(payload: dict[str, object]) -> list[str]:
|
||||
"""The `source_ips` list from a `/broker/live` response — every string
|
||||
entry, ignoring any non-string the host controller should never send. A
|
||||
missing/!list field is a malformed response (the query is meaningless
|
||||
without it), so it fails rather than silently reconciling against []."""
|
||||
raw = payload.get("source_ips")
|
||||
if not isinstance(raw, list):
|
||||
raise BrokerClientError("host controller response missing source_ips")
|
||||
return [ip for ip in raw if isinstance(ip, str) and ip]
|
||||
|
||||
|
||||
def _request_from(payload: dict[str, object]) -> LaunchRequest:
|
||||
"""Reconstruct the verified `LaunchRequest` the controller echoed, so the
|
||||
returned value matches the in-process broker's (which returns the request it
|
||||
acted on). A missing op/bottle_id means a malformed response."""
|
||||
op = payload.get("op")
|
||||
bottle_id = payload.get("bottle_id")
|
||||
if not isinstance(op, str) or not isinstance(bottle_id, str) or not bottle_id:
|
||||
raise BrokerClientError("host controller response missing op/bottle_id")
|
||||
source_ip = payload.get("source_ip")
|
||||
image_ref = payload.get("image_ref")
|
||||
slot = payload.get("slot")
|
||||
return LaunchRequest(
|
||||
op=op,
|
||||
bottle_id=bottle_id,
|
||||
source_ip=source_ip if isinstance(source_ip, str) else "",
|
||||
image_ref=image_ref if isinstance(image_ref, str) else "",
|
||||
slot=slot if isinstance(slot, int) and not isinstance(slot, bool) else None,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BrokerClient",
|
||||
"BrokerClientError",
|
||||
"DEFAULT_TIMEOUT_SECONDS",
|
||||
]
|
||||
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..log import debug
|
||||
@@ -194,14 +193,13 @@ class OrchestratorClient:
|
||||
raise OrchestratorClientError(f"teardown {bottle_id}: HTTP {status}")
|
||||
return True
|
||||
|
||||
def reconcile(
|
||||
self, live_source_ips: Iterable[str], *, grace_seconds: float | None = None,
|
||||
) -> list[str]:
|
||||
"""Drop registry rows for bottles that are no longer running
|
||||
(`POST /reconcile`), returning the reaped bottle ids. `live_source_ips`
|
||||
is the caller's enumeration of its live bottles — the orchestrator
|
||||
can't see the backend from inside the orchestrator container/VM."""
|
||||
body: dict[str, object] = {"live_source_ips": list(live_source_ips)}
|
||||
def reconcile(self, *, grace_seconds: float | None = None) -> list[str]:
|
||||
"""Trigger the orchestrator's self-heal sweep (`POST /reconcile`),
|
||||
returning the reaped bottle ids. The caller no longer enumerates the
|
||||
live set: the orchestrator pulls it from the host controller
|
||||
(`list_live`) itself, so this is a bare trigger with an optional
|
||||
`grace_seconds`."""
|
||||
body: dict[str, object] = {}
|
||||
if grace_seconds is not None:
|
||||
body["grace_seconds"] = grace_seconds
|
||||
payload = self._ok("POST", "/reconcile", body)
|
||||
|
||||
@@ -71,6 +71,31 @@ class DockerBroker(LaunchBroker):
|
||||
f"docker rm failed for {req.bottle_id}: {proc.stderr.strip()}"
|
||||
)
|
||||
|
||||
def _list_live(self) -> list[str]:
|
||||
"""Every running bottle container's network IP, from `docker ps` filtered
|
||||
to this broker's label. Raises `DockerBrokerError` if the listing or any
|
||||
inspect fails, so reconcile treats the live set as unknown and skips the
|
||||
sweep rather than reaping healthy rows against a partial enumeration."""
|
||||
listing = self._docker([
|
||||
"docker", "ps", "--filter", f"label={BOTTLE_ID_LABEL}",
|
||||
"--format", "{{.ID}}",
|
||||
])
|
||||
if listing.returncode != 0:
|
||||
raise DockerBrokerError(f"docker ps failed: {listing.stderr.strip()}")
|
||||
ips: list[str] = []
|
||||
for cid in (line for line in listing.stdout.splitlines() if line):
|
||||
inspected = self._docker([
|
||||
"docker", "inspect", "--format",
|
||||
"{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}", cid,
|
||||
])
|
||||
if inspected.returncode != 0:
|
||||
raise DockerBrokerError(
|
||||
f"docker inspect {cid} failed; live set is not authoritative: "
|
||||
f"{inspected.stderr.strip()}"
|
||||
)
|
||||
ips.extend(ip for ip in inspected.stdout.split() if ip)
|
||||
return ips
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DockerBroker",
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Host control server (issue #468) — the launch broker as a real host service.
|
||||
|
||||
Chunk 1 of the host-control-server stack closes the **transport** gap the PRD
|
||||
opens with: today `LaunchBroker.submit(token)` is an in-process method call from
|
||||
`OrchestratorCore`, and a real host service needs it reachable over the wire.
|
||||
This module is that service — the single privileged host component — reached over
|
||||
**HTTP** (the universal transport 0070 chose), mirroring the orchestrator control
|
||||
plane's shape (`orchestrator/server.py`): a pure `dispatch()` for socket-free
|
||||
testing, wrapped by a thin stdlib `http.server` adapter.
|
||||
|
||||
GET /health -> 200 {"status": "ok"}
|
||||
POST /broker -> 200 {"op", "bottle_id", "source_ip", "image_ref", "slot"}
|
||||
400 (bad body) | 401 (bad provenance/schema) | 502 (backend)
|
||||
body: {"token": "<signed launch/teardown JWT>"}
|
||||
POST /broker/live -> 200 {"source_ips": [...]}
|
||||
400 (bad body) | 401 (bad provenance/schema) | 502 (backend)
|
||||
body: {"token": "<signed list_live JWT>"}
|
||||
|
||||
The op vocabulary grows one signed op at a time (PRD gap 3); each verb reaches
|
||||
the backend only through `verify_request`, so nothing free-form ever crosses the
|
||||
wire. `/broker/live` is the first query op — reconcile's live-bottle enumeration,
|
||||
which the orchestrator (blind to the backend from inside its container) now pulls
|
||||
from the host controller instead of being handed by the CLI.
|
||||
|
||||
Only the **signed token** crosses the wire; the server holds the shared HS256
|
||||
secret and a real `LaunchBroker` (e.g. `DockerBroker`) and runs the existing
|
||||
`verify_request` + `_launch`/`_teardown` path behind the endpoint, so nothing
|
||||
free-form ever reaches it. Provenance/schema failures are fail-closed 401s that
|
||||
never touch the backend (`LaunchBroker.submit` verifies before acting), and a
|
||||
backend launch failure is a 502 the caller must surface — neither takes the
|
||||
controller down.
|
||||
|
||||
The signed launch token *is* the endpoint's authentication (its provenance is the
|
||||
whole point of the JWS), so `/broker` needs no separate caller credential; the
|
||||
host controller's own lifecycle endpoints, which do, arrive with the `host`-role
|
||||
tokens of the separate `HOST_CONTROLLER` trust domain in a later chunk.
|
||||
|
||||
The shared signing secret is the durable **launch-broker `TrustDomain` key**
|
||||
(#468/#476): a host-canonical key file minted 0600 on first use, provisioned to
|
||||
the orchestrator (signer) and this server (verifier). A backend launcher injects
|
||||
it via `$BOT_BOTTLE_LAUNCH_BROKER_KEY`; a host-side dev-harness process reads the
|
||||
key file directly. Durability is the point — a restarted orchestrator re-verifies
|
||||
against the same key, so re-adoption works.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import sys
|
||||
import typing
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .. import log
|
||||
from ..paths import LAUNCH_BROKER_KEY_ENV
|
||||
from ..trust_domain import LAUNCH_BROKER
|
||||
from .broker import BrokerAuthError, LaunchBroker
|
||||
from .docker_broker import DockerBroker
|
||||
|
||||
# JSON body payload type (parsed request / rendered response).
|
||||
Json = dict[str, object]
|
||||
|
||||
# Default host-controller port. Distinct from the orchestrator control plane
|
||||
# (8099) — a separate privileged component listening on its own socket.
|
||||
DEFAULT_PORT = 8091
|
||||
|
||||
# Cap on the request body. A signed broker request is tiny, so rejecting anything
|
||||
# larger *before reading it* keeps a caller that can merely reach the socket (no
|
||||
# signed token needed) from exhausting memory or a handler thread with a huge
|
||||
# Content-Length — the signed token, not mere reachability, is the authority.
|
||||
MAX_BODY_BYTES = 64 * 1024
|
||||
|
||||
# Per-request socket timeout, bounding how long a stalled / slow-loris caller can
|
||||
# hold a handler thread on this privileged listener.
|
||||
REQUEST_TIMEOUT_SECONDS = 15
|
||||
|
||||
|
||||
def _parse_json_object(body: bytes) -> Json:
|
||||
"""Parse a JSON object body. Raises ValueError for non-objects / bad JSON."""
|
||||
if not body:
|
||||
return {}
|
||||
obj = json.loads(body) # raises json.JSONDecodeError (a ValueError)
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
return obj
|
||||
|
||||
|
||||
def broker_secret(
|
||||
environ: typing.Mapping[str, str] | None = None, *, allow_host_file: bool = False,
|
||||
) -> bytes | None:
|
||||
"""The shared launch-broker HS256 secret, as this process should use it.
|
||||
|
||||
Always prefers the key injected into this process's env
|
||||
(`$BOT_BOTTLE_LAUNCH_BROKER_KEY`). `allow_host_file` decides the fallback when
|
||||
it is absent, and the distinction is a security boundary:
|
||||
|
||||
- **Host-side** processes — the host controller and the host dev-harness — pass
|
||||
``allow_host_file=True`` to read (minting on first use) the durable host key
|
||||
file (``bot_bottle_root()/launch-broker-key``) they legitimately own.
|
||||
- The **guest orchestrator** (``--broker http``) keeps the default ``False``.
|
||||
It runs inside a container/VM whose ``bot_bottle_root()`` is process-local,
|
||||
so minting a file there would silently create a key UNRELATED to the host
|
||||
controller's — startup would succeed but every launch would be rejected 401.
|
||||
It must instead be *given* the key by its launcher, and fail closed (None)
|
||||
if it wasn't, rather than diverge.
|
||||
|
||||
None when no key is available (a guest with no injection, or an unwritable
|
||||
host root)."""
|
||||
key = LAUNCH_BROKER.key_from_env(environ)
|
||||
if not key and allow_host_file:
|
||||
try:
|
||||
key = LAUNCH_BROKER.signing_key() # host-canonical, minted on first use
|
||||
except OSError:
|
||||
return None
|
||||
return key.encode("utf-8") if key else None
|
||||
|
||||
|
||||
def dispatch( # pylint: disable=too-many-return-statements
|
||||
broker: LaunchBroker, method: str, path: str, body: bytes,
|
||||
) -> tuple[int, Json]:
|
||||
"""Route one host-control request to a (status, payload) pair. Pure — the
|
||||
only side effect is the broker's own backend launch — so routing is testable
|
||||
without a socket.
|
||||
|
||||
Total by design: a provenance/schema failure becomes 401 and a backend launch
|
||||
failure becomes 502 rather than raising, so one bad request can neither act
|
||||
on the backend nor take the controller down for the next caller."""
|
||||
route = urlsplit(path).path.rstrip("/") or "/"
|
||||
|
||||
if method == "GET" and route == "/health":
|
||||
return 200, {"status": "ok"}
|
||||
|
||||
if method == "POST" and route == "/broker":
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
token = data.get("token")
|
||||
if not isinstance(token, str) or not token:
|
||||
return 400, {"error": "token (string) is required"}
|
||||
try:
|
||||
req = broker.submit(token)
|
||||
except BrokerAuthError as e:
|
||||
# Fail-closed: bad signature, malformed token, or off-schema payload.
|
||||
# `submit` verifies before acting, so nothing was launched.
|
||||
return 401, {"error": f"broker auth failed: {e}"}
|
||||
except Exception as e: # noqa: BLE001 — a backend launch failure (docker
|
||||
# down, image gone) is operational, not a control-plane bug; the
|
||||
# caller must see it as a distinct 502, and the server must stay up.
|
||||
return 502, {"error": f"backend launch failed: {e}"}
|
||||
return 200, {
|
||||
"op": req.op,
|
||||
"bottle_id": req.bottle_id,
|
||||
"source_ip": req.source_ip,
|
||||
"image_ref": req.image_ref,
|
||||
"slot": req.slot,
|
||||
}
|
||||
|
||||
if method == "POST" and route == "/broker/live":
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
token = data.get("token")
|
||||
if not isinstance(token, str) or not token:
|
||||
return 400, {"error": "token (string) is required"}
|
||||
try:
|
||||
source_ips = broker.list_live(token)
|
||||
except BrokerAuthError as e:
|
||||
# Fail-closed: bad signature, malformed token, or a non-list_live op.
|
||||
return 401, {"error": f"broker auth failed: {e}"}
|
||||
except Exception as e: # noqa: BLE001 — a backend enumeration failure
|
||||
# (docker down, inspect failed) is operational, not a control-plane
|
||||
# bug; surface it as a 502 so the caller skips reconcile rather than
|
||||
# reaping against a partial set, and keep the controller up.
|
||||
return 502, {"error": f"backend enumeration failed: {e}"}
|
||||
return 200, {"source_ips": source_ips}
|
||||
|
||||
return 404, {"error": "not found"}
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
"""Thin stdlib adapter: read the body, call `dispatch`, write JSON."""
|
||||
|
||||
# Socket timeout per request (applied by StreamRequestHandler.setup) so a
|
||||
# stalled caller can't pin a handler thread on this privileged listener.
|
||||
timeout = REQUEST_TIMEOUT_SECONDS
|
||||
|
||||
# Quiet by default; opt back into stdlib access logging with
|
||||
# BOT_BOTTLE_HOST_CONTROLLER_DEBUG (the controller has its own logging).
|
||||
def log_message(self, format: str, *args: typing.Any) -> None: # noqa: A002
|
||||
if os.environ.get("BOT_BOTTLE_HOST_CONTROLLER_DEBUG"):
|
||||
super().log_message(format, *args)
|
||||
|
||||
def _serve(self, method: str) -> None:
|
||||
"""Read the request body (bounded), dispatch it, and write the JSON
|
||||
reply. A dispatch that raises (it shouldn't — dispatch is total) still
|
||||
returns a 500 rather than dropping the connection."""
|
||||
server = self.server
|
||||
assert isinstance(server, HostControlServer)
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
self._reply(400, {"error": "invalid Content-Length"})
|
||||
return
|
||||
if length < 0 or length > MAX_BODY_BYTES:
|
||||
# Reject before reading: nothing legitimate is this big, so an
|
||||
# oversized declared length is a bug or a resource-exhaustion attempt.
|
||||
self._reply(413, {"error": "request body too large"})
|
||||
return
|
||||
body = self.rfile.read(length) if length > 0 else b""
|
||||
try:
|
||||
status, payload = dispatch(server.broker, method, self.path, body)
|
||||
except Exception as e: # noqa: BLE001 — the controller must stay up
|
||||
sys.stderr.write(f"host controller: {method} {self.path} failed: {e!r}\n")
|
||||
sys.stderr.flush()
|
||||
status, payload = 500, {"error": f"internal error: {e}"}
|
||||
self._reply(status, payload)
|
||||
|
||||
def _reply(self, status: int, payload: typing.Mapping[str, object]) -> None:
|
||||
"""Write one JSON response with an explicit Content-Length."""
|
||||
data = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._serve("GET")
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._serve("POST")
|
||||
|
||||
|
||||
class HostControlServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
"""Threading HTTP server that carries the launch broker for its handlers.
|
||||
|
||||
The broker holds the shared signing secret and performs the backend-native
|
||||
launch/teardown; the server itself keeps no secret of its own — provenance
|
||||
rides entirely in each request's signed token."""
|
||||
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], broker: LaunchBroker) -> None:
|
||||
self.broker = broker
|
||||
super().__init__(address, Handler)
|
||||
|
||||
|
||||
def make_host_server(
|
||||
broker: LaunchBroker, host: str = "127.0.0.1", port: int = DEFAULT_PORT
|
||||
) -> HostControlServer:
|
||||
"""Build (but do not start) a host control server. `port=0` binds an
|
||||
ephemeral port — read `server.server_address` for the actual one."""
|
||||
return HostControlServer((host, port), broker)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Run the host control server as a plain process (dev-harness).
|
||||
|
||||
python -m bot_bottle.orchestrator.host_server [--host H] [--port P]
|
||||
|
||||
Fail-closed: without the launch-broker key the server can verify no request's
|
||||
provenance, so it refuses to start rather than run a launcher that accepts
|
||||
unsigned input. As the host-side owner of the key, it may mint/read the host
|
||||
key file (`allow_host_file=True`)."""
|
||||
parser = argparse.ArgumentParser(prog="bot_bottle.orchestrator.host_server")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="bind address")
|
||||
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="bind port (0 = ephemeral)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
secret = broker_secret(allow_host_file=True)
|
||||
if secret is None:
|
||||
sys.stderr.write(
|
||||
f"host controller: refusing to start without the launch-broker key "
|
||||
f"(${LAUNCH_BROKER_KEY_ENV}, or a writable host root to mint it) — it "
|
||||
"could verify no request's provenance and would relay unsigned "
|
||||
"launches to the backend\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
return 2
|
||||
|
||||
broker = DockerBroker(secret)
|
||||
server = make_host_server(broker, host=args.host, port=args.port)
|
||||
bound_host, bound_port = server.server_address[0], server.server_address[1]
|
||||
log.info(
|
||||
"host control server listening",
|
||||
context={"host": bound_host, "port": bound_port},
|
||||
)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
log.info("host controller shutting down")
|
||||
finally:
|
||||
server.server_close()
|
||||
return 0
|
||||
|
||||
|
||||
__all__ = [
|
||||
"dispatch",
|
||||
"Handler",
|
||||
"HostControlServer",
|
||||
"make_host_server",
|
||||
"broker_secret",
|
||||
"main",
|
||||
"Json",
|
||||
"DEFAULT_PORT",
|
||||
]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -18,8 +18,8 @@ vsock / unix-socket portability caveats):
|
||||
body: {"env_var_secret"}
|
||||
DELETE /bottles/<bottle_id> -> 200 {"torn_down": true} | 404 (teardown)
|
||||
POST /reconcile -> 200 {"reaped": [bottle_id, ...]}
|
||||
body: {"live_source_ips": [...],
|
||||
["grace_seconds"]}
|
||||
body: {["grace_seconds"]} (live set is
|
||||
pulled from the host controller, not sent)
|
||||
POST /attribute -> 200 {"bottle_id"} | 403
|
||||
POST /resolve -> 200 {"bottle_id","policy"} | 403
|
||||
body: {"source_ip","identity_token"}
|
||||
@@ -207,20 +207,15 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
||||
return 404, {"error": "no such bottle"}
|
||||
|
||||
if method == "POST" and route == "/reconcile":
|
||||
# Host-driven self-heal: the caller enumerates its live bottles (only
|
||||
# the host can see the backend) and the orchestrator drops rows for
|
||||
# every other active bottle. Trusted-caller only — an agent that could
|
||||
# reach this would be able to unregister its neighbours.
|
||||
# Host-driven self-heal trigger: the orchestrator pulls its own live set
|
||||
# from the host controller (`list_live`) and drops rows for every other
|
||||
# active bottle — the caller no longer supplies the live IPs. Trusted-
|
||||
# caller only: an agent that could reach this would trigger a sweep that
|
||||
# unregisters its neighbours. Body is optional (just `grace_seconds`).
|
||||
try:
|
||||
data = _parse_json_object(body)
|
||||
except ValueError as e:
|
||||
return 400, {"error": f"invalid JSON: {e}"}
|
||||
raw_ips = data.get("live_source_ips")
|
||||
if not isinstance(raw_ips, list):
|
||||
return 400, {"error": "live_source_ips (list of strings) is required"}
|
||||
if any(not isinstance(ip, str) or not ip for ip in raw_ips):
|
||||
return 400, {"error": "live_source_ips must contain non-empty strings"}
|
||||
live = raw_ips
|
||||
grace = data.get("grace_seconds")
|
||||
kwargs: dict[str, float] = {}
|
||||
if grace is not None:
|
||||
@@ -230,7 +225,7 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
||||
if not math.isfinite(parsed_grace) or parsed_grace < 0:
|
||||
return 400, {"error": "grace_seconds must be a non-negative finite number"}
|
||||
kwargs["grace_seconds"] = parsed_grace
|
||||
return 200, {"reaped": orch.reconcile(live, **kwargs)}
|
||||
return 200, {"reaped": orch.reconcile(**kwargs)}
|
||||
|
||||
if method == "POST" and route == "/attribute":
|
||||
try:
|
||||
|
||||
@@ -22,10 +22,17 @@ Launch lifecycle:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .broker import LaunchBroker, LaunchRequest, sign_request
|
||||
from .. import log
|
||||
from .broker import (
|
||||
BrokerAuthError,
|
||||
BrokerUnavailableError,
|
||||
LaunchRequest,
|
||||
SubmitBroker,
|
||||
sign_request,
|
||||
)
|
||||
from .broker_client import BrokerClientError
|
||||
from .store.registry_store import DEFAULT_REAP_GRACE_SECONDS, BottleRecord, RegistryStore
|
||||
from .supervisor import (
|
||||
AuditEntry,
|
||||
@@ -62,7 +69,7 @@ class OrchestratorCore:
|
||||
def __init__(
|
||||
self,
|
||||
registry: RegistryStore,
|
||||
broker: LaunchBroker,
|
||||
broker: SubmitBroker,
|
||||
sign_secret: bytes,
|
||||
supervisor: Supervisor | None = None,
|
||||
) -> None:
|
||||
@@ -111,14 +118,23 @@ class OrchestratorCore:
|
||||
image_ref=image_ref,
|
||||
slot=slot,
|
||||
)
|
||||
launched = False
|
||||
try:
|
||||
self._broker.submit(sign_request(req, self._secret))
|
||||
launched = True
|
||||
finally:
|
||||
if not launched:
|
||||
self.registry.deregister(rec.bottle_id)
|
||||
self._tokens.pop(rec.bottle_id, None)
|
||||
except BrokerUnavailableError:
|
||||
# Ambiguous delivery failure (timeout / dropped response): the broker
|
||||
# may already have launched the bottle before the response was lost.
|
||||
# Do NOT deregister — that would orphan a running container with no
|
||||
# registry row (reconcile reaps rows, never containers). Keep the row
|
||||
# so reconcile reaps it iff the bottle is not actually live; surface
|
||||
# the error so the caller knows the launch is unconfirmed.
|
||||
raise
|
||||
except Exception:
|
||||
# A definite failure — a fail-closed rejection, a backend launch
|
||||
# error, or the host reporting it did not launch: nothing is running,
|
||||
# so roll the registry entry back to leave no orphan.
|
||||
self.registry.deregister(rec.bottle_id)
|
||||
self._tokens.pop(rec.bottle_id, None)
|
||||
raise
|
||||
return rec
|
||||
|
||||
def teardown_bottle(self, bottle_id: str) -> bool:
|
||||
@@ -136,22 +152,36 @@ class OrchestratorCore:
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
live_source_ips: Iterable[str],
|
||||
*,
|
||||
grace_seconds: float = DEFAULT_REAP_GRACE_SECONDS,
|
||||
) -> list[str]:
|
||||
"""Drop registry rows for bottles that are no longer running, and
|
||||
forget their in-memory egress tokens. Returns the reaped bottle ids.
|
||||
|
||||
The caller supplies the live set because only the host can enumerate
|
||||
its own containers — the orchestrator runs *inside* the infra
|
||||
container and has no view of the backend. Deliberately does not
|
||||
broker a teardown: the container is already gone, so there is nothing
|
||||
to stop, and a broker error must not stop the sweep from clearing
|
||||
the row that would otherwise brick the next bottle at that address.
|
||||
The live set is pulled from the **broker** (`list_live`), not passed in:
|
||||
only the host can enumerate its own containers, and the host controller
|
||||
is now that host component, so the orchestrator — blind to the backend
|
||||
from inside its container — asks it over the same signed seam it launches
|
||||
through. (Before the host control server this was a `live_source_ips`
|
||||
argument the CLI handed in; the tell that the orchestrator couldn't see
|
||||
the backend goes away with it — PRD gap 3.)
|
||||
|
||||
Fail-safe: if the broker can't return an authoritative live set
|
||||
(unreachable, timed out, enumeration failed), the sweep is **skipped**,
|
||||
never run against an empty/partial set — reaping a healthy bottle is far
|
||||
worse than leaving an orphan one more cycle. Deliberately does not broker
|
||||
a teardown: a reaped container is already gone, so there is nothing to
|
||||
stop.
|
||||
|
||||
See `RegistryStore.reap_absent` for why orphans accumulate and why
|
||||
they are harmful rather than merely untidy."""
|
||||
token = sign_request(LaunchRequest(op="list_live"), self._secret)
|
||||
try:
|
||||
live_source_ips = self._broker.list_live(token)
|
||||
except (BrokerUnavailableError, BrokerAuthError, BrokerClientError) as e:
|
||||
log.info("reconcile skipped: live set unavailable",
|
||||
context={"error": str(e)})
|
||||
return []
|
||||
reaped = self.registry.reap_absent(
|
||||
live_source_ips, grace_seconds=grace_seconds)
|
||||
for rec in reaped:
|
||||
|
||||
@@ -12,12 +12,22 @@ reattachment path reads ENV_VAR_SECRET from the running agent container via
|
||||
``POST /bottles/<id>/reprovision_gateway``; the orchestrator decrypts the
|
||||
stored rows and re-populates ``_tokens``.
|
||||
|
||||
Encryption scheme: HMAC-SHA256 used as a PRF in CTR mode (stdlib-only,
|
||||
no external deps). Each value is encrypted independently. The output blob is
|
||||
``nonce (16 bytes) || ciphertext`` encoded as URL-safe base64 (no padding).
|
||||
Encryption scheme: HMAC-SHA256 used as a PRF in CTR mode, **authenticated**
|
||||
encrypt-then-MAC (stdlib-only, no external deps). Each value is encrypted
|
||||
independently. The output blob is ``nonce (16 bytes) || ciphertext || tag
|
||||
(32 bytes)`` encoded as URL-safe base64 (no padding).
|
||||
|
||||
keystream_block_i = HMAC-SHA256(key, nonce || i.to_bytes(4, "big"))
|
||||
ciphertext_i = plaintext_i XOR keystream_block_i[:len(plaintext_i)]
|
||||
mac_key = HMAC-SHA256(key, "bottled-secret-mac-v1")
|
||||
tag = HMAC-SHA256(mac_key, nonce || ciphertext)
|
||||
|
||||
The tag is what makes a **wrong key deterministically detectable**: without it,
|
||||
CTR decryption with the wrong key yields garbage that only fails when it isn't
|
||||
valid UTF-8 (so ``reprovision`` would sometimes "succeed" with a wrong
|
||||
ENV_VAR_SECRET and inject garbage egress tokens). The MAC key is derived from
|
||||
the ENV_VAR_SECRET by a domain-separated HMAC so the same key never both
|
||||
generates the keystream and signs the tag with the same message shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -29,6 +39,7 @@ import secrets
|
||||
|
||||
_KEY_BYTES = 32 # 256-bit key from ENV_VAR_SECRET
|
||||
_NONCE_BYTES = 16 # 128-bit random nonce per encrypt call
|
||||
_TAG_BYTES = 32 # HMAC-SHA256 authentication tag
|
||||
_BLOCK = 32 # HMAC-SHA256 output width == one keystream block
|
||||
|
||||
# Env-var name the agent container receives at startup.
|
||||
@@ -50,45 +61,58 @@ def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes:
|
||||
).digest()
|
||||
|
||||
|
||||
def _tag(key: bytes, nonce: bytes, ciphertext: bytes) -> bytes:
|
||||
"""The authentication tag over ``nonce || ciphertext``, keyed by a MAC
|
||||
subkey domain-separated from the keystream key."""
|
||||
mac_key = hmac.new(key, b"bottled-secret-mac-v1", hashlib.sha256).digest()
|
||||
return hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _ctr(key: bytes, nonce: bytes, data: bytes) -> bytes:
|
||||
"""CTR keystream XOR — its own inverse, so it both encrypts and decrypts."""
|
||||
out = bytearray()
|
||||
for i in range(0, len(data), _BLOCK):
|
||||
chunk = data[i : i + _BLOCK]
|
||||
ks = _keystream(key, nonce, i)[: len(chunk)]
|
||||
out.extend(b ^ k for b, k in zip(chunk, ks))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encrypt_value(secret_b64: str, plaintext: str) -> str:
|
||||
"""Encrypt a single string value with *secret_b64* (the ENV_VAR_SECRET).
|
||||
|
||||
Returns a URL-safe base64 blob ``nonce || ciphertext`` suitable for
|
||||
Returns a URL-safe base64 blob ``nonce || ciphertext || tag`` suitable for
|
||||
the ``bottled_agent_secrets.value`` column."""
|
||||
key = _b64dec(secret_b64)
|
||||
pt = plaintext.encode()
|
||||
nonce = secrets.token_bytes(_NONCE_BYTES)
|
||||
ct = bytearray()
|
||||
for i in range(0, len(pt), _BLOCK):
|
||||
chunk = pt[i : i + _BLOCK]
|
||||
ks = _keystream(key, nonce, i)[: len(chunk)]
|
||||
ct.extend(p ^ k for p, k in zip(chunk, ks))
|
||||
return base64.urlsafe_b64encode(nonce + bytes(ct)).rstrip(b"=").decode()
|
||||
ct = _ctr(key, nonce, plaintext.encode())
|
||||
tag = _tag(key, nonce, ct)
|
||||
return base64.urlsafe_b64encode(nonce + ct + tag).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def decrypt_value(secret_b64: str, blob_b64: str) -> str:
|
||||
"""Decrypt a blob produced by :func:`encrypt_value`.
|
||||
|
||||
Returns the original plaintext string. Raises ``ValueError`` for malformed
|
||||
input or a key mismatch (wrong key produces garbage, not an error, unless
|
||||
the plaintext is non-UTF-8 — treat all such failures as wrong key)."""
|
||||
input, a **wrong key**, or a tampered ciphertext — all caught by the
|
||||
authentication tag before any plaintext is returned, so a wrong
|
||||
ENV_VAR_SECRET is rejected deterministically (never a garbage token)."""
|
||||
key = _b64dec(secret_b64)
|
||||
try:
|
||||
blob = _b64dec(blob_b64)
|
||||
except Exception as exc:
|
||||
raise ValueError(f"invalid ciphertext blob: {exc}") from exc
|
||||
if len(blob) < _NONCE_BYTES:
|
||||
if len(blob) < _NONCE_BYTES + _TAG_BYTES:
|
||||
raise ValueError("ciphertext blob too short")
|
||||
nonce, ciphertext = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:]
|
||||
pt = bytearray()
|
||||
for i in range(0, len(ciphertext), _BLOCK):
|
||||
chunk = ciphertext[i : i + _BLOCK]
|
||||
ks = _keystream(key, nonce, i)[: len(chunk)]
|
||||
pt.extend(c ^ k for c, k in zip(chunk, ks))
|
||||
nonce = blob[:_NONCE_BYTES]
|
||||
tag = blob[-_TAG_BYTES:]
|
||||
ciphertext = blob[_NONCE_BYTES:-_TAG_BYTES]
|
||||
if not hmac.compare_digest(tag, _tag(key, nonce, ciphertext)):
|
||||
raise ValueError("ciphertext failed authentication (wrong key or tampered)")
|
||||
try:
|
||||
return bytes(pt).decode()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError(f"decryption produced non-UTF-8 output (wrong key?): {exc}") from exc
|
||||
return _ctr(key, nonce, ciphertext).decode()
|
||||
except UnicodeDecodeError as exc: # pragma: no cover - authenticated, so unreachable
|
||||
raise ValueError(f"decryption produced non-UTF-8 output: {exc}") from exc
|
||||
|
||||
|
||||
__all__ = ["ENV_VAR_SECRET_NAME", "new_env_var_secret", "encrypt_value", "decrypt_value"]
|
||||
|
||||
@@ -36,6 +36,13 @@ ROLE_GATEWAY = "gateway"
|
||||
ROLE_CLI = "cli"
|
||||
ROLES: frozenset[str] = frozenset({ROLE_GATEWAY, ROLE_CLI})
|
||||
|
||||
# The host controller's own lifecycle role (#468). Deliberately OUTSIDE `ROLES`:
|
||||
# it belongs to a separate trust domain (`HOST_CONTROLLER`) signed by a key the
|
||||
# orchestrator never holds, so the orchestrator's control-plane key can neither
|
||||
# mint nor accept it — the orchestrator must not be able to forge the credential
|
||||
# used to start and stop it.
|
||||
ROLE_HOST = "host"
|
||||
|
||||
_ALG = "HS256"
|
||||
|
||||
|
||||
@@ -103,4 +110,4 @@ def verify(token: str, secret: str, *, roles: frozenset[str] = ROLES) -> str | N
|
||||
return role if isinstance(role, str) and role in roles else None
|
||||
|
||||
|
||||
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"]
|
||||
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLE_HOST", "ROLES", "mint", "verify"]
|
||||
|
||||
@@ -47,6 +47,22 @@ ORCHESTRATOR_TOKEN_ENV = "BOT_BOTTLE_ORCHESTRATOR_TOKEN"
|
||||
# cannot forge a higher-privilege `cli` token (issue #469 review).
|
||||
ORCHESTRATOR_AUTH_JWT_ENV = "BOT_BOTTLE_ORCHESTRATOR_AUTH_JWT"
|
||||
|
||||
# The durable launch-broker signing key: the HS256 secret the orchestrator
|
||||
# (signer) and the host control server (verifier) share to sign/verify launch
|
||||
# requests (#468). A host-canonical key file (minted 0600 on first use) so it
|
||||
# survives orchestrator restarts — re-adoption re-verifies against the same key —
|
||||
# instead of the ephemeral per-process secret of the in-process broker.
|
||||
LAUNCH_BROKER_KEY_FILENAME = "launch-broker-key"
|
||||
LAUNCH_BROKER_KEY_ENV = "BOT_BOTTLE_LAUNCH_BROKER_KEY"
|
||||
# The host controller's OWN key, for its lifecycle endpoints (the direct
|
||||
# cli -> host controller path that starts/stops the orchestrator). Separate from
|
||||
# the launch-broker key and never held by the orchestrator: the controller starts
|
||||
# and stops the orchestrator, so the orchestrator must not be able to mint the
|
||||
# credentials used to drive it (#468/#476).
|
||||
HOST_CONTROLLER_KEY_FILENAME = "host-controller-key"
|
||||
HOST_CONTROLLER_KEY_ENV = "BOT_BOTTLE_HOST_CONTROLLER_KEY"
|
||||
HOST_CONTROLLER_AUTH_JWT_ENV = "BOT_BOTTLE_HOST_CONTROLLER_AUTH_JWT"
|
||||
|
||||
# The host directory holding the gateway's persistent mitmproxy CA. Bind-mounted
|
||||
# into the infra/gateway container at mitmproxy's confdir so the self-generated
|
||||
# CA survives container recreation — every agent installs this one CA to trust
|
||||
@@ -142,6 +158,11 @@ __all__ = [
|
||||
"ORCHESTRATOR_TOKEN_FILENAME",
|
||||
"ORCHESTRATOR_TOKEN_ENV",
|
||||
"ORCHESTRATOR_AUTH_JWT_ENV",
|
||||
"LAUNCH_BROKER_KEY_FILENAME",
|
||||
"LAUNCH_BROKER_KEY_ENV",
|
||||
"HOST_CONTROLLER_KEY_FILENAME",
|
||||
"HOST_CONTROLLER_KEY_ENV",
|
||||
"HOST_CONTROLLER_AUTH_JWT_ENV",
|
||||
"GATEWAY_CA_DIRNAME",
|
||||
"bot_bottle_root",
|
||||
"host_db_path",
|
||||
|
||||
@@ -29,8 +29,13 @@ from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import orchestrator_auth
|
||||
from .orchestrator_auth import ROLE_GATEWAY
|
||||
from .orchestrator_auth import ROLE_GATEWAY, ROLE_HOST
|
||||
from .paths import (
|
||||
HOST_CONTROLLER_AUTH_JWT_ENV,
|
||||
HOST_CONTROLLER_KEY_ENV,
|
||||
HOST_CONTROLLER_KEY_FILENAME,
|
||||
LAUNCH_BROKER_KEY_ENV,
|
||||
LAUNCH_BROKER_KEY_FILENAME,
|
||||
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||
ORCHESTRATOR_TOKEN_ENV,
|
||||
ORCHESTRATOR_TOKEN_FILENAME,
|
||||
@@ -99,6 +104,40 @@ CONTROL_PLANE = TrustDomain(
|
||||
)
|
||||
|
||||
|
||||
# The launch-broker domain (#468): durable key material for the broker's own
|
||||
# signed launch requests (`broker.py`'s HS256 launch JWT), shared by the
|
||||
# orchestrator (signer) and the host control server (verifier). Unlike
|
||||
# `CONTROL_PLANE` it mints no role tokens — the broker's provenance is the launch
|
||||
# JWT, not a role token — so its `roles` set is empty and it is used only as a
|
||||
# provider of durable, host-canonical key material (`signing_key` / `key_from_env`).
|
||||
# The durability is the point: the key survives orchestrator restarts, so a
|
||||
# restarted orchestrator re-verifies against the same key instead of the
|
||||
# ephemeral per-process secret the in-process broker used.
|
||||
LAUNCH_BROKER = TrustDomain(
|
||||
name="launch-broker",
|
||||
key_filename=LAUNCH_BROKER_KEY_FILENAME,
|
||||
roles=frozenset(),
|
||||
key_env=LAUNCH_BROKER_KEY_ENV,
|
||||
token_env="",
|
||||
)
|
||||
|
||||
# The host controller's own domain (#468) — the SECOND domain #476 reserves. Its
|
||||
# key, which the orchestrator never holds, signs the `host`-role tokens the CLI
|
||||
# presents on the host controller's lifecycle endpoints (start / restart / status
|
||||
# of the orchestrator itself). Keeping it separate from `CONTROL_PLANE` is the
|
||||
# whole point: the host controller starts and stops the orchestrator, so the
|
||||
# orchestrator must not be able to mint the credentials used to drive it. (The
|
||||
# lifecycle endpoints themselves arrive in a later chunk; the domain is
|
||||
# established here alongside the durable launch-broker key.)
|
||||
HOST_CONTROLLER = TrustDomain(
|
||||
name="host-controller",
|
||||
key_filename=HOST_CONTROLLER_KEY_FILENAME,
|
||||
roles=frozenset({ROLE_HOST}),
|
||||
key_env=HOST_CONTROLLER_KEY_ENV,
|
||||
token_env=HOST_CONTROLLER_AUTH_JWT_ENV,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControlPlaneProvisioning:
|
||||
"""The one seam every backend launcher uses to provision control-plane auth,
|
||||
@@ -132,9 +171,56 @@ class ControlPlaneProvisioning:
|
||||
return self.domain.mint(ROLE_GATEWAY)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaunchBrokerProvisioning:
|
||||
"""The seam that provisions the host-side launch broker's durable keys (#468),
|
||||
the counterpart to `ControlPlaneProvisioning`. Both the orchestrator (signer)
|
||||
and the host control server (verifier) receive the SAME launch-broker key
|
||||
(carry it in `broker_domain.key_env`); the host controller ALSO receives its
|
||||
own lifecycle key (`controller_domain.key_env`) the orchestrator never holds.
|
||||
|
||||
Fail-closed like the control-plane seam: minting returns "" only if the host
|
||||
root is unwritable, and an empty launch-broker key would leave the verifier
|
||||
unable to authenticate any launch — so we raise rather than hand back a key
|
||||
that would make the host controller reject (or, if a caller defaulted it,
|
||||
accept) unsigned input."""
|
||||
|
||||
broker_domain: TrustDomain = LAUNCH_BROKER
|
||||
controller_domain: TrustDomain = HOST_CONTROLLER
|
||||
|
||||
def broker_key(self) -> str:
|
||||
"""The durable launch-broker key both the orchestrator and the host
|
||||
control server must receive (in `broker_domain.key_env`). Raises rather
|
||||
than return ""."""
|
||||
key = self.broker_domain.signing_key()
|
||||
if not key:
|
||||
raise ProvisioningError(
|
||||
f"refusing to provision the {self.broker_domain.name} broker "
|
||||
"without a signing key: the host controller could then verify no "
|
||||
"launch request's provenance"
|
||||
)
|
||||
return key
|
||||
|
||||
def controller_key(self) -> str:
|
||||
"""The host controller's own lifecycle key — provisioned ONLY to the host
|
||||
controller (in `controller_domain.key_env`), never to the orchestrator, so
|
||||
the orchestrator cannot mint the `host`-role tokens that start and stop
|
||||
it. Raises rather than return ""."""
|
||||
key = self.controller_domain.signing_key()
|
||||
if not key:
|
||||
raise ProvisioningError(
|
||||
f"refusing to provision the {self.controller_domain.name} without "
|
||||
"a signing key: its lifecycle endpoints would authenticate no one"
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProvisioningError",
|
||||
"TrustDomain",
|
||||
"CONTROL_PLANE",
|
||||
"LAUNCH_BROKER",
|
||||
"HOST_CONTROLLER",
|
||||
"ControlPlaneProvisioning",
|
||||
"LaunchBrokerProvisioning",
|
||||
]
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-06-25
|
||||
- **Deciders:** didericis
|
||||
- **Revised:** 2026-07-27 — thresholds relaxed (critical minimum 90→85%,
|
||||
diff-coverage gate 90→80%) to cut low-value test churn on changed lines.
|
||||
The risk-weighting structure and the "global is informational" rule are
|
||||
unchanged.
|
||||
|
||||
## Context
|
||||
|
||||
@@ -38,7 +34,7 @@ a regression (Goodhart's law).
|
||||
Coverage is **risk-weighted**, measured over the **combined unit +
|
||||
integration** suites, with three rules:
|
||||
|
||||
1. **Critical modules must remain ≥ 85%.** The curated security/logic core
|
||||
1. **Critical modules must remain ≥ 90%.** The curated security/logic core
|
||||
covers the host and gateway egress policy, manifest trust boundary,
|
||||
git-gate enforcement, supervise protocol/server, YAML parser, and bottle
|
||||
state. The concrete module list lives in `scripts/critical-modules.txt`;
|
||||
@@ -59,7 +55,7 @@ integration** suites, with three rules:
|
||||
|
||||
The forward-looking guard is a **diff-coverage gate**
|
||||
(`scripts/diff_coverage.py`): new/changed executable lines on a branch
|
||||
must be ≥ 80% covered. This catches regressions where they are
|
||||
must be ≥ 90% covered. This catches regressions where they are
|
||||
introduced without forcing a back-fill crusade through legacy glue. The
|
||||
gate skips lines in omitted files (there is no coverage data for them),
|
||||
so the omit list cannot launder *new* logic into the dark: anything that
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
# PRD 0011: Per-file Markdown manifest
|
||||
|
||||
- **Status:** Active (agent cwd-discovery superseded)
|
||||
- **Status:** Active
|
||||
- **Author:** didericis
|
||||
- **Created:** 2026-05-24
|
||||
|
||||
> **Superseded in part by PRD 0082.**
|
||||
> The `$CWD/.bot-bottle/agents/<name>.md` discovery/override path described
|
||||
> below is removed: agents are now **home-only**, like bottles. Once an agent
|
||||
> definition can select a host identity (`author`) and a host forge secret
|
||||
> (`forge-accounts`), letting checked-out workspace content define or override
|
||||
> an agent would let untrusted content select host credentials. A cwd
|
||||
> `agents/` (or `bottles/`) directory is now warned-about and ignored. The
|
||||
> filesystem-layout trust boundary still holds — it just admits nothing from
|
||||
> `$CWD`.
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the single-file `bot-bottle.json` manifest with a
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
# PRD 0082: Trusted agent forge identity and guidance
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Author:** didericis-claude
|
||||
- **Created:** 2026-07-25
|
||||
- **Issue:** #423
|
||||
|
||||
## Summary
|
||||
|
||||
Move author identity and named forge configurations into host-trusted agent
|
||||
definitions. A bottle may optionally associate a git-gate repository with one
|
||||
of the selected agent's forge aliases. When associated, bot-bottle gives the
|
||||
agent non-secret, provider-specific system guidance for that repository and
|
||||
routes authenticated forge API calls through the egress proxy without exposing
|
||||
the forge token to the bottle. The authenticated account is inferred from the
|
||||
identity that owns that token; it is not separately declared in the manifest.
|
||||
|
||||
Repository-local agent and bottle definitions are no longer discovered. Once
|
||||
agent definitions can select a host forge credential, allowing checked-out
|
||||
repository content to define or override an agent would let untrusted workspace
|
||||
content select host identities and secrets.
|
||||
|
||||
This PRD is deliberately limited to identity ownership, manifest trust, forge
|
||||
API access, and generated guidance. Per-activation signing, signature
|
||||
enforcement, commit attribution, and the commit audit model move to a follow-up
|
||||
PRD.
|
||||
|
||||
Successor to:
|
||||
|
||||
- **PRD 0011 (per-file manifests)** — allowed repository-local agent files to
|
||||
override home agents. This PRD removes that trust path: agents and bottles are
|
||||
loaded only from the host-owned `~/.bot-bottle` tree.
|
||||
- **PRD 0027 (agent git identity, #94)** / **ADR 0002** — put name/email in
|
||||
`git-gate.user` while keeping them claimed rather than vouched. This PRD moves
|
||||
those agent properties out of git-gate and into the trusted agent definition.
|
||||
- **PRD 0048 (deploy-key provisioning, #169)** — remains the Git push
|
||||
capability. A forge actor token is a separate API credential and never
|
||||
replaces a deploy key.
|
||||
|
||||
## Problem
|
||||
|
||||
### Forge workflow context is missing
|
||||
|
||||
The agent prompt does not know which forge backs a git-gate repository, which
|
||||
API base URL to use, or that authenticated requests must go through the egress
|
||||
proxy. Bespoke prompt text has drifted between agents. Missing guidance has
|
||||
already caused incorrect PR behavior, including attempts to use Gitea AGit
|
||||
review refs instead of a normal branch-backed pull request.
|
||||
|
||||
### Identity is owned by the wrong layer
|
||||
|
||||
`git-gate.user` puts an agent property on a repository transport component. An
|
||||
author identity and set of forge credentials should follow the agent across
|
||||
bottles and repositories. Git-gate should own Git transport policy, not decide
|
||||
who the agent is.
|
||||
|
||||
### Repository-local agents become a credential-selection path
|
||||
|
||||
Today `$CWD/.bot-bottle/agents/*.md` can define new agents and override
|
||||
home-resident agents. If an agent definition may reference an operator-provided
|
||||
forge token, a malicious repository could select a host credential merely by
|
||||
being the current workspace. Repository-local bottles are already ignored;
|
||||
agents need the same host-only boundary.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- **Agent-owned identity.** Author name/email and named forge configurations
|
||||
live on the agent definition, not under `git-gate`.
|
||||
- **Trusted definitions only.** Agent and bottle files are discovered only
|
||||
under `~/.bot-bottle/{agents,bottles}`. Repository-local definitions never
|
||||
contribute names, defaults, or overrides.
|
||||
- **Optional repository association.** A bottle repository may name one forge
|
||||
alias from the selected agent. Repositories without `forge` retain current
|
||||
behavior and generate no forge guidance or credential route.
|
||||
- **Proxy-held forge credential.** The host resolves the selected forge
|
||||
alias's token reference and gives the value only to the egress proxy. The
|
||||
bottle receives neither the token nor a credential file containing it.
|
||||
- **Forge-aware system guidance.** For associated repositories, bot-bottle
|
||||
generates provider-specific instructions describing the API base URL,
|
||||
repository/account association, proxy-authenticated access, and correct PR
|
||||
workflow.
|
||||
- **No secret prompt material.** Neither token values nor token
|
||||
environment-variable names appear in the prompt or bottle environment.
|
||||
- **Fail closed.** Unknown account references, unsupported/non-HTTPS URLs,
|
||||
missing host secrets, and misplaced agent/bottle fields fail before creating
|
||||
the bottle.
|
||||
- **Push capability unchanged.** Git transport remains PRD 0048 deploy keys.
|
||||
The forge actor token is only for API actions such as opening, reviewing, and
|
||||
commenting on pull requests.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Commit signing or signature enforcement.** No signing key is minted and
|
||||
git-gate does not verify commit signatures in this PRD.
|
||||
- **Commit attribution or audit tables.** There is no trustworthy commit
|
||||
observation event in this slice. A follow-up signing PRD owns activation keys,
|
||||
control-plane verification, and any configured-author-versus-claimed-author
|
||||
audit model.
|
||||
- **Cryptographically vouched author identity.** `author` configures Git and
|
||||
identifies the agent actor, but commit author/committer fields remain claims
|
||||
under ADR 0002.
|
||||
- **Forge account or token minting.** The operator creates the agent-specific
|
||||
account/token out of band. Bot-bottle references an existing host secret; it
|
||||
does not create, rotate, or revoke that credential.
|
||||
- **Forge-side commit attribution surfaces.** No commit status, signing-key
|
||||
registration, or "Verified" badge.
|
||||
- **Provider-generic arbitrary prompts.** Gitea is the first supported forge.
|
||||
A future provider adds typed validation and generated guidance in code rather
|
||||
than accepting repository-supplied prompt text.
|
||||
|
||||
## Design
|
||||
|
||||
### Ownership model
|
||||
|
||||
The resolved bottled agent is an agent definition composed with a bottle:
|
||||
|
||||
| Part | Source | Role |
|
||||
|------|--------|------|
|
||||
| Author identity | agent `author` | configures Git name/email and identifies the agent's claimed author |
|
||||
| Forge configurations | agent `forge-accounts` | maps forge aliases to API origins and host token references available to this agent; token ownership determines account identity |
|
||||
| Git repository | bottle `git-gate.repos` | configures Git transport, host verification, and push capability |
|
||||
| Repository/forge association | bottle repo `forge` | optionally selects an agent forge alias for guidance and API access |
|
||||
|
||||
This boundary keeps identity on the agent, capability/policy on the bottle, and
|
||||
transport enforcement in git-gate.
|
||||
|
||||
### Agent manifest
|
||||
|
||||
Agent identity and forge accounts live only in
|
||||
`~/.bot-bottle/agents/<name>.md`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
author:
|
||||
name: didericis-claude
|
||||
email: eric+claude@dideric.is
|
||||
forge-accounts:
|
||||
didericis-gitea:
|
||||
auth:
|
||||
type: token
|
||||
token_secret: GITEA_CLAUDE_TOKEN
|
||||
url: https://gitea.dideric.is/api/v1
|
||||
---
|
||||
```
|
||||
|
||||
`author` contains:
|
||||
|
||||
- `name`: non-empty string;
|
||||
- `email`: non-empty string passing the existing Git identity validation.
|
||||
|
||||
The resolved values populate `user.name` and `user.email`. Existing
|
||||
`git-gate.user` fields fail with migration guidance to move the values into the
|
||||
selected home agent's `author` block. There is no compatibility period where a
|
||||
bottle identity silently overrides the agent identity.
|
||||
|
||||
`forge-accounts` is a map whose keys are **forge aliases** and follow the
|
||||
manifest's existing kebab-case identifier grammar
|
||||
(`[a-z][a-z0-9-]*`). Each forge entry contains:
|
||||
|
||||
- `url`: a canonical HTTPS Gitea API base URL;
|
||||
- `auth.type`: `token` in this slice;
|
||||
- `auth.token_secret`: the name of a host environment variable containing the
|
||||
operator-provided, agent-specific API token.
|
||||
|
||||
The token's owner determines the authenticated forge account; there is no
|
||||
separate account-name field. The token secret name is host configuration, not
|
||||
bottle configuration. The token value is resolved only if a selected bottle
|
||||
repository references the forge alias.
|
||||
|
||||
### Bottle manifest
|
||||
|
||||
Repository policy remains in `~/.bot-bottle/bottles/<name>.md`:
|
||||
|
||||
```yaml
|
||||
---
|
||||
git-gate:
|
||||
repos:
|
||||
bot-bottle:
|
||||
url: ssh://git@100.78.141.42:30009/didericis/bot-bottle.git
|
||||
provisioned_key:
|
||||
provider: gitea
|
||||
token_env: GITEA_DEPLOY_TOKEN
|
||||
host_key: "ssh-ed25519 AAAA..."
|
||||
forge: didericis-gitea
|
||||
---
|
||||
```
|
||||
|
||||
`git-gate.repos.<name>.forge` is optional:
|
||||
|
||||
- When absent, the repository behaves exactly as it does today. Bot-bottle does
|
||||
not resolve a forge actor token and does not add forge-specific instructions
|
||||
for that repository.
|
||||
- When present, it must match a forge alias in `forge-accounts` on the selected
|
||||
agent.
|
||||
The resolved association enables a scoped proxy credential route and adds the
|
||||
repository/forge relationship to generated system guidance.
|
||||
|
||||
`provisioned_key.token_env` remains the deploy-key administration credential
|
||||
from PRD 0048. It is separate from `forge-accounts.*.auth.token_secret`: the
|
||||
former provisions Git push capability, while the latter performs API actions as
|
||||
the agent.
|
||||
|
||||
`author` and `forge-accounts` are agent-only. `git-gate.repos`, including
|
||||
`forge`, is bottle-only. Validation errors point to the correct file and trust
|
||||
domain rather than ignoring misplaced keys.
|
||||
|
||||
### Definition trust and discovery
|
||||
|
||||
Only the host-owned manifest tree is authoritative:
|
||||
|
||||
- Agents: `~/.bot-bottle/agents/*.md`
|
||||
- Bottles: `~/.bot-bottle/bottles/*.md`
|
||||
|
||||
`$CWD/.bot-bottle/agents/*.md` no longer contributes new agents and no longer
|
||||
overrides a home agent. `$CWD/.bot-bottle/bottles/*.md` remains unusable. If
|
||||
either repository-local directory contains manifest files, bot-bottle emits a
|
||||
warning that they are ignored and points to the corresponding home path.
|
||||
|
||||
Every discovery and resolution surface uses the same home-only index:
|
||||
|
||||
- agent enumeration and selectors;
|
||||
- `require_agent`;
|
||||
- lazy `load_for_agent`;
|
||||
- default-agent/default-bottle resolution;
|
||||
- dashboard and headless launch paths.
|
||||
|
||||
There must be no alternate direct-path load that can still select a workspace
|
||||
definition.
|
||||
|
||||
Workspace instructions remain repository content (for example `AGENTS.md`),
|
||||
but runtime policy, host secret references, and actor identity do not.
|
||||
Programmatic in-memory manifests remain available for tests and trusted internal
|
||||
composition; they are not a filesystem discovery path.
|
||||
|
||||
### Forge URL validation
|
||||
|
||||
The host parses and canonicalizes each referenced forge alias's URL before
|
||||
creating any runtime resources:
|
||||
|
||||
- scheme must be `https`;
|
||||
- userinfo, query, and fragment are forbidden;
|
||||
- hostname must be present;
|
||||
- the path must be a supported Gitea API base (initially `/api/v1`, with
|
||||
normalization of a trailing slash);
|
||||
- visually different inputs that canonicalize to the same origin/prefix are
|
||||
deduplicated;
|
||||
- unsupported providers or path shapes fail closed.
|
||||
|
||||
The provider is determined by typed support in bot-bottle, not by prompt text
|
||||
from a repository. Adding another provider requires a validator, auth scheme,
|
||||
and guidance renderer.
|
||||
|
||||
### Proxy credential provisioning
|
||||
|
||||
For each distinct forge alias referenced by at least one selected bottle
|
||||
repository, the host:
|
||||
|
||||
1. Resolves `auth.token_secret` from the host environment and rejects a missing
|
||||
or empty value.
|
||||
2. Copies the token value only into the egress proxy's credential environment.
|
||||
3. Adds an inspected route scoped to the canonical forge origin and API prefix.
|
||||
4. Configures the provider authentication scheme (`token` for Gitea) so the
|
||||
proxy injects authentication.
|
||||
|
||||
The bottle makes an unauthenticated request to the configured HTTPS API URL.
|
||||
The token is not copied into the bottle, `.gitconfig`, generated prompt,
|
||||
workspace, or process environment visible to the agent.
|
||||
|
||||
If several repositories reference the same forge alias, they share one
|
||||
credential route. Unreferenced forge aliases resolve no secret and create no
|
||||
route.
|
||||
|
||||
### Generated system guidance
|
||||
|
||||
Bot-bottle appends a generated, non-secret section to its existing system
|
||||
prompt file. It is derived from validated typed fields, not copied Markdown from
|
||||
a repository.
|
||||
|
||||
For each associated repository, Gitea guidance includes:
|
||||
|
||||
- forge alias (for example `didericis-gitea`);
|
||||
- API base URL (for example `https://gitea.dideric.is/api/v1`);
|
||||
- the git-gate repository name tied to that forge;
|
||||
- the instruction to call the configured HTTPS API through the proxy without
|
||||
reading, printing, or manually attaching an authorization token;
|
||||
- the distinction that Git pushes still use the git-gate remote;
|
||||
- the requirement to create/update a normal `refs/heads/<branch>` and open a
|
||||
branch-backed pull request through the API;
|
||||
- the prohibition on pushing `refs/for/*`, `refs/draft/*`, or
|
||||
`refs/for-review/*`;
|
||||
- the instruction to use the API for reviews/comments and verify returned
|
||||
object state before claiming completion.
|
||||
|
||||
The prompt contains neither the token value nor its `token_secret` name.
|
||||
Repositories without `forge` are omitted from this section. If no selected
|
||||
repository has a forge association, no forge guidance section is generated.
|
||||
|
||||
### Failure and lifecycle behavior
|
||||
|
||||
Forge configuration is validated before bottle creation. A bad association or
|
||||
credential must not leave a partially-created bottle or proxy.
|
||||
|
||||
The operator-owned token is not minted, rotated, or revoked by bot-bottle. On
|
||||
teardown, stopping the egress proxy discards the activation's in-memory/runtime
|
||||
copy. Later activations resolve the current host secret again.
|
||||
|
||||
Logs may include the forge alias, canonical API origin, and repository name.
|
||||
They must never contain the token value. Errors for missing secrets name the
|
||||
configuration field and host environment variable, but do not print any value.
|
||||
|
||||
## Migration
|
||||
|
||||
This change is intentionally breaking at the manifest trust boundary:
|
||||
|
||||
1. Move each home bottle/agent `git-gate.user.name` and `.email` into the
|
||||
corresponding home agent's `author`.
|
||||
2. Add agent-specific `forge-accounts` only to home agent definitions.
|
||||
3. Add optional `forge` associations to home bottle repository entries.
|
||||
4. Move any `$CWD/.bot-bottle/agents/*.md` that should remain selectable into
|
||||
`~/.bot-bottle/agents/`. Repository copies are ignored thereafter.
|
||||
5. Keep repository-specific behavioral instructions in `AGENTS.md` or another
|
||||
workspace instruction file; do not put runtime identity or secret references
|
||||
there.
|
||||
|
||||
Errors and warnings link to this migration rather than silently changing which
|
||||
identity or definition is active.
|
||||
|
||||
## Follow-up: signed commits and attribution
|
||||
|
||||
A separate PRD will consume the trusted agent `author` introduced here and own:
|
||||
|
||||
- per-activation signing key minting and sidecar isolation;
|
||||
- commit-time signing through a forwarded signing capability;
|
||||
- git-gate rejection of unsigned/wrong-key new commits;
|
||||
- independent control-plane object-ID recomputation and signature verification;
|
||||
- activation and attributed-commit audit tables;
|
||||
- storage of configured agent author separately from each commit's unenforced
|
||||
claimed author/committer;
|
||||
- post-teardown verification and key-retention policy.
|
||||
|
||||
That PRD must not reintroduce identity under git-gate or expand repository-local
|
||||
manifest trust.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
1. **This PRD.** Establish the identity, forge, and filesystem trust boundary.
|
||||
2. **Home-only definitions.** Remove cwd agents from discovery, override,
|
||||
enumeration, lazy loading, defaults, and selectors. Warn on ignored cwd
|
||||
agent/bottle files and provide migration guidance.
|
||||
3. **Manifest schema.** Add agent-only `author` and `forge-accounts`; remove
|
||||
`git-gate.user`; add optional bottle-only
|
||||
`git-gate.repos.<name>.forge`. Validate account names, composition
|
||||
references, field placement, and Gitea API URLs.
|
||||
4. **Proxy provisioning.** Lazily resolve only referenced token secrets and
|
||||
create scoped authenticated Gitea API routes without putting credentials in
|
||||
the bottle.
|
||||
5. **Prompt generation.** Render typed Gitea/repository workflow guidance into
|
||||
the existing bot-bottle prompt path for associated repositories only.
|
||||
6. **Docs and migration.** Update README examples, PRD 0011-facing discovery
|
||||
documentation, agent/bottle schema docs, and error guidance.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Trust boundary:** cwd agent files are ignored with a warning, cannot
|
||||
override a home agent, are absent from enumeration/selectors/defaults, and
|
||||
cannot be loaded by name. Cwd bottle behavior remains home-only.
|
||||
- **Agent schema:** `author` and `forge-accounts` parse; malformed identities,
|
||||
non-kebab account names, unknown fields, invalid auth types, and misplaced
|
||||
git-gate fields fail clearly.
|
||||
- **Bottle schema:** `forge` is optional; absent associations preserve current
|
||||
behavior; present associations resolve against the selected agent; unknown or
|
||||
misplaced associations fail before launch.
|
||||
- **URL validation:** HTTPS Gitea API bases pass and canonicalize; HTTP,
|
||||
userinfo, query, fragment, missing host, unsupported paths, and unsupported
|
||||
providers fail closed.
|
||||
- **Secret handling:** only referenced accounts resolve environment secrets;
|
||||
missing/empty secrets fail before runtime creation; token values are absent
|
||||
from bottle env, prompt, generated config, logs, and workspace; token secret
|
||||
names are absent from the bottle and prompt.
|
||||
- **Proxy behavior:** associated API requests receive proxy-injected Gitea
|
||||
authentication scoped to the configured origin/prefix; unrelated hosts and
|
||||
paths receive no credential.
|
||||
- **Prompt behavior:** guidance names account/API/repository associations,
|
||||
branch-backed PR workflow, prohibited AGit refs, and mutation verification;
|
||||
repositories without `forge` are omitted; no associations means no section.
|
||||
- **Migration:** legacy `git-gate.user` fails with an `author` migration pointer;
|
||||
ignored cwd definitions warn with the target home path.
|
||||
|
||||
## Open questions
|
||||
|
||||
- None.
|
||||
@@ -0,0 +1,273 @@
|
||||
# PRD prd-new: Host control server
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** Claude
|
||||
- **Created:** 2026-07-26
|
||||
- **Issue:** #468
|
||||
|
||||
## Summary
|
||||
|
||||
Promote the in-process launch broker into a standalone **host control
|
||||
server**: the single privileged component on the host. Both the CLI and the
|
||||
orchestrator drive it over HTTP; it brokers agent launches, owns the
|
||||
orchestrator's own lifecycle, and is the sole writer of host-durable state (the
|
||||
tamper-evident audit record). This closes the three gaps between today's
|
||||
well-formed broker *contract* ([`orchestrator/broker.py`](../../bot_bottle/orchestrator/broker.py))
|
||||
and a real out-of-process service — transport, durable provisioned secret,
|
||||
and a disciplined op vocabulary — and splits host state by
|
||||
owner and lifetime. The prize: **the CLI no longer needs the Docker socket**,
|
||||
which is what finally lets a dedicated Gitea runner user drop the
|
||||
root-equivalent `docker` group (PRD 0070, "Relationship to other work").
|
||||
|
||||
## Problem
|
||||
|
||||
Container launches run directly from a short-lived CLI process against the
|
||||
Docker socket. That socket is root-equivalent, so every host that launches
|
||||
bottles hands root to whoever invokes the CLI — including a CI runner user we
|
||||
want to keep unprivileged. PRD 0070 already argues for replacing the fat socket
|
||||
with a **thin, structured, auditable** launch broker, and the contract for that
|
||||
broker exists and is tested in-process. But it is *only* in-process:
|
||||
`LaunchBroker.submit(token)` is a method call from
|
||||
`OrchestratorCore.launch_bottle` ([`service.py:116`](../../bot_bottle/orchestrator/service.py)),
|
||||
and `DockerBroker` is on no production path — every backend starts the
|
||||
orchestrator with `--broker stub` ([`__main__.py:54`](../../bot_bottle/orchestrator/__main__.py)).
|
||||
|
||||
Three gaps stand between that scaffold and a host service:
|
||||
|
||||
1. **No transport.** `submit` is an in-process call. A real service needs a
|
||||
`BrokerClient` that POSTs the signed token and a host-side HTTP server that
|
||||
verifies and acts.
|
||||
2. **The signing secret is ephemeral and self-generated.**
|
||||
[`__main__.py:53`](../../bot_bottle/orchestrator/__main__.py) does
|
||||
`secrets.token_bytes(32)` and hands the *same value* to signer and verifier —
|
||||
viable only because they share a process. A separate daemon needs the secret
|
||||
provisioned out of band and durable across orchestrator restarts.
|
||||
3. **The op vocabulary is `launch` / `teardown` only.** Everything else
|
||||
host-privileged still lives in the CLI, so the schema has to grow — carefully,
|
||||
since PRD 0070's security argument rests on "structured requests only, static
|
||||
flags + ids."
|
||||
|
||||
Separately, host state has no clear owner. `OrchestratorCore.reconcile` takes
|
||||
`live_source_ips` as a parameter *only because the orchestrator cannot see the
|
||||
backend* ([`service.py:137`](../../bot_bottle/orchestrator/service.py)); the
|
||||
egress traffic log is written to the container's stderr; and there is no durable,
|
||||
tamper-evident home for the audit record that survives orchestrator destruction.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- A standalone host control server that the CLI and orchestrator reach over
|
||||
**HTTP**, with three entry paths working end to end:
|
||||
- `web console -(iroh)-> orchestrator -(http)-> host controller -> launch`
|
||||
- `cli -(http)-> orchestrator -(http)-> host controller -> launch`
|
||||
- `cli -(http)-> host controller` — start / restart / status of the
|
||||
orchestrator **itself** (the bootstrap/recovery path #391 targets).
|
||||
- The launch op is expressed as a **signed JWT of static flags + ids only**,
|
||||
verified against a closed schema.
|
||||
- The signing secret is **provisioned out of band and durable** across
|
||||
orchestrator restarts (a `TrustDomain` per #476, with a key the orchestrator
|
||||
never holds for the host controller's *own* endpoints).
|
||||
- Host-privileged operations move off the CLI to the control server; **the CLI
|
||||
no longer opens the Docker socket** for bottle operations.
|
||||
- `Orchestrator.reconcile` no longer takes `live_source_ips` — live-bottle
|
||||
enumeration becomes an internal control-server call.
|
||||
- Host-durable state lands as an **append-only, hash-chained JSONL** audit log
|
||||
owned solely by the host controller; operational state stays SQLite owned
|
||||
solely by the orchestrator.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Removing standing privilege.** This converts on-demand privilege (a CLI the
|
||||
user invokes) into standing privilege (a daemon under launchd/systemd). The
|
||||
win is that the privilege is *narrower* (structured requests vs. a raw socket),
|
||||
not that it disappears. "Always running" is an accepted new property.
|
||||
- **Asymmetric signing.** We stay HS256 — see Design / "Signing stays
|
||||
symmetric."
|
||||
- **Integrity against a live compromised orchestrator.** Host-location of the
|
||||
audit log does not buy this: the orchestrator makes the decisions being audited
|
||||
and can forge or omit entries wherever the file lives. An off-box copy is the
|
||||
answer, tracked separately.
|
||||
- **A single unified DB for all state.** Impossible over a guest-kernel share
|
||||
(SQLite locking is not coherent); state is split by owner and lifetime instead.
|
||||
- **The generic `SecretProvider` (#355)** and **remote terminal design (#478)** —
|
||||
both ride the same door but are their own work.
|
||||
|
||||
## Design
|
||||
|
||||
### Topology
|
||||
|
||||
The host controller is the sole privileged component. The orchestrator becomes a
|
||||
client of it for launches, and the CLI becomes a client of it for *both* bottle
|
||||
operations (indirectly, through the orchestrator) and orchestrator lifecycle
|
||||
(directly, for bootstrap/recovery — startup can't route through the thing being
|
||||
started).
|
||||
|
||||
```
|
||||
web console ─(iroh)─▶ orchestrator ─┐
|
||||
├─(http, signed JWT)─▶ host controller ─▶ launch
|
||||
cli ────────(http)──▶ orchestrator ─┘
|
||||
cli ────────(http, bearer)──────────────────────────────▶ host controller (orchestrator lifecycle)
|
||||
```
|
||||
|
||||
### Transport: `BrokerClient` + host server
|
||||
|
||||
`LaunchBroker.submit(token)` keeps its exact signature and semantics; only the
|
||||
*wire* changes. A new `BrokerClient` implements the same submit contract by
|
||||
POSTing the signed token to the host controller (stdlib `urllib`, like the
|
||||
existing [`orchestrator/client.py`](../../bot_bottle/orchestrator/client.py)),
|
||||
and the host controller's launch handler is the existing `verify_request` +
|
||||
`_launch`/`_teardown` path, now reached over HTTP instead of a method call. The
|
||||
in-process `StubBroker` stays for the dev-harness and tests; `DockerBroker`'s
|
||||
`_launch`/`_teardown` bodies move behind the server unchanged. Because the client
|
||||
satisfies the same interface `OrchestratorCore` already depends on, the core does
|
||||
not change to gain a real backend.
|
||||
|
||||
### Signing stays symmetric (HS256)
|
||||
|
||||
PRD 0070 nominally specifies asymmetric; the code is HS256 and we keep it.
|
||||
Asymmetric matters when the verifier is *less* privileged than the signer — here
|
||||
it is the reverse: the host controller (verifier) is strictly more privileged
|
||||
than the orchestrator (signer), and a controller that could forge orchestrator
|
||||
requests gains nothing, since it is already the component that launches. Staying
|
||||
symmetric also honors the no-runtime-deps policy (stdlib has no Ed25519). This
|
||||
matches the reasoning already inlined in `broker.py`'s module docstring.
|
||||
|
||||
### Replay protection is out of scope (tracked in #494)
|
||||
|
||||
Once the launch token travels over a wire, a captured token could be replayed —
|
||||
`sign_request` already emits `jti`/`iat` but `verify_request` reads neither, so
|
||||
there is no expiry window or `jti` cache today. Enforcing that (an `iat` window +
|
||||
a self-trimming `jti` cache) is a pure in-process change that lands independently
|
||||
of this work, and it is deferred to **#494** rather than gating the MVP of the
|
||||
host control server. Nothing here depends on it; it can merge before or after.
|
||||
|
||||
### Op vocabulary and the "ids + static flags" rule (gap 3)
|
||||
|
||||
Each op moved off the CLI widens the privileged surface, so growth is governed by
|
||||
one explicit rule, enforced in `verify_request`'s schema check:
|
||||
|
||||
> A broker op carries **only ids and enumerated static flags** — a bottle id, a
|
||||
> pool slot, a **content-addressed** image ref chosen from a fixed set, an op
|
||||
> name from a closed vocabulary. Never a free-form path, argv, command, or
|
||||
> caller-supplied filesystem location. If an operation cannot be expressed that
|
||||
> way, it does not become a broker op.
|
||||
|
||||
Operations that fit and move off the CLI (all today in
|
||||
`backend/*/consolidated_launch.py`, driven by a short-lived CLI process):
|
||||
|
||||
| Op | What it does | Fits the rule because |
|
||||
|---|---|---|
|
||||
| `launch` / `teardown` | existing | ids + slot + image ref |
|
||||
| `orchestrator.ensure_running` | start the infra container | no arguments |
|
||||
| `orchestrator.{start,restart,status}` | lifecycle (the #391 path) | no arguments |
|
||||
| `list_live` | enumerate running bottles for reconcile | no arguments; returns ids/IPs |
|
||||
| `allocate_ip` | `next_free_ip` over `_network_container_ips` | no arguments; returns an IP |
|
||||
| `provision_git_gate` | `cp`/`exec` a per-bottle deploy key into the gateway | bottle id + key handle, no path |
|
||||
| `reprovision` | `docker exec printenv <ENV_VAR_SECRET>` on a live agent | bottle id + secret *name* |
|
||||
|
||||
Image **builds** stay with the orchestrator for v1 (PRD 0070 §Memory: builds run
|
||||
control-plane-side; a dedicated slim build unit is later, #468-adjacent), so no
|
||||
`build` broker op is added here.
|
||||
|
||||
With `list_live` as an internal control-server call, `Orchestrator.reconcile`'s
|
||||
`live_source_ips` parameter goes away — the tell PRD 0070 called out that the
|
||||
orchestrator couldn't see the backend disappears with it.
|
||||
|
||||
### Secret provisioning (gap 2)
|
||||
|
||||
The shared HS256 secret becomes a durable, out-of-band artifact via the
|
||||
**`TrustDomain`** seam (#476,
|
||||
[`trust_domain.py`](../../bot_bottle/trust_domain.py)):
|
||||
|
||||
- The **launch-broker secret** is a `TrustDomain` whose key
|
||||
(`host_signing_key(<file>)`, minted 0600 on first use, durable under
|
||||
`bot_bottle_root()`) is provisioned to the orchestrator (signer) and the host
|
||||
controller (verifier). Durability across orchestrator restarts is what makes
|
||||
re-adoption work — a restart re-verifies against the same key.
|
||||
- The **host controller's own lifecycle endpoints** (the direct `cli -> host
|
||||
controller` path) get a **separate** `TrustDomain` key the orchestrator never
|
||||
holds — exactly the second domain #476's PRD reserves. The orchestrator must
|
||||
not be able to mint the credentials used to start and stop it.
|
||||
|
||||
This reuses the seam #476 landed rather than re-deriving provisioning per
|
||||
backend (the PR #471 bug class).
|
||||
|
||||
### One daemon, structurally separate handlers (open decision 1)
|
||||
|
||||
The audit writer and the broker live in **one daemon** for install simplicity,
|
||||
but with **no shared parsing** and **different credentials per handler**:
|
||||
|
||||
- the **launch** handler requires the signed launch **JWT** (provenance +
|
||||
un-coercible schema);
|
||||
- the **audit-append** handler takes a plain **bearer token** and writes to the
|
||||
JSONL log.
|
||||
|
||||
This does not defend against orchestrator compromise (it holds both creds) — it
|
||||
stops a bug in the boring audit path from reaching the privileged launch path.
|
||||
The launcher stays small enough to audit line-by-line, per PRD 0070.
|
||||
|
||||
### State ownership: split by owner and lifetime
|
||||
|
||||
A single mounted DB is impossible — SQLite locking is not coherent across guest
|
||||
kernels over a share, which is why the macOS backend already uses a container-only
|
||||
volume (`INFRA_DB_VOLUME`). So state splits three ways (depends on #469, which
|
||||
gets `bot-bottle.db` off the data plane first):
|
||||
|
||||
| Owner | State | Home | Shape |
|
||||
|---|---|---|---|
|
||||
| **Orchestrator** | `orchestrator_bottles` registry; `bottled_agent_secrets` (encrypted egress tokens); `supervise_proposals` / `supervise_responses` | volume nothing else mounts (generalizing the macOS design) | **SQLite** — mutable, transactional, queried |
|
||||
| **Host controller** | supervise audit entries; egress traffic log (today → container stderr); host-side config | host filesystem, survives orchestrator/volume destruction | **JSONL** — append-only |
|
||||
| **Gateway** | none | — | after #469 the data plane holds no DB state |
|
||||
|
||||
The historical record is **JSONL, not SQLite**, because it is append-only, never
|
||||
updated, never transactionally queried: `O_APPEND` writes are atomic, there is no
|
||||
locking protocol to get wrong, hash-chaining for tamper-evidence is cheap, and it
|
||||
survives container-runtime volume pruning (the #450 lesson) and stays readable
|
||||
without the orchestrator running. Both halves of "the audit record" — supervise
|
||||
decisions and the egress traffic log — land in the one place.
|
||||
|
||||
The orchestrator is **sole mounter and sole writer** of its SQLite volume; the
|
||||
host controller is **sole writer** of the JSONL log, over the authenticated
|
||||
audit-append channel.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
Ordered, each independently mergeable:
|
||||
|
||||
1. **`BrokerClient` + host launch server** over HTTP, reusing `verify_request`
|
||||
and the existing `DockerBroker` bodies. Wire `OrchestratorCore` to a
|
||||
`BrokerClient` behind a flag; keep `StubBroker` for the dev-harness. Closes
|
||||
gap 1.
|
||||
2. **Durable secret via `TrustDomain`** — provision the launch-broker key to
|
||||
signer + verifier; add the host controller's own lifecycle `TrustDomain`.
|
||||
Closes gap 2.
|
||||
3. **Grow the op vocabulary** one op at a time (`list_live` first — it also
|
||||
removes `reconcile`'s `live_source_ips`), each behind the ids + static-flags
|
||||
rule. Closes gap 3.
|
||||
4. **JSONL audit log** — the host-controller-owned, hash-chained historical
|
||||
record with the plain-bearer audit-append handler; redirect the egress traffic
|
||||
log into it.
|
||||
5. **Drop the Docker socket from the CLI** once every host-privileged op it used
|
||||
is a broker op — the payoff that unblocks the unprivileged Gitea runner user.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Schema-width rule enforcement.** The "ids + static flags" rule is stated;
|
||||
should `verify_request` reject unknown claim keys outright (strict schema) to
|
||||
keep the surface from drifting? Leaning yes.
|
||||
2. **Audit-append back-pressure.** What the audit handler does if the JSONL sink
|
||||
is unavailable (fail-closed vs. buffer) — resolve before shipping chunk 5.
|
||||
|
||||
## References
|
||||
|
||||
- **PRD 0070** — the contract, the launch broker, and the state tiers this
|
||||
implements.
|
||||
- **#469** — get `bot-bottle.db` off the data plane (lands underneath this).
|
||||
- **#476** ([`prd-new-control-plane-auth-provisioning`](prd-new-control-plane-auth-provisioning.md))
|
||||
— the `TrustDomain` seam this plugs the host controller's key into.
|
||||
- **#391** — backend-agnostic orchestrator restart (the bootstrap path).
|
||||
- **#494** — enforce broker replay protection (`iat` window + `jti` cache); split
|
||||
out of this PRD as an independent in-process change.
|
||||
- **#386** — prebuilt images from the Gitea OCI registry (the fixed image set the
|
||||
broker validates against).
|
||||
- **#355** — generic `SecretProvider`.
|
||||
- **#478** — remote terminal design.
|
||||
@@ -5,9 +5,10 @@ model: opus
|
||||
bottle: dev
|
||||
skills:
|
||||
- init-prd
|
||||
author:
|
||||
name: implementer-bot
|
||||
email: eric+implementer@dideric.is
|
||||
git-gate:
|
||||
user:
|
||||
name: implementer-bot
|
||||
email: eric+implementer@dideric.is
|
||||
---
|
||||
|
||||
You are a feature-implementation agent running inside an ephemeral
|
||||
|
||||
+5
-5
@@ -13,7 +13,7 @@
|
||||
# are re-executed; no KVM or Docker dependency.
|
||||
#
|
||||
# Pass "critical" as the last argument in either mode to also report just the
|
||||
# critical modules (ADR 0004 target: 85%).
|
||||
# critical modules (ADR 0004 target: 90%).
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
@@ -34,8 +34,8 @@ if [ "${1:-}" = "aggregate" ]; then
|
||||
"$PY" -m coverage report -m
|
||||
|
||||
if [ "${2:-}" = "critical" ]; then
|
||||
echo "== critical modules (ADR 0004 minimum: 85%) ==" >&2
|
||||
"$PY" -m coverage report --include="$CRITICAL" --fail-under=85
|
||||
echo "== critical modules (ADR 0004 minimum: 90%) ==" >&2
|
||||
"$PY" -m coverage report --include="$CRITICAL" --fail-under=90
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
@@ -55,6 +55,6 @@ echo "== combined report ==" >&2
|
||||
"$PY" -m coverage report -m
|
||||
|
||||
if [ "${1:-}" = "critical" ]; then
|
||||
echo "== critical modules (ADR 0004 minimum: 85%) ==" >&2
|
||||
"$PY" -m coverage report --include="$CRITICAL" --fail-under=85
|
||||
echo "== critical modules (ADR 0004 minimum: 90%) ==" >&2
|
||||
"$PY" -m coverage report --include="$CRITICAL" --fail-under=90
|
||||
fi
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Critical security/logic core held to the >=85% coverage bar by
|
||||
# Critical security/logic core held to the >=90% coverage bar by
|
||||
# docs/decisions/0004-coverage-policy.md.
|
||||
#
|
||||
# SINGLE SOURCE OF TRUTH: scripts/coverage.sh (the `critical` report) and
|
||||
@@ -30,7 +30,6 @@ bot_bottle/manifest/agent.py
|
||||
bot_bottle/manifest/bottle.py
|
||||
bot_bottle/manifest/egress.py
|
||||
bot_bottle/manifest/extends.py
|
||||
bot_bottle/manifest/forge.py
|
||||
bot_bottle/manifest/git.py
|
||||
bot_bottle/manifest/index.py
|
||||
bot_bottle/manifest/loader.py
|
||||
|
||||
@@ -13,8 +13,8 @@ policy.
|
||||
|
||||
Usage:
|
||||
scripts/coverage.sh # produce .coverage first
|
||||
python3 scripts/diff_coverage.py # gate against origin/main, min 80%
|
||||
python3 scripts/diff_coverage.py --base main --min 75
|
||||
python3 scripts/diff_coverage.py # gate against origin/main, min 90%
|
||||
python3 scripts/diff_coverage.py --base main --min 85
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -74,7 +74,7 @@ def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", default="origin/main",
|
||||
help="git ref to diff against (default: origin/main)")
|
||||
ap.add_argument("--min", type=float, default=80.0,
|
||||
ap.add_argument("--min", type=float, default=90.0,
|
||||
help="minimum %% of changed executable lines covered")
|
||||
args = ap.parse_args()
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -22,7 +21,7 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||
from bot_bottle.egress import EgressPlan
|
||||
from bot_bottle.git_gate import GitGatePlan
|
||||
from bot_bottle.manifest import ManifestGitUser, ManifestIndex
|
||||
from bot_bottle.manifest import ManifestIndex
|
||||
|
||||
|
||||
class _Provider(AgentProvider):
|
||||
@@ -51,39 +50,15 @@ def _plan(*, git_user: dict | None = None, # type: ignore
|
||||
user_cwd: str = "/tmp/x",
|
||||
stage_dir: Path | None = None) -> DockerBottlePlan:
|
||||
bottle_json: dict = {} # type: ignore
|
||||
if git_user is not None:
|
||||
bottle_json["git-gate"] = {"user": git_user}
|
||||
if git_repos is not None:
|
||||
bottle_json.setdefault("git-gate", {})["repos"] = git_repos
|
||||
# Identity now lives on the agent's `author` block; at composition it
|
||||
# populates manifest.bottle.git_user, which provision_git reads
|
||||
# (production unchanged). When the caller passes a full name+email we
|
||||
# route it through `author`; the name-only / email-only cases (which
|
||||
# exercise provision_git emitting a single `git config` line) can't be
|
||||
# expressed via `author` (both fields required), so we inject the
|
||||
# partial ManifestGitUser onto the composed bottle directly.
|
||||
agent_json: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||
full_author = (
|
||||
git_user
|
||||
if git_user and git_user.get("name") and git_user.get("email")
|
||||
else None
|
||||
)
|
||||
if full_author is not None:
|
||||
agent_json["author"] = full_author
|
||||
index = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": bottle_json},
|
||||
"agents": {"demo": agent_json},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
manifest = index.load_for_agent("demo")
|
||||
if git_user is not None and full_author is None:
|
||||
manifest = replace(
|
||||
manifest,
|
||||
bottle=replace(
|
||||
manifest.bottle,
|
||||
git_user=ManifestGitUser(
|
||||
name=git_user.get("name", ""),
|
||||
email=git_user.get("email", ""),
|
||||
),
|
||||
),
|
||||
)
|
||||
spec = BottleSpec(
|
||||
manifest=index, agent_name="demo",
|
||||
copy_cwd=copy_cwd, user_cwd=user_cwd,
|
||||
|
||||
@@ -1,426 +0,0 @@
|
||||
"""Unit: trusted agent forge identity & guidance
|
||||
(PRD 0082).
|
||||
|
||||
Covers the net-new surface: agent `author`, agent `forge-accounts` (Gitea API
|
||||
URL validation + host token reference), the repo→forge association resolved at
|
||||
composition, the synthesized proxy-held egress route, and the generated,
|
||||
non-secret prompt guidance. Legacy git-gate.user / cwd-agent behavior lives in
|
||||
the manifest test modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.egress import (
|
||||
egress_forge_routes,
|
||||
egress_render_routes,
|
||||
egress_resolve_token_values,
|
||||
egress_routes_for_bottle,
|
||||
egress_token_env_map,
|
||||
)
|
||||
from bot_bottle.manifest import ManifestError, ManifestIndex
|
||||
from bot_bottle.manifest.forge import (
|
||||
ManifestForgeAccount,
|
||||
canonicalize_forge_url,
|
||||
render_forge_guidance,
|
||||
)
|
||||
|
||||
|
||||
GITEA = {
|
||||
"url": "https://gitea.dideric.is/api/v1",
|
||||
"auth": {"type": "token", "token_secret": "GITEA_CLAUDE_TOKEN"},
|
||||
}
|
||||
|
||||
|
||||
def _repo(*, forge: str | None = None) -> dict[str, object]:
|
||||
entry: dict[str, object] = {
|
||||
"url": "ssh://git@100.78.141.42:30009/didericis/bot-bottle.git",
|
||||
"key": {"provider": "static", "path": "/k"},
|
||||
}
|
||||
if forge is not None:
|
||||
entry["forge"] = forge
|
||||
return entry
|
||||
|
||||
|
||||
def _index(
|
||||
*,
|
||||
author: dict[str, object] | None = None,
|
||||
forge_accounts: dict[str, object] | None = None,
|
||||
repo_forge: str | None = None,
|
||||
) -> ManifestIndex:
|
||||
"""Build an eager index with one agent 'claude' + bottle 'bb'."""
|
||||
agent: dict[str, object] = {"bottle": "bb", "prompt": ""}
|
||||
if author is not None:
|
||||
agent["author"] = author
|
||||
if forge_accounts is not None:
|
||||
agent["forge-accounts"] = forge_accounts
|
||||
bottle: dict[str, object] = {
|
||||
"git-gate": {"repos": {"bot-bottle": _repo(forge=repo_forge)}}
|
||||
}
|
||||
return ManifestIndex.from_json_obj(
|
||||
{"bottles": {"bb": bottle}, "agents": {"claude": agent}}
|
||||
)
|
||||
|
||||
|
||||
def _error(
|
||||
callable_: Callable[..., object], *args: object, **kwargs: object,
|
||||
) -> str:
|
||||
try:
|
||||
callable_(*args, **kwargs)
|
||||
except ManifestError as e:
|
||||
return str(e)
|
||||
raise AssertionError("expected ManifestError was not raised")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# author
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthor(unittest.TestCase):
|
||||
def test_populates_git_identity(self) -> None:
|
||||
idx = _index(author={"name": "didericis-claude", "email": "e+c@x.is"})
|
||||
m = idx.load_for_agent("claude", ())
|
||||
self.assertEqual("didericis-claude", m.bottle.git_user.name)
|
||||
self.assertEqual("e+c@x.is", m.bottle.git_user.email)
|
||||
self.assertEqual(
|
||||
"name=didericis-claude, email=e+c@x.is",
|
||||
m.git_identity_summary(),
|
||||
)
|
||||
|
||||
def test_absent_author_no_identity(self) -> None:
|
||||
m = _index().load_for_agent("claude", ())
|
||||
self.assertTrue(m.bottle.git_user.is_empty())
|
||||
self.assertIsNone(m.git_identity_summary())
|
||||
|
||||
def test_name_required(self) -> None:
|
||||
msg = _error(_index, author={"email": "e@x.is"})
|
||||
self.assertIn("author.name must be a non-empty string", msg)
|
||||
|
||||
def test_email_required(self) -> None:
|
||||
msg = _error(_index, author={"name": "n"})
|
||||
self.assertIn("author.email must be a non-empty string", msg)
|
||||
|
||||
def test_email_rejects_whitespace(self) -> None:
|
||||
msg = _error(_index, author={"name": "n", "email": "a b@x.is"})
|
||||
self.assertIn("must not contain whitespace", msg)
|
||||
|
||||
def test_name_rejects_newline(self) -> None:
|
||||
msg = _error(_index, author={"name": "a\nb", "email": "e@x.is"})
|
||||
self.assertIn("author.name must not contain newlines", msg)
|
||||
|
||||
def test_unknown_key(self) -> None:
|
||||
msg = _error(
|
||||
_index, author={"name": "n", "email": "e@x.is", "role": "x"}
|
||||
)
|
||||
self.assertIn("author has unknown key", msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# forge-accounts URL validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForgeUrl(unittest.TestCase):
|
||||
def test_canonical_ok(self) -> None:
|
||||
canonical, origin, host, prefix = canonicalize_forge_url(
|
||||
"a", "g", "https://Gitea.Dideric.is/api/v1/"
|
||||
)
|
||||
# trailing slash normalized; host lowercased.
|
||||
self.assertEqual("https://gitea.dideric.is/api/v1", canonical)
|
||||
self.assertEqual("https://gitea.dideric.is", origin)
|
||||
self.assertEqual("gitea.dideric.is", host)
|
||||
self.assertEqual("/api/v1", prefix)
|
||||
|
||||
def test_port_preserved(self) -> None:
|
||||
canonical, origin, _host, _ = canonicalize_forge_url(
|
||||
"a", "g", "https://gitea.local:3000/api/v1"
|
||||
)
|
||||
self.assertEqual("https://gitea.local:3000/api/v1", canonical)
|
||||
self.assertEqual("https://gitea.local:3000", origin)
|
||||
|
||||
def test_http_fails(self) -> None:
|
||||
self.assertIn("must use https", _error(
|
||||
canonicalize_forge_url, "a", "g", "http://gitea/api/v1"))
|
||||
|
||||
def test_userinfo_fails(self) -> None:
|
||||
self.assertIn("userinfo", _error(
|
||||
canonicalize_forge_url, "a", "g", "https://u:p@gitea/api/v1"))
|
||||
|
||||
def test_query_fails(self) -> None:
|
||||
self.assertIn("query string", _error(
|
||||
canonicalize_forge_url, "a", "g", "https://gitea/api/v1?x=1"))
|
||||
|
||||
def test_fragment_fails(self) -> None:
|
||||
self.assertIn("fragment", _error(
|
||||
canonicalize_forge_url, "a", "g", "https://gitea/api/v1#x"))
|
||||
|
||||
def test_missing_host_fails(self) -> None:
|
||||
self.assertIn("hostname", _error(
|
||||
canonicalize_forge_url, "a", "g", "https:///api/v1"))
|
||||
|
||||
def test_bad_path_fails(self) -> None:
|
||||
self.assertIn("Gitea API base", _error(
|
||||
canonicalize_forge_url, "a", "g", "https://gitea/api/v2"))
|
||||
|
||||
def test_non_string_fails(self) -> None:
|
||||
self.assertIn("required", _error(
|
||||
canonicalize_forge_url, "a", "g", None))
|
||||
|
||||
def test_malformed_url_fails(self) -> None:
|
||||
# An unparseable URL (invalid IPv6 literal) trips urlsplit's ValueError.
|
||||
self.assertIn("not a valid URL", _error(
|
||||
canonicalize_forge_url, "a", "g", "https://[/api/v1"))
|
||||
|
||||
|
||||
class TestForgeAccount(unittest.TestCase):
|
||||
def test_parses(self) -> None:
|
||||
acct = ManifestForgeAccount.from_dict("a", "didericis-gitea", GITEA)
|
||||
self.assertEqual("didericis-gitea", acct.alias)
|
||||
self.assertEqual("gitea.dideric.is", acct.host)
|
||||
self.assertEqual("token", acct.auth_type)
|
||||
self.assertEqual("GITEA_CLAUDE_TOKEN", acct.token_secret)
|
||||
|
||||
def test_non_kebab_alias_fails(self) -> None:
|
||||
self.assertIn("valid alias", _error(
|
||||
ManifestForgeAccount.from_dict, "a", "Bad_Alias", GITEA))
|
||||
|
||||
def test_auth_required(self) -> None:
|
||||
self.assertIn("missing required 'auth'", _error(
|
||||
ManifestForgeAccount.from_dict, "a", "g",
|
||||
{"url": "https://gitea/api/v1"}))
|
||||
|
||||
def test_bad_auth_type_fails(self) -> None:
|
||||
self.assertIn("auth.type must be", _error(
|
||||
ManifestForgeAccount.from_dict, "a", "g",
|
||||
{"url": "https://gitea/api/v1",
|
||||
"auth": {"type": "basic", "token_secret": "T"}}))
|
||||
|
||||
def test_token_secret_required(self) -> None:
|
||||
self.assertIn("token_secret must be", _error(
|
||||
ManifestForgeAccount.from_dict, "a", "g",
|
||||
{"url": "https://gitea/api/v1", "auth": {"type": "token"}}))
|
||||
|
||||
def test_unknown_key_fails(self) -> None:
|
||||
self.assertIn("unknown key", _error(
|
||||
ManifestForgeAccount.from_dict, "a", "g",
|
||||
{**GITEA, "bogus": 1}))
|
||||
|
||||
def test_unknown_auth_key_fails(self) -> None:
|
||||
self.assertIn("auth has unknown key", _error(
|
||||
ManifestForgeAccount.from_dict, "a", "g",
|
||||
{"url": "https://gitea/api/v1",
|
||||
"auth": {"type": "token", "token_secret": "T", "bogus": 1}}))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# repo -> forge association (composition)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForgeAssociations(unittest.TestCase):
|
||||
def test_resolves_when_repo_references_alias(self) -> None:
|
||||
idx = _index(
|
||||
forge_accounts={"didericis-gitea": GITEA},
|
||||
repo_forge="didericis-gitea",
|
||||
)
|
||||
m = idx.load_for_agent("claude", ())
|
||||
self.assertEqual(1, len(m.forge_associations))
|
||||
assoc = m.forge_associations[0]
|
||||
self.assertEqual("didericis-gitea", assoc.alias)
|
||||
self.assertEqual(("bot-bottle",), assoc.repo_names)
|
||||
|
||||
def test_unreferenced_account_yields_no_association(self) -> None:
|
||||
idx = _index(forge_accounts={"didericis-gitea": GITEA}) # repo has no forge
|
||||
m = idx.load_for_agent("claude", ())
|
||||
self.assertEqual((), m.forge_associations)
|
||||
|
||||
def test_unknown_alias_fails_closed(self) -> None:
|
||||
idx = _index(
|
||||
forge_accounts={"didericis-gitea": GITEA}, repo_forge="nope"
|
||||
)
|
||||
msg = _error(idx.load_for_agent, "claude", ())
|
||||
self.assertIn("references forge alias 'nope'", msg)
|
||||
self.assertIn("not defined on agent", msg)
|
||||
|
||||
def test_forge_without_account_fails_closed(self) -> None:
|
||||
idx = _index(repo_forge="didericis-gitea") # no forge-accounts at all
|
||||
self.assertIn("(none)", _error(idx.load_for_agent, "claude", ()))
|
||||
|
||||
def _two_alias_index(self, t1: str, t2: str) -> ManifestIndex:
|
||||
"""Two aliases on the SAME host, each referenced by a distinct repo."""
|
||||
def acct(token: str) -> dict[str, object]:
|
||||
return {
|
||||
"url": "https://same.example/api/v1",
|
||||
"auth": {"type": "token", "token_secret": token},
|
||||
}
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {"bb": {"git-gate": {"repos": {
|
||||
"r1": {"url": "ssh://git@h/x/r1.git",
|
||||
"key": {"provider": "static", "path": "/k"},
|
||||
"forge": "g1"},
|
||||
"r2": {"url": "ssh://git@h/x/r2.git",
|
||||
"key": {"provider": "static", "path": "/k"},
|
||||
"forge": "g2"},
|
||||
}}}},
|
||||
"agents": {"claude": {"bottle": "bb", "prompt": "",
|
||||
"forge-accounts": {"g1": acct(t1),
|
||||
"g2": acct(t2)}}},
|
||||
})
|
||||
|
||||
def test_conflicting_aliases_same_host_fail_closed(self) -> None:
|
||||
# Two aliases on the same host with DIFFERENT tokens is ambiguous —
|
||||
# the proxy routes by host, so it must fail before launch rather than
|
||||
# silently authenticate every call as one account.
|
||||
idx = self._two_alias_index("FIRST_TOKEN", "SECOND_TOKEN")
|
||||
msg = _error(idx.load_for_agent, "claude", ())
|
||||
self.assertIn("both resolve to host 'same.example'", msg)
|
||||
self.assertIn("ambiguous", msg)
|
||||
|
||||
def test_matching_aliases_same_host_ok(self) -> None:
|
||||
# Identical url/auth under two aliases is unambiguous: one route.
|
||||
idx = self._two_alias_index("SAME_TOKEN", "SAME_TOKEN")
|
||||
m = idx.load_for_agent("claude", ())
|
||||
self.assertEqual(2, len(m.forge_associations)) # both referenced
|
||||
routes = egress_forge_routes(m.forge_associations)
|
||||
self.assertEqual(1, len(routes)) # deduped to a single proxy route
|
||||
self.assertEqual("SAME_TOKEN", routes[0].token_ref)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# synthesized egress route (proxy-held credential)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForgeEgressRoutes(unittest.TestCase):
|
||||
def _assoc(self):
|
||||
idx = _index(
|
||||
forge_accounts={"didericis-gitea": GITEA},
|
||||
repo_forge="didericis-gitea",
|
||||
)
|
||||
return idx.load_for_agent("claude", ())
|
||||
|
||||
def test_route_shape(self) -> None:
|
||||
routes = egress_forge_routes(self._assoc().forge_associations)
|
||||
self.assertEqual(1, len(routes))
|
||||
r = routes[0]
|
||||
self.assertEqual("gitea.dideric.is", r.host)
|
||||
self.assertEqual("token", r.auth_scheme)
|
||||
self.assertEqual("GITEA_CLAUDE_TOKEN", r.token_ref)
|
||||
self.assertTrue(r.inspect)
|
||||
# scoped to the API prefix.
|
||||
self.assertEqual("/api/v1", r.matches[0].paths[0].value)
|
||||
|
||||
def test_token_slot_and_resolution(self) -> None:
|
||||
m = self._assoc()
|
||||
forge_routes = egress_forge_routes(m.forge_associations)
|
||||
routes = egress_routes_for_bottle(m.bottle, (), forge_routes)
|
||||
token_map = egress_token_env_map(routes)
|
||||
# one slot mapping the proxy env slot -> host env var name.
|
||||
self.assertEqual({"EGRESS_TOKEN_0": "GITEA_CLAUDE_TOKEN"}, token_map)
|
||||
resolved = egress_resolve_token_values(
|
||||
token_map, {"GITEA_CLAUDE_TOKEN": "sekret-value"}
|
||||
)
|
||||
self.assertEqual({"EGRESS_TOKEN_0": "sekret-value"}, resolved)
|
||||
|
||||
def test_rendered_routes_hide_secret_name_and_value(self) -> None:
|
||||
m = self._assoc()
|
||||
routes = egress_routes_for_bottle(
|
||||
m.bottle, (), egress_forge_routes(m.forge_associations)
|
||||
)
|
||||
rendered = egress_render_routes(routes)
|
||||
self.assertIn("gitea.dideric.is", rendered)
|
||||
self.assertIn("EGRESS_TOKEN_0", rendered) # slot, not the host var name
|
||||
self.assertNotIn("GITEA_CLAUDE_TOKEN", rendered)
|
||||
self.assertNotIn("sekret-value", rendered)
|
||||
|
||||
def test_dedup_by_host(self) -> None:
|
||||
# Two repos referencing the same alias share one route/credential.
|
||||
idx = ManifestIndex.from_json_obj({
|
||||
"bottles": {"bb": {"git-gate": {"repos": {
|
||||
"r1": _repo(forge="g"),
|
||||
"r2": {
|
||||
"url": "ssh://git@h/x/other.git",
|
||||
"key": {"provider": "static", "path": "/k"},
|
||||
"forge": "g",
|
||||
},
|
||||
}}}},
|
||||
"agents": {"claude": {"bottle": "bb", "prompt": "",
|
||||
"forge-accounts": {"g": GITEA}}},
|
||||
})
|
||||
m = idx.load_for_agent("claude", ())
|
||||
routes = egress_forge_routes(m.forge_associations)
|
||||
self.assertEqual(1, len(routes))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generated prompt guidance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestForgeGuidance(unittest.TestCase):
|
||||
def _assoc(self):
|
||||
idx = _index(
|
||||
forge_accounts={"didericis-gitea": GITEA},
|
||||
repo_forge="didericis-gitea",
|
||||
)
|
||||
return idx.load_for_agent("claude", ()).forge_associations
|
||||
|
||||
def test_empty_when_no_associations(self) -> None:
|
||||
self.assertEqual("", render_forge_guidance(()))
|
||||
|
||||
def test_names_account_repo_and_workflow(self) -> None:
|
||||
text = render_forge_guidance(self._assoc())
|
||||
self.assertIn("didericis-gitea", text)
|
||||
self.assertIn("https://gitea.dideric.is/api/v1", text)
|
||||
self.assertIn("bot-bottle", text)
|
||||
# branch-backed PR workflow + AGit prohibition.
|
||||
self.assertIn("refs/heads/", text)
|
||||
self.assertIn("refs/for/*", text)
|
||||
self.assertIn("pull request", text)
|
||||
|
||||
def test_no_token_secret_name_or_value(self) -> None:
|
||||
text = render_forge_guidance(self._assoc())
|
||||
self.assertNotIn("GITEA_CLAUDE_TOKEN", text)
|
||||
|
||||
def test_prompt_file_appends_guidance(self) -> None:
|
||||
from bot_bottle.backend.resolve_common import prepare_agent_state_dir
|
||||
|
||||
idx = _index(
|
||||
author={"name": "n", "email": "e@x.is"},
|
||||
forge_accounts={"didericis-gitea": GITEA},
|
||||
repo_forge="didericis-gitea",
|
||||
)
|
||||
# Give the agent a real prompt body.
|
||||
m = idx.load_for_agent("claude", ())
|
||||
from dataclasses import replace
|
||||
m = replace(m, agent=replace(m.agent, prompt="BASE PROMPT"))
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": td}):
|
||||
_dir, prompt_file = prepare_agent_state_dir("slug1", m)
|
||||
body = Path(prompt_file).read_text()
|
||||
self.assertIn("BASE PROMPT", body)
|
||||
self.assertIn("Forge access", body)
|
||||
self.assertIn("https://gitea.dideric.is/api/v1", body)
|
||||
|
||||
def test_prompt_file_no_guidance_without_forge(self) -> None:
|
||||
from bot_bottle.backend.resolve_common import prepare_agent_state_dir
|
||||
|
||||
m = _index(author={"name": "n", "email": "e@x.is"}).load_for_agent(
|
||||
"claude", ()
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
with patch.dict(os.environ, {"BOT_BOTTLE_ROOT": td}):
|
||||
_dir, prompt_file = prepare_agent_state_dir("slug2", m)
|
||||
body = Path(prompt_file).read_text()
|
||||
self.assertNotIn("Forge access", body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -190,8 +190,7 @@ class TestRegisterAgentReconciles(unittest.TestCase):
|
||||
|
||||
def _register(self, client: Mock) -> None:
|
||||
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_UTIL}.provision_git_gate"), \
|
||||
patch(f"{_MOD}.live_source_ips", return_value=["10.0.0.7"]):
|
||||
patch(f"{_UTIL}.provision_git_gate"):
|
||||
register_agent(
|
||||
_egress_plan(), _git_plan(),
|
||||
source_ip="10.0.0.7", endpoint=_endpoint(),
|
||||
@@ -213,7 +212,9 @@ class TestRegisterAgentReconciles(unittest.TestCase):
|
||||
client.register_bottle.side_effect = _register_bottle
|
||||
self._register(client)
|
||||
self.assertEqual(["reconcile", "register"], calls)
|
||||
client.reconcile.assert_called_once_with(["10.0.0.7"])
|
||||
# A bare trigger now — the orchestrator enumerates the live set itself
|
||||
# (from the host controller), so the CLI passes no IPs.
|
||||
client.reconcile.assert_called_once_with()
|
||||
|
||||
def test_a_reconcile_failure_does_not_block_the_launch(self) -> None:
|
||||
from bot_bottle.orchestrator.client import OrchestratorClientError
|
||||
@@ -221,18 +222,3 @@ class TestRegisterAgentReconciles(unittest.TestCase):
|
||||
client.reconcile.side_effect = OrchestratorClientError("unreachable")
|
||||
self._register(client)
|
||||
client.register_bottle.assert_called_once()
|
||||
|
||||
def test_enumeration_error_does_not_block_the_launch(self) -> None:
|
||||
"""A partial container listing must not abort the launch — skip
|
||||
reconciliation and proceed, just as with an unreachable orchestrator."""
|
||||
from bot_bottle.backend.macos_container.enumerate import EnumerationError
|
||||
client = _client()
|
||||
with patch(f"{_MOD}.OrchestratorClient", return_value=client), \
|
||||
patch(f"{_UTIL}.provision_git_gate"), \
|
||||
patch(f"{_MOD}.live_source_ips",
|
||||
side_effect=EnumerationError("container list failed")):
|
||||
register_agent(
|
||||
_egress_plan(), _git_plan(),
|
||||
source_ip="10.0.0.7", endpoint=_endpoint(),
|
||||
)
|
||||
client.register_bottle.assert_called_once()
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
"""Unit: agent-owned identity via `author` (PRD
|
||||
0082).
|
||||
"""Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047).
|
||||
|
||||
Identity is agent-only now: an agent file declares an `author` block
|
||||
(name + email, both required) and at `ManifestIndex.load_for_agent()`
|
||||
it populates the effective bottle's `git_user`. There is no per-field
|
||||
overlay against the bottle anymore — the bottle no longer carries a
|
||||
user identity. `git-gate` (user or repos) is rejected on an agent with
|
||||
a migration message. `Manifest.git_identity_summary()` reports the
|
||||
effective identity with no provenance annotation.
|
||||
An agent file may declare `git-gate.user` (name/email). At
|
||||
`ManifestIndex.load_for_agent()` it overlays the referenced bottle's
|
||||
`git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is
|
||||
rejected on agents. `Manifest.git_identity_summary()` reports the
|
||||
effective identity with per-field `(agent)`/`(bottle)` provenance.
|
||||
|
||||
The `from_json_obj` path drives `ManifestAgent.from_dict` + the
|
||||
composition in load_for_agent; a temp-dir case locks the md loader
|
||||
(the agent `author` frontmatter key threads into the parsed agent)."""
|
||||
The `from_json_obj` path drives `Agent.from_dict` + the overlay in
|
||||
load_for_agent; a temp-dir case locks the md loader (the `_AGENT_KEYS`
|
||||
allow + the `git-gate` threading into `agent_dict`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -34,61 +31,97 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
||||
raise AssertionError("expected ManifestError was not raised")
|
||||
|
||||
|
||||
def _manifest(*, author=None, agent_git=None) -> Manifest: # type: ignore
|
||||
def _manifest(*, bottle_user=None, agent_git=None) -> Manifest: # type: ignore
|
||||
"""Build an index with one agent 'impl' and load it, returning a Manifest."""
|
||||
bottle: dict = {} # type: ignore
|
||||
if bottle_user is not None:
|
||||
bottle = {"git-gate": {"user": bottle_user}}
|
||||
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||
if author is not None:
|
||||
agent["author"] = author
|
||||
if agent_git is not None:
|
||||
agent["git-gate"] = agent_git
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"bottles": {"dev": bottle},
|
||||
"agents": {"impl": agent},
|
||||
}).load_for_agent("impl")
|
||||
|
||||
|
||||
def _index(*, author: dict[str, object] | None = None) -> ManifestIndex:
|
||||
def _index(*, bottle_user: dict[str, object] | None = None, agent_git: dict[str, object] | None = None) -> ManifestIndex:
|
||||
"""Build an index with one agent 'impl' without loading it."""
|
||||
bottle: dict = {} # type: ignore
|
||||
if bottle_user is not None:
|
||||
bottle = {"git-gate": {"user": bottle_user}}
|
||||
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||
if author is not None:
|
||||
agent["author"] = author
|
||||
if agent_git is not None:
|
||||
agent["git-gate"] = agent_git
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"bottles": {"dev": bottle},
|
||||
"agents": {"impl": agent},
|
||||
})
|
||||
|
||||
|
||||
class TestAgentAuthorPopulatesBottle(unittest.TestCase):
|
||||
def test_agent_author_supplies_both_fields(self):
|
||||
m = _manifest(author={"name": "a", "email": "a@b"})
|
||||
class TestAgentGitUserOverlay(unittest.TestCase):
|
||||
def test_agent_supplies_both_fields(self):
|
||||
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("a", u.name)
|
||||
self.assertEqual("a@b", u.email)
|
||||
|
||||
def test_bottle_has_no_identity_until_agent_composed(self):
|
||||
idx = _index(author={"name": "a", "email": "a@b"})
|
||||
# Raw bottle has no git_user; loaded manifest has it from the agent.
|
||||
def test_agent_name_only_email_falls_through_to_bottle(self):
|
||||
m = _manifest(
|
||||
bottle_user={"name": "B", "email": "b@c"},
|
||||
agent_git={"user": {"name": "a"}},
|
||||
)
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("a", u.name) # agent wins
|
||||
self.assertEqual("b@c", u.email) # bottle falls through
|
||||
|
||||
def test_agent_email_only_name_falls_through_to_bottle(self):
|
||||
m = _manifest(
|
||||
bottle_user={"name": "B", "email": "b@c"},
|
||||
agent_git={"user": {"email": "a@b"}},
|
||||
)
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("B", u.name)
|
||||
self.assertEqual("a@b", u.email)
|
||||
|
||||
def test_agent_identity_with_bottle_declaring_none(self):
|
||||
idx = _index(agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||
# Raw bottle has no git_user; loaded manifest has merged git_user from agent
|
||||
self.assertTrue(idx.bottles["dev"].git_user.is_empty())
|
||||
m = idx.load_for_agent("impl")
|
||||
self.assertFalse(m.bottle.git_user.is_empty())
|
||||
|
||||
def test_agent_silent_leaves_bottle_identity_empty(self):
|
||||
idx = _index()
|
||||
def test_bottle_only_identity_preserved_when_agent_silent(self):
|
||||
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("B", u.name)
|
||||
self.assertEqual("b@c", u.email)
|
||||
|
||||
def test_no_overlay_uses_bottle_instance_directly(self):
|
||||
idx = _index(bottle_user={"name": "B"})
|
||||
m = idx.load_for_agent("impl")
|
||||
# No author -> git_user stays empty; the bottle instance is reused
|
||||
# directly (no replace needed).
|
||||
self.assertTrue(m.bottle.git_user.is_empty())
|
||||
# Agent has no git_user — bottle instance should be the same object
|
||||
self.assertIs(idx.bottles["dev"], m.bottle)
|
||||
|
||||
def test_other_bottle_fields_untouched_by_identity(self):
|
||||
def test_noop_overlay_uses_bottle_instance_directly(self):
|
||||
idx = _index(
|
||||
bottle_user={"name": "B", "email": "b@c"},
|
||||
agent_git={"user": {"name": "B", "email": "b@c"}},
|
||||
)
|
||||
m = idx.load_for_agent("impl")
|
||||
# Agent git_user == bottle git_user — no replace needed
|
||||
self.assertEqual(idx.bottles["dev"].git_user, m.bottle.git_user)
|
||||
|
||||
def test_other_bottle_fields_untouched_by_overlay(self):
|
||||
idx = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {
|
||||
"env": {"FOO": "bar"},
|
||||
"supervise": True,
|
||||
"git-gate": {"user": {"name": "B"}},
|
||||
}},
|
||||
"agents": {"impl": {
|
||||
"bottle": "dev", "skills": [], "prompt": "",
|
||||
"author": {"name": "a", "email": "a@b"},
|
||||
"git-gate": {"user": {"name": "a"}},
|
||||
}},
|
||||
})
|
||||
b = idx.load_for_agent("impl").bottle
|
||||
@@ -97,77 +130,93 @@ class TestAgentAuthorPopulatesBottle(unittest.TestCase):
|
||||
self.assertTrue(b.supervise)
|
||||
|
||||
|
||||
class TestAgentGitGateRejections(unittest.TestCase):
|
||||
"""`git-gate` is no longer accepted on an agent (user or repos):
|
||||
identity moved to `author`, repos stays bottle-only."""
|
||||
|
||||
def test_agent_git_gate_user_dies(self):
|
||||
msg = _error_message(_manifest, agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||
self.assertIn("no longer", msg)
|
||||
self.assertIn("author", msg)
|
||||
|
||||
def test_agent_git_gate_repos_dies(self):
|
||||
class TestAgentGitUserRejections(unittest.TestCase):
|
||||
def test_agent_repos_dies_bottle_only(self):
|
||||
msg = _error_message(_manifest, agent_git={
|
||||
"repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}},
|
||||
})
|
||||
self.assertIn("no longer", msg)
|
||||
self.assertIn("author", msg)
|
||||
self.assertIn("git-gate.repos", msg)
|
||||
self.assertIn("bottle-only", msg)
|
||||
|
||||
def test_agent_unknown_git_subkey_dies(self):
|
||||
msg = _error_message(_manifest, agent_git={"nope": {}})
|
||||
self.assertIn("not allowed at the agent level", msg)
|
||||
|
||||
def test_agent_git_user_both_empty_dies(self):
|
||||
msg = _error_message(_manifest, agent_git={"user": {"name": "", "email": ""}})
|
||||
self.assertIn("neither name nor email", msg)
|
||||
|
||||
|
||||
class TestGitIdentitySummary(unittest.TestCase):
|
||||
"""Summary reports the effective identity (from the agent's author)
|
||||
with no per-field provenance annotation."""
|
||||
def test_both_from_agent(self):
|
||||
m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
|
||||
self.assertEqual(
|
||||
"name=a (agent), email=a@b (agent)",
|
||||
m.git_identity_summary(),
|
||||
)
|
||||
|
||||
def test_summary_from_author(self):
|
||||
m = _manifest(author={"name": "a", "email": "a@b"})
|
||||
self.assertEqual("name=a, email=a@b", m.git_identity_summary())
|
||||
def test_mixed_provenance(self):
|
||||
m = _manifest(
|
||||
bottle_user={"name": "B", "email": "b@c"},
|
||||
agent_git={"user": {"name": "a"}},
|
||||
)
|
||||
self.assertEqual(
|
||||
"name=a (agent), email=b@c (bottle)",
|
||||
m.git_identity_summary(),
|
||||
)
|
||||
|
||||
def test_none_when_no_author(self):
|
||||
def test_bottle_only(self):
|
||||
m = _manifest(bottle_user={"name": "B", "email": "b@c"})
|
||||
self.assertEqual(
|
||||
"name=B (bottle), email=b@c (bottle)",
|
||||
m.git_identity_summary(),
|
||||
)
|
||||
|
||||
def test_none_when_unset_anywhere(self):
|
||||
m = _manifest()
|
||||
self.assertIsNone(m.git_identity_summary())
|
||||
|
||||
|
||||
_BOTTLE_DEV = """
|
||||
---
|
||||
egress:
|
||||
routes:
|
||||
- host: example.com
|
||||
git-gate:
|
||||
user:
|
||||
name: bottle-name
|
||||
email: bottle@example.com
|
||||
---
|
||||
|
||||
dev bottle.
|
||||
"""
|
||||
|
||||
_AGENT_WITH_AUTHOR = """
|
||||
_AGENT_WITH_GIT = """
|
||||
---
|
||||
bottle: dev
|
||||
author:
|
||||
name: agent-name
|
||||
email: agent@example.com
|
||||
git-gate:
|
||||
user:
|
||||
name: agent-name
|
||||
---
|
||||
|
||||
impl agent.
|
||||
"""
|
||||
|
||||
_AGENT_WITH_GIT_GATE = """
|
||||
_AGENT_WITH_REPOS = """
|
||||
---
|
||||
bottle: dev
|
||||
git-gate:
|
||||
repos:
|
||||
r:
|
||||
url: ssh://git@x/y.git
|
||||
key:
|
||||
provider: static
|
||||
path: /dev/null
|
||||
identity: /dev/null
|
||||
---
|
||||
|
||||
bad agent.
|
||||
"""
|
||||
|
||||
|
||||
class TestAgentAuthorMdLoader(unittest.TestCase):
|
||||
"""Locks the md path: `author` is an accepted agent frontmatter key
|
||||
and threads into the parsed agent, populating identity; a stale
|
||||
agent `git-gate` block dies through the same loader."""
|
||||
class TestAgentGitUserMdLoader(unittest.TestCase):
|
||||
"""Locks the md path: `git-gate` is an accepted agent key and threads
|
||||
into the parsed Agent (not rejected as an unknown frontmatter key),
|
||||
and agent `git-gate.repos` dies through the same loader."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.home = Path(tempfile.mkdtemp(prefix="cb-home-"))
|
||||
@@ -186,30 +235,31 @@ class TestAgentAuthorMdLoader(unittest.TestCase):
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(textwrap.dedent(text).lstrip("\n"))
|
||||
|
||||
def test_md_agent_author_populates_identity(self):
|
||||
def test_md_agent_git_user_overlays_bottle(self):
|
||||
self._write("bottles/dev.md", _BOTTLE_DEV)
|
||||
self._write("agents/impl.md", _AGENT_WITH_AUTHOR)
|
||||
self._write("agents/impl.md", _AGENT_WITH_GIT)
|
||||
m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl")
|
||||
u = m.bottle.git_user
|
||||
self.assertEqual("agent-name", u.name)
|
||||
self.assertEqual("agent@example.com", u.email)
|
||||
self.assertEqual("bottle@example.com", u.email)
|
||||
self.assertEqual(
|
||||
"name=agent-name, email=agent@example.com",
|
||||
"name=agent-name (agent), email=bottle@example.com (bottle)",
|
||||
m.git_identity_summary(),
|
||||
)
|
||||
|
||||
def test_md_agent_git_gate_fails_at_preflight(self):
|
||||
"""A stale agent `git-gate` block is an error; resolve() still
|
||||
succeeds so other agents remain accessible, but load_for_agent
|
||||
raises. The lazy loader's frontmatter-key validator rejects the
|
||||
unknown `git-gate` key first."""
|
||||
def test_md_agent_repos_fails_at_preflight(self):
|
||||
"""git-gate.repos on an agent is an error; resolve() still succeeds
|
||||
so other agents remain accessible, but load_for_agent raises."""
|
||||
self._write("bottles/dev.md", _BOTTLE_DEV)
|
||||
self._write("agents/impl.md", _AGENT_WITH_GIT_GATE)
|
||||
self._write("agents/impl.md", _AGENT_WITH_REPOS)
|
||||
from bot_bottle.manifest import ManifestError
|
||||
names = ManifestIndex.resolve(str(self.home))
|
||||
self.assertIn("impl", names.all_agent_names)
|
||||
with self.assertRaises(ManifestError) as ctx:
|
||||
names.load_for_agent("impl")
|
||||
self.assertIn("git-gate", str(ctx.exception))
|
||||
msg = str(ctx.exception)
|
||||
self.assertIn("git-gate.repos", msg)
|
||||
self.assertIn("bottle-only", msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -130,10 +130,8 @@ class TestExtendsEnvMerge(unittest.TestCase):
|
||||
|
||||
|
||||
class TestExtendsGitMerge(unittest.TestCase):
|
||||
"""git-gate.repos merges by name, with same-name child entries
|
||||
merging field-by-field (child wins). Bottles no longer carry a user
|
||||
identity (PRD 0082), so only repos
|
||||
merging is meaningful across extends chains."""
|
||||
"""git-gate.user overlays by field; git-gate.repos merges by name,
|
||||
with same-name child entries merging field-by-field (child wins)."""
|
||||
|
||||
_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", "key": {"provider": "static", "path": "/dev/null"}}
|
||||
@@ -256,16 +254,13 @@ class TestExtendsGitMerge(unittest.TestCase):
|
||||
repo_entry = next(e for e in child.git if e.Name == "repo")
|
||||
self.assertEqual("gitea", repo_entry.Key.provider)
|
||||
|
||||
def test_child_inherits_parent_repos_no_user_identity(self):
|
||||
# Child omits git-gate entirely -> inherits the parent's repos.
|
||||
# Bottles carry no user identity anymore, so git_user stays empty
|
||||
# across the extends chain.
|
||||
def test_child_git_user_inherits_parent_repos(self):
|
||||
m = _build(
|
||||
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}},
|
||||
child={"extends": "base"},
|
||||
child={"extends": "base", "git-gate": {"user": {"name": "Child"}}},
|
||||
)
|
||||
self.assertEqual(["a"], [e.Name for e in m.bottles["child"].git])
|
||||
self.assertTrue(m.bottles["child"].git_user.is_empty())
|
||||
self.assertEqual("Child", m.bottles["child"].git_user.name)
|
||||
|
||||
|
||||
class TestExtendsEgressMerge(unittest.TestCase):
|
||||
@@ -337,29 +332,48 @@ class TestExtendsEgressMerge(unittest.TestCase):
|
||||
self.assertIn("A.EXAMPLE.COM", msg)
|
||||
|
||||
|
||||
class TestExtendsNoBottleUserIdentity(unittest.TestCase):
|
||||
"""Bottles no longer carry a user identity (PRD
|
||||
0082): identity moved to the agent's
|
||||
`author` block. `git-gate.user` on a bottle is rejected outright, and
|
||||
`git_user` is always empty across an extends chain."""
|
||||
class TestExtendsGitUserOverlay(unittest.TestCase):
|
||||
"""git-gate.user: per-field overlay. Each non-empty field on child
|
||||
wins; empties fall through to parent."""
|
||||
|
||||
def test_bottle_git_gate_user_dies(self):
|
||||
# A stale `git-gate.user` on a bottle fails with the migration die
|
||||
# even inside an extends chain.
|
||||
msg = _error_message(
|
||||
_build,
|
||||
def test_parent_full_child_omits(self):
|
||||
m = _build(
|
||||
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
child={"extends": "base"},
|
||||
)
|
||||
self.assertIn("git-gate.user is no longer supported", msg)
|
||||
u = m.bottles["child"].git_user
|
||||
self.assertEqual("Parent", u.name)
|
||||
self.assertEqual("p@x", u.email)
|
||||
|
||||
def test_git_user_empty_across_chain(self):
|
||||
def test_child_overrides_both(self):
|
||||
m = _build(
|
||||
base={"git-gate": {"repos": {}}},
|
||||
child={"extends": "base"},
|
||||
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
child={
|
||||
"extends": "base",
|
||||
"git-gate": {"user": {"name": "Child", "email": "c@x"}},
|
||||
},
|
||||
)
|
||||
self.assertTrue(m.bottles["base"].git_user.is_empty())
|
||||
self.assertTrue(m.bottles["child"].git_user.is_empty())
|
||||
u = m.bottles["child"].git_user
|
||||
self.assertEqual("Child", u.name)
|
||||
self.assertEqual("c@x", u.email)
|
||||
|
||||
def test_child_adds_email_inherits_name(self):
|
||||
m = _build(
|
||||
base={"git-gate": {"user": {"name": "Parent"}}},
|
||||
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
|
||||
)
|
||||
u = m.bottles["child"].git_user
|
||||
self.assertEqual("Parent", u.name)
|
||||
self.assertEqual("c@x", u.email)
|
||||
|
||||
def test_child_overrides_only_email(self):
|
||||
m = _build(
|
||||
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
|
||||
child={"extends": "base", "git-gate": {"user": {"email": "c@x"}}},
|
||||
)
|
||||
u = m.bottles["child"].git_user
|
||||
self.assertEqual("Parent", u.name)
|
||||
self.assertEqual("c@x", u.email)
|
||||
|
||||
|
||||
class TestExtendsChain(unittest.TestCase):
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
"""Unit: agent `author` identity -> bottle.git_user (PRD
|
||||
0082).
|
||||
|
||||
Identity moved off `git-gate.user` (bottle) onto the trusted agent's
|
||||
`author` block. At `load_for_agent` the agent's author populates the
|
||||
effective bottle's `git_user` (a `ManifestGitUser`). This locks the
|
||||
`author` validation/rejection paths and the bottle `git-gate.user`
|
||||
migration die."""
|
||||
"""Unit: Bottle git-gate.user manifest parsing + validation (issue #86, PRD 0047)."""
|
||||
|
||||
import unittest
|
||||
|
||||
@@ -21,92 +14,89 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
|
||||
raise AssertionError("expected ManifestError was not raised")
|
||||
|
||||
|
||||
def _manifest(author): # type: ignore
|
||||
"""Build an index with one agent 'demo' carrying the given `author`
|
||||
block, then load it, returning the composed Manifest."""
|
||||
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
|
||||
if author is not None:
|
||||
agent["author"] = author
|
||||
return ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": agent},
|
||||
}).load_for_agent("demo")
|
||||
def _manifest(git_user): # type: ignore
|
||||
return {
|
||||
"bottles": {"dev": {"git-gate": {"user": git_user}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}
|
||||
|
||||
|
||||
class TestAuthorIdentity(unittest.TestCase):
|
||||
"""The agent's `author` block populates bottle.git_user."""
|
||||
|
||||
class TestGitUserParsing(unittest.TestCase):
|
||||
def test_parses_both_fields(self):
|
||||
m = _manifest({
|
||||
m = ManifestIndex.from_json_obj(_manifest({
|
||||
"name": "Eric Bauerfeld",
|
||||
"email": "eric+claude@dideric.is",
|
||||
})
|
||||
u = m.bottle.git_user
|
||||
}))
|
||||
u = m.bottles["dev"].git_user
|
||||
self.assertEqual("Eric Bauerfeld", u.name)
|
||||
self.assertEqual("eric+claude@dideric.is", u.email)
|
||||
self.assertFalse(u.is_empty())
|
||||
|
||||
def test_omitted_author_defaults_to_empty(self):
|
||||
# No author block at all -> empty git_user, is_empty True ->
|
||||
def test_name_only(self):
|
||||
m = ManifestIndex.from_json_obj(_manifest({"name": "Bot"}))
|
||||
u = m.bottles["dev"].git_user
|
||||
self.assertEqual("Bot", u.name)
|
||||
self.assertEqual("", u.email)
|
||||
|
||||
def test_email_only(self):
|
||||
m = ManifestIndex.from_json_obj(_manifest({"email": "bot@example.com"}))
|
||||
u = m.bottles["dev"].git_user
|
||||
self.assertEqual("", u.name)
|
||||
self.assertEqual("bot@example.com", u.email)
|
||||
|
||||
def test_omitted_defaults_to_empty(self):
|
||||
# No git.user block at all → empty GitUser, is_empty True →
|
||||
# provisioner skips the `git config` step entirely.
|
||||
m = _manifest(None)
|
||||
self.assertTrue(m.bottle.git_user.is_empty())
|
||||
|
||||
def test_missing_name_dies(self):
|
||||
# `author` is present but name is absent -> both fields required.
|
||||
msg = _error_message(_manifest, {"email": "bot@example.com"})
|
||||
self.assertIn("author.name must be a non-empty string", msg)
|
||||
|
||||
def test_missing_email_dies(self):
|
||||
msg = _error_message(_manifest, {"name": "Bot"})
|
||||
self.assertIn("author.email must be a non-empty string", msg)
|
||||
m = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
u = m.bottles["dev"].git_user
|
||||
self.assertTrue(u.is_empty())
|
||||
|
||||
def test_both_empty_strings_dies(self):
|
||||
# An explicit `author: {name: "", email: ""}` is a typo /
|
||||
# half-finished edit; fail loudly rather than silently no-op.
|
||||
msg = _error_message(_manifest, {"name": "", "email": ""})
|
||||
self.assertIn("author.name must be a non-empty string", msg)
|
||||
# An explicit `git.user: {name: "", email: ""}` is a typo
|
||||
# / half-finished edit; fail loudly rather than silently
|
||||
# no-op (the operator clearly meant to configure something).
|
||||
msg = _error_message(
|
||||
ManifestIndex.from_json_obj, _manifest({"name": "", "email": ""}),
|
||||
)
|
||||
self.assertIn("neither name nor email", msg)
|
||||
|
||||
def test_unknown_key_dies(self):
|
||||
msg = _error_message(
|
||||
_manifest,
|
||||
{"name": "Bot", "email": "b@x", "username": "bot"},
|
||||
ManifestIndex.from_json_obj,
|
||||
_manifest({"name": "Bot", "username": "bot"}),
|
||||
)
|
||||
self.assertIn("unknown key", msg)
|
||||
self.assertIn("username", msg)
|
||||
|
||||
def test_non_string_name_dies(self):
|
||||
msg = _error_message(_manifest, {"name": 42, "email": "b@x"})
|
||||
self.assertIn("author.name must be a non-empty string", msg)
|
||||
msg = _error_message(
|
||||
ManifestIndex.from_json_obj, _manifest({"name": 42}),
|
||||
)
|
||||
self.assertIn("git-gate.user.name must be a string", msg)
|
||||
|
||||
def test_non_string_email_dies(self):
|
||||
msg = _error_message(_manifest, {"name": "Bot", "email": ["x@y.z"]})
|
||||
self.assertIn("author.email must be a non-empty string", msg)
|
||||
msg = _error_message(
|
||||
ManifestIndex.from_json_obj, _manifest({"email": ["x@y.z"]}),
|
||||
)
|
||||
self.assertIn("git-gate.user.email must be a string", msg)
|
||||
|
||||
def test_email_with_whitespace_dies(self):
|
||||
msg = _error_message(_manifest, {"name": "Bot", "email": "a @b"})
|
||||
self.assertIn("author.email must not contain whitespace", msg)
|
||||
|
||||
|
||||
class TestBottleGitGateUserMigration(unittest.TestCase):
|
||||
"""`git-gate.user` on a bottle is no longer supported: it raises a
|
||||
ManifestError pointing at the agent's `author` block."""
|
||||
|
||||
def test_bottle_git_gate_user_dies(self):
|
||||
def test_legacy_top_level_git_user_dies(self):
|
||||
msg = _error_message(
|
||||
ManifestIndex.from_json_obj,
|
||||
{
|
||||
"bottles": {"dev": {"git-gate": {"user": {"name": "Bot"}}}},
|
||||
"bottles": {"dev": {"git_user": {"name": "Bot"}}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
},
|
||||
)
|
||||
self.assertIn("git-gate.user is no longer supported", msg)
|
||||
self.assertIn("author", msg)
|
||||
self.assertIn("git_user", msg)
|
||||
self.assertIn("git-gate.user", msg)
|
||||
|
||||
|
||||
class TestGitUserDirect(unittest.TestCase):
|
||||
"""Direct GitUser dataclass exercises (no manifest wrapper). The
|
||||
dataclass is still the runtime carrier on ManifestBottle.git_user."""
|
||||
"""Direct GitUser dataclass exercises (no manifest wrapper)."""
|
||||
|
||||
def test_is_empty_default(self):
|
||||
self.assertTrue(ManifestGitUser().is_empty())
|
||||
|
||||
@@ -71,14 +71,11 @@ class _LazyCase(unittest.TestCase):
|
||||
|
||||
|
||||
class TestAllAgentNamesLazy(_LazyCase):
|
||||
def test_cwd_agents_ignored_home_only(self) -> None:
|
||||
# Agents are home-only (PRD 0082):
|
||||
# a cwd agents/ dir is warned-and-ignored, so only the home agent
|
||||
# appears in all_agent_names.
|
||||
def test_merges_home_and_cwd_agents(self) -> None:
|
||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||
_write(self.cwd_cb / "agents" / "beta.md", _AGENT)
|
||||
self.assertEqual(["alpha"], self.resolve().all_agent_names)
|
||||
self.assertEqual(["alpha", "beta"], self.resolve().all_agent_names)
|
||||
|
||||
|
||||
class TestLoadForAgentLazy(_LazyCase):
|
||||
@@ -99,13 +96,11 @@ class TestRequireAgentLazy(_LazyCase):
|
||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||
self.resolve().require_agent("alpha") # no raise
|
||||
|
||||
def test_cwd_only_agent_not_selectable(self) -> None:
|
||||
# Agents are home-only (PRD 0082):
|
||||
# a cwd-only agent file is never selectable, so require_agent raises.
|
||||
def test_existing_cwd_agent_ok(self) -> None:
|
||||
# File only under cwd -> require_agent's cwd_path branch.
|
||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||
_write(self.cwd_cb / "agents" / "beta.md", _AGENT)
|
||||
with self.assertRaises(ManifestError):
|
||||
self.resolve().require_agent("beta")
|
||||
self.resolve().require_agent("beta") # no raise
|
||||
|
||||
def test_unknown_agent_raises(self) -> None:
|
||||
_write(self.home_cb / "agents" / "alpha.md", _AGENT)
|
||||
|
||||
@@ -110,16 +110,14 @@ class TestAgentFileParses(_ResolveCase):
|
||||
self.assertFalse(a.prompt.endswith("\n"))
|
||||
|
||||
|
||||
class TestCwdAgentIgnoredHomeWins(_ResolveCase):
|
||||
"""SC #3 (revised, PRD 0082): agents
|
||||
are home-only. A cwd agent file with the same name as a home agent no
|
||||
longer wins — it is warned-and-ignored, so the HOME agent's prompt is
|
||||
used and the home bottle stays intact."""
|
||||
class TestCwdAgentOverridesHome(_ResolveCase):
|
||||
"""SC #3: a cwd agent file with the same name as a home agent
|
||||
wins. The home bottle stays intact."""
|
||||
|
||||
def test_home_wins_cwd_ignored(self):
|
||||
def test_cwd_wins(self):
|
||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL)
|
||||
# Cwd agent with a different prompt is ignored entirely.
|
||||
# Cwd overrides with a different prompt
|
||||
_write(
|
||||
self.cwd_cb / "agents" / "implementer.md",
|
||||
"""
|
||||
@@ -131,19 +129,14 @@ class TestCwdAgentIgnoredHomeWins(_ResolveCase):
|
||||
""",
|
||||
)
|
||||
m = self.resolve().load_for_agent("implementer")
|
||||
# Home agent's body is used; the cwd override never applies.
|
||||
self.assertIn("feature implementation agent", m.agent.prompt)
|
||||
self.assertNotIn("CWD-OVERRIDE-PROMPT", m.agent.prompt)
|
||||
self.assertIn("CWD-OVERRIDE-PROMPT", m.agent.prompt)
|
||||
# Home bottle still present with its two egress routes
|
||||
self.assertEqual(2, len(m.bottle.egress.routes))
|
||||
|
||||
|
||||
class TestCwdBottlesIgnored(_ResolveCase):
|
||||
"""SC #4: a bottles/ dir under $CWD is ignored (with a warn).
|
||||
The home bottle still wins. Under
|
||||
PRD 0082 a cwd agents/ dir is also
|
||||
ignored, so $CWD contributes nothing — the filesystem layout is the
|
||||
trust boundary."""
|
||||
The home bottle still wins; cwd contributes only agents."""
|
||||
|
||||
def test_ignored(self):
|
||||
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
|
||||
|
||||
@@ -212,21 +212,13 @@ class TestAgentValidation(unittest.TestCase):
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict("a", {"prompt": 5}, set())
|
||||
|
||||
def test_git_gate_rejected_at_agent_level(self) -> None:
|
||||
# `git-gate` (user or repos) is no longer accepted on an agent;
|
||||
# identity moved to `author`, repos stays bottle-only.
|
||||
def test_git_gate_repos_rejected_at_agent_level(self) -> None:
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict("a", {"git-gate": {"repos": {}}}, set())
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestAgent.from_dict(
|
||||
"a", {"git-gate": {"user": {"name": "x"}}}, set()
|
||||
)
|
||||
|
||||
def test_bottle_empty_git_gate_is_allowed(self) -> None:
|
||||
# An empty `git-gate: {}` on a bottle is still allowed (only the
|
||||
# optional `repos` subkey exists now); it contributes no git repos.
|
||||
bottle = ManifestBottle.from_dict("b", {"git-gate": {}})
|
||||
self.assertEqual((), bottle.git)
|
||||
def test_git_gate_empty_is_allowed(self) -> None:
|
||||
agent = ManifestAgent.from_dict("a", {"git-gate": {}}, set())
|
||||
self.assertTrue(agent.git_user.is_empty())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -236,12 +228,9 @@ class TestAgentValidation(unittest.TestCase):
|
||||
|
||||
class TestEagerIndexLookups(unittest.TestCase):
|
||||
def _idx(self) -> ManifestIndex:
|
||||
# Identity lives on the agent's `author` block now; at composition
|
||||
# it populates the effective bottle's git_user.
|
||||
return _idx({
|
||||
"bottles": {"b": {}},
|
||||
"agents": {"a": {"bottle": "b",
|
||||
"author": {"name": "Bot", "email": "b@x"}}},
|
||||
"bottles": {"b": {"git-gate": {"user": {"name": "Bot", "email": "b@x"}}}},
|
||||
"agents": {"a": {"bottle": "b"}},
|
||||
})
|
||||
|
||||
def test_unknown_bottle_section_is_empty(self) -> None:
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import unittest
|
||||
|
||||
@@ -14,6 +18,21 @@ from bot_bottle.orchestrator.broker import (
|
||||
)
|
||||
|
||||
|
||||
def _sign_claims(claims: dict[str, object], secret: bytes) -> str:
|
||||
"""Sign an arbitrary claims dict (bypassing `sign_request`'s fixed fields)
|
||||
so a test can craft off-schema payloads — e.g. an extra claim key, or a
|
||||
query op carrying ids — with a *valid* signature, isolating the schema check
|
||||
from the provenance check."""
|
||||
def b64(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
||||
header = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, sort_keys=True,
|
||||
separators=(",", ":")).encode())
|
||||
payload = b64(json.dumps(claims, sort_keys=True, separators=(",", ":")).encode())
|
||||
signing_input = f"{header}.{payload}"
|
||||
sig = b64(hmac.new(secret, signing_input.encode("ascii"), hashlib.sha256).digest())
|
||||
return f"{signing_input}.{sig}"
|
||||
|
||||
|
||||
class TestSignVerify(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.secret = secrets.token_bytes(16)
|
||||
@@ -54,6 +73,41 @@ class TestSignVerify(unittest.TestCase):
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
verify_request(token, self.secret)
|
||||
|
||||
def test_list_live_round_trip(self) -> None:
|
||||
req = LaunchRequest(op="list_live")
|
||||
got = verify_request(sign_request(req, self.secret), self.secret)
|
||||
self.assertEqual("list_live", got.op)
|
||||
self.assertEqual("", got.bottle_id)
|
||||
|
||||
def test_mutation_without_bottle_id_rejected(self) -> None:
|
||||
# A launch/teardown must name its bottle even with a valid signature.
|
||||
for op in ("launch", "teardown"):
|
||||
token = _sign_claims(
|
||||
{"op": op, "bottle_id": "", "jti": "x", "iat": 1}, self.secret)
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
verify_request(token, self.secret)
|
||||
|
||||
def test_query_op_carrying_ids_or_flags_rejected(self) -> None:
|
||||
# list_live is "no arguments" — a signed one that smuggles a bottle_id /
|
||||
# source_ip / image_ref / slot is off-schema and refused.
|
||||
for extra in (
|
||||
{"bottle_id": "b1"}, {"source_ip": "10.0.0.1"},
|
||||
{"image_ref": "img"}, {"slot": 2},
|
||||
):
|
||||
claims: dict[str, object] = {"op": "list_live", "jti": "x", "iat": 1, **extra}
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
verify_request(_sign_claims(claims, self.secret), self.secret)
|
||||
|
||||
def test_unknown_claim_key_rejected(self) -> None:
|
||||
# Strict schema (open question 1): a well-signed token with an extra claim
|
||||
# key is refused, so the surface can't widen past the fixed fields.
|
||||
token = _sign_claims(
|
||||
{"op": "launch", "bottle_id": "b1", "cmd": "rm -rf /", "jti": "x", "iat": 1},
|
||||
self.secret,
|
||||
)
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
verify_request(token, self.secret)
|
||||
|
||||
|
||||
class TestStubBroker(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -81,6 +135,37 @@ class TestStubBroker(unittest.TestCase):
|
||||
self.broker.submit(forged)
|
||||
self.assertEqual([], self.broker.launched) # nothing acted on
|
||||
|
||||
def test_list_live_derives_from_launches(self) -> None:
|
||||
# Default live set: launched, minus torn-down, by source IP.
|
||||
self.broker.submit(
|
||||
sign_request(LaunchRequest(op="launch", bottle_id="b1", source_ip="10.0.0.1"),
|
||||
self.secret))
|
||||
self.broker.submit(
|
||||
sign_request(LaunchRequest(op="launch", bottle_id="b2", source_ip="10.0.0.2"),
|
||||
self.secret))
|
||||
self.broker.submit(
|
||||
sign_request(LaunchRequest(op="teardown", bottle_id="b1", source_ip="10.0.0.1"),
|
||||
self.secret))
|
||||
live = self.broker.list_live(
|
||||
sign_request(LaunchRequest(op="list_live"), self.secret))
|
||||
self.assertEqual(["10.0.0.2"], live)
|
||||
|
||||
def test_list_live_override(self) -> None:
|
||||
self.broker.live_source_ips = ["10.0.0.9"]
|
||||
live = self.broker.list_live(
|
||||
sign_request(LaunchRequest(op="list_live"), self.secret))
|
||||
self.assertEqual(["10.0.0.9"], live)
|
||||
|
||||
def test_submit_rejects_a_list_live_token(self) -> None:
|
||||
# The verbs don't cross: a query token routed to submit is fail-closed.
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
self.broker.submit(sign_request(LaunchRequest(op="list_live"), self.secret))
|
||||
|
||||
def test_list_live_rejects_a_mutation_token(self) -> None:
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
self.broker.list_live(
|
||||
sign_request(LaunchRequest(op="launch", bottle_id="b1"), self.secret))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Unit: orchestrator-side broker client (issue #468, chunk 1). HTTP mocked."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.orchestrator.broker import (
|
||||
BrokerAuthError,
|
||||
BrokerUnavailableError,
|
||||
LaunchRequest,
|
||||
)
|
||||
from bot_bottle.orchestrator.broker_client import BrokerClient, BrokerClientError
|
||||
|
||||
_URLOPEN = "bot_bottle.orchestrator.broker_client.urllib.request.urlopen"
|
||||
|
||||
|
||||
def _resp(payload: object) -> MagicMock:
|
||||
m = MagicMock()
|
||||
m.__enter__.return_value.read.return_value = json.dumps(payload).encode()
|
||||
return m
|
||||
|
||||
|
||||
def _http_error(code: int, payload: object = None) -> urllib.error.HTTPError:
|
||||
body = json.dumps(payload).encode() if payload is not None else b""
|
||||
return urllib.error.HTTPError(
|
||||
"http://host/broker", code, "err", {}, io.BytesIO(body)) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestSubmit(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.c = BrokerClient("http://host:8091")
|
||||
|
||||
def test_returns_the_verified_request(self) -> None:
|
||||
echo = {
|
||||
"op": "launch", "bottle_id": "b1", "source_ip": "10.0.0.1",
|
||||
"image_ref": "img", "slot": 3,
|
||||
}
|
||||
with patch(_URLOPEN, return_value=_resp(echo)):
|
||||
got = self.c.submit("tok")
|
||||
self.assertEqual(
|
||||
LaunchRequest(op="launch", bottle_id="b1", source_ip="10.0.0.1",
|
||||
image_ref="img", slot=3),
|
||||
got,
|
||||
)
|
||||
|
||||
def test_posts_token_to_broker_endpoint(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp({"op": "teardown", "bottle_id": "b1"})) as m:
|
||||
self.c.submit("signed-token")
|
||||
request = m.call_args.args[0]
|
||||
self.assertEqual("POST", request.get_method())
|
||||
self.assertTrue(request.full_url.endswith("/broker"))
|
||||
self.assertEqual({"token": "signed-token"}, json.loads(request.data))
|
||||
|
||||
def test_401_raises_broker_auth_error(self) -> None:
|
||||
# A fail-closed provenance/schema rejection surfaces as the SAME exception
|
||||
# the in-process broker raises, so the launch path's rollback is identical.
|
||||
with patch(_URLOPEN, side_effect=_http_error(401, {"error": "bad signature"})):
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
self.c.submit("forged")
|
||||
|
||||
def test_502_is_a_definite_client_error(self) -> None:
|
||||
# The host responded — it processed the request and did not launch, so a
|
||||
# definite BrokerClientError (the caller may safely roll back).
|
||||
with patch(_URLOPEN, side_effect=_http_error(502, {"error": "docker down"})):
|
||||
with self.assertRaises(BrokerClientError):
|
||||
self.c.submit("tok")
|
||||
|
||||
def test_unreachable_is_ambiguous_unavailable(self) -> None:
|
||||
# No response at all — the request may already have launched, so the
|
||||
# AMBIGUOUS BrokerUnavailableError (the caller must NOT roll back).
|
||||
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||
with self.assertRaises(BrokerUnavailableError):
|
||||
self.c.submit("tok")
|
||||
|
||||
def test_timeout_is_ambiguous_unavailable(self) -> None:
|
||||
# A dropped/late response after the request was sent is the exact orphan
|
||||
# risk: the host may have launched. Must be ambiguous, not a definite fail.
|
||||
with patch(_URLOPEN, side_effect=TimeoutError("read timed out")):
|
||||
with self.assertRaises(BrokerUnavailableError):
|
||||
self.c.submit("tok")
|
||||
|
||||
def test_malformed_success_body_raises(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp({"op": "launch"})): # missing bottle_id
|
||||
with self.assertRaises(BrokerClientError):
|
||||
self.c.submit("tok")
|
||||
|
||||
def test_empty_error_body_is_tolerated(self) -> None:
|
||||
# An error with no readable JSON body still classifies by status code.
|
||||
with patch(_URLOPEN, side_effect=_http_error(401)):
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
self.c.submit("forged")
|
||||
|
||||
def test_non_json_success_body_raises(self) -> None:
|
||||
# A 200 whose body isn't JSON is tolerated into {} then fails the
|
||||
# missing-field check — a definite client error, not a crash.
|
||||
m = MagicMock()
|
||||
m.__enter__.return_value.read.return_value = b"not json at all"
|
||||
with patch(_URLOPEN, return_value=m):
|
||||
with self.assertRaises(BrokerClientError):
|
||||
self.c.submit("tok")
|
||||
|
||||
def test_unreadable_error_body_is_tolerated(self) -> None:
|
||||
# An HTTPError whose body can't be read (fp=None) still classifies by
|
||||
# status — the error detail is best-effort.
|
||||
err = urllib.error.HTTPError(
|
||||
"http://host/broker", 502, "err", {}, None) # type: ignore[arg-type]
|
||||
with patch(_URLOPEN, side_effect=err):
|
||||
with self.assertRaises(BrokerClientError):
|
||||
self.c.submit("tok")
|
||||
|
||||
|
||||
class TestListLive(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.c = BrokerClient("http://host:8091")
|
||||
|
||||
def test_returns_source_ips(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp({"source_ips": ["10.0.0.1", "10.0.0.2"]})):
|
||||
self.assertEqual(["10.0.0.1", "10.0.0.2"], self.c.list_live("tok"))
|
||||
|
||||
def test_posts_token_to_live_endpoint(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp({"source_ips": []})) as m:
|
||||
self.c.list_live("signed-token")
|
||||
request = m.call_args.args[0]
|
||||
self.assertEqual("POST", request.get_method())
|
||||
self.assertTrue(request.full_url.endswith("/broker/live"))
|
||||
self.assertEqual({"token": "signed-token"}, json.loads(request.data))
|
||||
|
||||
def test_non_string_entries_are_filtered(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp({"source_ips": ["10.0.0.1", 5, None, ""]})):
|
||||
self.assertEqual(["10.0.0.1"], self.c.list_live("tok"))
|
||||
|
||||
def test_missing_source_ips_raises(self) -> None:
|
||||
# A response without the field is malformed — better to fail (reconcile
|
||||
# skips) than to reconcile against a silent empty set.
|
||||
with patch(_URLOPEN, return_value=_resp({})):
|
||||
with self.assertRaises(BrokerClientError):
|
||||
self.c.list_live("tok")
|
||||
|
||||
def test_401_raises_broker_auth_error(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=_http_error(401, {"error": "bad signature"})):
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
self.c.list_live("forged")
|
||||
|
||||
def test_502_enumeration_failure_is_client_error(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=_http_error(502, {"error": "docker down"})):
|
||||
with self.assertRaises(BrokerClientError):
|
||||
self.c.list_live("tok")
|
||||
|
||||
def test_unreachable_is_unavailable(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=urllib.error.URLError("refused")):
|
||||
with self.assertRaises(BrokerUnavailableError):
|
||||
self.c.list_live("tok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -159,26 +159,27 @@ class TestReconcile(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.c = OrchestratorClient("http://orch:8080")
|
||||
|
||||
def test_posts_live_ips_and_returns_reaped(self) -> None:
|
||||
def test_triggers_sweep_and_returns_reaped(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"reaped": ["b1", "b2"]})) as m:
|
||||
got = self.c.reconcile(["10.0.0.2", "10.0.0.3"])
|
||||
got = self.c.reconcile()
|
||||
self.assertEqual(["b1", "b2"], got)
|
||||
sent = json.loads(m.call_args.args[0].data)
|
||||
self.assertEqual(["10.0.0.2", "10.0.0.3"], sent["live_source_ips"])
|
||||
# The live set is no longer sent — the orchestrator enumerates it.
|
||||
self.assertNotIn("live_source_ips", sent)
|
||||
self.assertNotIn("grace_seconds", sent) # omitted -> server default
|
||||
|
||||
def test_grace_seconds_is_forwarded_when_given(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"reaped": []})) as m:
|
||||
self.c.reconcile([], grace_seconds=30)
|
||||
self.c.reconcile(grace_seconds=30)
|
||||
self.assertEqual(30, json.loads(m.call_args.args[0].data)["grace_seconds"])
|
||||
|
||||
def test_malformed_reaped_is_tolerated(self) -> None:
|
||||
with patch(_URLOPEN, return_value=_resp(200, {"reaped": ["ok", 5, None]})):
|
||||
self.assertEqual(["ok"], self.c.reconcile([]))
|
||||
self.assertEqual(["ok"], self.c.reconcile())
|
||||
with patch(_URLOPEN, return_value=_resp(200, {})):
|
||||
self.assertEqual([], self.c.reconcile([]))
|
||||
self.assertEqual([], self.c.reconcile())
|
||||
|
||||
def test_error_status_raises(self) -> None:
|
||||
with patch(_URLOPEN, side_effect=_http_error(500)):
|
||||
with self.assertRaises(OrchestratorClientError):
|
||||
self.c.reconcile([])
|
||||
self.c.reconcile()
|
||||
|
||||
@@ -8,6 +8,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
from bot_bottle.orchestrator.broker import (
|
||||
BrokerAuthError,
|
||||
BrokerUnavailableError,
|
||||
LaunchRequest,
|
||||
sign_request,
|
||||
)
|
||||
@@ -96,5 +97,50 @@ class TestDockerBroker(unittest.TestCase):
|
||||
m.assert_not_called()
|
||||
|
||||
|
||||
class TestDockerBrokerListLive(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.secret = secrets.token_bytes(16)
|
||||
self.broker = DockerBroker(self.secret)
|
||||
|
||||
def test_enumerates_labeled_container_ips(self) -> None:
|
||||
# `docker ps` lists two labeled containers; each inspect yields an IP.
|
||||
ps = Mock(returncode=0, stdout="c1\nc2\n", stderr="")
|
||||
i1 = Mock(returncode=0, stdout="10.0.0.1 \n", stderr="")
|
||||
i2 = Mock(returncode=0, stdout="10.0.0.2 \n", stderr="")
|
||||
with patch.object(self.broker, "_docker", side_effect=[ps, i1, i2]) as m:
|
||||
got = self.broker._list_live()
|
||||
self.assertEqual(["10.0.0.1", "10.0.0.2"], got)
|
||||
# First call is the label-filtered `docker ps`.
|
||||
ps_argv = m.call_args_list[0].args[0]
|
||||
self.assertEqual(["docker", "ps", "--filter", f"label={BOTTLE_ID_LABEL}"], ps_argv[:4])
|
||||
|
||||
def test_no_containers_is_empty(self) -> None:
|
||||
with patch.object(self.broker, "_docker",
|
||||
return_value=Mock(returncode=0, stdout="", stderr="")):
|
||||
self.assertEqual([], self.broker._list_live())
|
||||
|
||||
def test_ps_failure_raises(self) -> None:
|
||||
with patch.object(self.broker, "_docker",
|
||||
return_value=Mock(returncode=1, stdout="", stderr="daemon down")):
|
||||
with self.assertRaises(DockerBrokerError):
|
||||
self.broker._list_live()
|
||||
|
||||
def test_inspect_failure_raises(self) -> None:
|
||||
ps = Mock(returncode=0, stdout="c1\n", stderr="")
|
||||
bad = Mock(returncode=1, stdout="", stderr="no such container")
|
||||
with patch.object(self.broker, "_docker", side_effect=[ps, bad]):
|
||||
with self.assertRaises(DockerBrokerError):
|
||||
self.broker._list_live()
|
||||
|
||||
def test_enumeration_failure_surfaces_as_unavailable_via_list_live(self) -> None:
|
||||
# Through the verb, a backend failure is the single "live set unknown"
|
||||
# signal (BrokerUnavailableError) reconcile catches to skip the sweep.
|
||||
token = sign_request(LaunchRequest(op="list_live"), self.secret)
|
||||
with patch.object(self.broker, "_docker",
|
||||
return_value=Mock(returncode=1, stdout="", stderr="down")):
|
||||
with self.assertRaises(BrokerUnavailableError):
|
||||
self.broker.list_live(token)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -248,88 +248,6 @@ class TestDockerGateway(unittest.TestCase):
|
||||
calls,
|
||||
)
|
||||
|
||||
def test_ensure_running_replaces_poisoned_ipv6_network(self) -> None:
|
||||
# A daemon that default-enables IPv6 leaves the gateway network with a
|
||||
# malformed fdd0::/64 gateway, so `docker network inspect` exits
|
||||
# non-zero with a ParseAddr error (not "No such network"). `--ipv6=false`
|
||||
# can't heal an already-poisoned network — the create just no-ops on
|
||||
# "already exists" — so _ensure_network must force-remove and recreate
|
||||
# it, else every later subnet read keeps failing.
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout="")
|
||||
if argv[:3] == ["docker", "network", "inspect"]:
|
||||
return _proc(
|
||||
returncode=1,
|
||||
stderr='ParseAddr("fdd0:0:0:4::1/64"): unexpected character, '
|
||||
'want colon (at "/64")',
|
||||
)
|
||||
return _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
self.assertIn(["docker", "rm", "--force", self.sc.name], calls)
|
||||
self.assertIn(["docker", "network", "rm", self.sc.network], calls)
|
||||
creates = [c for c in calls if c[:3] == ["docker", "network", "create"]]
|
||||
self.assertEqual(
|
||||
[[
|
||||
"docker", "network", "create",
|
||||
"--ipv6=false",
|
||||
"--subnet", DEFAULT_GATEWAY_SUBNET,
|
||||
"--label",
|
||||
f"bot-bottle.gateway-subnet={DEFAULT_GATEWAY_SUBNET}",
|
||||
self.sc.network,
|
||||
]],
|
||||
creates,
|
||||
)
|
||||
|
||||
def test_ensure_running_creates_network_when_inspect_reports_absent(self) -> None:
|
||||
# The absent case (inspect fails with "No such network") must NOT try to
|
||||
# remove anything — it just creates. Guards the poisoned-vs-absent split.
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:3] == ["docker", "network", "inspect"]:
|
||||
return _proc(returncode=1, stderr="Error: No such network: x")
|
||||
return _proc(stdout="") if argv[:2] == ["docker", "ps"] else _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
self.assertNotIn(["docker", "network", "rm", self.sc.network], calls)
|
||||
creates = [c for c in calls if c[:3] == ["docker", "network", "create"]]
|
||||
self.assertEqual(1, len(creates))
|
||||
|
||||
def test_ensure_running_does_not_destroy_on_generic_inspect_error(self) -> None:
|
||||
# A generic inspect failure (daemon hiccup, permission, timeout) is NOT
|
||||
# evidence of a poisoned network. Only the ParseAddr poison signature may
|
||||
# take the destructive heal path; anything else must surface as an error
|
||||
# without tearing down a possibly-healthy shared gateway.
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def fake(argv: list[str], **_kw: object) -> Mock:
|
||||
calls.append(argv)
|
||||
if argv[:2] == ["docker", "ps"]:
|
||||
return _proc(stdout="")
|
||||
if argv[:3] == ["docker", "network", "inspect"]:
|
||||
return _proc(
|
||||
returncode=1,
|
||||
stderr="Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
|
||||
)
|
||||
return _proc()
|
||||
|
||||
with patch(_RUN_DOCKER, side_effect=fake):
|
||||
with self.assertRaises(GatewayError):
|
||||
self.sc.connect_to_orchestrator(_ORCH_URL, _TOKEN)
|
||||
# No mutation of the shared gateway: neither the container nor the
|
||||
# network is removed, and nothing is recreated.
|
||||
self.assertNotIn(["docker", "network", "rm", self.sc.network], calls)
|
||||
self.assertFalse(any(c[:3] == ["docker", "rm", "--force"] for c in calls))
|
||||
self.assertEqual([], [c for c in calls if c[:3] == ["docker", "network", "create"]])
|
||||
|
||||
def test_ca_cert_pem_reads_from_container(self) -> None:
|
||||
with patch(_RUN_DOCKER, return_value=_proc(stdout=_CA_PEM)) as m:
|
||||
self.assertEqual(_CA_PEM, self.sc.ca_cert_pem())
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Unit tests for the host control server (issue #468, chunk 1).
|
||||
|
||||
Mostly exercises the pure `dispatch()` (socket-free, like the orchestrator
|
||||
server tests), plus a real-socket round-trip through `BrokerClient` that proves
|
||||
the full sign -> POST -> verify -> act seam over HTTP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
import threading
|
||||
import typing
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.orchestrator.broker import (
|
||||
BrokerAuthError,
|
||||
LaunchBroker,
|
||||
LaunchRequest,
|
||||
StubBroker,
|
||||
sign_request,
|
||||
)
|
||||
from bot_bottle.orchestrator.broker_client import BrokerClient
|
||||
from bot_bottle.orchestrator.host_server import (
|
||||
MAX_BODY_BYTES,
|
||||
Handler,
|
||||
HostControlServer,
|
||||
broker_secret,
|
||||
dispatch,
|
||||
main,
|
||||
make_host_server,
|
||||
)
|
||||
from bot_bottle.paths import LAUNCH_BROKER_KEY_ENV
|
||||
|
||||
|
||||
def _body(obj: object) -> bytes:
|
||||
return json.dumps(obj).encode()
|
||||
|
||||
|
||||
class _RaisingBroker(LaunchBroker):
|
||||
"""A broker whose backend launch/enumeration always fails — exercises the
|
||||
502 path (an operational backend failure, distinct from a fail-closed
|
||||
provenance 401)."""
|
||||
|
||||
def _launch(self, req: LaunchRequest) -> None:
|
||||
raise RuntimeError("docker down")
|
||||
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
raise RuntimeError("docker down")
|
||||
|
||||
def _list_live(self) -> list[str]:
|
||||
raise RuntimeError("docker down")
|
||||
|
||||
|
||||
class TestDispatch(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.secret = secrets.token_bytes(16)
|
||||
self.broker = StubBroker(self.secret)
|
||||
|
||||
def _token(self, **kwargs: object) -> str:
|
||||
return sign_request(LaunchRequest(**kwargs), self.secret) # type: ignore[arg-type]
|
||||
|
||||
def test_health(self) -> None:
|
||||
status, payload = dispatch(self.broker, "GET", "/health", b"")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("ok", payload["status"])
|
||||
|
||||
def test_broker_launch_verifies_and_acts(self) -> None:
|
||||
token = self._token(
|
||||
op="launch", bottle_id="b1", source_ip="10.243.0.1",
|
||||
image_ref="img", slot=2,
|
||||
)
|
||||
status, payload = dispatch(self.broker, "POST", "/broker", _body({"token": token}))
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual("launch", payload["op"])
|
||||
self.assertEqual("b1", payload["bottle_id"])
|
||||
self.assertEqual("img", payload["image_ref"])
|
||||
self.assertEqual(2, payload["slot"])
|
||||
self.assertEqual(["b1"], [r.bottle_id for r in self.broker.launched])
|
||||
|
||||
def test_broker_teardown_acts(self) -> None:
|
||||
token = self._token(op="teardown", bottle_id="b1")
|
||||
status, _ = dispatch(self.broker, "POST", "/broker", _body({"token": token}))
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual(["b1"], [r.bottle_id for r in self.broker.torn_down])
|
||||
|
||||
def test_forged_token_is_401_and_nothing_acted(self) -> None:
|
||||
forged = sign_request(
|
||||
LaunchRequest(op="launch", bottle_id="b1"), secrets.token_bytes(16))
|
||||
status, payload = dispatch(self.broker, "POST", "/broker", _body({"token": forged}))
|
||||
self.assertEqual(401, status)
|
||||
self.assertIn("broker auth failed", str(payload["error"]))
|
||||
self.assertEqual([], self.broker.launched) # fail-closed: never launched
|
||||
|
||||
def test_backend_failure_is_502(self) -> None:
|
||||
broker = _RaisingBroker(self.secret)
|
||||
token = self._token(op="launch", bottle_id="b1", image_ref="img")
|
||||
status, payload = dispatch(broker, "POST", "/broker", _body({"token": token}))
|
||||
self.assertEqual(502, status)
|
||||
self.assertIn("backend launch failed", str(payload["error"]))
|
||||
|
||||
def test_broker_live_returns_source_ips(self) -> None:
|
||||
# Two launched bottles -> the stub reports both as live.
|
||||
self.broker.submit(self._token(op="launch", bottle_id="b1", source_ip="10.0.0.1"))
|
||||
self.broker.submit(self._token(op="launch", bottle_id="b2", source_ip="10.0.0.2"))
|
||||
token = self._token(op="list_live")
|
||||
status, payload = dispatch(self.broker, "POST", "/broker/live", _body({"token": token}))
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual(
|
||||
["10.0.0.1", "10.0.0.2"],
|
||||
sorted(typing.cast("list[str]", payload["source_ips"])),
|
||||
)
|
||||
|
||||
def test_broker_live_forged_token_is_401(self) -> None:
|
||||
forged = sign_request(LaunchRequest(op="list_live"), secrets.token_bytes(16))
|
||||
status, payload = dispatch(self.broker, "POST", "/broker/live", _body({"token": forged}))
|
||||
self.assertEqual(401, status)
|
||||
self.assertIn("broker auth failed", str(payload["error"]))
|
||||
|
||||
def test_broker_live_rejects_a_mutation_token(self) -> None:
|
||||
# A launch token routed to the query endpoint is a fail-closed 401 — the
|
||||
# endpoints don't share a schema even though they share a secret.
|
||||
token = self._token(op="launch", bottle_id="b1", image_ref="img")
|
||||
status, _ = dispatch(self.broker, "POST", "/broker/live", _body({"token": token}))
|
||||
self.assertEqual(401, status)
|
||||
|
||||
def test_broker_live_backend_failure_is_502(self) -> None:
|
||||
broker = _RaisingBroker(self.secret)
|
||||
token = self._token(op="list_live")
|
||||
status, payload = dispatch(broker, "POST", "/broker/live", _body({"token": token}))
|
||||
self.assertEqual(502, status)
|
||||
self.assertIn("backend enumeration failed", str(payload["error"]))
|
||||
|
||||
def test_broker_live_missing_token_is_400(self) -> None:
|
||||
status, _ = dispatch(self.broker, "POST", "/broker/live", _body({}))
|
||||
self.assertEqual(400, status)
|
||||
|
||||
def test_missing_token_is_400(self) -> None:
|
||||
status, _ = dispatch(self.broker, "POST", "/broker", _body({}))
|
||||
self.assertEqual(400, status)
|
||||
|
||||
def test_bad_json_is_400(self) -> None:
|
||||
status, _ = dispatch(self.broker, "POST", "/broker", b"{not json")
|
||||
self.assertEqual(400, status)
|
||||
|
||||
def test_empty_body_is_missing_token_400(self) -> None:
|
||||
# Empty body parses to {} (no token) → 400, never reaching the broker.
|
||||
status, _ = dispatch(self.broker, "POST", "/broker", b"")
|
||||
self.assertEqual(400, status)
|
||||
self.assertEqual([], self.broker.launched)
|
||||
|
||||
def test_non_object_body_is_400(self) -> None:
|
||||
status, _ = dispatch(self.broker, "POST", "/broker", b"[1, 2]")
|
||||
self.assertEqual(400, status)
|
||||
|
||||
def test_unknown_route_404(self) -> None:
|
||||
status, _ = dispatch(self.broker, "GET", "/nope", b"")
|
||||
self.assertEqual(404, status)
|
||||
|
||||
def test_trailing_slash_normalized(self) -> None:
|
||||
status, _ = dispatch(self.broker, "GET", "/health/", b"")
|
||||
self.assertEqual(200, status)
|
||||
|
||||
|
||||
class TestBrokerSecret(unittest.TestCase):
|
||||
"""The durable launch-broker key (#468/#476): prefer the env-injected key,
|
||||
else the durable host key file, so signer and verifier resolve the same one."""
|
||||
|
||||
def test_reads_injected_key_from_env(self) -> None:
|
||||
# The injected key is honoured regardless of allow_host_file — both the
|
||||
# host controller and the guest orchestrator take an injected key.
|
||||
self.assertEqual(
|
||||
b"injected-key", broker_secret({LAUNCH_BROKER_KEY_ENV: "injected-key"}))
|
||||
self.assertEqual(
|
||||
b"injected-key",
|
||||
broker_secret({LAUNCH_BROKER_KEY_ENV: "injected-key"}, allow_host_file=True))
|
||||
|
||||
def test_guest_without_injection_fails_closed(self) -> None:
|
||||
# The default (guest orchestrator): no env key and NO host-file fallback,
|
||||
# so it returns None rather than mint a divergent process-local key.
|
||||
self.assertIsNone(broker_secret({}))
|
||||
|
||||
def test_host_side_falls_back_to_the_durable_key_file(self) -> None:
|
||||
# allow_host_file=True (host controller / dev-harness): mint/read the
|
||||
# durable host key file, the same key on every call (restart re-adoption).
|
||||
with tempfile.TemporaryDirectory() as root:
|
||||
with patch.dict("os.environ", {"BOT_BOTTLE_ROOT": root}, clear=False):
|
||||
os.environ.pop(LAUNCH_BROKER_KEY_ENV, None)
|
||||
first = broker_secret(allow_host_file=True)
|
||||
second = broker_secret(allow_host_file=True)
|
||||
self.assertTrue(first)
|
||||
self.assertEqual(first, second)
|
||||
|
||||
|
||||
class TestSeamRoundTrip(unittest.TestCase):
|
||||
"""The whole point of chunk 1: a request signed by the orchestrator side is
|
||||
POSTed to a real host control server, verified there, and acted on — over
|
||||
HTTP, not an in-process call."""
|
||||
|
||||
def _serve(self, broker: LaunchBroker) -> BrokerClient:
|
||||
server = make_host_server(broker, "127.0.0.1", 0)
|
||||
self.addCleanup(server.server_close)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
self.addCleanup(server.shutdown)
|
||||
host, port = server.server_address[0], server.server_address[1]
|
||||
return BrokerClient(f"http://{host}:{port}")
|
||||
|
||||
def test_sign_post_verify_act_over_http(self) -> None:
|
||||
secret = secrets.token_bytes(16)
|
||||
broker = StubBroker(secret)
|
||||
client = self._serve(broker)
|
||||
req = LaunchRequest(
|
||||
op="launch", bottle_id="b1", source_ip="10.0.0.1", image_ref="img", slot=1)
|
||||
got = client.submit(sign_request(req, secret))
|
||||
self.assertEqual(req, got) # the controller echoes the verified request
|
||||
self.assertEqual(["b1"], [r.bottle_id for r in broker.launched])
|
||||
|
||||
def test_forged_token_raises_broker_auth_error_over_http(self) -> None:
|
||||
secret = secrets.token_bytes(16)
|
||||
broker = StubBroker(secret)
|
||||
client = self._serve(broker)
|
||||
forged = sign_request(
|
||||
LaunchRequest(op="launch", bottle_id="b1"), secrets.token_bytes(16))
|
||||
with self.assertRaises(BrokerAuthError):
|
||||
client.submit(forged)
|
||||
self.assertEqual([], broker.launched) # fail-closed across the wire
|
||||
|
||||
def test_list_live_enumerates_over_http(self) -> None:
|
||||
secret = secrets.token_bytes(16)
|
||||
broker = StubBroker(secret)
|
||||
broker.live_source_ips = ["10.0.0.7", "10.0.0.8"]
|
||||
client = self._serve(broker)
|
||||
got = client.list_live(sign_request(LaunchRequest(op="list_live"), secret))
|
||||
self.assertEqual(["10.0.0.7", "10.0.0.8"], got)
|
||||
|
||||
|
||||
class TestRequestLimits(unittest.TestCase):
|
||||
"""The privileged listener must not let a caller that can merely reach the
|
||||
socket (no signed token) exhaust it via an oversized declared body — and it
|
||||
rejects on the Content-Length *header*, before reading the body."""
|
||||
|
||||
def _addr(self) -> tuple[str, int]:
|
||||
self.broker = StubBroker(secrets.token_bytes(16))
|
||||
server = make_host_server(self.broker, "127.0.0.1", 0)
|
||||
self.addCleanup(server.server_close)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
self.addCleanup(server.shutdown)
|
||||
host, port = server.server_address[:2]
|
||||
return typing.cast(str, host), port
|
||||
|
||||
def test_oversized_content_length_is_rejected_before_reading(self) -> None:
|
||||
host, port = self._addr()
|
||||
conn = http.client.HTTPConnection(host, port, timeout=5)
|
||||
self.addCleanup(conn.close)
|
||||
# Declare an oversized body but send only a sliver: the server must reject
|
||||
# on the header before reading, so the caller gets a clean, deterministic
|
||||
# 413 (no large unread body to race a connection reset).
|
||||
conn.putrequest("POST", "/broker", skip_accept_encoding=True)
|
||||
conn.putheader("Content-Type", "application/json")
|
||||
conn.putheader("Content-Length", str(MAX_BODY_BYTES + 1))
|
||||
conn.endheaders()
|
||||
conn.send(b"{}") # far short of the declared length; never read
|
||||
resp = conn.getresponse()
|
||||
self.assertEqual(413, resp.status)
|
||||
self.assertEqual([], self.broker.launched) # never reached the broker
|
||||
|
||||
|
||||
class TestServeUnit(unittest.TestCase):
|
||||
"""Drive `Handler._serve` directly (no socket). The real per-request handler
|
||||
runs in a daemon thread whose coverage/trace data is lost, so the
|
||||
bounded-body and error paths are exercised here in the main thread instead."""
|
||||
|
||||
def _handler(self, broker: LaunchBroker, headers: dict[str, str],
|
||||
body: bytes = b"") -> tuple[Handler, MagicMock]:
|
||||
server = HostControlServer.__new__(HostControlServer)
|
||||
server.broker = broker
|
||||
h = Handler.__new__(Handler)
|
||||
h.server = server
|
||||
h.headers = headers # type: ignore[assignment] — dict is a valid .get() stand-in
|
||||
h.path = "/broker"
|
||||
h.rfile = io.BytesIO(body)
|
||||
h.wfile = io.BytesIO()
|
||||
send_response = MagicMock()
|
||||
h.send_response = send_response # type: ignore[method-assign]
|
||||
h.send_header = MagicMock() # type: ignore[method-assign]
|
||||
h.end_headers = MagicMock() # type: ignore[method-assign]
|
||||
return h, send_response
|
||||
|
||||
def test_oversized_content_length_is_413(self) -> None:
|
||||
broker = StubBroker(secrets.token_bytes(16))
|
||||
h, send_response = self._handler(broker, {"Content-Length": str(MAX_BODY_BYTES + 1)})
|
||||
h.do_POST() # exercises do_POST -> _serve
|
||||
send_response.assert_called_once_with(413)
|
||||
self.assertEqual([], broker.launched) # rejected before the broker
|
||||
|
||||
def test_invalid_content_length_is_400(self) -> None:
|
||||
h, send_response = self._handler(StubBroker(secrets.token_bytes(16)),
|
||||
{"Content-Length": "not-a-number"})
|
||||
h._serve("POST")
|
||||
send_response.assert_called_once_with(400)
|
||||
|
||||
def test_valid_request_dispatches_200(self) -> None:
|
||||
secret = secrets.token_bytes(16)
|
||||
broker = StubBroker(secret)
|
||||
body = _body({"token": sign_request(
|
||||
LaunchRequest(op="teardown", bottle_id="b1"), secret)})
|
||||
h, send_response = self._handler(broker, {"Content-Length": str(len(body))}, body)
|
||||
h._serve("POST")
|
||||
send_response.assert_called_once_with(200)
|
||||
self.assertEqual(["b1"], [r.bottle_id for r in broker.torn_down])
|
||||
|
||||
def test_dispatch_exception_becomes_500(self) -> None:
|
||||
# dispatch is total, but the handler still guards it: a raised dispatch
|
||||
# returns 500 rather than dropping the connection.
|
||||
h, send_response = self._handler(
|
||||
StubBroker(secrets.token_bytes(16)), {"Content-Length": "0"})
|
||||
with patch("bot_bottle.orchestrator.host_server.dispatch",
|
||||
side_effect=RuntimeError("boom")):
|
||||
h._serve("POST")
|
||||
send_response.assert_called_once_with(500)
|
||||
|
||||
def test_health_over_do_get(self) -> None:
|
||||
h, send_response = self._handler(StubBroker(secrets.token_bytes(16)), {})
|
||||
h.path = "/health"
|
||||
h.do_GET()
|
||||
send_response.assert_called_once_with(200)
|
||||
|
||||
|
||||
class TestMain(unittest.TestCase):
|
||||
def test_fail_closed_without_secret(self) -> None:
|
||||
with patch("bot_bottle.orchestrator.host_server.broker_secret",
|
||||
return_value=None):
|
||||
self.assertEqual(2, main(["--port", "0"]))
|
||||
|
||||
def test_serves_then_shuts_down_cleanly(self) -> None:
|
||||
fake = MagicMock()
|
||||
fake.server_address = ("127.0.0.1", 0)
|
||||
fake.serve_forever.side_effect = KeyboardInterrupt
|
||||
with patch("bot_bottle.orchestrator.host_server.broker_secret",
|
||||
return_value=b"k"), \
|
||||
patch("bot_bottle.orchestrator.host_server.make_host_server",
|
||||
return_value=fake):
|
||||
self.assertEqual(0, main(["--port", "0"]))
|
||||
fake.serve_forever.assert_called_once()
|
||||
fake.server_close.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Unit: the orchestrator dev-harness entrypoint (`python -m bot_bottle.orchestrator`).
|
||||
|
||||
Exercises broker selection (stub / docker / http) and the fail-closed http path,
|
||||
patching `make_server` so the serve loop returns instead of blocking.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from bot_bottle.orchestrator.__main__ import main
|
||||
from bot_bottle.paths import LAUNCH_BROKER_KEY_ENV
|
||||
|
||||
|
||||
def _fake_server() -> MagicMock:
|
||||
fake = MagicMock()
|
||||
fake.server_address = ("127.0.0.1", 0)
|
||||
# Break out of serve_forever immediately, exercising the try/finally.
|
||||
fake.serve_forever.side_effect = KeyboardInterrupt
|
||||
return fake
|
||||
|
||||
|
||||
class TestMain(unittest.TestCase):
|
||||
def _run(self, broker: str, env: dict[str, str] | None = None) -> tuple[int, MagicMock]:
|
||||
fake = _fake_server()
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
argv = ["--db", str(Path(d) / "r.db"), "--port", "0", "--broker", broker]
|
||||
with patch("bot_bottle.orchestrator.__main__.make_server", return_value=fake), \
|
||||
patch.dict("os.environ", env or {}, clear=False):
|
||||
if env is None:
|
||||
os.environ.pop(LAUNCH_BROKER_KEY_ENV, None)
|
||||
rc = main(argv)
|
||||
return rc, fake
|
||||
|
||||
def test_stub_broker_serves_and_closes(self) -> None:
|
||||
rc, fake = self._run("stub")
|
||||
self.assertEqual(0, rc)
|
||||
fake.serve_forever.assert_called_once()
|
||||
fake.server_close.assert_called_once()
|
||||
|
||||
def test_docker_broker_serves(self) -> None:
|
||||
rc, _ = self._run("docker")
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
def test_http_broker_with_injected_key_serves(self) -> None:
|
||||
# The guest orchestrator takes the launch-broker key by injection.
|
||||
rc, _ = self._run(
|
||||
"http", env={LAUNCH_BROKER_KEY_ENV: secrets.token_urlsafe(16)})
|
||||
self.assertEqual(0, rc)
|
||||
|
||||
def test_http_broker_without_injected_key_exits(self) -> None:
|
||||
# Fail-closed: no host-file fallback for the guest, so a missing injected
|
||||
# key is a usage error rather than a silently-minted divergent key.
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
with patch.dict("os.environ", {}, clear=False):
|
||||
os.environ.pop(LAUNCH_BROKER_KEY_ENV, None)
|
||||
with self.assertRaises(SystemExit):
|
||||
main(["--db", str(Path(d) / "r.db"), "--broker", "http"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import unittest
|
||||
|
||||
from bot_bottle.orchestrator.store.secret_store import (
|
||||
ENV_VAR_SECRET_NAME,
|
||||
_NONCE_BYTES,
|
||||
decrypt_value,
|
||||
encrypt_value,
|
||||
new_env_var_secret,
|
||||
@@ -65,22 +67,27 @@ class TestDecryptErrors(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.secret = new_env_var_secret()
|
||||
|
||||
def test_wrong_key_raises_value_error(self) -> None:
|
||||
def test_wrong_key_always_raises_value_error(self) -> None:
|
||||
# Deterministic: the authentication tag rejects a wrong key every time,
|
||||
# so reprovision can never inject a garbage token. Repeat across many
|
||||
# random keys (the old unauthenticated scheme let ~5% through when the
|
||||
# garbage happened to decode as valid UTF-8).
|
||||
for _ in range(200):
|
||||
ct = encrypt_value(self.secret, "secret-token")
|
||||
with self.assertRaises(ValueError):
|
||||
decrypt_value(new_env_var_secret(), ct)
|
||||
|
||||
def test_tampered_ciphertext_raises_value_error(self) -> None:
|
||||
ct = encrypt_value(self.secret, "secret-token")
|
||||
other_key = new_env_var_secret()
|
||||
# Wrong key produces garbage bytes; decrypt_value raises ValueError
|
||||
# when the result is non-UTF-8 (which is very likely for 12-char data).
|
||||
# We allow it to succeed only if garbage happens to be valid UTF-8, but
|
||||
# the plaintext must not match.
|
||||
try:
|
||||
result = decrypt_value(other_key, ct)
|
||||
self.assertNotEqual("secret-token", result)
|
||||
except ValueError:
|
||||
pass
|
||||
raw = bytearray(base64.urlsafe_b64decode(ct + "=" * (-len(ct) % 4)))
|
||||
raw[_NONCE_BYTES] ^= 0x01 # flip a bit in the ciphertext body → tag mismatch
|
||||
tampered = base64.urlsafe_b64encode(bytes(raw)).rstrip(b"=").decode()
|
||||
with self.assertRaises(ValueError):
|
||||
decrypt_value(self.secret, tampered)
|
||||
|
||||
def test_truncated_blob_raises_value_error(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
decrypt_value(self.secret, "dG9vc2hvcnQ") # "tooshort" — under 16 nonce bytes
|
||||
decrypt_value(self.secret, "dG9vc2hvcnQ") # "tooshort" — under nonce+tag
|
||||
|
||||
def test_invalid_base64_raises_value_error(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
|
||||
@@ -624,13 +624,18 @@ if __name__ == "__main__":
|
||||
|
||||
|
||||
class TestReconcileRoute(unittest.TestCase):
|
||||
"""`POST /reconcile` — the host tells the orchestrator which bottles are
|
||||
actually up, since the orchestrator can't see the backend from inside the
|
||||
infra container."""
|
||||
"""`POST /reconcile` — a bare self-heal trigger. The orchestrator pulls its
|
||||
own live set from the broker (`list_live`); the caller no longer supplies it,
|
||||
so the request body carries at most `grace_seconds`."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.orch = _orchestrator(Path(self._tmp.name) / "r.db")
|
||||
store = RegistryStore(Path(self._tmp.name) / "r.db")
|
||||
store.migrate()
|
||||
secret = secrets.token_bytes(16)
|
||||
# Hold a typed StubBroker so tests can set its live set directly.
|
||||
self.broker = StubBroker(secret)
|
||||
self.orch = OrchestratorCore(store, self.broker, secret)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
@@ -647,44 +652,35 @@ class TestReconcileRoute(unittest.TestCase):
|
||||
def test_reaps_absent_and_reports_ids(self) -> None:
|
||||
dead = self._old("10.0.0.1")
|
||||
alive = self._old("10.0.0.2")
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile", _body({"live_source_ips": ["10.0.0.2"]}))
|
||||
self.broker.live_source_ips = ["10.0.0.2"] # broker: only .2 is up
|
||||
status, payload = dispatch(self.orch, "POST", "/reconcile", _body({}))
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([dead], payload["reaped"])
|
||||
self.assertIsNone(self.orch.registry.get(dead))
|
||||
self.assertIsNotNone(self.orch.registry.get(alive))
|
||||
|
||||
def test_missing_live_source_ips_is_400(self) -> None:
|
||||
status, _ = dispatch(self.orch, "POST", "/reconcile", _body({}))
|
||||
self.assertEqual(400, status)
|
||||
def test_empty_body_triggers_the_sweep(self) -> None:
|
||||
"""No body is fine — the live set comes from the broker, not the body."""
|
||||
dead = self._old("10.0.0.1")
|
||||
self.broker.live_source_ips = [] # broker: nothing running
|
||||
status, payload = dispatch(self.orch, "POST", "/reconcile", b"")
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([dead], payload["reaped"])
|
||||
|
||||
def test_grace_seconds_is_honoured(self) -> None:
|
||||
"""A grace window wide enough to cover the row protects it."""
|
||||
self.orch.registry.register("10.0.0.3")
|
||||
self.broker.live_source_ips = []
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile",
|
||||
_body({"live_source_ips": [], "grace_seconds": 3600}))
|
||||
self.orch, "POST", "/reconcile", _body({"grace_seconds": 3600}))
|
||||
self.assertEqual(200, status)
|
||||
self.assertEqual([], payload["reaped"])
|
||||
|
||||
def test_non_string_entries_are_rejected(self) -> None:
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile",
|
||||
_body({"live_source_ips": [None, 7, "10.0.0.9"]}))
|
||||
self.assertEqual(400, status)
|
||||
self.assertIn("live_source_ips", str(payload["error"]))
|
||||
|
||||
def test_empty_live_source_ip_is_rejected(self) -> None:
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile", _body({"live_source_ips": [""]}))
|
||||
self.assertEqual(400, status)
|
||||
self.assertIn("live_source_ips", str(payload["error"]))
|
||||
|
||||
def test_invalid_grace_seconds_is_rejected(self) -> None:
|
||||
for value in (True, "30", -1, float("inf"), float("nan")):
|
||||
with self.subTest(value=value):
|
||||
status, payload = dispatch(
|
||||
self.orch, "POST", "/reconcile",
|
||||
_body({"live_source_ips": [], "grace_seconds": value}))
|
||||
_body({"grace_seconds": value}))
|
||||
self.assertEqual(400, status)
|
||||
self.assertIn("grace_seconds", str(payload["error"]))
|
||||
|
||||
@@ -11,7 +11,12 @@ from contextlib import closing
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle.orchestrator.broker import LaunchBroker, LaunchRequest, StubBroker
|
||||
from bot_bottle.orchestrator.broker import (
|
||||
BrokerUnavailableError,
|
||||
LaunchBroker,
|
||||
LaunchRequest,
|
||||
StubBroker,
|
||||
)
|
||||
from bot_bottle.orchestrator.store.registry_store import RegistryStore
|
||||
from bot_bottle.orchestrator.service import OrchestratorCore
|
||||
from bot_bottle.orchestrator.store.secret_store import new_env_var_secret
|
||||
@@ -25,8 +30,8 @@ from bot_bottle.orchestrator.supervisor import (
|
||||
|
||||
|
||||
class _FailingBroker(LaunchBroker):
|
||||
"""Verifies the token like any broker, then fails the launch — to
|
||||
exercise the orchestrator's registry rollback."""
|
||||
"""Verifies the token like any broker, then fails the launch *definitely* —
|
||||
to exercise the orchestrator's registry rollback."""
|
||||
|
||||
def _launch(self, req: LaunchRequest) -> None:
|
||||
raise RuntimeError("launch failed")
|
||||
@@ -34,6 +39,24 @@ class _FailingBroker(LaunchBroker):
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
pass
|
||||
|
||||
def _list_live(self) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
class _UnavailableBroker(LaunchBroker):
|
||||
"""Verifies the token, then raises the *ambiguous* BrokerUnavailableError —
|
||||
the host may already have launched — so the orchestrator must KEEP the
|
||||
registry row rather than orphan a running container."""
|
||||
|
||||
def _launch(self, req: LaunchRequest) -> None:
|
||||
raise BrokerUnavailableError("delivery dropped after send")
|
||||
|
||||
def _teardown(self, req: LaunchRequest) -> None:
|
||||
pass
|
||||
|
||||
def _list_live(self) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
class TestOrchestrator(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
@@ -144,11 +167,20 @@ class TestOrchestrator(unittest.TestCase):
|
||||
self.assertIsNotNone(self.orch.resolve("10.243.0.3", rec.identity_token))
|
||||
self.assertIsNone(self.orch.resolve("10.243.0.3", "wrong-token"))
|
||||
|
||||
def test_launch_rolls_back_registry_on_broker_failure(self) -> None:
|
||||
def test_launch_rolls_back_registry_on_definite_broker_failure(self) -> None:
|
||||
orch = OrchestratorCore(self.store, _FailingBroker(self.secret), self.secret)
|
||||
with self.assertRaises(RuntimeError):
|
||||
orch.launch_bottle("10.243.0.9")
|
||||
self.assertEqual([], self.store.all()) # no orphan
|
||||
self.assertEqual([], self.store.all()) # no orphan row
|
||||
|
||||
def test_launch_keeps_registry_on_ambiguous_broker_failure(self) -> None:
|
||||
# The host may already have launched the bottle before the response was
|
||||
# lost, so deregistering would orphan a running container with no row.
|
||||
# The row is kept for reconcile to reap iff the bottle is not live.
|
||||
orch = OrchestratorCore(self.store, _UnavailableBroker(self.secret), self.secret)
|
||||
with self.assertRaises(BrokerUnavailableError):
|
||||
orch.launch_bottle("10.243.0.9")
|
||||
self.assertEqual(1, len(self.store.all())) # row survives — no orphan container
|
||||
|
||||
def test_gateway_status_reports_unconfigured(self) -> None:
|
||||
# The orchestrator no longer owns a standalone gateway lifecycle; the
|
||||
@@ -339,8 +371,9 @@ class TestOrchestratorSupervise(unittest.TestCase):
|
||||
pid = self.orch.supervise_queue_proposal(
|
||||
rec.bottle_id, tool=TOOL_EGRESS_ALLOW,
|
||||
proposed_file="routes:\n - host: google.com\n", justification="j")
|
||||
# No live source IPs -> the bottle is reaped (grace 0 so it's immediate).
|
||||
self.assertEqual([rec.bottle_id], self.orch.reconcile([], grace_seconds=0))
|
||||
# No live source IPs (nothing launched through the stub) -> the bottle
|
||||
# is reaped (grace 0 so it's immediate).
|
||||
self.assertEqual([rec.bottle_id], self.orch.reconcile(grace_seconds=0))
|
||||
self.assertEqual(
|
||||
{"status": "unknown"}, self.orch.supervise_poll_response(rec.bottle_id, pid))
|
||||
|
||||
@@ -385,7 +418,9 @@ class TestOrchestratorReconcile(unittest.TestCase):
|
||||
live = self.orch.launch_bottle("10.243.0.2", tokens={"EGRESS_TOKEN_0": "keep"})
|
||||
self._age_all(600)
|
||||
|
||||
self.assertEqual([dead.bottle_id], self.orch.reconcile(["10.243.0.2"]))
|
||||
# The broker reports only .2 as still running -> .1 is reaped.
|
||||
self.broker.live_source_ips = ["10.243.0.2"]
|
||||
self.assertEqual([dead.bottle_id], self.orch.reconcile())
|
||||
self.assertIsNone(self.store.get(dead.bottle_id))
|
||||
self.assertIsNotNone(self.store.get(live.bottle_id))
|
||||
# The in-memory egress credential goes with the row.
|
||||
@@ -397,14 +432,31 @@ class TestOrchestratorReconcile(unittest.TestCase):
|
||||
broker error must not stop the sweep clearing the row."""
|
||||
self.orch.launch_bottle("10.243.0.1")
|
||||
self._age_all(600)
|
||||
self.broker.launched.clear()
|
||||
self.orch.reconcile([])
|
||||
self.broker.live_source_ips = [] # broker reports nothing running
|
||||
self.orch.reconcile()
|
||||
self.assertEqual([], self.broker.torn_down)
|
||||
|
||||
def test_reconcile_keeps_everything_when_all_are_live(self) -> None:
|
||||
a = self.orch.launch_bottle("10.243.0.1")
|
||||
b = self.orch.launch_bottle("10.243.0.2")
|
||||
self._age_all(600)
|
||||
self.assertEqual([], self.orch.reconcile(["10.243.0.1", "10.243.0.2"]))
|
||||
self.broker.live_source_ips = ["10.243.0.1", "10.243.0.2"]
|
||||
self.assertEqual([], self.orch.reconcile())
|
||||
self.assertIsNotNone(self.store.get(a.bottle_id))
|
||||
self.assertIsNotNone(self.store.get(b.bottle_id))
|
||||
|
||||
def test_reconcile_skipped_when_broker_cannot_enumerate(self) -> None:
|
||||
"""A broker that can't return an authoritative live set must NOT be
|
||||
treated as "nothing is live" — that would reap every healthy bottle.
|
||||
The sweep is skipped instead."""
|
||||
a = self.orch.launch_bottle("10.243.0.1")
|
||||
b = self.orch.launch_bottle("10.243.0.2")
|
||||
self._age_all(600)
|
||||
|
||||
def _boom() -> list[str]:
|
||||
raise RuntimeError("docker ps failed")
|
||||
|
||||
self.broker._list_live = _boom # type: ignore[method-assign]
|
||||
self.assertEqual([], self.orch.reconcile())
|
||||
self.assertIsNotNone(self.store.get(a.bottle_id))
|
||||
self.assertIsNotNone(self.store.get(b.bottle_id))
|
||||
|
||||
@@ -6,10 +6,13 @@ import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle import orchestrator_auth
|
||||
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY
|
||||
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY, ROLE_HOST
|
||||
from bot_bottle.trust_domain import (
|
||||
CONTROL_PLANE,
|
||||
HOST_CONTROLLER,
|
||||
LAUNCH_BROKER,
|
||||
ControlPlaneProvisioning,
|
||||
LaunchBrokerProvisioning,
|
||||
ProvisioningError,
|
||||
TrustDomain,
|
||||
)
|
||||
@@ -101,5 +104,73 @@ class TestControlPlaneProvisioning(unittest.TestCase):
|
||||
self.assertNotEqual(ROLE_CLI, CONTROL_PLANE.verify(tok, "k"))
|
||||
|
||||
|
||||
class TestLaunchBrokerAndHostControllerDomains(unittest.TestCase):
|
||||
"""The real #468 domains: the launch-broker key (shared by orchestrator +
|
||||
host controller) and the host controller's own lifecycle key."""
|
||||
|
||||
def test_launch_broker_mints_no_role_tokens(self) -> None:
|
||||
# Empty role set — it provides durable key material for the broker's own
|
||||
# launch JWT, not orchestrator_auth role tokens.
|
||||
self.assertEqual(frozenset(), LAUNCH_BROKER.roles)
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||
with self.assertRaises(ValueError):
|
||||
LAUNCH_BROKER.mint(ROLE_CLI)
|
||||
|
||||
def test_host_controller_signs_host_role_only(self) -> None:
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||
tok = HOST_CONTROLLER.mint(ROLE_HOST)
|
||||
self.assertEqual(ROLE_HOST, HOST_CONTROLLER.verify(tok, "k"))
|
||||
# A control-plane `cli` token (the orchestrator's key) never verifies as a
|
||||
# host-controller role — the orchestrator can't forge lifecycle creds.
|
||||
cli_tok = orchestrator_auth.mint(ROLE_CLI, "k")
|
||||
self.assertIsNone(HOST_CONTROLLER.verify(cli_tok, "k"))
|
||||
|
||||
def test_control_plane_cannot_mint_the_host_role(self) -> None:
|
||||
# `host` is outside the control-plane role set on purpose.
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||
with self.assertRaises(ValueError):
|
||||
CONTROL_PLANE.mint(ROLE_HOST)
|
||||
|
||||
def test_the_three_domains_use_distinct_keys_and_env_vars(self) -> None:
|
||||
self.assertEqual(3, len({
|
||||
CONTROL_PLANE.key_filename,
|
||||
LAUNCH_BROKER.key_filename,
|
||||
HOST_CONTROLLER.key_filename,
|
||||
}))
|
||||
self.assertEqual(3, len({
|
||||
CONTROL_PLANE.key_env, LAUNCH_BROKER.key_env, HOST_CONTROLLER.key_env,
|
||||
}))
|
||||
|
||||
|
||||
class TestLaunchBrokerProvisioning(unittest.TestCase):
|
||||
def test_broker_key_returns_the_durable_key(self) -> None:
|
||||
prov = LaunchBrokerProvisioning()
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value="bk"):
|
||||
self.assertEqual("bk", prov.broker_key())
|
||||
|
||||
def test_broker_key_fail_closes_when_empty(self) -> None:
|
||||
# An empty key would leave the host controller unable to verify any
|
||||
# launch — fail-closed rather than hand back a useless/dangerous key.
|
||||
prov = LaunchBrokerProvisioning()
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value=""):
|
||||
with self.assertRaises(ProvisioningError):
|
||||
prov.broker_key()
|
||||
|
||||
def test_controller_key_is_distinct_from_the_broker_key(self) -> None:
|
||||
# The orchestrator holds the broker key but NEVER the controller key.
|
||||
prov = LaunchBrokerProvisioning()
|
||||
keys = {"launch-broker-key": "bk", "host-controller-key": "ck"}
|
||||
with patch("bot_bottle.trust_domain.host_signing_key",
|
||||
side_effect=keys.__getitem__):
|
||||
self.assertEqual("bk", prov.broker_key())
|
||||
self.assertEqual("ck", prov.controller_key())
|
||||
|
||||
def test_controller_key_fail_closes_when_empty(self) -> None:
|
||||
prov = LaunchBrokerProvisioning()
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value=""):
|
||||
with self.assertRaises(ProvisioningError):
|
||||
prov.controller_key()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user