Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f351f5f93e | |||
| 528a20e2ce | |||
| 9c19ce4c58 | |||
| 010e253d66 | |||
| 245f258f20 | |||
| c62d57d5ac | |||
| 34bb7263fa | |||
| 2644759b0d | |||
| e53104d5c1 | |||
| 8f6148d571 | |||
| 45f3cefbc5 | |||
| 0e70d26af4 | |||
| f2d8158742 |
@@ -19,7 +19,6 @@ from .util import run_docker
|
|||||||
from ...paths import (
|
from ...paths import (
|
||||||
ORCHESTRATOR_TOKEN_ENV,
|
ORCHESTRATOR_TOKEN_ENV,
|
||||||
bot_bottle_root,
|
bot_bottle_root,
|
||||||
host_orchestrator_token,
|
|
||||||
)
|
)
|
||||||
from ...gateway import GatewayError
|
from ...gateway import GatewayError
|
||||||
from ...orchestrator.lifecycle import (
|
from ...orchestrator.lifecycle import (
|
||||||
@@ -171,7 +170,9 @@ class DockerOrchestrator(Orchestrator):
|
|||||||
fixed-name container first)."""
|
fixed-name container first)."""
|
||||||
self._ensure_control_network()
|
self._ensure_control_network()
|
||||||
run_docker(["docker", "rm", "--force", self.name])
|
run_docker(["docker", "rm", "--force", self.name])
|
||||||
_signing_key = host_orchestrator_token()
|
# The signing key comes through the shared provisioning contract (#476),
|
||||||
|
# which fail-closes rather than yield an empty key that would run OPEN.
|
||||||
|
_signing_key = self.control_plane_key()
|
||||||
proc = run_docker([
|
proc = run_docker([
|
||||||
"docker", "run", "--detach",
|
"docker", "run", "--detach",
|
||||||
"--name", self.name,
|
"--name", self.name,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
from ...paths import host_orchestrator_token
|
|
||||||
from ...orchestrator.lifecycle import (
|
from ...orchestrator.lifecycle import (
|
||||||
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||||
Orchestrator,
|
Orchestrator,
|
||||||
@@ -108,11 +107,13 @@ class FirecrackerOrchestrator(Orchestrator):
|
|||||||
data_drive=self._ensure_registry_volume(),
|
data_drive=self._ensure_registry_volume(),
|
||||||
)
|
)
|
||||||
# Push the host-canonical signing key (the init waits for it before
|
# Push the host-canonical signing key (the init waits for it before
|
||||||
# starting the control plane). The host token file stays the single
|
# starting the control plane). It comes through the shared provisioning
|
||||||
# source of truth, so a co-running docker/macOS control plane keeps
|
# contract (#476) — the same host token file every backend uses, so a
|
||||||
# working; the guest verifies tokens with the same key the CLI signs from.
|
# co-running docker/macOS control plane keeps working and the guest
|
||||||
|
# verifies tokens with the same key the CLI signs from; fail-closed, so
|
||||||
|
# the guest is never handed an empty key that would run it OPEN.
|
||||||
infra_vm.push_secret(
|
infra_vm.push_secret(
|
||||||
vm, host_orchestrator_token(), infra_vm._GUEST_SIGNING_KEY_PATH,
|
vm, self.control_plane_key(), infra_vm._GUEST_SIGNING_KEY_PATH,
|
||||||
"the control-plane signing key to the orchestrator VM "
|
"the control-plane signing key to the orchestrator VM "
|
||||||
"(its control plane will not start)",
|
"(its control plane will not start)",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -18,10 +18,7 @@ import urllib.request
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ... import log
|
from ... import log
|
||||||
from ...paths import (
|
from ...paths import ORCHESTRATOR_TOKEN_ENV
|
||||||
ORCHESTRATOR_TOKEN_ENV,
|
|
||||||
host_orchestrator_token,
|
|
||||||
)
|
|
||||||
from ...orchestrator.lifecycle import (
|
from ...orchestrator.lifecycle import (
|
||||||
DEFAULT_HEALTH_TIMEOUT_SECONDS,
|
DEFAULT_HEALTH_TIMEOUT_SECONDS,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
@@ -134,7 +131,9 @@ class MacosOrchestrator(Orchestrator):
|
|||||||
|
|
||||||
def _run_container(self, current_hash: str) -> None:
|
def _run_container(self, current_hash: str) -> None:
|
||||||
container_mod.force_remove_container(self.name)
|
container_mod.force_remove_container(self.name)
|
||||||
_signing_key = host_orchestrator_token()
|
# The signing key comes through the shared provisioning contract (#476),
|
||||||
|
# which fail-closes rather than yield an empty key that would run OPEN.
|
||||||
|
_signing_key = self.control_plane_key()
|
||||||
argv = [
|
argv = [
|
||||||
"container", "run", "--detach",
|
"container", "run", "--detach",
|
||||||
"--name", self.name,
|
"--name", self.name,
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import urllib.request
|
|||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from ..orchestrator_auth import ROLE_CLI, mint
|
from ..orchestrator_auth import ROLE_CLI
|
||||||
from ..paths import host_orchestrator_token
|
from ..trust_domain import CONTROL_PLANE
|
||||||
from .server import ORCHESTRATOR_AUTH_HEADER
|
from .server import ORCHESTRATOR_AUTH_HEADER
|
||||||
|
|
||||||
DEFAULT_TIMEOUT_SECONDS = 5.0
|
DEFAULT_TIMEOUT_SECONDS = 5.0
|
||||||
@@ -32,7 +32,7 @@ def _host_auth_token() -> str:
|
|||||||
"" means 'send no auth header' — correct against an open (unconfigured)
|
"" means 'send no auth header' — correct against an open (unconfigured)
|
||||||
control plane, and harmlessly rejected by a secured one."""
|
control plane, and harmlessly rejected by a secured one."""
|
||||||
try:
|
try:
|
||||||
return mint(ROLE_CLI, host_orchestrator_token())
|
return CONTROL_PLANE.mint(ROLE_CLI)
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..orchestrator_auth import ROLE_GATEWAY, mint
|
from ..trust_domain import ControlPlaneProvisioning
|
||||||
from ..paths import host_orchestrator_token
|
|
||||||
|
|
||||||
DEFAULT_PORT = 8099
|
DEFAULT_PORT = 8099
|
||||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
||||||
@@ -58,6 +57,12 @@ class Orchestrator(abc.ABC):
|
|||||||
so it — not the gateway — mints the gateway's role-scoped token.
|
so it — not the gateway — mints the gateway's role-scoped token.
|
||||||
Backend-neutral."""
|
Backend-neutral."""
|
||||||
|
|
||||||
|
# The shared control-plane auth provisioning contract (#476). Every backend
|
||||||
|
# gets its signing key + gateway token through this one seam rather than
|
||||||
|
# re-deriving the wiring; it is fail-closed for every backend — the
|
||||||
|
# orchestrator never starts without its signing key.
|
||||||
|
provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()
|
||||||
|
|
||||||
def ensure_built(self) -> None:
|
def ensure_built(self) -> None:
|
||||||
"""Ensure the orchestrator's image / rootfs exists, building it if
|
"""Ensure the orchestrator's image / rootfs exists, building it if
|
||||||
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
|
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
|
||||||
@@ -105,9 +110,17 @@ class Orchestrator(abc.ABC):
|
|||||||
def mint_gateway_token(self) -> str:
|
def mint_gateway_token(self) -> str:
|
||||||
"""Mint a role-scoped `gateway` JWT from the host signing key for the
|
"""Mint a role-scoped `gateway` JWT from the host signing key for the
|
||||||
gateway to present. The orchestrator holds the key; the gateway never
|
gateway to present. The orchestrator holds the key; the gateway never
|
||||||
does (#469). Backend-neutral — the same host token file is the single
|
does (#469). Routed through the shared provisioning contract (#476), so
|
||||||
source of truth across backends."""
|
the same host token file is the single source of truth across backends."""
|
||||||
return mint(ROLE_GATEWAY, host_orchestrator_token())
|
return self.provisioning.gateway_token()
|
||||||
|
|
||||||
|
def control_plane_key(self) -> str:
|
||||||
|
"""The raw signing key the control-plane *process* must receive — the ONE
|
||||||
|
place a backend obtains it (docker/macOS inject it as `key_env`;
|
||||||
|
firecracker pushes it to the guest). Fail-closed via the provisioning
|
||||||
|
contract: it raises rather than yield an empty key that would run the
|
||||||
|
server OPEN (#476)."""
|
||||||
|
return self.provisioning.orchestrator_key()
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -117,4 +130,5 @@ __all__ = [
|
|||||||
"OrchestratorStartError",
|
"OrchestratorStartError",
|
||||||
"source_hash",
|
"source_hash",
|
||||||
"Orchestrator",
|
"Orchestrator",
|
||||||
|
"ControlPlaneProvisioning",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ import sys
|
|||||||
import typing
|
import typing
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from ..orchestrator_auth import ROLE_CLI, ROLES, verify
|
from ..orchestrator_auth import ROLE_CLI, ROLES
|
||||||
from ..paths import ORCHESTRATOR_TOKEN_ENV
|
from ..trust_domain import CONTROL_PLANE
|
||||||
from ..supervisor.types import TOOLS
|
from ..supervisor.types import TOOLS
|
||||||
from .service import OrchestratorCore
|
from .service import OrchestratorCore
|
||||||
|
|
||||||
@@ -413,11 +413,13 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
|
|
||||||
def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None:
|
def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None:
|
||||||
self.orchestrator = orchestrator
|
self.orchestrator = orchestrator
|
||||||
self._signing_key = os.environ.get(ORCHESTRATOR_TOKEN_ENV, "").strip()
|
# The control-plane trust domain's signing key, as injected into THIS
|
||||||
|
# (the owning) process by the launcher (#476). Unset → open mode below.
|
||||||
|
self._signing_key = CONTROL_PLANE.key_from_env()
|
||||||
if not self._signing_key:
|
if not self._signing_key:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"orchestrator: WARNING — no control-plane signing key "
|
"orchestrator: WARNING — no control-plane signing key "
|
||||||
f"(${ORCHESTRATOR_TOKEN_ENV}); running WITHOUT caller "
|
f"(${CONTROL_PLANE.key_env}); running WITHOUT caller "
|
||||||
"authentication. Any client that can reach this port can drive "
|
"authentication. Any client that can reach this port can drive "
|
||||||
"it. Backends that put the control plane on an agent-reachable "
|
"it. Backends that put the control plane on an agent-reachable "
|
||||||
"network MUST set this.\n"
|
"network MUST set this.\n"
|
||||||
@@ -433,7 +435,7 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|||||||
role (→ per-route 401/403 in `dispatch`)."""
|
role (→ per-route 401/403 in `dispatch`)."""
|
||||||
if not self._signing_key:
|
if not self._signing_key:
|
||||||
return ROLE_CLI
|
return ROLE_CLI
|
||||||
return verify(presented, self._signing_key)
|
return CONTROL_PLANE.verify(presented, self._signing_key)
|
||||||
|
|
||||||
|
|
||||||
def make_server(
|
def make_server(
|
||||||
|
|||||||
@@ -59,12 +59,17 @@ _HEADER_SEGMENT = _b64url_encode(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def mint(role: str, secret: str) -> str:
|
def mint(role: str, secret: str, *, roles: frozenset[str] = ROLES) -> str:
|
||||||
"""A compact HS256 token asserting `role`, signed with `secret`.
|
"""A compact HS256 token asserting `role`, signed with `secret`.
|
||||||
|
|
||||||
Raises ValueError for an unknown role (mint only what the control plane will
|
`roles` is the set the signing key is allowed to sign (default: the
|
||||||
accept) or an empty signing key (an unsigned credential is never valid)."""
|
orchestrator's `{gateway, cli}`). A separate service (e.g. the host
|
||||||
if role not in ROLES:
|
controller) passes its own key + role set so its tokens can't be forged with
|
||||||
|
the orchestrator's key — see `trust_domain.py`, issues #476/#468.
|
||||||
|
|
||||||
|
Raises ValueError for a role outside `roles`, or an empty signing key (an
|
||||||
|
unsigned credential is never valid)."""
|
||||||
|
if role not in roles:
|
||||||
raise ValueError(f"unknown control-plane role {role!r}")
|
raise ValueError(f"unknown control-plane role {role!r}")
|
||||||
if not secret:
|
if not secret:
|
||||||
raise ValueError("cannot mint a control-plane token without a signing key")
|
raise ValueError("cannot mint a control-plane token without a signing key")
|
||||||
@@ -73,10 +78,11 @@ def mint(role: str, secret: str) -> str:
|
|||||||
return f"{signing_input}.{_sign(secret, signing_input)}"
|
return f"{signing_input}.{_sign(secret, signing_input)}"
|
||||||
|
|
||||||
|
|
||||||
def verify(token: str, secret: str) -> str | None:
|
def verify(token: str, secret: str, *, roles: frozenset[str] = ROLES) -> str | None:
|
||||||
"""The role a valid `token` carries, or None if it is malformed, wrongly
|
"""The role a valid `token` carries, or None if it is malformed, wrongly
|
||||||
signed, or names an unknown role. Constant-time signature check; rejects any
|
signed, or names a role outside `roles` (the verifying trust domain's set —
|
||||||
header whose alg isn't HS256 (no alg-confusion / `none`)."""
|
default `{gateway, cli}`). Constant-time signature check; rejects any header
|
||||||
|
whose alg isn't HS256 (no alg-confusion / `none`)."""
|
||||||
if not token or not secret:
|
if not token or not secret:
|
||||||
return None
|
return None
|
||||||
parts = token.split(".")
|
parts = token.split(".")
|
||||||
@@ -94,7 +100,7 @@ def verify(token: str, secret: str) -> str | None:
|
|||||||
if not isinstance(header, dict) or header.get("alg") != _ALG:
|
if not isinstance(header, dict) or header.get("alg") != _ALG:
|
||||||
return None
|
return None
|
||||||
role = payload.get("role") if isinstance(payload, dict) else None
|
role = payload.get("role") if isinstance(payload, dict) else None
|
||||||
return role if isinstance(role, str) and role in ROLES else None
|
return role if isinstance(role, str) and role in roles else None
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"]
|
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"]
|
||||||
|
|||||||
+19
-9
@@ -97,16 +97,17 @@ def host_gateway_ca_dir() -> Path:
|
|||||||
return ca_dir
|
return ca_dir
|
||||||
|
|
||||||
|
|
||||||
def host_orchestrator_token() -> str:
|
def host_signing_key(filename: str) -> str:
|
||||||
"""The per-host control-plane secret, minted (256-bit, url-safe) and
|
"""A per-host signing key at `<root>/<filename>`, minted (256-bit, url-safe)
|
||||||
persisted 0600 on first use, then reused.
|
and persisted 0600 on first use, then reused.
|
||||||
|
|
||||||
This is the shared secret the launchers inject into the control-plane and
|
The generic form of `host_orchestrator_token()`: each service names its own
|
||||||
gateway containers and that the host CLI presents on every call. It is a
|
key file (`trust_domain.py`), so the orchestrator and a separate service like
|
||||||
*host* artifact — the file lives under the root the agent never mounts, and
|
the host controller (#468) get distinct keys neither can read. It is a *host*
|
||||||
the env var is set only on the trusted containers — so reading it here is
|
artifact — the file lives under the root the agent never mounts, and its value
|
||||||
safe on the host launch path but the value never reaches a bottle."""
|
is injected only into the trusted control-plane process — so reading it here
|
||||||
path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME
|
is safe on the launch path but the value never reaches a bottle."""
|
||||||
|
path = bot_bottle_root() / filename
|
||||||
try:
|
try:
|
||||||
existing = path.read_text().strip()
|
existing = path.read_text().strip()
|
||||||
if existing:
|
if existing:
|
||||||
@@ -128,6 +129,14 @@ def host_orchestrator_token() -> str:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def host_orchestrator_token() -> str:
|
||||||
|
"""The per-host control-plane signing key — the host-canonical key the
|
||||||
|
launchers inject into the control-plane process and the host CLI mints its
|
||||||
|
own `cli` token from. The `control-plane` trust domain's specialization of
|
||||||
|
`host_signing_key()`."""
|
||||||
|
return host_signing_key(ORCHESTRATOR_TOKEN_FILENAME)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"HOST_DB_FILENAME",
|
"HOST_DB_FILENAME",
|
||||||
"ORCHESTRATOR_TOKEN_FILENAME",
|
"ORCHESTRATOR_TOKEN_FILENAME",
|
||||||
@@ -138,5 +147,6 @@ __all__ = [
|
|||||||
"host_db_path",
|
"host_db_path",
|
||||||
"host_db_dir",
|
"host_db_dir",
|
||||||
"host_gateway_ca_dir",
|
"host_gateway_ca_dir",
|
||||||
|
"host_signing_key",
|
||||||
"host_orchestrator_token",
|
"host_orchestrator_token",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""Per-service control-plane signing keys (issue #476).
|
||||||
|
|
||||||
|
A `TrustDomain` is one service's signing material: its host-canonical key file,
|
||||||
|
the roles that key may sign, and the env vars its key and a pre-minted token ride
|
||||||
|
in. Scoping `mint`/`verify` to a domain's roles keeps one service's key from
|
||||||
|
signing (or accepting) another service's tokens.
|
||||||
|
|
||||||
|
Today there is one domain, `CONTROL_PLANE` — the orchestrator's key (roles
|
||||||
|
`{gateway, cli}`): the orchestrator holds it and mints the gateway's and CLI's
|
||||||
|
tokens. The host controller (#468) will add a **second** domain with its own key
|
||||||
|
the orchestrator never holds. That is the point: the host controller starts and
|
||||||
|
stops the orchestrator, so the orchestrator must not be able to mint the
|
||||||
|
credentials it uses to talk to it. Adding a `host` role to `CONTROL_PLANE`
|
||||||
|
instead would defeat that — the orchestrator holds that key, so it could forge
|
||||||
|
`host` tokens.
|
||||||
|
|
||||||
|
`ControlPlaneProvisioning` is the one seam every backend launcher uses to get the
|
||||||
|
orchestrator its key and the gateway its token, instead of re-deriving that
|
||||||
|
wiring per backend (the bug class behind PR #471 — see
|
||||||
|
`docs/prds/prd-new-control-plane-auth-provisioning.md`).
|
||||||
|
|
||||||
|
Stdlib-only: the HMAC lives in `orchestrator_auth`, the key file in `paths`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from . import orchestrator_auth
|
||||||
|
from .orchestrator_auth import ROLE_GATEWAY
|
||||||
|
from .paths import (
|
||||||
|
ORCHESTRATOR_AUTH_JWT_ENV,
|
||||||
|
ORCHESTRATOR_TOKEN_ENV,
|
||||||
|
ORCHESTRATOR_TOKEN_FILENAME,
|
||||||
|
host_signing_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProvisioningError(RuntimeError):
|
||||||
|
"""A control-plane auth invariant would be violated (e.g. starting the
|
||||||
|
orchestrator without its signing key — which would run OPEN)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TrustDomain:
|
||||||
|
"""One service's signing material: a host-canonical key file, the roles that
|
||||||
|
key may sign, and the env vars its key and a minted token ride in.
|
||||||
|
|
||||||
|
The service that *owns* the domain (e.g. the orchestrator) receives the raw
|
||||||
|
key via `key_env`; a delegate (e.g. the gateway) receives only a pre-minted,
|
||||||
|
role-scoped token via `token_env` it cannot rewrite. `mint`/`verify` are
|
||||||
|
scoped to `roles`, so this service's key can neither sign nor accept another
|
||||||
|
service's role."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
key_filename: str
|
||||||
|
roles: frozenset[str]
|
||||||
|
key_env: str
|
||||||
|
token_env: str
|
||||||
|
|
||||||
|
def signing_key(self) -> str:
|
||||||
|
"""This service's host-canonical signing key (minted 0600 on first use).
|
||||||
|
Host-side only — the value is injected into the owning process."""
|
||||||
|
return host_signing_key(self.key_filename)
|
||||||
|
|
||||||
|
def key_from_env(self, environ: Mapping[str, str] | None = None) -> str:
|
||||||
|
"""The signing key as the owning process sees it — read from `key_env`
|
||||||
|
(default `os.environ`). "" when unset; the caller decides whether that is
|
||||||
|
fatal (`ControlPlaneProvisioning`) or the open-mode fallback
|
||||||
|
(`OrchestratorServer`)."""
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
return env.get(self.key_env, "").strip()
|
||||||
|
|
||||||
|
def mint(self, role: str) -> str:
|
||||||
|
"""A role-scoped token for a delegate, signed with this service's key.
|
||||||
|
Raises ValueError for a role this service doesn't sign."""
|
||||||
|
if role not in self.roles:
|
||||||
|
raise ValueError(f"role {role!r} is not in trust domain {self.name!r}")
|
||||||
|
return orchestrator_auth.mint(role, self.signing_key(), roles=self.roles)
|
||||||
|
|
||||||
|
def verify(self, token: str, key: str) -> str | None:
|
||||||
|
"""The role `token` carries under `key`, or None. `key` is passed in
|
||||||
|
(not read from disk) because the verifier — the control-plane process —
|
||||||
|
holds it in `key_env`, not on disk in its guest."""
|
||||||
|
return orchestrator_auth.verify(token, key, roles=self.roles)
|
||||||
|
|
||||||
|
|
||||||
|
# The orchestrator's domain: the key the orchestrator (and host CLI) holds, the
|
||||||
|
# `gateway` token it mints for the data plane, and the `cli` token the CLI mints
|
||||||
|
# for itself. #468's host controller will add a second, separate domain.
|
||||||
|
CONTROL_PLANE = TrustDomain(
|
||||||
|
name="control-plane",
|
||||||
|
key_filename=ORCHESTRATOR_TOKEN_FILENAME,
|
||||||
|
roles=orchestrator_auth.ROLES,
|
||||||
|
key_env=ORCHESTRATOR_TOKEN_ENV,
|
||||||
|
token_env=ORCHESTRATOR_AUTH_JWT_ENV,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ControlPlaneProvisioning:
|
||||||
|
"""The one seam every backend launcher uses to provision control-plane auth,
|
||||||
|
instead of re-deriving the four invariants that each cost a PR #471 review
|
||||||
|
round: the orchestrator gets the raw key (`orchestrator_key`), the gateway
|
||||||
|
gets a minted `gateway` token (`gateway_token`), the host CLI mints its own
|
||||||
|
`cli` token from the same host-canonical key, and the orchestrator never
|
||||||
|
starts open."""
|
||||||
|
|
||||||
|
domain: TrustDomain = CONTROL_PLANE
|
||||||
|
|
||||||
|
def orchestrator_key(self) -> str:
|
||||||
|
"""The raw signing key the orchestrator process must receive (carry it in
|
||||||
|
`domain.key_env`). Fail-closed: raises rather than return "", since an
|
||||||
|
empty key runs the server open — and being on a separate host does not
|
||||||
|
stop the gateway from reaching the control plane (it must, for
|
||||||
|
`/resolve`), so an open orchestrator would treat that gateway as `cli`."""
|
||||||
|
key = self.domain.signing_key()
|
||||||
|
if not key:
|
||||||
|
raise ProvisioningError(
|
||||||
|
f"refusing to start the {self.domain.name} orchestrator without "
|
||||||
|
"a signing key: an open orchestrator authenticates no one and "
|
||||||
|
"grants every caller that reaches it full `cli` (#476)"
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
|
||||||
|
def gateway_token(self) -> str:
|
||||||
|
"""The `gateway`-role token the gateway receives (carry it in
|
||||||
|
`domain.token_env`) — minted from the key, never the key itself, so a
|
||||||
|
compromised gateway cannot forge a `cli` token."""
|
||||||
|
return self.domain.mint(ROLE_GATEWAY)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ProvisioningError",
|
||||||
|
"TrustDomain",
|
||||||
|
"CONTROL_PLANE",
|
||||||
|
"ControlPlaneProvisioning",
|
||||||
|
]
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# PRD prd-new: Per-service signing keys for control-plane auth
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **Author:** claude
|
||||||
|
- **Created:** 2026-07-26
|
||||||
|
- **Issue:** #476
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Provision control-plane signing keys **per service**, through one shared seam, so
|
||||||
|
no service can mint another's credentials. Concretely: the orchestrator holds the
|
||||||
|
control-plane key and mints the gateway's and CLI's tokens; the host controller
|
||||||
|
(#468, next) gets a **separate** key the orchestrator never holds — so the
|
||||||
|
orchestrator cannot forge the credentials it uses to talk to the host controller
|
||||||
|
that starts and stops it. Landing this seam also retires the per-backend
|
||||||
|
provisioning duplication that made PR #471 take three review rounds.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
**1. The orchestrator could forge host-controller credentials.** The
|
||||||
|
orchestrator's key signs roles `{gateway, cli}`. The tempting way to add the host
|
||||||
|
controller (#468) is a third role, `host`, on that same key. But then the
|
||||||
|
orchestrator — which holds the key — can mint `host` tokens, and the host
|
||||||
|
controller, which owns the orchestrator's lifecycle, must not trust anything the
|
||||||
|
orchestrator can mint. The two services need separate keys.
|
||||||
|
|
||||||
|
**2. Every backend provisioned auth by hand.** Each launcher (docker
|
||||||
|
gateway/infra, macOS infra, firecracker infra) re-derived how to generate the
|
||||||
|
signing key, scope it to the orchestrator, mint the gateway JWT, and keep the
|
||||||
|
host key file canonical. All three PR #471 High-severity findings were this one
|
||||||
|
integration bug in different launchers: the data plane got the full `cli` token;
|
||||||
|
the firecracker control plane ran open; the firecracker guest clobbered the host
|
||||||
|
key.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
- The orchestrator and the host controller sign with **different** keys; neither
|
||||||
|
can mint the other's tokens. (This PR provisions the orchestrator's key and
|
||||||
|
leaves a drop-in seam for the host controller's.)
|
||||||
|
- One shared provisioning seam every backend uses — a new backend or daemon
|
||||||
|
implements it instead of rediscovering these four invariants:
|
||||||
|
1. the signing key is host-canonical: a guest is handed it, never generates or
|
||||||
|
overwrites it;
|
||||||
|
2. only the orchestrator process gets the raw key; the gateway gets a
|
||||||
|
pre-minted `gateway` token it can't rewrite into `cli`;
|
||||||
|
3. the host CLI's `cli` token is minted from the same key, so it stays valid
|
||||||
|
across co-running backends;
|
||||||
|
4. the orchestrator never runs open — the signing key is mandatory, with no
|
||||||
|
topology opt-out (a separate host does not stop a caller from reaching the
|
||||||
|
control-plane listener, so it cannot make open mode safe).
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- The host controller itself (#468) — this only provisions the orchestrator's
|
||||||
|
key and the seam #468 plugs into.
|
||||||
|
- Rewriting the HMAC primitive: `orchestrator_auth.mint/verify` gain an optional
|
||||||
|
`roles=` arg (default unchanged) so a key can carry a different role set;
|
||||||
|
nothing else changes.
|
||||||
|
- Network topology, the plane split (#469), or the server's open-mode fallback
|
||||||
|
for tests.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
A **`TrustDomain`** is one service's signing material: its host-canonical key
|
||||||
|
file, the roles that key may sign, and the env vars its key and a pre-minted
|
||||||
|
token ride in. `mint`/`verify` are scoped to that domain's roles, so a token
|
||||||
|
signed by one service's key neither carries nor verifies another service's role.
|
||||||
|
|
||||||
|
- `CONTROL_PLANE` — the orchestrator's domain: key `orchestrator-token`, roles
|
||||||
|
`{gateway, cli}`. The orchestrator process holds the key; the gateway holds
|
||||||
|
only a minted `gateway` token; the host CLI mints its own `cli` token.
|
||||||
|
- The host controller (#468) will add a second `TrustDomain` — its own key file
|
||||||
|
and role(s) — that the orchestrator never holds.
|
||||||
|
|
||||||
|
**`ControlPlaneProvisioning`** is the seam the backends call.
|
||||||
|
`orchestrator_key()` returns the raw key for the control-plane process
|
||||||
|
(fail-closed for every backend: it raises rather than hand back an empty key that
|
||||||
|
would run the server open). `gateway_token()` mints the gateway's token. Each backend applies these through its own transport —
|
||||||
|
docker/macOS inject env vars, firecracker pushes over SSH — but none re-derives
|
||||||
|
*which* key or role.
|
||||||
|
|
||||||
|
`paths.host_signing_key(filename)` generalizes `host_orchestrator_token()` so each
|
||||||
|
domain names its own key file.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None blocking. #468 adds its `TrustDomain` and a second
|
||||||
|
`ControlPlaneProvisioning`-shaped consumer; renaming that class to something
|
||||||
|
service-neutral is a cosmetic call to make then.
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# PRD prd-new: Trusted agent forge identity and guidance
|
||||||
|
|
||||||
|
- **Status:** Draft
|
||||||
|
- **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.
|
||||||
@@ -19,7 +19,9 @@ _ORCH = "bot_bottle.backend.docker.orchestrator"
|
|||||||
_RUN = f"{_ORCH}.run_docker"
|
_RUN = f"{_ORCH}.run_docker"
|
||||||
_SLEEP = f"{_ORCH}.time.sleep"
|
_SLEEP = f"{_ORCH}.time.sleep"
|
||||||
_MONOTONIC = f"{_ORCH}.time.monotonic"
|
_MONOTONIC = f"{_ORCH}.time.monotonic"
|
||||||
_TOKEN = f"{_ORCH}.host_orchestrator_token"
|
# The signing key is read through the shared provisioning contract (#476); patch
|
||||||
|
# its host-canonical key file read to keep it off the real host file.
|
||||||
|
_TOKEN = "bot_bottle.trust_domain.host_signing_key"
|
||||||
# The ABC's is_healthy probes /health via urllib in the lifecycle module.
|
# The ABC's is_healthy probes /health via urllib in the lifecycle module.
|
||||||
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
|
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class TestEnsureRunning(unittest.TestCase):
|
|||||||
vm = infra_vm.InfraVm(guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
|
vm = infra_vm.InfraVm(guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
|
||||||
with patch.object(infra_vm, "boot_vm", return_value=vm) as boot, \
|
with patch.object(infra_vm, "boot_vm", return_value=vm) as boot, \
|
||||||
patch.object(infra_vm, "push_secret") as push, \
|
patch.object(infra_vm, "push_secret") as push, \
|
||||||
patch(f"{_ORCH}.host_orchestrator_token", return_value="host-key"), \
|
patch("bot_bottle.trust_domain.host_signing_key", return_value="host-key"), \
|
||||||
patch.object(orch, "is_running", return_value=False), \
|
patch.object(orch, "is_running", return_value=False), \
|
||||||
patch.object(orch, "_ensure_registry_volume", return_value=Path("/reg")), \
|
patch.object(orch, "_ensure_registry_volume", return_value=Path("/reg")), \
|
||||||
patch.object(orch, "_wait_for_health"):
|
patch.object(orch, "_wait_for_health"):
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class TestMacosOrchestratorRun(unittest.TestCase):
|
|||||||
def _run(self) -> list[str]:
|
def _run(self) -> list[str]:
|
||||||
run = Mock(return_value=_ok())
|
run = Mock(return_value=_ok())
|
||||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||||
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"):
|
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
mod.dns_server.return_value = "1.1.1.1"
|
mod.dns_server.return_value = "1.1.1.1"
|
||||||
mod.bind_mount_spec.side_effect = _spec
|
mod.bind_mount_spec.side_effect = _spec
|
||||||
mod.run_container_argv = run
|
mod.run_container_argv = run
|
||||||
@@ -65,7 +65,7 @@ class TestMacosOrchestratorRun(unittest.TestCase):
|
|||||||
|
|
||||||
def test_start_failure_raises(self) -> None:
|
def test_start_failure_raises(self) -> None:
|
||||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||||
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"):
|
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
mod.dns_server.return_value = "1.1.1.1"
|
mod.dns_server.return_value = "1.1.1.1"
|
||||||
mod.run_container_argv = Mock(return_value=_fail())
|
mod.run_container_argv = Mock(return_value=_fail())
|
||||||
with self.assertRaises(OrchestratorStartError):
|
with self.assertRaises(OrchestratorStartError):
|
||||||
|
|||||||
@@ -82,5 +82,26 @@ class TestMintVerify(unittest.TestCase):
|
|||||||
mint(ROLE_GATEWAY, "")
|
mint(ROLE_GATEWAY, "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomRoleSet(unittest.TestCase):
|
||||||
|
"""The `roles=` seam a trust domain other than the control plane uses (#476):
|
||||||
|
mint/verify are scoped to the passed role set, not the module default."""
|
||||||
|
|
||||||
|
_ROLES = frozenset({"host"})
|
||||||
|
|
||||||
|
def test_round_trips_a_role_in_the_custom_set(self) -> None:
|
||||||
|
tok = mint("host", _KEY, roles=self._ROLES)
|
||||||
|
self.assertEqual("host", verify(tok, _KEY, roles=self._ROLES))
|
||||||
|
|
||||||
|
def test_mint_rejects_a_role_outside_the_custom_set(self) -> None:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
mint(ROLE_CLI, _KEY, roles=self._ROLES)
|
||||||
|
|
||||||
|
def test_verify_rejects_a_role_outside_the_verifiers_set(self) -> None:
|
||||||
|
# A validly signed token whose role isn't in the verifier's set fails —
|
||||||
|
# this is what keeps one domain's key from asserting another's role.
|
||||||
|
tok = mint(ROLE_CLI, _KEY) # a control-plane `cli` token
|
||||||
|
self.assertIsNone(verify(tok, _KEY, roles=self._ROLES))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -20,13 +20,15 @@ _URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
|
|||||||
|
|
||||||
class TestHostAuthToken(unittest.TestCase):
|
class TestHostAuthToken(unittest.TestCase):
|
||||||
def test_mints_a_cli_token_from_the_host_key(self) -> None:
|
def test_mints_a_cli_token_from_the_host_key(self) -> None:
|
||||||
with patch("bot_bottle.orchestrator.client.host_orchestrator_token",
|
# The CLI mints its `cli` token from the control-plane trust domain's
|
||||||
|
# host-canonical key (#476) — patch the key file read underneath it.
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key",
|
||||||
return_value="signing-key"):
|
return_value="signing-key"):
|
||||||
tok = _host_auth_token()
|
tok = _host_auth_token()
|
||||||
self.assertEqual(ROLE_CLI, verify(tok, "signing-key"))
|
self.assertEqual(ROLE_CLI, verify(tok, "signing-key"))
|
||||||
|
|
||||||
def test_returns_empty_when_key_unreadable(self) -> None:
|
def test_returns_empty_when_key_unreadable(self) -> None:
|
||||||
with patch("bot_bottle.orchestrator.client.host_orchestrator_token",
|
with patch("bot_bottle.trust_domain.host_signing_key",
|
||||||
side_effect=OSError("no host root")):
|
side_effect=OSError("no host root")):
|
||||||
self.assertEqual("", _host_auth_token())
|
self.assertEqual("", _host_auth_token())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""Unit: trust domains + the shared control-plane provisioning contract (#476)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from bot_bottle import orchestrator_auth
|
||||||
|
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY
|
||||||
|
from bot_bottle.trust_domain import (
|
||||||
|
CONTROL_PLANE,
|
||||||
|
ControlPlaneProvisioning,
|
||||||
|
ProvisioningError,
|
||||||
|
TrustDomain,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A second, unrelated domain — the shape #468's host controller would take: its
|
||||||
|
# own key file, its own role, its own env vars. Distinct from CONTROL_PLANE.
|
||||||
|
_HOST_CTRL = TrustDomain(
|
||||||
|
name="host-controller",
|
||||||
|
key_filename="host-controller-token",
|
||||||
|
roles=frozenset({"host"}),
|
||||||
|
key_env="BOT_BOTTLE_HOST_CONTROLLER_TOKEN",
|
||||||
|
token_env="BOT_BOTTLE_HOST_CONTROLLER_JWT",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTrustDomainMintVerify(unittest.TestCase):
|
||||||
|
def test_mint_verify_round_trips_within_a_domain(self) -> None:
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
|
tok = CONTROL_PLANE.mint(ROLE_GATEWAY)
|
||||||
|
self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k"))
|
||||||
|
|
||||||
|
def test_mint_rejects_a_role_outside_the_domain(self) -> None:
|
||||||
|
# `host` is a valid role in _HOST_CTRL but not in the control plane —
|
||||||
|
# the control-plane key must refuse to mint it.
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
CONTROL_PLANE.mint("host")
|
||||||
|
|
||||||
|
def test_a_custom_role_set_verifies_its_own_role(self) -> None:
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
|
tok = _HOST_CTRL.mint("host")
|
||||||
|
self.assertEqual("host", _HOST_CTRL.verify(tok, "k"))
|
||||||
|
|
||||||
|
def test_key_from_env_reads_the_domains_env_var(self) -> None:
|
||||||
|
self.assertEqual(
|
||||||
|
"secret", CONTROL_PLANE.key_from_env({CONTROL_PLANE.key_env: " secret "}))
|
||||||
|
self.assertEqual("", CONTROL_PLANE.key_from_env({}))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDomainBoundary(unittest.TestCase):
|
||||||
|
"""The #476/#468 invariant: two domains, two keys, two role sets — one
|
||||||
|
domain's key can neither mint nor verify the other's tokens."""
|
||||||
|
|
||||||
|
def test_a_control_plane_token_does_not_verify_under_another_domains_key(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
# Even with an IDENTICAL underlying key, a `cli` token minted under the
|
||||||
|
# control-plane domain must not verify as a role in the host-controller
|
||||||
|
# domain — the role isn't in that domain's set.
|
||||||
|
cli_tok = orchestrator_auth.mint(ROLE_CLI, "shared-bytes")
|
||||||
|
self.assertIsNone(_HOST_CTRL.verify(cli_tok, "shared-bytes"))
|
||||||
|
|
||||||
|
def test_distinct_keys_do_not_cross_verify(self) -> None:
|
||||||
|
# The realistic case: distinct host-canonical keys per domain. A token
|
||||||
|
# signed by one key never verifies under the other.
|
||||||
|
host_tok = orchestrator_auth.mint(
|
||||||
|
"host", "host-ctrl-key", roles=_HOST_CTRL.roles)
|
||||||
|
self.assertIsNone(_HOST_CTRL.verify(host_tok, "control-plane-key"))
|
||||||
|
self.assertEqual("host", _HOST_CTRL.verify(host_tok, "host-ctrl-key"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestControlPlaneProvisioning(unittest.TestCase):
|
||||||
|
def test_orchestrator_key_returns_the_canonical_key(self) -> None:
|
||||||
|
prov = ControlPlaneProvisioning()
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="key"):
|
||||||
|
self.assertEqual("key", prov.orchestrator_key())
|
||||||
|
|
||||||
|
def test_orchestrator_key_fail_closes_when_empty(self) -> None:
|
||||||
|
# Invariant 4: the orchestrator must never start without a key — it would
|
||||||
|
# run OPEN and grant every caller that reaches it full `cli`. There is no
|
||||||
|
# topology opt-out: a separate host does not stop the gateway (or any
|
||||||
|
# other caller) from reaching the control-plane listener.
|
||||||
|
prov = ControlPlaneProvisioning()
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value=""):
|
||||||
|
with self.assertRaises(ProvisioningError):
|
||||||
|
prov.orchestrator_key()
|
||||||
|
|
||||||
|
def test_gateway_token_is_a_verifiable_gateway_role_token(self) -> None:
|
||||||
|
prov = ControlPlaneProvisioning()
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
|
tok = prov.gateway_token()
|
||||||
|
self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k"))
|
||||||
|
|
||||||
|
def test_gateway_token_cannot_be_reused_as_cli(self) -> None:
|
||||||
|
# The data plane's token is `gateway`-scoped: it never carries `cli`.
|
||||||
|
prov = ControlPlaneProvisioning()
|
||||||
|
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||||
|
tok = prov.gateway_token()
|
||||||
|
self.assertNotEqual(ROLE_CLI, CONTROL_PLANE.verify(tok, "k"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user