151 lines
7.9 KiB
Python
151 lines
7.9 KiB
Python
"""Per-card feature flags and the capabilities record clients negotiate with.
|
|
|
|
Flags come from the ``HUX_FLAGS`` comma list. A card counts as enabled only
|
|
when it and every card it depends on are enabled, so a half-configured
|
|
deployment fails closed. Route ownership per card is declared here so the
|
|
capabilities record can tell a client exactly what it may call, and the
|
|
worker allowlist (SO-08) says which of those a ``trust: worker`` caller may
|
|
reach at all.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
from collections.abc import Mapping
|
|
|
|
from hux.errors import FlagOff
|
|
from hux.identity import Identity
|
|
from hux.rules import flag_enabled, flag_registry
|
|
|
|
CONTRACT_VERSION = "1.1.0"
|
|
RELEASE_TAG = re.compile(r"^git-([0-9a-f]{40})-build-[1-9][0-9]*-release$")
|
|
CARD_ROUTES: dict[str, list[str]] = {
|
|
"HUX-11": ["/hux/v1/capabilities", "/hux/v1/manifest", "/hux/v1/context/bootstrap"],
|
|
"HUX-01": ["/hux/v1/conversations/{id}/events", "/hux/v1/conversations/{id}/events/stream"],
|
|
"HUX-02": ["/hux/v1/memory", "/hux/v1/memory/{id}", "/hux/v1/memory/{id}/{action}", "/hux/v1/memory/export"],
|
|
"HUX-03": ["/hux/v1/projects", "/hux/v1/projects/{id}", "/hux/v1/conversations", "/hux/v1/conversations/{id}", "/hux/v1/conversations/{id}/branch", "/hux/v1/conversations/{id}/lineage", "/hux/v1/search"],
|
|
"HUX-04": ["/hux/v1/artifacts", "/hux/v1/artifacts/{id}", "/hux/v1/artifacts/{id}/versions", "/hux/v1/artifacts/{id}/versions/{n}", "/hux/v1/artifacts/{id}/versions/{n}/diff", "/hux/v1/artifacts/{id}/promote"],
|
|
"HUX-05": ["/hux/v1/policy", "/hux/v1/approvals", "/hux/v1/approvals/{id}", "/hux/v1/runs/{id}/stop", "/hux/v1/runs/{id}/budget", "/hux/v1/runs/{id}/gate"],
|
|
"HUX-06": ["/hux/v1/modes", "/hux/v1/projects/{project_id}/conversations/{id}/mode"],
|
|
"HUX-07": [
|
|
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items",
|
|
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}",
|
|
"/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items/{item_id}/transcript-corrections",
|
|
"/hux/v1/projects/{project_id}/conversations/{id}/capture-intents",
|
|
],
|
|
"HUX-08": ["/hux/v1/sources", "/hux/v1/sources/{id}", "/hux/v1/passages", "/hux/v1/messages/{id}/citations", "/hux/v1/notebooks", "/hux/v1/notebooks/{id}"],
|
|
"HUX-09": [
|
|
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/evaluate",
|
|
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/{suggestion_id}/decisions",
|
|
"/hux/v1/projects/{project_id}/conversations/{id}/suggestions/states",
|
|
],
|
|
"HUX-10": ["/hux/v1/privacy/policy", "/hux/v1/privacy/notices", "/hux/v1/conversations/{id}/forget", "/hux/v1/privacy/audit", "/hux/v1/conversations/{id}/privacy"],
|
|
"HUX-12": [
|
|
"/hux/v1/projects/{project_id}/conversations/{id}/releases",
|
|
"/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}",
|
|
"/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}/transitions",
|
|
],
|
|
}
|
|
|
|
# The only (method, template) pairs a ``trust: worker`` caller may reach (SO-08).
|
|
# Everything else is 403 before the flag check. These are the agent-hook
|
|
# routes: negotiation, the approval/gate/budget/stop loop, activity events,
|
|
# the privacy policy, memory retrieval and proposals, research inputs and
|
|
# artifact writes. The conversation read is there so the hook can honour
|
|
# private mode (SO-28) before proposing a memory.
|
|
WORKER_ROUTES: frozenset[tuple[str, str]] = frozenset({
|
|
("GET", "/hux/v1/capabilities"), ("GET", "/hux/v1/manifest"),
|
|
("POST", "/hux/v1/context/bootstrap"),
|
|
("POST", "/hux/v1/approvals"),
|
|
("POST", "/hux/v1/runs/{id}/gate"), ("POST", "/hux/v1/runs/{id}/budget"), ("POST", "/hux/v1/runs/{id}/stop"),
|
|
("GET", "/hux/v1/runs/{id}/budget"),
|
|
("POST", "/hux/v1/conversations/{id}/events"), ("GET", "/hux/v1/conversations/{id}"),
|
|
("GET", "/hux/v1/privacy/policy"), ("GET", "/hux/v1/conversations/{id}/privacy"),
|
|
("GET", "/hux/v1/memory"), ("POST", "/hux/v1/memory"),
|
|
("POST", "/hux/v1/sources"), ("POST", "/hux/v1/passages"), ("POST", "/hux/v1/messages/{id}/citations"),
|
|
("POST", "/hux/v1/artifacts"), ("POST", "/hux/v1/artifacts/{id}/versions"),
|
|
("GET", "/hux/v1/modes"),
|
|
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/mode"),
|
|
("POST", "/hux/v1/projects/{project_id}/conversations/{id}/multimodal/items"),
|
|
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases"),
|
|
("GET", "/hux/v1/projects/{project_id}/conversations/{id}/releases/{release_id}"),
|
|
})
|
|
|
|
|
|
def worker_may_call(method: str, template: str) -> bool:
|
|
"""True when a ``trust: worker`` caller is allowed on this route (SO-08)."""
|
|
return (method, template) in WORKER_ROUTES
|
|
|
|
|
|
class Flags:
|
|
"""Snapshot of which cards are on for this process."""
|
|
|
|
def __init__(self, environ: Mapping[str, str] | None = None) -> None:
|
|
self._environ = dict(os.environ if environ is None else environ)
|
|
self._registry = flag_registry()
|
|
self._route_cards = frozenset(card for card, routes in CARD_ROUTES.items() if routes)
|
|
|
|
def bind_routes(self, routes: Mapping[str, set[str]]) -> None:
|
|
"""Bind capability flags to the route templates actually registered by this process."""
|
|
unknown = set(routes) - set(self._registry)
|
|
if unknown:
|
|
raise ValueError(f"routes registered for unknown cards: {sorted(unknown)}")
|
|
for card, actual in routes.items():
|
|
undeclared = actual - set(CARD_ROUTES.get(card, []))
|
|
if undeclared:
|
|
raise ValueError(f"undeclared routes for {card}: {sorted(undeclared)}")
|
|
self._route_cards = frozenset(
|
|
card for card, declared in CARD_ROUTES.items() if declared and set(declared) == routes.get(card, set())
|
|
)
|
|
|
|
def enabled(self, card: str) -> bool:
|
|
"""True only for a route-backed card whose configured flag chain is on."""
|
|
entry = self._registry.get(card)
|
|
enabled = bool(entry) and card in self._route_cards and flag_enabled(entry["flag"], self._environ)
|
|
if enabled and card == "HUX-12":
|
|
from hux.release_security import configured
|
|
|
|
return configured(self._environ)
|
|
return enabled
|
|
|
|
def require(self, card: str) -> None:
|
|
"""Raise FlagOff unless the card is enabled."""
|
|
if not self.enabled(card):
|
|
raise FlagOff(f"{card} is not enabled")
|
|
|
|
@property
|
|
def environ(self) -> Mapping[str, str]:
|
|
"""Read-only process configuration for route-family policy checks."""
|
|
return self._environ
|
|
|
|
def capabilities(self, identity: Identity, build: Mapping[str, str] | None = None) -> dict:
|
|
"""Serialise ``hux.capabilities.v1`` for one caller."""
|
|
cards = [
|
|
{"card": card, "flag": entry["flag"], "enabled": self.enabled(card), "routes": CARD_ROUTES.get(card, [])}
|
|
for card, entry in sorted(self._registry.items())
|
|
]
|
|
server = {k: v for k, v in (build or {}).items() if k in {"commit", "image_digest"} and v}
|
|
return {
|
|
"schema": "hux.capabilities.v1",
|
|
"contract_version": CONTRACT_VERSION,
|
|
"identity": identity.record(),
|
|
"cards": cards,
|
|
"server": server,
|
|
}
|
|
|
|
|
|
def build_from_environ(environ: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
"""Commit and image digest the pod was started with, when the operator set them."""
|
|
environ = os.environ if environ is None else environ
|
|
tag = environ.get("HUX_IMAGE_TAG", "")
|
|
commit = environ.get("HUX_BUILD_COMMIT", "")
|
|
if tag:
|
|
match = RELEASE_TAG.fullmatch(tag)
|
|
if not match:
|
|
raise ValueError("HUX_IMAGE_TAG is not an immutable WebUI release tag")
|
|
if commit and commit != match.group(1):
|
|
raise ValueError("HUX build commit conflicts with the immutable image tag")
|
|
commit = match.group(1)
|
|
return {"commit": commit, "image_digest": environ.get("HUX_IMAGE_DIGEST", "")}
|