Compare commits

..

5 Commits

Author SHA1 Message Date
didericis-claude 7a48ea2b0c fix(secret): authenticate bottled-secret encryption (#468)
refresh-image-locks / refresh (push) Successful in 42s
lint / lint (push) Successful in 1m7s
test / image-input-builds (pull_request) Successful in 1m12s
test / unit (pull_request) Successful in 44s
test / integration-docker (pull_request) Successful in 55s
test / coverage (pull_request) Successful in 13s
tracker-policy-pr / check-pr (pull_request) Successful in 7s
The per-bottle egress-secret encryption (secret_store.py) was
unauthenticated CTR/XOR: decrypting with the WRONG ENV_VAR_SECRET
produced garbage that decrypt_value only rejected when it wasn't valid
UTF-8. For short token values that garbage is coincidentally valid UTF-8
~5% of the time, so `reprovision_from_secret` would occasionally "succeed"
with a wrong key and inject a garbage egress credential — and
test_reprovision_rejects_missing_rows_and_wrong_key failed ~5% of runs
(flaky CI, surfaced by this stack's unit job).

Switch to authenticated encrypt-then-MAC: append an HMAC-SHA256 tag over
`nonce || ciphertext`, keyed by a domain-separated MAC subkey derived from
the ENV_VAR_SECRET. decrypt_value verifies the tag (constant-time) before
returning any plaintext, so a wrong key or tampered ciphertext is rejected
deterministically. Blob format is now `nonce || ciphertext || tag`
(the stored rows are transient — re-written every launch — so no
migration is needed).

Pre-existing bug on main, unrelated to the transport work, but it blocks
this stack's CI. Tests: wrong key rejected 200/200; tampered ciphertext
rejected; round-trips unchanged. Deterministic now (was ~5% flaky).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:35:21 +00:00
didericis-claude ec953ceda7 fix(orchestrator): address review on host control server transport (#468)
Codex review on #496:

- **High — ambiguous delivery no longer orphans a launched bottle.** A
  timeout / dropped response from the host controller is now the ambiguous
  BrokerUnavailableError (distinct from the definite BrokerAuthError /
  BrokerClientError). OrchestratorCore.launch_bottle keeps the registry
  row on the ambiguous case instead of deregistering — deregistering would
  orphan a running container with no record (reconcile reaps rows, never
  containers). The row is left for reconcile to reap iff the bottle is not
  actually live. Definite failures still roll back, so a real failure
  leaves no orphan row.
- **Medium — the privileged endpoint bounds request bodies.** The host
  server rejects an oversized Content-Length with 413 before reading it,
  and sets a per-request socket timeout, so a caller that can merely reach
  the socket (no signed token) can't exhaust memory or a handler thread.

Tests: ambiguous-keep vs definite-rollback in the launch path; the
BrokerUnavailableError/BrokerClientError split in BrokerClient; the 413
body cap + handler error paths (driven in-thread, since daemon request
threads lose coverage) plus a deterministic real-socket check that
declares an oversized Content-Length but sends a sliver (rejection on the
header, no unread-body reset race); and the __main__ entrypoint broker
selection. Diff-coverage 98%; pyright clean; pylint 9.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:35:21 +00:00
didericis-claude ed0f95f445 feat(orchestrator): host control server transport (#468)
Chunk 1 of the host-control-server stack: close the PRD's **transport**
gap. Today LaunchBroker.submit(token) is an in-process method call from
OrchestratorCore; this makes it a real out-of-process service reached
over HTTP.

- host_server.py: the host control server. A pure dispatch() (POST
  /broker verifies a signed token via the existing verify_request +
  _launch/_teardown path, GET /health) wrapped by a thin http.server
  adapter, mirroring orchestrator/server.py. Only the signed token
  crosses the wire; provenance/schema failures are fail-closed 401s that
  never touch the backend, a backend launch failure is a 502.
- broker_client.py: BrokerClient — a drop-in submit(token) that POSTs the
  signed token to the host controller. A 401 re-raises as BrokerAuthError
  so the launch path's rollback is identical local or remote.
- broker.py: SubmitBroker Protocol — the one method OrchestratorCore
  depends on, satisfied by both LaunchBroker and BrokerClient, so the
  core is unchanged (service.py annotation only).
- __main__.py: wire `--broker http` behind the shared-secret env var
  (BOT_BOTTLE_BROKER_SECRET, hex) — a chunk-1 stopgap the durable
  TrustDomain key (chunk 2, #476) replaces.

Tested: pure-dispatch cases, BrokerClient with HTTP mocked, and a
real-socket sign -> POST -> verify -> act round-trip (incl. fail-closed
forged token). pyright clean; pylint 9.86.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:35:21 +00:00
didericis-claude 794e4e662d docs: defer broker replay protection to its own issue (#494)
prd-number-check / require-numbered-prds (pull_request) Failing after 7s
tracker-policy-pr / check-pr (pull_request) Successful in 10s
refresh-image-locks / refresh (push) Successful in 30s
lint / lint (push) Successful in 1m6s
Per PR review, replay protection is too heavy for the host control
server MVP. Drop it from the four-gap framing (now three gaps), remove
the enforcement design section and implementation chunk, and track the
iat-window + jti-cache work in #494 as an independent in-process change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:35:13 +00:00
didericis-claude f2fe1f9b2d docs: PRD for host-side control server (#468)
Promote the in-process launch broker into a standalone host control
server: the single privileged host component that brokers launches, owns
orchestrator lifecycle, and is the sole writer of host-durable state.

Closes the four broker gaps (transport, durable provisioned secret,
replay protection, disciplined op vocabulary) and splits host state by
owner and lifetime (orchestrator SQLite / host JSONL audit / gateway
none). The payoff is dropping the Docker socket from the CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:35:13 +00:00
44 changed files with 1760 additions and 1926 deletions
+2 -16
View File
@@ -102,20 +102,6 @@ jobs:
python3 --version python3 --version
python3 cli.py backend status --backend=docker 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 - name: Run integration tests (docker) with coverage
env: env:
BOT_BOTTLE_BACKEND: docker BOT_BOTTLE_BACKEND: docker
@@ -298,7 +284,7 @@ jobs:
- name: Combined coverage (unit + docker integration) - name: Combined coverage (unit + docker integration)
run: PYTHON=python3 bash scripts/coverage.sh aggregate critical run: PYTHON=python3 bash scripts/coverage.sh aggregate critical
- name: Diff-coverage gate (changed lines >= 80%) - name: Diff-coverage gate (changed lines >= 90%)
run: | run: |
git fetch --no-tags origin main:refs/remotes/origin/main 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
+30 -30
View File
@@ -182,9 +182,7 @@ BOT_BOTTLE_BACKEND=firecracker ./cli.py start <agent>
## Manifest ## 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. 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`.
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:`.
**Bottle** (`~/.bot-bottle/bottles/gitea-dev.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 extends: claude # inherit the Claude provider boundary
git-gate: env:
repos: GIT_AUTHOR_NAME: didericis
bot-bottle:
url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git git:
key: user:
provider: gitea name: "Eric Bauerfeld"
forge_token_env: GITEA_DEPLOY_TOKEN # deploy-key admin (push), PRD 0048 email: "eric+claude@dideric.is"
host_key: "ssh-ed25519 AAAA..." remotes:
forge: didericis-gitea # ← selects the agent's forge alias 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 The `gitea-dev` bottle. Provider auth via the inherited Claude route;
workflow guidance come from the agent's `forge: didericis-gitea` association. gitea over SSH for push, token over HTTPS for the API.
```` ````
**Agent** (`~/.bot-bottle/agents/gitea-helper.md`): **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 bottle: gitea-dev
skills: skills:
- init-prd - 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. 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:** **Egress route fields:**
| Field | Required | Description | | Field | Required | Description |
+4 -35
View File
@@ -140,43 +140,12 @@ class DockerGateway(Gateway):
marker = inspected.stdout.strip() marker = inspected.stdout.strip()
if marker in {"", self._subnet}: if marker in {"", self._subnet}:
return return
# Inspectable but mislabelled: the stale auto-IPAM network created if inspected.returncode == 0:
# by older releases. Replace it below. # Migrate the stale auto-IPAM network created by older releases.
stale = True # Removing the fixed gateway is safe here: this launch recreates it.
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.
run_docker(["docker", "rm", "--force", self.name]) run_docker(["docker", "rm", "--force", self.name])
removed = run_docker(["docker", "network", "rm", self.network]) 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( raise GatewayError(
f"gateway network {self.network} needs explicit subnet " f"gateway network {self.network} needs explicit subnet "
f"{self._subnet} but could not be replaced: " f"{self._subnet} but could not be replaced: "
+1 -1
View File
@@ -118,7 +118,7 @@ class BottlePreparationPlanner:
slug=slug, slug=slug,
resolved_env=resolved_env, resolved_env=resolved_env,
agent_provision_plan=provision, 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), git_gate_plan=prepare_git_gate(bottle, slug),
supervise_plan=prepare_supervise(bottle, slug), supervise_plan=prepare_supervise(bottle, slug),
) )
+5 -22
View File
@@ -24,11 +24,10 @@ from ..bottle_state import (
supervise_state_dir, supervise_state_dir,
write_metadata, write_metadata,
) )
from ..egress import Egress, EgressPlan, egress_forge_routes from ..egress import Egress, EgressPlan
from ..git_gate import GitGate, GitGatePlan from ..git_gate import GitGate, GitGatePlan
from ..log import die from ..log import die
from ..manifest import Manifest, ManifestBottle from ..manifest import Manifest, ManifestBottle
from ..manifest.forge import render_forge_guidance
from ..supervisor.plan import SupervisePlan from ..supervisor.plan import SupervisePlan
from ..orchestrator.supervisor import Supervisor from ..orchestrator.supervisor import Supervisor
from ..util import slugify from ..util import slugify
@@ -72,21 +71,12 @@ def write_launch_metadata(
def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]: def prepare_agent_state_dir(slug: str, manifest: Manifest) -> tuple[Path, Path]:
"""Create the agent state subdir, write the prompt file. """Create the agent state subdir, write the prompt file.
Returns (agent_dir, 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."""
agent = manifest.agent agent = manifest.agent
agent_dir = agent_state_dir(slug) agent_dir = agent_state_dir(slug)
agent_dir.mkdir(parents=True, exist_ok=True) agent_dir.mkdir(parents=True, exist_ok=True)
prompt_file = agent_dir / "prompt.txt" prompt_file = agent_dir / "prompt.txt"
prompt = agent.prompt or "" prompt_file.write_text(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.chmod(0o600) prompt_file.chmod(0o600)
return agent_dir, prompt_file return agent_dir, prompt_file
@@ -98,18 +88,11 @@ def prepare_git_gate(bottle: ManifestBottle, slug: str) -> GitGatePlan:
def prepare_egress( def prepare_egress(
manifest: Manifest, slug: str, provision: AgentProvisionPlan, bottle: ManifestBottle, slug: str, provision: AgentProvisionPlan,
) -> EgressPlan: ) -> 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 = egress_state_dir(slug)
egress_dir.mkdir(parents=True, exist_ok=True) egress_dir.mkdir(parents=True, exist_ok=True)
forge_routes = egress_forge_routes(manifest.forge_associations) return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
return Egress().prepare(
manifest.bottle, slug, egress_dir, provision.egress_routes, forge_routes,
)
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None: def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
+26 -23
View File
@@ -128,7 +128,7 @@ def cmd_start(argv: list[str]) -> int:
if not manifest.all_agent_names: if not manifest.all_agent_names:
print( print(
"bot-bottle: no agents defined. " "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, file=sys.stderr,
) )
return 1 return 1
@@ -383,9 +383,12 @@ def _peek_agent_bottle(manifest: ManifestIndex, agent_name: str) -> str:
from ...manifest.loader import scan_agent_names from ...manifest.loader import scan_agent_names
from ...yaml_subset import YamlSubsetError, parse_frontmatter from ...yaml_subset import YamlSubsetError, parse_frontmatter
# Agents are home-only (PRD 0082).
home_agents = scan_agent_names(manifest.home_md / "agents") 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: if path is None:
return "" return ""
try: try:
@@ -485,19 +488,13 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
lines.append(" skills:") lines.append(" skills:")
for s in agent.skills: for s in agent.skills:
lines.append(f" - {s}") lines.append(f" - {s}")
if agent.author is not None: if not agent.git_user.is_empty():
lines.append(" author:") lines.append(" git-gate:")
lines.append(f" name: {agent.author.name}") lines.append(" user:")
lines.append(f" email: {agent.author.email}") if agent.git_user.name:
if agent.forge_accounts: lines.append(f" name: {agent.git_user.name}")
lines.append(" forge-accounts:") if agent.git_user.email:
for alias, acct in sorted(agent.forge_accounts.items()): lines.append(f" email: {agent.git_user.email}")
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}")
bottle = manifest.bottle bottle = manifest.bottle
lines.append("bottle:") lines.append("bottle:")
@@ -513,14 +510,20 @@ def _manifest_to_yaml(manifest: Manifest) -> str:
for k, v in sorted(bottle.env.items()): for k, v in sorted(bottle.env.items()):
lines.append(f" {k}: {v}") 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(" git-gate:")
lines.append(" repos:") if not bottle.git_user.is_empty():
for entry in bottle.git: lines.append(" user:")
lines.append(f" {entry.Name}:") if bottle.git_user.name:
lines.append(f" url: {entry.Upstream}") lines.append(f" name: {bottle.git_user.name}")
if entry.Forge: if bottle.git_user.email:
lines.append(f" forge: {entry.Forge}") 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: if bottle.egress.routes:
lines.append(" egress:") lines.append(" egress:")
-3
View File
@@ -32,7 +32,6 @@ if TYPE_CHECKING:
EGRESS_ROUTES_IN_CONTAINER, EGRESS_ROUTES_IN_CONTAINER,
Egress, Egress,
egress_agent_env_entries, egress_agent_env_entries,
egress_forge_routes,
egress_gateway_env_entries, egress_gateway_env_entries,
egress_manifest_routes, egress_manifest_routes,
egress_render_routes, egress_render_routes,
@@ -52,7 +51,6 @@ _LAZY: dict[str, str] = {
"EGRESS_ROUTES_FILENAME": ".service", "EGRESS_ROUTES_FILENAME": ".service",
"EGRESS_ROUTES_IN_CONTAINER": ".service", "EGRESS_ROUTES_IN_CONTAINER": ".service",
"egress_agent_env_entries": ".service", "egress_agent_env_entries": ".service",
"egress_forge_routes": ".service",
"egress_gateway_env_entries": ".service", "egress_gateway_env_entries": ".service",
"egress_manifest_routes": ".service", "egress_manifest_routes": ".service",
"egress_render_routes": ".service", "egress_render_routes": ".service",
@@ -82,7 +80,6 @@ __all__ = [
"Egress", "Egress",
"EgressPlan", "EgressPlan",
"EgressRoute", "EgressRoute",
"egress_forge_routes",
"egress_manifest_routes", "egress_manifest_routes",
"egress_render_routes", "egress_render_routes",
"egress_resolve_token_values", "egress_resolve_token_values",
+6 -53
View File
@@ -26,7 +26,7 @@ from ..log import die
from .plan import EgressPlan, EgressRoute from .plan import EgressPlan, EgressRoute
if TYPE_CHECKING: if TYPE_CHECKING:
from ..manifest import ManifestBottle, ResolvedForgeAssociation from ..manifest import ManifestBottle
CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN" CODEX_HOST_CREDENTIAL_TOKEN_REF = "BOT_BOTTLE_CODEX_HOST_ACCESS_TOKEN"
@@ -119,61 +119,15 @@ def egress_manifest_routes(
return tuple(out) 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( def egress_routes_for_bottle(
bottle: ManifestBottle, bottle: ManifestBottle,
provider_routes: tuple[EgressRoute, ...] = (), provider_routes: tuple[EgressRoute, ...] = (),
forge_routes: tuple[EgressRoute, ...] = (),
) -> tuple[EgressRoute, ...]: ) -> tuple[EgressRoute, ...]:
manifest = egress_manifest_routes(bottle) manifest = egress_manifest_routes(bottle)
# Provider routes (LLM API) default to redact-on-match; forge routes are provisioned_hosts = {pr.host.lower() for pr in provider_routes}
# host-injected but keep the default DLP policy. Both take precedence over merged = list(_default_provider_on_match(provider_routes)) + [
# a manifest route to the same host. r for r in manifest if r.host.lower() not in provisioned_hosts
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]
)
return _assign_token_slots(merged) return _assign_token_slots(merged)
@@ -413,9 +367,8 @@ class Egress:
slug: str, slug: str,
stage_dir: Path, stage_dir: Path,
provider_routes: tuple[EgressRoute, ...] = (), provider_routes: tuple[EgressRoute, ...] = (),
forge_routes: tuple[EgressRoute, ...] = (),
) -> EgressPlan: ) -> EgressPlan:
routes = egress_routes_for_bottle(bottle, provider_routes, forge_routes) routes = egress_routes_for_bottle(bottle, provider_routes)
log = bottle.egress.Log log = bottle.egress.Log
routes_path = stage_dir / EGRESS_ROUTES_FILENAME routes_path = stage_dir / EGRESS_ROUTES_FILENAME
routes_path.write_text(egress_render_routes(routes, log=log)) routes_path.write_text(egress_render_routes(routes, log=log))
+10 -32
View File
@@ -1,10 +1,10 @@
"""Manifest dataclasses (PRD 0011 layout). """Manifest dataclasses (PRD 0011 layout).
Reads the per-file manifest tree (home-only Reads the per-file manifest tree:
PRD 0082):
$HOME/.bot-bottle/bottles/<name>.md one bottle per file $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 Each file is Markdown with YAML frontmatter. The frontmatter holds
the structured config (see schema below); for agents the body is the structured config (see schema below); for agents the body is
@@ -15,38 +15,27 @@ Bottle schema (frontmatter):
extends: <bottle-name> # optional (PRD 0025) extends: <bottle-name> # optional (PRD 0025)
env: { <NAME>: <env-entry>, ... } env: { <NAME>: <env-entry>, ... }
git-gate: # optional (PRD 0047) git-gate: # optional (PRD 0047)
user: { name: <str>, email: <str> } # optional
repos: { <name>: <git-gate-entry>, ... } # 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>, ... ] } egress: { routes: [ <egress-route>, ... ] }
# route keys: host, matches, auth, role, dlp # route keys: host, matches, auth, role, dlp
supervise: <bool> # optional (default true) supervise: <bool> # optional (default true)
nested_containers: <bool> # optional (default false) nested_containers: <bool> # optional (default false)
Agent schema (frontmatter): Agent schema (frontmatter):
bottle: <bottle-name> # optional bottle: <bottle-name> # required
skills: [ <skill-name>, ... ] # optional skills: [ <skill-name>, ... ] # optional
author: # optional; agent git identity git-gate:
name: <str> # required when author is present user: { name: <str>, email: <str> } # optional; overlays bottle
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> }
# Claude Code subagent passthrough fields — accepted, ignored: # Claude Code subagent passthrough fields — accepted, ignored:
name, description, model, color, memory 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). The agent file's Markdown body is the system prompt (stripped).
Unknown top-level frontmatter keys raise ManifestError with a hint. Unknown top-level frontmatter keys raise ManifestError with a hint.
Both bottles and agents can ONLY live under $HOME. An agents/ or bottles/ Bottles can ONLY live under $HOME. A bottles/ dir under $CWD is a
dir under $CWD is a warn at load time and contributes nothing. The trust warn at load time and contributes nothing. The trust boundary is
boundary is expressed as filesystem layout rather than resolver logic. expressed as filesystem layout rather than resolver logic.
Two types are exported: Two types are exported:
@@ -77,11 +66,6 @@ if TYPE_CHECKING:
from .agent import ManifestAgent, ManifestAgentProvider from .agent import ManifestAgent, ManifestAgentProvider
from .bottle import ManifestBottle from .bottle import ManifestBottle
from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute
from .forge import (
ManifestAuthor,
ManifestForgeAccount,
ResolvedForgeAssociation,
)
from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
@@ -97,9 +81,6 @@ _LAZY_MODULES: dict[str, str] = {
"EGRESS_AUTH_SCHEMES": "egress", "EGRESS_AUTH_SCHEMES": "egress",
"ManifestEgressRoute": "egress", "ManifestEgressRoute": "egress",
"ManifestEgressConfig": "egress", "ManifestEgressConfig": "egress",
"ManifestAuthor": "forge",
"ManifestForgeAccount": "forge",
"ResolvedForgeAssociation": "forge",
"ManifestGitEntry": "git", "ManifestGitEntry": "git",
"ManifestGitUser": "git", "ManifestGitUser": "git",
"ManifestKeyConfig": "git", "ManifestKeyConfig": "git",
@@ -134,7 +115,4 @@ __all__ = [
"EGRESS_AUTH_SCHEMES", "EGRESS_AUTH_SCHEMES",
"ManifestEgressRoute", "ManifestEgressRoute",
"ManifestEgressConfig", "ManifestEgressConfig",
"ManifestAuthor",
"ManifestForgeAccount",
"ResolvedForgeAssociation",
] ]
+24 -44
View File
@@ -3,11 +3,11 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Mapping, cast from typing import cast
from ..agent_provider import PROVIDER_TEMPLATES from ..agent_provider import PROVIDER_TEMPLATES
from .util import ManifestError, as_json_object 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 from .schema import AGENT_MODEL_KEYS, is_valid_entity_name
@@ -119,29 +119,15 @@ class ManifestAgent:
bottle: str = "" bottle: str = ""
skills: tuple[str, ...] = () skills: tuple[str, ...] = ()
prompt: str = "" prompt: str = ""
# Agent-owned identity (PRD 0082). # Per-agent git identity (issue #94). Overlays the referenced
# `author` populates the bottle's git user.name/user.email; # bottle's git-gate.user per-field at `Manifest.bottle_for`. Only
# `forge_accounts` maps a forge alias to a canonical Gitea API origin and # `user` is allowed at the agent level; `repos` stays bottle-only
# a host token reference. Both live only on the agent — never under # because it carries credentials and host trust.
# `git-gate`, which is bottle-only transport policy. git_user: ManifestGitUser = ManifestGitUser()
author: ManifestAuthor | None = None
forge_accounts: Mapping[str, ManifestForgeAccount] = field(
default_factory=dict
)
@classmethod @classmethod
def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent": def from_dict(cls, name: str, raw: object, bottle_names: set[str]) -> "ManifestAgent":
d = as_json_object(raw, f"agent '{name}'") 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 unknown = set(d.keys()) - AGENT_MODEL_KEYS
if unknown: if unknown:
allowed = ", ".join(sorted(AGENT_MODEL_KEYS)) allowed = ", ".join(sorted(AGENT_MODEL_KEYS))
@@ -205,30 +191,24 @@ class ManifestAgent:
f"(was {type(prompt_raw).__name__})" f"(was {type(prompt_raw).__name__})"
) )
# author: agent-owned git identity (optional; both fields required # git-gate: agents may declare only `git-gate.user` (name/email).
# when present). Populates the bottle's user.name/user.email. # `git-gate.repos` is bottle-only — it carries credentials and host trust.
author = ( git_user = ManifestGitUser()
ManifestAuthor.from_dict(name, d["author"]) git_raw = d.get("git-gate")
if "author" in d else None 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. return cls(bottle=bottle, skills=skills, prompt=prompt, git_user=git_user)
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,
)
def _parse_provider_settings( def _parse_provider_settings(
+1 -4
View File
@@ -107,14 +107,11 @@ class ManifestBottle:
) )
env[var] = value 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: tuple[ManifestGitEntry, ...] = ()
git_user = ManifestGitUser() git_user = ManifestGitUser()
git_raw = d.get("git-gate") git_raw = d.get("git-gate")
if git_raw is not None: 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 = ( agent_provider = (
ManifestAgentProvider.from_dict(name, d["agent_provider"]) ManifestAgentProvider.from_dict(name, d["agent_provider"])
+1 -1
View File
@@ -210,7 +210,7 @@ def _fold_two_bottles(
for n in names for n in names
} }
if merged_repos_raw: 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: else:
merged_git = () merged_git = ()
-312
View File
@@ -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
View File
@@ -117,11 +117,6 @@ class ManifestGitEntry:
UpstreamHost: str = "" UpstreamHost: str = ""
UpstreamPort: str = "" UpstreamPort: str = ""
UpstreamPath: 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 @classmethod
def from_repos_entry( def from_repos_entry(
@@ -144,10 +139,10 @@ class ManifestGitEntry:
label = f"git-gate.repos[{repo_name!r}]" label = f"git-gate.repos[{repo_name!r}]"
d = as_json_object(raw, f"bottle '{bottle_name}' {label}") d = as_json_object(raw, f"bottle '{bottle_name}' {label}")
for k in d: for k in d:
if k not in {"url", "key", "host_key", "forge"}: if k not in {"url", "key", "host_key"}:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label} has unknown key {k!r}; " f"bottle '{bottle_name}' {label} has unknown key {k!r}; "
f"allowed: url, key, host_key, forge" f"allowed: url, key, host_key"
) )
upstream = d.get("url") upstream = d.get("url")
if not isinstance(upstream, str) or not upstream: if not isinstance(upstream, str) or not upstream:
@@ -155,21 +150,6 @@ class ManifestGitEntry:
f"bottle '{bottle_name}' {label} missing required string field 'url'" 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: if "key" not in d:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' {label} missing required 'key' block" f"bottle '{bottle_name}' {label} missing required 'key' block"
@@ -196,7 +176,6 @@ class ManifestGitEntry:
UpstreamHost=host, UpstreamHost=host,
UpstreamPort=port, UpstreamPort=port,
UpstreamPath=path, UpstreamPath=path,
Forge=forge,
) )
@@ -307,26 +286,21 @@ class ManifestGitUser:
def parse_git_gate_config( def parse_git_gate_config(
bottle_name: str, bottle_name: str,
raw: object, raw: object,
) -> tuple[ManifestGitEntry, ...]: ) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
"""Parse `git-gate` on a bottle. Only `repos` is accepted; `git-gate.user`
moved to the agent's `author` block (PRD 0082)."""
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate") 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: for k in d:
if k != "repos": if k not in {"user", "repos"}:
raise ManifestError( raise ManifestError(
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; " 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, ...] = () git: tuple[ManifestGitEntry, ...] = ()
repos_raw = d.get("repos") repos_raw = d.get("repos")
if repos_raw is not None: if repos_raw is not None:
@@ -337,4 +311,4 @@ def parse_git_gate_config(
) )
validate_unique_git_names(bottle_name, git) validate_unique_git_names(bottle_name, git)
return git return git, git_user
+74 -87
View File
@@ -19,7 +19,6 @@ from .util import ManifestError, as_json_object
from .agent import ManifestAgent from .agent import ManifestAgent
from .bottle import ManifestBottle from .bottle import ManifestBottle
from .extends import merge_bottles_runtime, resolve_bottles from .extends import merge_bottles_runtime, resolve_bottles
from .forge import ResolvedForgeAssociation, resolve_forge_associations
from .git import ManifestGitUser from .git import ManifestGitUser
from .loader import ( from .loader import (
check_stale_json, check_stale_json,
@@ -38,50 +37,30 @@ def _section_dict(value: object, label: str) -> dict[str, object]:
return as_json_object(value, label) return as_json_object(value, label)
def _warn_ignored_cwd_dir(cwd_dir: Path, kind: str, home_path: str) -> None: def _merge_git_user(
"""Warn (once) that manifest files of `kind` under `$CWD/.bot-bottle/` agent_user: ManifestGitUser, base_user: ManifestGitUser
are ignored the filesystem layout IS the trust boundary. `kind` is the ) -> ManifestGitUser:
subdir name (`bottles`/`agents`); `home_path` is where they belong.""" """Merge the agent's git.user over the bottle's, agent-wins-on-non-empty."""
stale = cwd_dir / kind if agent_user.is_empty():
if not stale.is_dir(): return base_user
return return ManifestGitUser(
files = sorted(stale.glob("*.md")) name=agent_user.name or base_user.name,
if not files: email=agent_user.email or base_user.email,
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 _compose_manifest( def _manifest_with_merged_git_user(
agent_name: str, agent: "ManifestAgent", raw_bottle: "ManifestBottle"
agent: "ManifestAgent",
raw_bottle: "ManifestBottle",
) -> "Manifest": ) -> "Manifest":
"""Build the single-value Manifest from the selected agent and its """Build the single-value Manifest, overlaying the agent's git-gate.user
effective bottle (PRD 0082): onto the bottle (agent wins on non-empty, per-field). Shared by the eager
and lazy load_for_agent paths."""
- the agent's `author` populates the bottle's git user.name/user.email; merged = _merge_git_user(agent.git_user, raw_bottle.git_user)
- 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()
)
bottle = ( bottle = (
raw_bottle if identity == raw_bottle.git_user raw_bottle if merged == raw_bottle.git_user
else replace(raw_bottle, git_user=identity) else replace(raw_bottle, git_user=merged)
) )
associations = resolve_forge_associations( return Manifest(agent=agent, bottle=bottle)
agent_name, dict(agent.forge_accounts), bottle.git,
)
return Manifest(agent=agent, bottle=bottle, forge_associations=associations)
def _resolve_effective_bottle_eager( def _resolve_effective_bottle_eager(
@@ -142,28 +121,26 @@ def _resolve_effective_bottle_lazy(
class Manifest: class Manifest:
"""Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent(). """Single-agent/bottle value type. Returned by ManifestIndex.load_for_agent().
`bottle` is the effective bottle with the agent's `author` already `bottle` is the effective bottle with the agent's git-gate.user already
populated into its git identity. `forge_associations` holds the distinct overlaid per-field (agent wins on non-empty). Backends and provisioners
forge aliases referenced by the effective bottle's git-gate repos, resolved use this directly no agent_name lookup needed."""
against the agent's `forge-accounts`. Backends and provisioners use this
directly no agent_name lookup needed."""
agent: ManifestAgent agent: ManifestAgent
bottle: ManifestBottle bottle: ManifestBottle
forge_associations: tuple[ResolvedForgeAssociation, ...] = ()
def git_identity_summary(self) -> str | None: def git_identity_summary(self) -> str | None:
"""One-line effective git identity, e.g. """One-line effective git identity with per-field provenance, e.g.
`name=claude, email=eric@dideric.is`. Sourced from the agent's `name=claude (agent), email=eric@dideric.is (bottle)`.
`author` block. Returns None when the agent declares no author.""" Returns None when neither agent nor bottle sets an identity."""
gu = self.bottle.git_user over = self.agent.git_user # agent's declared git_user (pre-merge)
if gu.is_empty(): merged = self.bottle.git_user # effective git_user (post-merge)
if merged.is_empty():
return None return None
parts: list[str] = [] parts: list[str] = []
if gu.name: if merged.name:
parts.append(f"name={gu.name}") parts.append(f"name={merged.name} ({'agent' if over.name else 'bottle'})")
if gu.email: if merged.email:
parts.append(f"email={gu.email}") parts.append(f"email={merged.email} ({'agent' if over.email else 'bottle'})")
return ", ".join(parts) return ", ".join(parts)
@@ -187,15 +164,15 @@ class ManifestIndex:
def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex": def resolve(cls, cwd: str, *, missing_ok: bool = False) -> "ManifestIndex":
"""Walk the per-file manifest tree and build a 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/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 Cwd agents merge into the home agents on the same name
(PRD 0082): a `bottles/` or `agents/` (cwd wins). A bottles/ subdir under $CWD is logged as a
subdir under $CWD is logged as a warning and ignored the filesystem warning and ignored the filesystem layout IS the trust
layout IS the trust boundary, since an agent may now select a host boundary.
identity and forge secret.
If `missing_ok` is true, a missing `$HOME/.bot-bottle/` If `missing_ok` is true, a missing `$HOME/.bot-bottle/`
returns an empty index instead of dying. This is for 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 Used by tests to build a ManifestIndex from fixture directories
without touching `os.environ`.""" without touching `os.environ`."""
if cwd_dir is not None: if cwd_dir is not None:
_warn_ignored_cwd_dir(cwd_dir, "bottles", "$HOME/.bot-bottle/bottles/") stale_bottles = cwd_dir / "bottles"
# Agents became home-only in if stale_bottles.is_dir():
# PRD 0082: a cwd agent file that files = sorted(stale_bottles.glob("*.md"))
# once shadowed a home agent could select a host identity/secret, if files:
# so it is now ignored with a migration pointer. names = ", ".join(p.name for p in files)
_warn_ignored_cwd_dir(cwd_dir, "agents", "$HOME/.bot-bottle/agents/") 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) return cls(bottles={}, agents={}, home_md=home_dir, cwd_md=cwd_dir)
@classmethod @classmethod
@@ -293,12 +275,13 @@ class ManifestIndex:
In names-only mode (from resolve/from_md_dirs) this scans agent In names-only mode (from resolve/from_md_dirs) this scans agent
filenames without reading their content. In eager mode (from filenames without reading their content. In eager mode (from
from_json_obj) it returns the pre-parsed agents' names. from_json_obj) it returns the pre-parsed agents' names."""
Agents are home-only (PRD 0082): cwd
agent files never contribute names."""
if self.home_md is not None: 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()) return sorted(self.agents.keys())
def load_for_agent( def load_for_agent(
@@ -343,7 +326,7 @@ class ManifestIndex:
raw_bottle = _resolve_effective_bottle_eager( raw_bottle = _resolve_effective_bottle_eager(
agent_name, agent, bottle_names, self.bottles 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( def _load_for_agent_lazy(
self, agent_name: str, bottle_names: tuple[str, ...] 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 """Lazy path (resolve/from_md_dirs): read and parse the agent file and
its bottle chain from disk for the first time here.""" its bottle chain from disk for the first time here."""
assert self.home_md is not None # guaranteed by load_for_agent dispatch assert self.home_md is not None # guaranteed by load_for_agent dispatch
# Agents are home-only (PRD 0082): # Locate the agent file; cwd wins over home on name collision.
# a cwd agent file must not select a host identity or forge secret.
home_agents = scan_agent_names(self.home_md / "agents") 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: if agent_name not in merged_agents:
available = ", ".join(sorted(home_agents.keys())) or "(none)" available = ", ".join(sorted(merged_agents.keys())) or "(none)"
raise ManifestError( raise ManifestError(
f"agent '{agent_name}' not defined. Available: {available}" f"agent '{agent_name}' not defined. Available: {available}"
) )
agent_path = home_agents[agent_name] agent_path = merged_agents[agent_name]
try: try:
fm, body = parse_frontmatter(agent_path.read_text()) fm, body = parse_frontmatter(agent_path.read_text())
except OSError as e: except OSError as e:
@@ -388,18 +374,15 @@ class ManifestIndex:
} }
if agent_bottle: if agent_bottle:
agent_dict["bottle"] = agent_bottle agent_dict["bottle"] = agent_bottle
# Surface agent-owned identity keys (and any stale git-gate, so if "git-gate" in fm:
# ManifestAgent.from_dict raises the migration error). agent_dict["git-gate"] = fm["git-gate"]
for key in ("author", "forge-accounts", "git-gate"):
if key in fm:
agent_dict[key] = fm[key]
# Pass the effective bottle name as the known-bottles set so agents # Pass the effective bottle name as the known-bottles set so agents
# that have bottle: set are validated; agents without bottle: pass {} # that have bottle: set are validated; agents without bottle: pass {}
# since bottle_names were already resolved above. # since bottle_names were already resolved above.
known = {effective_bottle_name} if effective_bottle_name else set() known = {effective_bottle_name} if effective_bottle_name else set()
agent = ManifestAgent.from_dict(agent_name, agent_dict, known) 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: def has_agent(self, name: str) -> bool:
return name in self.agents return name in self.agents
@@ -411,9 +394,13 @@ class ManifestIndex:
if self.has_agent(name): if self.has_agent(name):
return return
if self.home_md is not None: if self.home_md is not None:
# Names-only mode: check home file existence without parsing. # Names-only mode: check file existence without parsing.
# Agents are home-only; a cwd agent file is never selectable. home_path = self.home_md / "agents" / f"{name}.md"
if (self.home_md / "agents" / f"{name}.md").is_file(): 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 return
available = ", ".join(self.all_agent_names) or "(none)" available = ", ".join(self.all_agent_names) or "(none)"
raise ManifestError( raise ManifestError(
+1 -4
View File
@@ -22,10 +22,7 @@ BOTTLE_KEYS = frozenset(
} }
) )
AGENT_KEYS_REQUIRED: frozenset[str] = frozenset() AGENT_KEYS_REQUIRED: frozenset[str] = frozenset()
# `author` / `forge-accounts` are agent-owned identity (PRD AGENT_KEYS_OPTIONAL = frozenset({"bottle", "skills", "git-gate"})
# 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"})
# Claude Code subagent fields bot-bottle ignores at launch but does # Claude Code subagent fields bot-bottle ignores at launch but does
# not reject. This lets the same file double as # not reject. This lets the same file double as
+29 -8
View File
@@ -17,7 +17,9 @@ from pathlib import Path
from .. import log from .. import log
from .store.store_manager import StoreManager from .store.store_manager import StoreManager
from .broker import LaunchBroker, StubBroker from .broker import StubBroker, SubmitBroker
from .broker_client import BrokerClient
from .host_server import BROKER_SECRET_ENV, DEFAULT_PORT, broker_secret_from_env
from .server import make_server from .server import make_server
from .docker_broker import DockerBroker from .docker_broker import DockerBroker
from .store.registry_store import RegistryStore, default_db_path from .store.registry_store import RegistryStore, default_db_path
@@ -34,8 +36,13 @@ def main(argv: list[str] | None = None) -> int:
help=f"registry DB path (default: {default_db_path()})", help=f"registry DB path (default: {default_db_path()})",
) )
parser.add_argument( parser.add_argument(
"--broker", choices=("stub", "docker"), default="stub", "--broker", choices=("stub", "docker", "http"), default="stub",
help="launch broker: 'stub' records requests; 'docker' runs containers", 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) args = parser.parse_args(argv)
@@ -47,11 +54,25 @@ def main(argv: list[str] | None = None) -> int:
# operator reaches it over HTTP (never a second, disconnected DB). # operator reaches it over HTTP (never a second, disconnected DB).
StoreManager(registry.db_path).migrate() StoreManager(registry.db_path).migrate()
# An ephemeral signing secret ties the orchestrator (signer) to its # A signing secret ties the orchestrator (signer) to its broker (verifier).
# broker (verifier). 'stub' records launches instead of starting # 'stub' records launches instead of starting anything; 'docker' runs real
# anything; 'docker' runs real containers (firecracker drops in later). # containers in-process; 'http' relays signed requests to a separate host
secret = secrets.token_bytes(32) # control server, which verifies and launches. For 'stub'/'docker' the
broker: LaunchBroker = DockerBroker(secret) if args.broker == "docker" else StubBroker(secret) # secret is ephemeral (signer and verifier share this process); for 'http'
# it must be the SAME secret the host controller holds, so it is read from
# the shared env var (the chunk-1 stand-in for out-of-band provisioning).
broker: SubmitBroker
if args.broker == "http":
secret = broker_secret_from_env()
if secret is None:
parser.error(
f"--broker http requires a shared signing secret in "
f"${BROKER_SECRET_ENV} (hex), matching the host control server"
)
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) orchestrator = OrchestratorCore(registry, broker, secret)
server = make_server(orchestrator, host=args.host, port=args.port) server = make_server(orchestrator, host=args.host, port=args.port)
+28 -1
View File
@@ -29,6 +29,7 @@ import json
import secrets import secrets
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from typing import Protocol
_JWT_HEADER = {"alg": "HS256", "typ": "JWT"} _JWT_HEADER = {"alg": "HS256", "typ": "JWT"}
_ALLOWED_OPS = ("launch", "teardown") _ALLOWED_OPS = ("launch", "teardown")
@@ -37,7 +38,21 @@ _ALLOWED_OPS = ("launch", "teardown")
class BrokerAuthError(Exception): class BrokerAuthError(Exception):
"""A broker request failed provenance or schema verification — """A broker request failed provenance or schema verification —
bad/absent signature, malformed token, or a payload that doesn't match 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) @dataclass(frozen=True)
@@ -123,6 +138,16 @@ def verify_request(token: str, secret: bytes) -> LaunchRequest:
# --- the broker itself ------------------------------------------------------ # --- the broker itself ------------------------------------------------------
class SubmitBroker(Protocol):
"""The single method `OrchestratorCore` depends on: verify a signed token and
perform its op, returning the verified request. 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: ...
class LaunchBroker(abc.ABC): class LaunchBroker(abc.ABC):
"""Verifies a signed request came from the orchestrator, then performs """Verifies a signed request came from the orchestrator, then performs
the backend-native launch/teardown. Subclasses implement `_launch` / the backend-native launch/teardown. Subclasses implement `_launch` /
@@ -168,7 +193,9 @@ class StubBroker(LaunchBroker):
__all__ = [ __all__ = [
"BrokerAuthError", "BrokerAuthError",
"BrokerUnavailableError",
"LaunchRequest", "LaunchRequest",
"SubmitBroker",
"LaunchBroker", "LaunchBroker",
"StubBroker", "StubBroker",
"sign_request", "sign_request",
+126
View File
@@ -0,0 +1,126 @@
"""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 _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 _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",
]
+270
View File
@@ -0,0 +1,270 @@
"""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>"}
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 durable
`TrustDomain` key in a later chunk.
The shared signing secret is read from `$BOT_BOTTLE_BROKER_SECRET` (hex). That is
a **chunk-1 stopgap**: it must be provisioned to signer and verifier out of band,
which is exactly what the durable `TrustDomain` key in chunk 2 (#476) replaces.
"""
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 .broker import BrokerAuthError, LaunchBroker
from .docker_broker import DockerBroker
# JSON body payload type (parsed request / rendered response).
Json = dict[str, object]
# The hex-encoded HS256 secret shared with the request signer (the orchestrator).
# Chunk-1 stopgap for the durable, out-of-band `TrustDomain` key of chunk 2.
BROKER_SECRET_ENV = "BOT_BOTTLE_BROKER_SECRET"
# 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_from_env(environ: typing.Mapping[str, str] | None = None) -> bytes | None:
"""The shared HS256 secret from `$BOT_BOTTLE_BROKER_SECRET` (hex), or None
when unset or not valid hex. The signer (orchestrator, `--broker http`) and
the verifier (this server) read the same env var so both hold the same key
the chunk-1 stand-in for out-of-band provisioning."""
env = os.environ if environ is None else environ
raw = env.get(BROKER_SECRET_ENV, "").strip()
if not raw:
return None
try:
return bytes.fromhex(raw)
except ValueError:
return 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,
}
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 a shared `$BOT_BOTTLE_BROKER_SECRET` the server can
verify no request's provenance, so it refuses to start rather than run a
launcher that accepts unsigned input."""
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_from_env()
if secret is None:
sys.stderr.write(
f"host controller: refusing to start without a shared signing secret "
f"(${BROKER_SECRET_ENV}, hex) — 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_from_env",
"main",
"Json",
"BROKER_SECRET_ENV",
"DEFAULT_PORT",
]
if __name__ == "__main__":
raise SystemExit(main())
+17 -8
View File
@@ -25,7 +25,7 @@ import json
from collections.abc import Iterable from collections.abc import Iterable
from datetime import datetime, timezone from datetime import datetime, timezone
from .broker import LaunchBroker, LaunchRequest, sign_request from .broker import BrokerUnavailableError, LaunchRequest, SubmitBroker, sign_request
from .store.registry_store import DEFAULT_REAP_GRACE_SECONDS, BottleRecord, RegistryStore from .store.registry_store import DEFAULT_REAP_GRACE_SECONDS, BottleRecord, RegistryStore
from .supervisor import ( from .supervisor import (
AuditEntry, AuditEntry,
@@ -62,7 +62,7 @@ class OrchestratorCore:
def __init__( def __init__(
self, self,
registry: RegistryStore, registry: RegistryStore,
broker: LaunchBroker, broker: SubmitBroker,
sign_secret: bytes, sign_secret: bytes,
supervisor: Supervisor | None = None, supervisor: Supervisor | None = None,
) -> None: ) -> None:
@@ -111,14 +111,23 @@ class OrchestratorCore:
image_ref=image_ref, image_ref=image_ref,
slot=slot, slot=slot,
) )
launched = False
try: try:
self._broker.submit(sign_request(req, self._secret)) self._broker.submit(sign_request(req, self._secret))
launched = True except BrokerUnavailableError:
finally: # Ambiguous delivery failure (timeout / dropped response): the broker
if not launched: # may already have launched the bottle before the response was lost.
self.registry.deregister(rec.bottle_id) # Do NOT deregister — that would orphan a running container with no
self._tokens.pop(rec.bottle_id, None) # 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 return rec
def teardown_bottle(self, bottle_id: str) -> bool: def teardown_bottle(self, bottle_id: str) -> bool:
+47 -23
View File
@@ -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 ``POST /bottles/<id>/reprovision_gateway``; the orchestrator decrypts the
stored rows and re-populates ``_tokens``. stored rows and re-populates ``_tokens``.
Encryption scheme: HMAC-SHA256 used as a PRF in CTR mode (stdlib-only, Encryption scheme: HMAC-SHA256 used as a PRF in CTR mode, **authenticated**
no external deps). Each value is encrypted independently. The output blob is encrypt-then-MAC (stdlib-only, no external deps). Each value is encrypted
``nonce (16 bytes) || ciphertext`` encoded as URL-safe base64 (no padding). 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")) keystream_block_i = HMAC-SHA256(key, nonce || i.to_bytes(4, "big"))
ciphertext_i = plaintext_i XOR keystream_block_i[:len(plaintext_i)] 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 from __future__ import annotations
@@ -29,6 +39,7 @@ import secrets
_KEY_BYTES = 32 # 256-bit key from ENV_VAR_SECRET _KEY_BYTES = 32 # 256-bit key from ENV_VAR_SECRET
_NONCE_BYTES = 16 # 128-bit random nonce per encrypt call _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 _BLOCK = 32 # HMAC-SHA256 output width == one keystream block
# Env-var name the agent container receives at startup. # Env-var name the agent container receives at startup.
@@ -50,45 +61,58 @@ def _keystream(key: bytes, nonce: bytes, block_index: int) -> bytes:
).digest() ).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: def encrypt_value(secret_b64: str, plaintext: str) -> str:
"""Encrypt a single string value with *secret_b64* (the ENV_VAR_SECRET). """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.""" the ``bottled_agent_secrets.value`` column."""
key = _b64dec(secret_b64) key = _b64dec(secret_b64)
pt = plaintext.encode()
nonce = secrets.token_bytes(_NONCE_BYTES) nonce = secrets.token_bytes(_NONCE_BYTES)
ct = bytearray() ct = _ctr(key, nonce, plaintext.encode())
for i in range(0, len(pt), _BLOCK): tag = _tag(key, nonce, ct)
chunk = pt[i : i + _BLOCK] return base64.urlsafe_b64encode(nonce + ct + tag).rstrip(b"=").decode()
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()
def decrypt_value(secret_b64: str, blob_b64: str) -> str: def decrypt_value(secret_b64: str, blob_b64: str) -> str:
"""Decrypt a blob produced by :func:`encrypt_value`. """Decrypt a blob produced by :func:`encrypt_value`.
Returns the original plaintext string. Raises ``ValueError`` for malformed Returns the original plaintext string. Raises ``ValueError`` for malformed
input or a key mismatch (wrong key produces garbage, not an error, unless input, a **wrong key**, or a tampered ciphertext all caught by the
the plaintext is non-UTF-8 treat all such failures as wrong key).""" authentication tag before any plaintext is returned, so a wrong
ENV_VAR_SECRET is rejected deterministically (never a garbage token)."""
key = _b64dec(secret_b64) key = _b64dec(secret_b64)
try: try:
blob = _b64dec(blob_b64) blob = _b64dec(blob_b64)
except Exception as exc: except Exception as exc:
raise ValueError(f"invalid ciphertext blob: {exc}") from 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") raise ValueError("ciphertext blob too short")
nonce, ciphertext = blob[:_NONCE_BYTES], blob[_NONCE_BYTES:] nonce = blob[:_NONCE_BYTES]
pt = bytearray() tag = blob[-_TAG_BYTES:]
for i in range(0, len(ciphertext), _BLOCK): ciphertext = blob[_NONCE_BYTES:-_TAG_BYTES]
chunk = ciphertext[i : i + _BLOCK] if not hmac.compare_digest(tag, _tag(key, nonce, ciphertext)):
ks = _keystream(key, nonce, i)[: len(chunk)] raise ValueError("ciphertext failed authentication (wrong key or tampered)")
pt.extend(c ^ k for c, k in zip(chunk, ks))
try: try:
return bytes(pt).decode() return _ctr(key, nonce, ciphertext).decode()
except UnicodeDecodeError as exc: except UnicodeDecodeError as exc: # pragma: no cover - authenticated, so unreachable
raise ValueError(f"decryption produced non-UTF-8 output (wrong key?): {exc}") from exc 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"] __all__ = ["ENV_VAR_SECRET_NAME", "new_env_var_secret", "encrypt_value", "decrypt_value"]
+2 -6
View File
@@ -3,10 +3,6 @@
- **Status:** Accepted - **Status:** Accepted
- **Date:** 2026-06-25 - **Date:** 2026-06-25
- **Deciders:** didericis - **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 ## Context
@@ -38,7 +34,7 @@ a regression (Goodhart's law).
Coverage is **risk-weighted**, measured over the **combined unit + Coverage is **risk-weighted**, measured over the **combined unit +
integration** suites, with three rules: 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, covers the host and gateway egress policy, manifest trust boundary,
git-gate enforcement, supervise protocol/server, YAML parser, and bottle git-gate enforcement, supervise protocol/server, YAML parser, and bottle
state. The concrete module list lives in `scripts/critical-modules.txt`; 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** The forward-looking guard is a **diff-coverage gate**
(`scripts/diff_coverage.py`): new/changed executable lines on a branch (`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 introduced without forcing a back-fill crusade through legacy glue. The
gate skips lines in omitted files (there is no coverage data for them), 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 so the omit list cannot launder *new* logic into the dark: anything that
+1 -11
View File
@@ -1,19 +1,9 @@
# PRD 0011: Per-file Markdown manifest # PRD 0011: Per-file Markdown manifest
- **Status:** Active (agent cwd-discovery superseded) - **Status:** Active
- **Author:** didericis - **Author:** didericis
- **Created:** 2026-05-24 - **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 ## Summary
Replace the single-file `bot-bottle.json` manifest with a 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.
+273
View File
@@ -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.
+4 -3
View File
@@ -5,9 +5,10 @@ model: opus
bottle: dev bottle: dev
skills: skills:
- init-prd - init-prd
author: git-gate:
name: implementer-bot user:
email: eric+implementer@dideric.is name: implementer-bot
email: eric+implementer@dideric.is
--- ---
You are a feature-implementation agent running inside an ephemeral You are a feature-implementation agent running inside an ephemeral
+5 -5
View File
@@ -13,7 +13,7 @@
# are re-executed; no KVM or Docker dependency. # are re-executed; no KVM or Docker dependency.
# #
# Pass "critical" as the last argument in either mode to also report just the # 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 set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
@@ -34,8 +34,8 @@ if [ "${1:-}" = "aggregate" ]; then
"$PY" -m coverage report -m "$PY" -m coverage report -m
if [ "${2:-}" = "critical" ]; then if [ "${2:-}" = "critical" ]; then
echo "== critical modules (ADR 0004 minimum: 85%) ==" >&2 echo "== critical modules (ADR 0004 minimum: 90%) ==" >&2
"$PY" -m coverage report --include="$CRITICAL" --fail-under=85 "$PY" -m coverage report --include="$CRITICAL" --fail-under=90
fi fi
exit 0 exit 0
fi fi
@@ -55,6 +55,6 @@ echo "== combined report ==" >&2
"$PY" -m coverage report -m "$PY" -m coverage report -m
if [ "${1:-}" = "critical" ]; then if [ "${1:-}" = "critical" ]; then
echo "== critical modules (ADR 0004 minimum: 85%) ==" >&2 echo "== critical modules (ADR 0004 minimum: 90%) ==" >&2
"$PY" -m coverage report --include="$CRITICAL" --fail-under=85 "$PY" -m coverage report --include="$CRITICAL" --fail-under=90
fi fi
+1 -2
View File
@@ -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. # docs/decisions/0004-coverage-policy.md.
# #
# SINGLE SOURCE OF TRUTH: scripts/coverage.sh (the `critical` report) and # 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/bottle.py
bot_bottle/manifest/egress.py bot_bottle/manifest/egress.py
bot_bottle/manifest/extends.py bot_bottle/manifest/extends.py
bot_bottle/manifest/forge.py
bot_bottle/manifest/git.py bot_bottle/manifest/git.py
bot_bottle/manifest/index.py bot_bottle/manifest/index.py
bot_bottle/manifest/loader.py bot_bottle/manifest/loader.py
+3 -3
View File
@@ -13,8 +13,8 @@ policy.
Usage: Usage:
scripts/coverage.sh # produce .coverage first scripts/coverage.sh # produce .coverage first
python3 scripts/diff_coverage.py # gate against origin/main, min 80% python3 scripts/diff_coverage.py # gate against origin/main, min 90%
python3 scripts/diff_coverage.py --base main --min 75 python3 scripts/diff_coverage.py --base main --min 85
""" """
from __future__ import annotations from __future__ import annotations
@@ -74,7 +74,7 @@ def main() -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--base", default="origin/main", ap.add_argument("--base", default="origin/main",
help="git ref to diff against (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") help="minimum %% of changed executable lines covered")
args = ap.parse_args() args = ap.parse_args()
+4 -29
View File
@@ -9,7 +9,6 @@ from __future__ import annotations
import tempfile import tempfile
import unittest import unittest
from dataclasses import replace
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock 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.backend.docker.bottle_plan import DockerBottlePlan
from bot_bottle.egress import EgressPlan from bot_bottle.egress import EgressPlan
from bot_bottle.git_gate import GitGatePlan from bot_bottle.git_gate import GitGatePlan
from bot_bottle.manifest import ManifestGitUser, ManifestIndex from bot_bottle.manifest import ManifestIndex
class _Provider(AgentProvider): class _Provider(AgentProvider):
@@ -51,39 +50,15 @@ def _plan(*, git_user: dict | None = None, # type: ignore
user_cwd: str = "/tmp/x", user_cwd: str = "/tmp/x",
stage_dir: Path | None = None) -> DockerBottlePlan: stage_dir: Path | None = None) -> DockerBottlePlan:
bottle_json: dict = {} # type: ignore bottle_json: dict = {} # type: ignore
if git_user is not None:
bottle_json["git-gate"] = {"user": git_user}
if git_repos is not None: if git_repos is not None:
bottle_json.setdefault("git-gate", {})["repos"] = git_repos 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({ index = ManifestIndex.from_json_obj({
"bottles": {"dev": bottle_json}, "bottles": {"dev": bottle_json},
"agents": {"demo": agent_json}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}) })
manifest = index.load_for_agent("demo") 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( spec = BottleSpec(
manifest=index, agent_name="demo", manifest=index, agent_name="demo",
copy_cwd=copy_cwd, user_cwd=user_cwd, copy_cwd=copy_cwd, user_cwd=user_cwd,
-426
View File
@@ -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 repoforge 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()
+127 -77
View File
@@ -1,17 +1,14 @@
"""Unit: agent-owned identity via `author` (PRD """Unit: agent-level git-gate.user overlay + provenance (PRD 0027, PRD 0047).
0082).
Identity is agent-only now: an agent file declares an `author` block An agent file may declare `git-gate.user` (name/email). At
(name + email, both required) and at `ManifestIndex.load_for_agent()` `ManifestIndex.load_for_agent()` it overlays the referenced bottle's
it populates the effective bottle's `git_user`. There is no per-field `git-gate.user` per-field, agent-wins-on-non-empty. `git-gate.repos` is
overlay against the bottle anymore the bottle no longer carries a rejected on agents. `Manifest.git_identity_summary()` reports the
user identity. `git-gate` (user or repos) is rejected on an agent with effective identity with per-field `(agent)`/`(bottle)` provenance.
a migration message. `Manifest.git_identity_summary()` reports the
effective identity with no provenance annotation.
The `from_json_obj` path drives `ManifestAgent.from_dict` + the The `from_json_obj` path drives `Agent.from_dict` + the overlay in
composition in load_for_agent; a temp-dir case locks the md loader load_for_agent; a temp-dir case locks the md loader (the `_AGENT_KEYS`
(the agent `author` frontmatter key threads into the parsed agent).""" allow + the `git-gate` threading into `agent_dict`)."""
from __future__ import annotations from __future__ import annotations
@@ -34,61 +31,97 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
raise AssertionError("expected ManifestError was not raised") 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.""" """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 agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
if author is not None:
agent["author"] = author
if agent_git is not None: if agent_git is not None:
agent["git-gate"] = agent_git agent["git-gate"] = agent_git
return ManifestIndex.from_json_obj({ return ManifestIndex.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": bottle},
"agents": {"impl": agent}, "agents": {"impl": agent},
}).load_for_agent("impl") }).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.""" """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 agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore
if author is not None: if agent_git is not None:
agent["author"] = author agent["git-gate"] = agent_git
return ManifestIndex.from_json_obj({ return ManifestIndex.from_json_obj({
"bottles": {"dev": {}}, "bottles": {"dev": bottle},
"agents": {"impl": agent}, "agents": {"impl": agent},
}) })
class TestAgentAuthorPopulatesBottle(unittest.TestCase): class TestAgentGitUserOverlay(unittest.TestCase):
def test_agent_author_supplies_both_fields(self): def test_agent_supplies_both_fields(self):
m = _manifest(author={"name": "a", "email": "a@b"}) m = _manifest(agent_git={"user": {"name": "a", "email": "a@b"}})
u = m.bottle.git_user u = m.bottle.git_user
self.assertEqual("a", u.name) self.assertEqual("a", u.name)
self.assertEqual("a@b", u.email) self.assertEqual("a@b", u.email)
def test_bottle_has_no_identity_until_agent_composed(self): def test_agent_name_only_email_falls_through_to_bottle(self):
idx = _index(author={"name": "a", "email": "a@b"}) m = _manifest(
# Raw bottle has no git_user; loaded manifest has it from the agent. 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()) self.assertTrue(idx.bottles["dev"].git_user.is_empty())
m = idx.load_for_agent("impl") m = idx.load_for_agent("impl")
self.assertFalse(m.bottle.git_user.is_empty()) self.assertFalse(m.bottle.git_user.is_empty())
def test_agent_silent_leaves_bottle_identity_empty(self): def test_bottle_only_identity_preserved_when_agent_silent(self):
idx = _index() 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") m = idx.load_for_agent("impl")
# No author -> git_user stays empty; the bottle instance is reused # Agent has no git_user — bottle instance should be the same object
# directly (no replace needed).
self.assertTrue(m.bottle.git_user.is_empty())
self.assertIs(idx.bottles["dev"], m.bottle) 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({ idx = ManifestIndex.from_json_obj({
"bottles": {"dev": { "bottles": {"dev": {
"env": {"FOO": "bar"}, "env": {"FOO": "bar"},
"supervise": True, "supervise": True,
"git-gate": {"user": {"name": "B"}},
}}, }},
"agents": {"impl": { "agents": {"impl": {
"bottle": "dev", "skills": [], "prompt": "", "bottle": "dev", "skills": [], "prompt": "",
"author": {"name": "a", "email": "a@b"}, "git-gate": {"user": {"name": "a"}},
}}, }},
}) })
b = idx.load_for_agent("impl").bottle b = idx.load_for_agent("impl").bottle
@@ -97,77 +130,93 @@ class TestAgentAuthorPopulatesBottle(unittest.TestCase):
self.assertTrue(b.supervise) self.assertTrue(b.supervise)
class TestAgentGitGateRejections(unittest.TestCase): class TestAgentGitUserRejections(unittest.TestCase):
"""`git-gate` is no longer accepted on an agent (user or repos): def test_agent_repos_dies_bottle_only(self):
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):
msg = _error_message(_manifest, agent_git={ msg = _error_message(_manifest, agent_git={
"repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}}, "repos": {"r": {"url": "ssh://git@x/y.git", "key": {"provider": "static", "path": "/dev/null"}}},
}) })
self.assertIn("no longer", msg) self.assertIn("git-gate.repos", msg)
self.assertIn("author", 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): class TestGitIdentitySummary(unittest.TestCase):
"""Summary reports the effective identity (from the agent's author) def test_both_from_agent(self):
with no per-field provenance annotation.""" 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): def test_mixed_provenance(self):
m = _manifest(author={"name": "a", "email": "a@b"}) m = _manifest(
self.assertEqual("name=a, email=a@b", m.git_identity_summary()) 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() m = _manifest()
self.assertIsNone(m.git_identity_summary()) self.assertIsNone(m.git_identity_summary())
_BOTTLE_DEV = """ _BOTTLE_DEV = """
--- ---
egress: git-gate:
routes: user:
- host: example.com name: bottle-name
email: bottle@example.com
--- ---
dev bottle. dev bottle.
""" """
_AGENT_WITH_AUTHOR = """ _AGENT_WITH_GIT = """
--- ---
bottle: dev bottle: dev
author: git-gate:
name: agent-name user:
email: agent@example.com name: agent-name
--- ---
impl agent. impl agent.
""" """
_AGENT_WITH_GIT_GATE = """ _AGENT_WITH_REPOS = """
--- ---
bottle: dev bottle: dev
git-gate: git-gate:
repos: repos:
r: r:
url: ssh://git@x/y.git url: ssh://git@x/y.git
key: identity: /dev/null
provider: static
path: /dev/null
--- ---
bad agent. bad agent.
""" """
class TestAgentAuthorMdLoader(unittest.TestCase): class TestAgentGitUserMdLoader(unittest.TestCase):
"""Locks the md path: `author` is an accepted agent frontmatter key """Locks the md path: `git-gate` is an accepted agent key and threads
and threads into the parsed agent, populating identity; a stale into the parsed Agent (not rejected as an unknown frontmatter key),
agent `git-gate` block dies through the same loader.""" and agent `git-gate.repos` dies through the same loader."""
def setUp(self) -> None: def setUp(self) -> None:
self.home = Path(tempfile.mkdtemp(prefix="cb-home-")) 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.parent.mkdir(parents=True, exist_ok=True)
p.write_text(textwrap.dedent(text).lstrip("\n")) 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("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") m = ManifestIndex.resolve(str(self.home)).load_for_agent("impl")
u = m.bottle.git_user u = m.bottle.git_user
self.assertEqual("agent-name", u.name) self.assertEqual("agent-name", u.name)
self.assertEqual("agent@example.com", u.email) self.assertEqual("bottle@example.com", u.email)
self.assertEqual( self.assertEqual(
"name=agent-name, email=agent@example.com", "name=agent-name (agent), email=bottle@example.com (bottle)",
m.git_identity_summary(), m.git_identity_summary(),
) )
def test_md_agent_git_gate_fails_at_preflight(self): def test_md_agent_repos_fails_at_preflight(self):
"""A stale agent `git-gate` block is an error; resolve() still """git-gate.repos on an agent is an error; resolve() still succeeds
succeeds so other agents remain accessible, but load_for_agent so other agents remain accessible, but load_for_agent raises."""
raises. The lazy loader's frontmatter-key validator rejects the
unknown `git-gate` key first."""
self._write("bottles/dev.md", _BOTTLE_DEV) 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)) names = ManifestIndex.resolve(str(self.home))
self.assertIn("impl", names.all_agent_names) self.assertIn("impl", names.all_agent_names)
with self.assertRaises(ManifestError) as ctx: with self.assertRaises(ManifestError) as ctx:
names.load_for_agent("impl") 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__": if __name__ == "__main__":
+40 -26
View File
@@ -130,10 +130,8 @@ class TestExtendsEnvMerge(unittest.TestCase):
class TestExtendsGitMerge(unittest.TestCase): class TestExtendsGitMerge(unittest.TestCase):
"""git-gate.repos merges by name, with same-name child entries """git-gate.user overlays by field; git-gate.repos merges by name,
merging field-by-field (child wins). Bottles no longer carry a user with same-name child entries merging field-by-field (child wins)."""
identity (PRD 0082), so only repos
merging is meaningful across extends chains."""
_GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}} _GIT_ENTRY_A = {"url": "ssh://git@host-a/a.git", "key": {"provider": "static", "path": "/dev/null"}}
_GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "key": {"provider": "static", "path": "/dev/null"}} _GIT_ENTRY_B = {"url": "ssh://git@host-b/b.git", "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") repo_entry = next(e for e in child.git if e.Name == "repo")
self.assertEqual("gitea", repo_entry.Key.provider) self.assertEqual("gitea", repo_entry.Key.provider)
def test_child_inherits_parent_repos_no_user_identity(self): def test_child_git_user_inherits_parent_repos(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.
m = _build( m = _build(
base={"git-gate": {"repos": {"a": self._GIT_ENTRY_A}}}, 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.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): class TestExtendsEgressMerge(unittest.TestCase):
@@ -337,29 +332,48 @@ class TestExtendsEgressMerge(unittest.TestCase):
self.assertIn("A.EXAMPLE.COM", msg) self.assertIn("A.EXAMPLE.COM", msg)
class TestExtendsNoBottleUserIdentity(unittest.TestCase): class TestExtendsGitUserOverlay(unittest.TestCase):
"""Bottles no longer carry a user identity (PRD """git-gate.user: per-field overlay. Each non-empty field on child
0082): identity moved to the agent's wins; empties fall through to parent."""
`author` block. `git-gate.user` on a bottle is rejected outright, and
`git_user` is always empty across an extends chain."""
def test_bottle_git_gate_user_dies(self): def test_parent_full_child_omits(self):
# A stale `git-gate.user` on a bottle fails with the migration die m = _build(
# even inside an extends chain.
msg = _error_message(
_build,
base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}}, base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base"}, 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( m = _build(
base={"git-gate": {"repos": {}}}, base={"git-gate": {"user": {"name": "Parent", "email": "p@x"}}},
child={"extends": "base"}, child={
"extends": "base",
"git-gate": {"user": {"name": "Child", "email": "c@x"}},
},
) )
self.assertTrue(m.bottles["base"].git_user.is_empty()) u = m.bottles["child"].git_user
self.assertTrue(m.bottles["child"].git_user.is_empty()) 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): class TestExtendsChain(unittest.TestCase):
+52 -62
View File
@@ -1,11 +1,4 @@
"""Unit: agent `author` identity -> bottle.git_user (PRD """Unit: Bottle git-gate.user manifest parsing + validation (issue #86, PRD 0047)."""
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."""
import unittest import unittest
@@ -21,92 +14,89 @@ def _error_message(callable_, *args, **kwargs) -> str: # type: ignore
raise AssertionError("expected ManifestError was not raised") raise AssertionError("expected ManifestError was not raised")
def _manifest(author): # type: ignore def _manifest(git_user): # type: ignore
"""Build an index with one agent 'demo' carrying the given `author` return {
block, then load it, returning the composed Manifest.""" "bottles": {"dev": {"git-gate": {"user": git_user}}},
agent: dict = {"skills": [], "prompt": "", "bottle": "dev"} # type: ignore "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
if author is not None: }
agent["author"] = author
return ManifestIndex.from_json_obj({
"bottles": {"dev": {}},
"agents": {"demo": agent},
}).load_for_agent("demo")
class TestAuthorIdentity(unittest.TestCase): class TestGitUserParsing(unittest.TestCase):
"""The agent's `author` block populates bottle.git_user."""
def test_parses_both_fields(self): def test_parses_both_fields(self):
m = _manifest({ m = ManifestIndex.from_json_obj(_manifest({
"name": "Eric Bauerfeld", "name": "Eric Bauerfeld",
"email": "eric+claude@dideric.is", "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 Bauerfeld", u.name)
self.assertEqual("eric+claude@dideric.is", u.email) self.assertEqual("eric+claude@dideric.is", u.email)
self.assertFalse(u.is_empty()) self.assertFalse(u.is_empty())
def test_omitted_author_defaults_to_empty(self): def test_name_only(self):
# No author block at all -> empty git_user, is_empty True -> 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. # provisioner skips the `git config` step entirely.
m = _manifest(None) m = ManifestIndex.from_json_obj({
self.assertTrue(m.bottle.git_user.is_empty()) "bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
def test_missing_name_dies(self): })
# `author` is present but name is absent -> both fields required. u = m.bottles["dev"].git_user
msg = _error_message(_manifest, {"email": "bot@example.com"}) self.assertTrue(u.is_empty())
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)
def test_both_empty_strings_dies(self): def test_both_empty_strings_dies(self):
# An explicit `author: {name: "", email: ""}` is a typo / # An explicit `git.user: {name: "", email: ""}` is a typo
# half-finished edit; fail loudly rather than silently no-op. # / half-finished edit; fail loudly rather than silently
msg = _error_message(_manifest, {"name": "", "email": ""}) # no-op (the operator clearly meant to configure something).
self.assertIn("author.name must be a non-empty string", msg) msg = _error_message(
ManifestIndex.from_json_obj, _manifest({"name": "", "email": ""}),
)
self.assertIn("neither name nor email", msg)
def test_unknown_key_dies(self): def test_unknown_key_dies(self):
msg = _error_message( msg = _error_message(
_manifest, ManifestIndex.from_json_obj,
{"name": "Bot", "email": "b@x", "username": "bot"}, _manifest({"name": "Bot", "username": "bot"}),
) )
self.assertIn("unknown key", msg) self.assertIn("unknown key", msg)
self.assertIn("username", msg) self.assertIn("username", msg)
def test_non_string_name_dies(self): def test_non_string_name_dies(self):
msg = _error_message(_manifest, {"name": 42, "email": "b@x"}) msg = _error_message(
self.assertIn("author.name must be a non-empty string", msg) 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): def test_non_string_email_dies(self):
msg = _error_message(_manifest, {"name": "Bot", "email": ["x@y.z"]}) msg = _error_message(
self.assertIn("author.email must be a non-empty string", msg) 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): def test_legacy_top_level_git_user_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):
msg = _error_message( msg = _error_message(
ManifestIndex.from_json_obj, ManifestIndex.from_json_obj,
{ {
"bottles": {"dev": {"git-gate": {"user": {"name": "Bot"}}}}, "bottles": {"dev": {"git_user": {"name": "Bot"}}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}, },
) )
self.assertIn("git-gate.user is no longer supported", msg) self.assertIn("git_user", msg)
self.assertIn("author", msg) self.assertIn("git-gate.user", msg)
class TestGitUserDirect(unittest.TestCase): class TestGitUserDirect(unittest.TestCase):
"""Direct GitUser dataclass exercises (no manifest wrapper). The """Direct GitUser dataclass exercises (no manifest wrapper)."""
dataclass is still the runtime carrier on ManifestBottle.git_user."""
def test_is_empty_default(self): def test_is_empty_default(self):
self.assertTrue(ManifestGitUser().is_empty()) self.assertTrue(ManifestGitUser().is_empty())
+5 -10
View File
@@ -71,14 +71,11 @@ class _LazyCase(unittest.TestCase):
class TestAllAgentNamesLazy(_LazyCase): class TestAllAgentNamesLazy(_LazyCase):
def test_cwd_agents_ignored_home_only(self) -> None: def test_merges_home_and_cwd_agents(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.
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "alpha.md", _AGENT) _write(self.home_cb / "agents" / "alpha.md", _AGENT)
_write(self.cwd_cb / "agents" / "beta.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): class TestLoadForAgentLazy(_LazyCase):
@@ -99,13 +96,11 @@ class TestRequireAgentLazy(_LazyCase):
_write(self.home_cb / "agents" / "alpha.md", _AGENT) _write(self.home_cb / "agents" / "alpha.md", _AGENT)
self.resolve().require_agent("alpha") # no raise self.resolve().require_agent("alpha") # no raise
def test_cwd_only_agent_not_selectable(self) -> None: def test_existing_cwd_agent_ok(self) -> None:
# Agents are home-only (PRD 0082): # File only under cwd -> require_agent's cwd_path branch.
# a cwd-only agent file is never selectable, so require_agent raises.
_write(self.home_cb / "agents" / "alpha.md", _AGENT) _write(self.home_cb / "agents" / "alpha.md", _AGENT)
_write(self.cwd_cb / "agents" / "beta.md", _AGENT) _write(self.cwd_cb / "agents" / "beta.md", _AGENT)
with self.assertRaises(ManifestError): self.resolve().require_agent("beta") # no raise
self.resolve().require_agent("beta")
def test_unknown_agent_raises(self) -> None: def test_unknown_agent_raises(self) -> None:
_write(self.home_cb / "agents" / "alpha.md", _AGENT) _write(self.home_cb / "agents" / "alpha.md", _AGENT)
+7 -14
View File
@@ -110,16 +110,14 @@ class TestAgentFileParses(_ResolveCase):
self.assertFalse(a.prompt.endswith("\n")) self.assertFalse(a.prompt.endswith("\n"))
class TestCwdAgentIgnoredHomeWins(_ResolveCase): class TestCwdAgentOverridesHome(_ResolveCase):
"""SC #3 (revised, PRD 0082): agents """SC #3: a cwd agent file with the same name as a home agent
are home-only. A cwd agent file with the same name as a home agent no wins. The home bottle stays intact."""
longer wins it is warned-and-ignored, so the HOME agent's prompt is
used and 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 / "bottles" / "dev.md", _BOTTLE_DEV)
_write(self.home_cb / "agents" / "implementer.md", _AGENT_IMPL) _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( _write(
self.cwd_cb / "agents" / "implementer.md", self.cwd_cb / "agents" / "implementer.md",
""" """
@@ -131,19 +129,14 @@ class TestCwdAgentIgnoredHomeWins(_ResolveCase):
""", """,
) )
m = self.resolve().load_for_agent("implementer") m = self.resolve().load_for_agent("implementer")
# Home agent's body is used; the cwd override never applies. self.assertIn("CWD-OVERRIDE-PROMPT", m.agent.prompt)
self.assertIn("feature implementation agent", m.agent.prompt)
self.assertNotIn("CWD-OVERRIDE-PROMPT", m.agent.prompt)
# Home bottle still present with its two egress routes # Home bottle still present with its two egress routes
self.assertEqual(2, len(m.bottle.egress.routes)) self.assertEqual(2, len(m.bottle.egress.routes))
class TestCwdBottlesIgnored(_ResolveCase): class TestCwdBottlesIgnored(_ResolveCase):
"""SC #4: a bottles/ dir under $CWD is ignored (with a warn). """SC #4: a bottles/ dir under $CWD is ignored (with a warn).
The home bottle still wins. Under The home bottle still wins; cwd contributes only agents."""
PRD 0082 a cwd agents/ dir is also
ignored, so $CWD contributes nothing the filesystem layout is the
trust boundary."""
def test_ignored(self): def test_ignored(self):
_write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV) _write(self.home_cb / "bottles" / "dev.md", _BOTTLE_DEV)
+6 -17
View File
@@ -212,21 +212,13 @@ class TestAgentValidation(unittest.TestCase):
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestAgent.from_dict("a", {"prompt": 5}, set()) ManifestAgent.from_dict("a", {"prompt": 5}, set())
def test_git_gate_rejected_at_agent_level(self) -> None: def test_git_gate_repos_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.
with self.assertRaises(ManifestError): with self.assertRaises(ManifestError):
ManifestAgent.from_dict("a", {"git-gate": {"repos": {}}}, set()) 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: def test_git_gate_empty_is_allowed(self) -> None:
# An empty `git-gate: {}` on a bottle is still allowed (only the agent = ManifestAgent.from_dict("a", {"git-gate": {}}, set())
# optional `repos` subkey exists now); it contributes no git repos. self.assertTrue(agent.git_user.is_empty())
bottle = ManifestBottle.from_dict("b", {"git-gate": {}})
self.assertEqual((), bottle.git)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -236,12 +228,9 @@ class TestAgentValidation(unittest.TestCase):
class TestEagerIndexLookups(unittest.TestCase): class TestEagerIndexLookups(unittest.TestCase):
def _idx(self) -> ManifestIndex: 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({ return _idx({
"bottles": {"b": {}}, "bottles": {"b": {"git-gate": {"user": {"name": "Bot", "email": "b@x"}}}},
"agents": {"a": {"bottle": "b", "agents": {"a": {"bottle": "b"}},
"author": {"name": "Bot", "email": "b@x"}}},
}) })
def test_unknown_bottle_section_is_empty(self) -> None: def test_unknown_bottle_section_is_empty(self) -> None:
@@ -0,0 +1,117 @@
"""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")
if __name__ == "__main__":
unittest.main()
-82
View File
@@ -248,88 +248,6 @@ class TestDockerGateway(unittest.TestCase):
calls, 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: def test_ca_cert_pem_reads_from_container(self) -> None:
with patch(_RUN_DOCKER, return_value=_proc(stdout=_CA_PEM)) as m: with patch(_RUN_DOCKER, return_value=_proc(stdout=_CA_PEM)) as m:
self.assertEqual(_CA_PEM, self.sc.ca_cert_pem()) self.assertEqual(_CA_PEM, self.sc.ca_cert_pem())
+285
View File
@@ -0,0 +1,285 @@
"""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 secrets
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_from_env,
dispatch,
main,
make_host_server,
)
def _body(obj: object) -> bytes:
return json.dumps(obj).encode()
class _RaisingBroker(LaunchBroker):
"""A broker whose backend launch 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")
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_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 TestBrokerSecretFromEnv(unittest.TestCase):
def test_reads_hex_secret(self) -> None:
s = secrets.token_bytes(16)
self.assertEqual(s, broker_secret_from_env({"BOT_BOTTLE_BROKER_SECRET": s.hex()}))
def test_unset_is_none(self) -> None:
self.assertIsNone(broker_secret_from_env({}))
def test_invalid_hex_is_none(self) -> None:
self.assertIsNone(broker_secret_from_env({"BOT_BOTTLE_BROKER_SECRET": "not-hex"}))
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
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_from_env",
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_from_env",
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()
+64
View File
@@ -0,0 +1,64 @@
"""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
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("BOT_BOTTLE_BROKER_SECRET", 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_secret_serves(self) -> None:
rc, _ = self._run(
"http", env={"BOT_BOTTLE_BROKER_SECRET": secrets.token_bytes(16).hex()})
self.assertEqual(0, rc)
def test_http_broker_without_secret_exits(self) -> None:
# Fail-closed: --broker http with no shared secret is a usage error.
with tempfile.TemporaryDirectory() as d:
with patch.dict("os.environ", {}, clear=False):
os.environ.pop("BOT_BOTTLE_BROKER_SECRET", None)
with self.assertRaises(SystemExit):
main(["--db", str(Path(d) / "r.db"), "--broker", "http"])
if __name__ == "__main__":
unittest.main()
+19 -12
View File
@@ -2,10 +2,12 @@
from __future__ import annotations from __future__ import annotations
import base64
import unittest import unittest
from bot_bottle.orchestrator.store.secret_store import ( from bot_bottle.orchestrator.store.secret_store import (
ENV_VAR_SECRET_NAME, ENV_VAR_SECRET_NAME,
_NONCE_BYTES,
decrypt_value, decrypt_value,
encrypt_value, encrypt_value,
new_env_var_secret, new_env_var_secret,
@@ -65,22 +67,27 @@ class TestDecryptErrors(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.secret = new_env_var_secret() 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") ct = encrypt_value(self.secret, "secret-token")
other_key = new_env_var_secret() raw = bytearray(base64.urlsafe_b64decode(ct + "=" * (-len(ct) % 4)))
# Wrong key produces garbage bytes; decrypt_value raises ValueError raw[_NONCE_BYTES] ^= 0x01 # flip a bit in the ciphertext body → tag mismatch
# when the result is non-UTF-8 (which is very likely for 12-char data). tampered = base64.urlsafe_b64encode(bytes(raw)).rstrip(b"=").decode()
# We allow it to succeed only if garbage happens to be valid UTF-8, but with self.assertRaises(ValueError):
# the plaintext must not match. decrypt_value(self.secret, tampered)
try:
result = decrypt_value(other_key, ct)
self.assertNotEqual("secret-token", result)
except ValueError:
pass
def test_truncated_blob_raises_value_error(self) -> None: def test_truncated_blob_raises_value_error(self) -> None:
with self.assertRaises(ValueError): 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: def test_invalid_base64_raises_value_error(self) -> None:
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
+31 -5
View File
@@ -11,7 +11,12 @@ from contextlib import closing
from pathlib import Path from pathlib import Path
from unittest.mock import patch 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.store.registry_store import RegistryStore
from bot_bottle.orchestrator.service import OrchestratorCore from bot_bottle.orchestrator.service import OrchestratorCore
from bot_bottle.orchestrator.store.secret_store import new_env_var_secret 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): class _FailingBroker(LaunchBroker):
"""Verifies the token like any broker, then fails the launch — to """Verifies the token like any broker, then fails the launch *definitely*
exercise the orchestrator's registry rollback.""" to exercise the orchestrator's registry rollback."""
def _launch(self, req: LaunchRequest) -> None: def _launch(self, req: LaunchRequest) -> None:
raise RuntimeError("launch failed") raise RuntimeError("launch failed")
@@ -35,6 +40,18 @@ class _FailingBroker(LaunchBroker):
pass pass
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
class TestOrchestrator(unittest.TestCase): class TestOrchestrator(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory() self._tmp = tempfile.TemporaryDirectory()
@@ -144,11 +161,20 @@ class TestOrchestrator(unittest.TestCase):
self.assertIsNotNone(self.orch.resolve("10.243.0.3", rec.identity_token)) self.assertIsNotNone(self.orch.resolve("10.243.0.3", rec.identity_token))
self.assertIsNone(self.orch.resolve("10.243.0.3", "wrong-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) orch = OrchestratorCore(self.store, _FailingBroker(self.secret), self.secret)
with self.assertRaises(RuntimeError): with self.assertRaises(RuntimeError):
orch.launch_bottle("10.243.0.9") 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: def test_gateway_status_reports_unconfigured(self) -> None:
# The orchestrator no longer owns a standalone gateway lifecycle; the # The orchestrator no longer owns a standalone gateway lifecycle; the